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
This commit is contained in:
Sergio
2026-08-30 11:19:10 +00:00
co-authored by Claude Opus 5
parent 726068e653
commit 9142cd0565
2 changed files with 128 additions and 0 deletions
+120
View File
@@ -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();
+8
View File
@@ -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