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:
Sergio
2026-08-30 11:14:13 +00:00
co-authored by Claude Opus 5
parent 19b5d51dfe
commit 726068e653
3 changed files with 155 additions and 4 deletions
+49 -2
View File
@@ -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"
);
}
+30 -2
View File
@@ -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