From 726068e653c06a7b4d7fce1d86ad7b36e4093e82 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 30 Aug 2026 11:14:13 +0000 Subject: [PATCH] =?UTF-8?q?hidrataci=C3=B3n:=20no=20se=20escribe=20a=20tra?= =?UTF-8?q?v=C3=A9s=20de=20un=20symlink=20que=20sale=20del=20root=20(SDD?= =?UTF-8?q?=2025=20H3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih --- crates/hammer-build/src/hydrate.rs | 51 ++++++++++++- .../tests/hidratacion_no_escapa.rs | 76 +++++++++++++++++++ docs/25-tasas-del-kernel.md | 32 +++++++- 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 crates/hammer-build/tests/hidratacion_no_escapa.rs 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