> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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
diff --git a/crates/hammer-build/src/hydrate.rs b/crates/hammer-build/src/hydrate.rs
index 35d9e9d9..903ddb9a 100644
--- a/crates/hammer-build/src/hydrate.rs
+++ b/crates/hammer-build/src/hydrate.rs
@@ -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)?;
diff --git a/crates/hammer-build/tests/hidratacion_no_escapa.rs b/crates/hammer-build/tests/hidratacion_no_escapa.rs
new file mode 100644
index 00000000..ea521202
--- /dev/null
+++ b/crates/hammer-build/tests/hidratacion_no_escapa.rs
@@ -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"
+ );
+}
diff --git a/docs/25-tasas-del-kernel.md b/docs/25-tasas-del-kernel.md
index 999ce225..9a822af3 100644
--- a/docs/25-tasas-del-kernel.md
+++ b/docs/25-tasas-del-kernel.md
@@ -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