hidratación: no se escribe a través de un symlink que sale del root (SDD 25 H3)
Buscando el sitio de H3 —«harkaq valida rutas a mano»— resultó que harkaq NO valida rutas a mano: delega en Landlock. Donde no se validaba NADA era en hydrate. Reproducido: hidratar es target_fhs.join(rel) y escribir por ruta, así que un symlink de directorio ya puesto en el FHS se sigue. Artefacto A trae usr/share/pkg → /algún/lado (los symlinks se replican literales, y así debe ser); al hidratar B, usr/share/pkg/archivo aterrizaba FUERA del root y hydrate devolvía Ok(1). Con --into sobre una imagen eso es escribir en el anfitrión diciendo que todo fue bien. Cuánto se estaba disparando: cero. En el store hay 160 symlinks absolutos y NINGUNO apunta a un directorio; de ~42 000 relativos ninguno sale de su artefacto. Mina desactivada, no incendio. La comprobación va por DIRECTORIO (un canonicalize por fichero sería un realpath por entrada, T8) y la semántica es la de RESOLVE_IN_ROOT, no la de NO_SYMLINKS: un symlink que se queda dentro tiene que seguir andando —usr-merge /lib → usr/lib, los 22@2x → 22 de los iconos— y hay test para cada dirección. Lo que queda de la propuesta (la versión sin carrera, con openat2 + linkat/renameat) queda escrito con su precio: arrastra el camino de patchelf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
This commit is contained in:
@@ -90,15 +90,59 @@ pub fn hydrate(
|
||||
};
|
||||
std::fs::create_dir_all(target_fhs)?;
|
||||
|
||||
// La raíz REAL contra la que se mide la contención (ver `no_escapa`). Se resuelve una sola vez:
|
||||
// si el propio `target_fhs` es un symlink, eso es elección de quien hidrata y vale como raíz.
|
||||
let raiz = target_fhs.canonicalize().map_err(|e| {
|
||||
hammer_core::Error::Store(format!("canonicalize {}: {e}", target_fhs.display()))
|
||||
})?;
|
||||
|
||||
let mut report = HydrateReport::default();
|
||||
walk_and_link(artifact_dir, artifact_dir, target_fhs, spec.as_ref(), &mut report)?;
|
||||
walk_and_link(artifact_dir, artifact_dir, target_fhs, &raiz, spec.as_ref(), &mut report)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// El destino no puede salirse de la raíz. Se comprueba **por directorio**, no por fichero.
|
||||
///
|
||||
/// EL FALLO QUE TAPA (medido 2026-08-30, `tests/hidratacion_no_escapa.rs`): hidratar es
|
||||
/// `target_fhs.join(rel)` y escribir por ruta, así que **un symlink de directorio ya presente en el
|
||||
/// FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto (se
|
||||
/// replican literales, y así debe ser), y la hidratación SIGUIENTE escribe `usr/share/pkg/archivo`
|
||||
/// **fuera del root** — devolviendo `Ok`. Con `--into` sobre una imagen, eso es escribir en el
|
||||
/// sistema anfitrión diciendo que todo fue bien: exactamente el fallo de CLAUDE.md §3.
|
||||
///
|
||||
/// Hoy el corpus NO lo dispara —de 160 symlinks absolutos ninguno apunta a un directorio, y de
|
||||
/// ~42 000 relativos ninguno sale de su artefacto— pero nada lo impedía.
|
||||
///
|
||||
/// Semántica elegida: symlinks **permitidos mientras no salgan** (un `/lib → usr/lib` de usr-merge,
|
||||
/// o los `22@2x → 22` de los temas de iconos, tienen que seguir funcionando). Es la de
|
||||
/// `openat2(RESOLVE_IN_ROOT)`; acá se hace con `canonicalize` porque las escrituras siguen siendo
|
||||
/// por ruta (`hard_link`, `rename`, `patchelf`). La diferencia que queda: `canonicalize`+escribir
|
||||
/// es comprobar-y-después-usar, o sea que un adversario CONCURRENTE podría cambiar el componente
|
||||
/// entre las dos. Para el fallo real —un symlink persistente puesto por otro artefacto— alcanza y
|
||||
/// es determinista; la versión sin carrera es mover las escrituras a `openat2` + `linkat`/`renameat`
|
||||
/// (SDD 25 §7-H3), y eso arrastra el camino de `patchelf`.
|
||||
fn no_escapa(raiz: &Path, dst: &Path) -> hammer_core::Result<()> {
|
||||
let real = dst.canonicalize().map_err(|e| {
|
||||
hammer_core::Error::Store(format!("canonicalize {}: {e}", dst.display()))
|
||||
})?;
|
||||
if !real.starts_with(raiz) {
|
||||
return Err(hammer_core::Error::Store(format!(
|
||||
"la hidratación se saldría del root: {} lleva a {}, fuera de {}.\n\
|
||||
Es un symlink de directorio ya presente en el FHS (probablemente puesto por otro \
|
||||
artefacto). No se escribe a través de él: borralo o hidratá sobre un root limpio.",
|
||||
dst.display(),
|
||||
real.display(),
|
||||
raiz.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn walk_and_link(
|
||||
root: &Path,
|
||||
cur: &Path,
|
||||
target_fhs: &Path,
|
||||
raiz: &Path,
|
||||
spec: Option<&DynamicSpec>,
|
||||
report: &mut HydrateReport,
|
||||
) -> hammer_core::Result<()> {
|
||||
@@ -111,8 +155,11 @@ fn walk_and_link(
|
||||
|
||||
if ft.is_dir() {
|
||||
std::fs::create_dir_all(&dst)?;
|
||||
// Acá y no en cada fichero: los ficheros cuelgan de un directorio ya comprobado, y
|
||||
// `canonicalize` por fichero costaría un realpath por entrada (SDD 25 T8/§9.4).
|
||||
no_escapa(raiz, &dst)?;
|
||||
report.dirs_created += 1;
|
||||
walk_and_link(root, &src, target_fhs, spec, report)?;
|
||||
walk_and_link(root, &src, target_fhs, raiz, spec, report)?;
|
||||
} else if ft.is_symlink() {
|
||||
// Replicamos el symlink con el mismo target literal (no resolvemos).
|
||||
let link_target = std::fs::read_link(&src)?;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! La hidratación no escribe fuera de su root, aunque el FHS ya traiga un symlink que salga.
|
||||
//!
|
||||
//! EL FALLO (medido 2026-08-30, antes de `no_escapa`): hidratar es `target_fhs.join(rel)` y
|
||||
//! escribir por ruta. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto
|
||||
//! —los symlinks se replican literales, y así debe ser— y la hidratación SIGUIENTE escribía
|
||||
//! `usr/share/pkg/archivo` **fuera del root**, devolviendo `Ok(1)`. Con `hammer hydrate --into`
|
||||
//! sobre una imagen, eso es escribir en el sistema anfitrión diciendo que todo fue bien.
|
||||
//!
|
||||
//! El corpus no lo disparaba (de 160 symlinks absolutos ninguno apunta a un directorio, y de
|
||||
//! ~42 000 relativos ninguno sale de su artefacto), así que esto es una mina desactivada, no un
|
||||
//! incendio apagado. Por eso el test: lo que no dispara nadie tampoco lo nota nadie.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use hammer_build::hydrate::hydrate;
|
||||
use hammer_core::LinkMode;
|
||||
|
||||
/// Artefacto que trae `usr/share/pkg` como symlink a `destino`.
|
||||
fn artefacto_con_symlink(dir: &Path, destino: &Path) {
|
||||
std::fs::create_dir_all(dir.join("usr/share")).unwrap();
|
||||
std::os::unix::fs::symlink(destino, dir.join("usr/share/pkg")).unwrap();
|
||||
}
|
||||
|
||||
/// Artefacto que trae un fichero DEBAJO de esa misma ruta.
|
||||
fn artefacto_con_fichero(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("usr/share/pkg")).unwrap();
|
||||
std::fs::write(dir.join("usr/share/pkg/archivo"), b"contenido").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn un_symlink_del_fhs_que_sale_del_root_no_deja_escribir() {
|
||||
let fuera = tempfile::tempdir().unwrap();
|
||||
let fhs = tempfile::tempdir().unwrap();
|
||||
let a = tempfile::tempdir().unwrap();
|
||||
let b = tempfile::tempdir().unwrap();
|
||||
|
||||
artefacto_con_symlink(a.path(), fuera.path());
|
||||
hydrate(a.path(), fhs.path(), LinkMode::Static, None).unwrap();
|
||||
|
||||
artefacto_con_fichero(b.path());
|
||||
let r = hydrate(b.path(), fhs.path(), LinkMode::Static, None);
|
||||
|
||||
assert!(r.is_err(), "hidratar a través del symlink tiene que FALLAR, no devolver Ok");
|
||||
let msg = format!("{}", r.unwrap_err());
|
||||
assert!(msg.contains("se saldría del root"), "el error tiene que decir qué pasó: {msg}");
|
||||
assert!(
|
||||
!fuera.path().join("archivo").exists(),
|
||||
"ESCAPÓ: el fichero se escribió fuera del target_fhs"
|
||||
);
|
||||
}
|
||||
|
||||
/// El contrapeso: un symlink de directorio que se queda DENTRO tiene que seguir funcionando.
|
||||
/// Es el caso de usr-merge (`/lib → usr/lib`) y el de los temas de iconos (`22@2x → 22`), o sea
|
||||
/// la mayoría de los symlinks de directorio que hay en el corpus. Un guardián que los rompiera
|
||||
/// sería peor que el agujero que tapa.
|
||||
#[test]
|
||||
fn un_symlink_que_se_queda_dentro_sigue_hidratando() {
|
||||
let fhs = tempfile::tempdir().unwrap();
|
||||
let a = tempfile::tempdir().unwrap();
|
||||
let b = tempfile::tempdir().unwrap();
|
||||
|
||||
// `usr/share/pkg → ../otro` (relativo y dentro), con el destino existiendo.
|
||||
std::fs::create_dir_all(a.path().join("usr/otro")).unwrap();
|
||||
std::fs::write(a.path().join("usr/otro/.marca"), b"x").unwrap();
|
||||
std::fs::create_dir_all(a.path().join("usr/share")).unwrap();
|
||||
std::os::unix::fs::symlink("../otro", a.path().join("usr/share/pkg")).unwrap();
|
||||
hydrate(a.path(), fhs.path(), LinkMode::Static, None).unwrap();
|
||||
|
||||
artefacto_con_fichero(b.path());
|
||||
let r = hydrate(b.path(), fhs.path(), LinkMode::Static, None);
|
||||
assert!(r.is_ok(), "un symlink que no sale del root no puede bloquear la hidratación: {r:?}");
|
||||
assert!(
|
||||
fhs.path().join("usr/otro/archivo").exists(),
|
||||
"el fichero tiene que aterrizar donde apunta el symlink, dentro del root"
|
||||
);
|
||||
}
|
||||
@@ -417,8 +417,36 @@ Su precio, concreto:
|
||||
plan de gioser sobre el config nuevo (35 cumplidos, 0 incumplidos).
|
||||
- **H2 · `MODULES=n` se queda.** Si algún día hace falta código de kernel, es built-in y
|
||||
parcheado en la receta. Queda escrito para no rediscutirlo.
|
||||
- **H3 · `harkaq` usa `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS`** donde hoy valida rutas
|
||||
a mano (T9, ~gratis).
|
||||
- **H3 · Contención de rutas: el sitio no era harkaq, era la hidratación.** La propuesta original
|
||||
decía «que harkaq use `openat2 RESOLVE_BENEATH` donde hoy valida rutas a mano (T9, ~gratis)».
|
||||
Buscando ese sitio (2026-08-30) resultó que **harkaq no valida rutas a mano**: delega la
|
||||
contención en Landlock, que es más fuerte que cualquier chequeo de ruta. Donde no se validaba
|
||||
**nada** era en `hammer-build/hydrate.rs`.
|
||||
|
||||
**El fallo, reproducido**: hidratar es `target_fhs.join(rel)` y escribir por ruta ⇒ **un symlink
|
||||
de directorio ya presente en el FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado`
|
||||
deja ese symlink puesto —los symlinks se replican literales, y así debe ser— y la hidratación
|
||||
siguiente escribía `usr/share/pkg/archivo` **fuera del root, devolviendo `Ok(1)`**. Con
|
||||
`hammer hydrate --into` sobre una imagen, eso es escribir en el sistema anfitrión diciendo que
|
||||
todo fue bien: el fallo de CLAUDE.md §3, otra vez.
|
||||
|
||||
**Cuánto se estaba disparando: cero.** Del store: **160 symlinks absolutos, ninguno a un
|
||||
directorio**; **~42 000 relativos, ninguno que salga de su artefacto**. Era una mina desactivada,
|
||||
no un incendio — y por eso nadie lo iba a encontrar mirando logs.
|
||||
|
||||
**HECHO**: `no_escapa()` comprueba la contención **por directorio** (no por fichero: un
|
||||
`canonicalize` por entrada costaría un realpath por fichero, T8/§9.4) y falla nombrando el
|
||||
symlink y adónde lleva. Semántica **`RESOLVE_IN_ROOT`, no `RESOLVE_NO_SYMLINKS`**: un symlink que
|
||||
se queda dentro tiene que seguir andando (usr-merge `/lib → usr/lib`, los `22@2x → 22` de los
|
||||
temas de iconos), y hay un test para cada dirección en
|
||||
`crates/hammer-build/tests/hidratacion_no_escapa.rs`.
|
||||
|
||||
**Lo que queda de la propuesta original**: `canonicalize`+escribir es comprobar-y-después-usar, o
|
||||
sea que un adversario **concurrente** podría cambiar un componente entre las dos. Para el fallo
|
||||
real —un symlink persistente puesto por otro artefacto— alcanza y es determinista. La versión sin
|
||||
carrera es mover las escrituras a `openat2(RESOLVE_IN_ROOT)` + `linkat`/`renameat`, y arrastra el
|
||||
camino de `patchelf` (que necesita una ruta, no un fd). Queda escrito acá con su precio, que es
|
||||
más de lo que se sabía cuando H3 se llamaba «~gratis».
|
||||
- **H4 · Ningún `Command` de hammer pone `pre_exec`** — Rust apaga el camino `posix_spawn` en
|
||||
cuanto hay uno, y cae a `fork+exec` (T1: 17× a 256 MB tocados). `hammer-build/sandbox.rs` lanza
|
||||
`bwrap` sin `pre_exec`, o sea que estaba bien **por suerte**: nadie lo comprobaba. **HECHO**: el
|
||||
|
||||
Reference in New Issue
Block a user