gioser

sergio/takana

> 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…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

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

726068e653c06a7b4d7fce1d86ad7b36e4093e82 · por Sergio · (hace 1 mes) · padre 19b5d51df · ver el árbol en este commit

Delta semántico · 7

crates/hammer-build/src/hydrate.rs

3 archivos · +155 −4

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