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 = 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, report_deleted: &mut Vec, ) -> 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 → ` (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