tawasuyu

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 1572Shell 203Rust 109Markdown 993728 Definiciones

upgrade: el mismo agujero que hydrate, en el proyector de generaciones

project_plan es target_root.join(rel) + escribir por ruta, igual que hydrate: un symlink de
directorio dejado por una generación anterior se seguía. Reproducido — la generación 2 escribía
usr/share/pkg/archivo FUERA del root y ApplyReport salía en verde.

Con --root / no cambia nada (todo empieza por /); el caso real es , que es como se arma una imagen.

Se mide el DIRECTORIO PADRE, no el destino: un Replaced sobre un symlink que apunta afuera es
legítimo porque rename pisa el symlink en vez de escribir a través de él, y medir el destino lo
rechazaría por error. Y raiz_real() resuelve el tramo existente del root dejando pegado el que
todavía no existe, para que la comprobación valga también sobre una imagen nueva.

Dos tests, uno por dirección: el que sale falla ruidoso, el que se queda dentro (usr-merge,
temas de iconos) sigue proyectando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih

9142cd05650376627da4839c61ed8e63a9481510 · por Sergio · (hace 1 mes) · padre 726068e65 · ver el árbol en este commit

Delta semántico · 6

crates/hammer-upgrade/src/lib.rs

2 archivos · +128 −0

diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs
index 2367ba55..b99db0e2 100644
--- a/crates/hammer-upgrade/src/lib.rs
+++ b/crates/hammer-upgrade/src/lib.rs
@@ -456,6 +456,56 @@ fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
     r
 }
 
+/// La ruta REAL de `target_root`, resolviendo los symlinks del tramo que ya existe y dejando
+/// pegado el tramo que todavía no (que `apply` va a crear como directorios de verdad). Sin esto no
+/// habría con qué comparar cuando el root aún no existe — el caso de una imagen nueva.
+fn raiz_real(target_root: &Path) -> PathBuf {
+    let mut faltan: Vec<std::ffi::OsString> = Vec::new();
+    let mut p = target_root.to_path_buf();
+    loop {
+        if let Ok(mut r) = p.canonicalize() {
+            for c in faltan.iter().rev() {
+                r.push(c);
+            }
+            return r;
+        }
+        match (p.file_name().map(|n| n.to_os_string()), p.parent().map(|x| x.to_path_buf())) {
+            (Some(n), Some(par)) => {
+                faltan.push(n);
+                p = par;
+            }
+            _ => return target_root.to_path_buf(),
+        }
+    }
+}
+
+/// El destino no puede salirse del root. **Se mide el DIRECTORIO padre, no el destino**: un
+/// `Replaced` sobre un symlink que apunta afuera es legítimo —`rename` pisa el symlink, no escribe
+/// a través de él— y medir el destino lo rechazaría por error.
+///
+/// EL FALLO QUE TAPA (medido 2026-08-30, `apply_no_escribe_fuera_del_root_por_un_symlink`): igual
+/// que en `hammer-build/hydrate.rs`, proyectar es `target_root.join(rel)` + escribir por ruta, así
+/// que un symlink de DIRECTORIO ya presente en el root se sigue. Una generación que deja puesto
+/// `usr/share/pkg → /algún/lado` hacía que la siguiente escribiera `usr/share/pkg/archivo` FUERA
+/// del root, con `ApplyReport` en verde. Con `--root /` no cambia nada (todo empieza por `/`); con
+/// `--root /mnt/imagen`, que es el caso real de armar una imagen, sí.
+fn dentro_de_raiz(raiz: &Path, dst: &Path) -> Result<()> {
+    let padre = match dst.parent() {
+        Some(p) => p,
+        None => return Ok(()),
+    };
+    let real = raiz_real(padre);
+    if !real.starts_with(raiz) {
+        return Err(Error::Io(std::io::Error::other(format!(
+            "la proyección se saldría del root: {} lleva a {}, fuera de {}.              Es un symlink de directorio ya presente en el root (probablemente de otra generación);              no se escribe a través de él.",
+            padre.display(),
+            real.display(),
+            raiz.display()
+        ))));
+    }
+    Ok(())
+}
+
 /// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
 /// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
 /// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
@@ -470,8 +520,10 @@ fn project_plan(
     let entries = enumerate_tree(tree_path)?;
     let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
         entries.iter().map(|e| (&e.rel, e)).collect();
+    let raiz = raiz_real(target_root);
     for ch in &manifest.changes {
         let dst = target_root.join(&ch.path);
+        dentro_de_raiz(&raiz, &dst)?;
         match ch.op {
             ChangeOp::Added | ChangeOp::Replaced => {
                 if ch.op == ChangeOp::Replaced {
@@ -501,8 +553,10 @@ fn undo_changes(
     report_restored: &mut Vec<PathBuf>,
     report_deleted: &mut Vec<PathBuf>,
 ) -> Result<()> {
+    let raiz = raiz_real(target_root);
     for ch in manifest.changes.iter().rev() {
         let dst = target_root.join(&ch.path);
+        dentro_de_raiz(&raiz, &dst)?;
         match ch.op {
             ChangeOp::Added => {
                 if std::fs::symlink_metadata(&dst).is_ok() {
@@ -784,6 +838,72 @@ mod tests {
         std::fs::write(p, bytes).unwrap();
     }
 
+    /// EVIDENCIA (2026-08-30): ¿el proyector de `apply` escribe a través de un symlink de
+    /// directorio que sale del root? Es el mismo patrón que se midió en `hydrate` — `root.join(rel)`
+    /// y escribir por ruta — y con `--root /mnt/imagen` el root NO es `/`.
+    #[test]
+    fn apply_no_escribe_fuera_del_root_por_un_symlink() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+        let fuera = tmp.path().join("fuera");
+        std::fs::create_dir_all(&fuera).unwrap();
+
+        // Generación 1: deja puesto `usr/share/pkg → <fuera>` (los symlinks se proyectan literales).
+        let g1 = seal_tree(&store, &h64("a1"), "v1", |w| {
+            std::fs::create_dir_all(w.join("usr/share")).unwrap();
+            std::os::unix::fs::symlink(&fuera, w.join("usr/share/pkg")).unwrap();
+        });
+        apply(&store, &g1, None, &root, &state, None).unwrap();
+
+        // Generación 2: trae un fichero DEBAJO de esa ruta.
+        let g2 = seal_tree(&store, &h64("a2"), "v2", |w| {
+            write(&w.join("usr/share/pkg/archivo"), b"contenido");
+        });
+        let r = apply(&store, &g2, None, &root, &state, None);
+
+        assert!(
+            !fuera.join("archivo").exists(),
+            "ESCAPÓ: apply escribió fuera del root a través del symlink ({r:?})"
+        );
+        let err = format!("{}", r.unwrap_err());
+        assert!(err.contains("se saldría del root"), "el error tiene que decir qué pasó: {err}");
+    }
+
+    /// El contrapeso del guardián anterior: un symlink de directorio que se queda DENTRO del root
+    /// tiene que seguir proyectando. Es el caso de usr-merge (`/lib → usr/lib`) y el de los temas de
+    /// iconos; romperlo sería peor que el agujero que se tapa.
+    #[test]
+    fn apply_sigue_proyectando_por_un_symlink_que_no_sale() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = tmp.path().join("store");
+        let root = tmp.path().join("root");
+        let state = tmp.path().join("state");
+
+        // Generación 1: `usr/share/pkg → ../otro`, relativo y dentro del propio root.
+        let g1 = seal_tree(&store, &h64("b1"), "v1", |w| {
+            write(&w.join("usr/otro/.marca"), b"x");
+            std::fs::create_dir_all(w.join("usr/share")).unwrap();
+            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
+        });
+        apply(&store, &g1, None, &root, &state, None).unwrap();
+
+        // Generación 2: un fichero debajo de esa ruta + lo de la 1 (si no, la 2 la retira).
+        let g2 = seal_tree(&store, &h64("b2"), "v2", |w| {
+            write(&w.join("usr/otro/.marca"), b"x");
+            std::fs::create_dir_all(w.join("usr/share")).unwrap();
+            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
+            write(&w.join("usr/share/pkg/archivo"), b"contenido");
+        });
+        apply(&store, &g2, None, &root, &state, None).unwrap();
+
+        assert!(
+            root.join("usr/otro/archivo").exists(),
+            "el fichero tiene que aterrizar donde apunta el symlink, dentro del root"
+        );
+    }
+
     #[test]
     fn apply_projects_tree_and_records_generation() {
         let tmp = tempfile::tempdir().unwrap();
diff --git a/docs/25-tasas-del-kernel.md b/docs/25-tasas-del-kernel.md
index 9a822af3..141faf24 100644
--- a/docs/25-tasas-del-kernel.md
+++ b/docs/25-tasas-del-kernel.md
@@ -441,6 +441,14 @@ Su precio, concreto:
   temas de iconos), y hay un test para cada dirección en
   `crates/hammer-build/tests/hidratacion_no_escapa.rs`.
 
+  **Y el mismo patrón estaba en el OTRO proyector**: `hammer-upgrade::project_plan` es
+  `target_root.join(rel)` + escribir por ruta, igual que hydrate. Con `--root /` no cambia nada
+  (todo empieza por `/`), pero `hammer upgrade apply --root /mnt/imagen` es el caso real de armar
+  una imagen: reproducido —la generación 2 escribía fuera del root con `ApplyReport` en verde— y
+  tapado con la misma regla (se mide el **directorio padre**, porque un `Replaced` sobre un symlink
+  que apunta afuera es legítimo: `rename` pisa el symlink, no escribe a través de él). Dos tests,
+  uno por dirección, en `crates/hammer-upgrade/src/lib.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