diff --git a/crates/hammer-overlay/src/lib.rs b/crates/hammer-overlay/src/lib.rs index 90e89a77..016a54a2 100644 --- a/crates/hammer-overlay/src/lib.rs +++ b/crates/hammer-overlay/src/lib.rs @@ -94,6 +94,12 @@ pub enum Error { Mount(String), #[error("umount falló: {0}")] Umount(String), + #[error( + "overlay {id} tiene overlays más jóvenes apilados encima ({}); \ + commitea/descarta primero los de arriba (LIFO)", + .shadowed_by.iter().map(|o| o.as_str()).collect::>().join(", ") + )] + Shadowed { id: OverlayId, shadowed_by: Vec }, } pub type Result = std::result::Result; @@ -113,15 +119,21 @@ fn slug_for(target: &Path) -> String { if s.is_empty() { "root".to_string() } else { s } } -/// Genera un `OverlayId` legible: `-`. Determinista por proceso, único en la -/// práctica cuando varios `try` no se llaman en el mismo segundo desde el mismo PID -/// (suficiente para Fase 2; si llegamos a anidación real, añadir nonce). +/// Genera un `OverlayId` legible: `--`. El `seq` es un contador atómico +/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo +/// segundo desde el mismo PID — el caso de la anidación real, donde un orquestador apila +/// varios overlays en ráfaga. El padding mantiene el orden lexicográfico del id alineado con +/// el orden de creación, que es justo lo que [`stack_key`] usa como desempate del apilamiento. +/// (La unicidad estricta entre procesos concurrentes no la cubre el seq; ver `stack_key`.) fn fresh_id() -> OverlayId { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - OverlayId(format!("{ts}-{}", std::process::id())) + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + OverlayId(format!("{ts}-{}-{seq:06}", std::process::id())) } /// Monta un overlay nuevo sobre `targets` y persiste su manifiesto. @@ -209,8 +221,12 @@ pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> { } let bytes = std::fs::read(&state_file)?; let state: OverlayState = serde_json::from_slice(&bytes)?; - // Desmonta en orden inverso al de montaje — mismo principio LIFO que descarga bash - // anidaba `try`s, por si alguien apila overlays sobre los mismos paths. + // LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima. + let blockers = blocking_overlays(state_root, id)?; + if !blockers.is_empty() { + return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers }); + } + // Desmonta en orden inverso al de montaje dentro de este overlay. for m in state.mounts.iter().rev() { do_umount_lenient(&m.target)?; } @@ -253,6 +269,12 @@ fn commit_inner( } let bytes = std::fs::read(&state_file)?; let state: OverlayState = serde_json::from_slice(&bytes)?; + // LIFO: commitear una capa con overlays más jóvenes encima promocionaría el upper a un + // target todavía sombreado por otro mount. Rechazamos hasta que se resuelvan los de arriba. + let blockers = blocking_overlays(state_root, id)?; + if !blockers.is_empty() { + return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers }); + } let mut report = CommitReport::default(); @@ -315,6 +337,43 @@ fn record_commit_to_journal( } } +/// Clave de orden de apilamiento total y determinista: primero por instante de creación, +/// desempatado por el `id` (que lleva ts+pid). Un overlay con clave mayor es "más joven" y, +/// si comparte targets, queda apilado *encima*. +fn stack_key(s: &OverlayState) -> (u64, &str) { + (s.created_at, s.id.as_str()) +} + +/// `true` si dos targets se solapan en el árbol del FHS: iguales, o uno ancestro del otro +/// (p. ej. un overlay sobre `/usr` y otro sobre `/usr/bin` comparten el mountpoint efectivo). +fn targets_overlap(a: &Path, b: &Path) -> bool { + a.starts_with(b) || b.starts_with(a) +} + +/// Overlays más jóvenes que `id` que solapan alguno de sus targets — es decir, los que +/// están apilados *encima* en el FHS. Mientras existan, `commit`/`discard` de `id` violaría +/// el orden LIFO (desmontaría la capa equivocada del target compartido), así que los +/// rechazamos. Es inspección pura del estado, sin tocar mounts. +fn blocking_overlays(state_root: &Path, id: &OverlayId) -> Result> { + let all = status(state_root)?; + let Some(me) = all.iter().find(|s| &s.id == id) else { + return Ok(Vec::new()); + }; + let me_key = stack_key(me); + let mut blockers: Vec = all + .iter() + .filter(|other| &other.id != id && stack_key(other) > me_key) + .filter(|other| { + other.mounts.iter().any(|om| { + me.mounts.iter().any(|mm| targets_overlap(&om.target, &mm.target)) + }) + }) + .map(|other| other.id.clone()) + .collect(); + blockers.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(blockers) +} + fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> { // overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma, // overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en @@ -519,6 +578,88 @@ mod tests { assert!(err.contains("no encuentro overlay"), "msg = {err}"); } + /// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de + /// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría. + fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) { + let base = state_root.join(id); + std::fs::create_dir_all(&base).unwrap(); + let mounts = targets + .iter() + .map(|t| OverlayMount { + target: PathBuf::from(t), + upper: base.join("upper"), + work: base.join("work"), + }) + .collect(); + let st = OverlayState { id: OverlayId(id.into()), mounts, created_at }; + std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap(); + } + + #[test] + fn no_blockers_for_disjoint_targets() { + let d = tempfile::tempdir().unwrap(); + plant(d.path(), "100-1", 100, &["/usr/bin"]); + plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto + assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty()); + } + + #[test] + fn younger_overlapping_overlay_blocks() { + let d = tempfile::tempdir().unwrap(); + plant(d.path(), "100-1", 100, &["/usr/bin"]); + plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target + let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(); + assert_eq!(blockers, vec![OverlayId("200-1".into())]); + // El de arriba no tiene a nadie por encima: se puede resolver primero. + assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty()); + } + + #[test] + fn ancestor_target_counts_as_overlap() { + let d = tempfile::tempdir().unwrap(); + plant(d.path(), "100-1", 100, &["/usr"]); + plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr + let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(); + assert_eq!(blockers, vec![OverlayId("200-1".into())]); + } + + #[test] + fn same_second_ties_broken_by_id() { + let d = tempfile::tempdir().unwrap(); + // Mismo created_at: el desempate por id define quién está encima. + plant(d.path(), "100-1", 100, &["/etc"]); + plant(d.path(), "100-2", 100, &["/etc"]); + assert_eq!( + blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(), + vec![OverlayId("100-2".into())] + ); + assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty()); + } + + #[test] + fn commit_refuses_when_shadowed() { + let d = tempfile::tempdir().unwrap(); + plant(d.path(), "100-1", 100, &["/usr/bin"]); + plant(d.path(), "200-1", 200, &["/usr/bin"]); + let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err(); + match err { + Error::Shadowed { id, shadowed_by } => { + assert_eq!(id.as_str(), "100-1"); + assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]); + } + other => panic!("esperaba Shadowed, vino {other:?}"), + } + } + + #[test] + fn discard_refuses_when_shadowed() { + let d = tempfile::tempdir().unwrap(); + plant(d.path(), "100-1", 100, &["/etc"]); + plant(d.path(), "200-1", 200, &["/etc"]); + let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err(); + assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}"); + } + #[test] fn journal_hook_records_copied_and_removed() { // No tocamos mount real: ejercitamos directamente la función privada que el commit diff --git a/crates/hammer-overlay/tests/overlayfs_e2e.rs b/crates/hammer-overlay/tests/overlayfs_e2e.rs index f5022f17..0d3ea434 100644 --- a/crates/hammer-overlay/tests/overlayfs_e2e.rs +++ b/crates/hammer-overlay/tests/overlayfs_e2e.rs @@ -166,6 +166,71 @@ fn overlay_try_commit_discard_inside_userns() { body_try_commit_discard(); } +/// Anidación real: dos overlays apilados sobre el mismo target. Prueba que (1) el segundo +/// `try` ve el upper del primero como su lower (stack por mountpoint), (2) el guard LIFO +/// rechaza commitear el de abajo mientras el de arriba sigue montado, y (3) commitear de +/// arriba hacia abajo deja en el lower real la fusión de ambas capas. +fn body_nested_stack() { + let scratch = PathBuf::from(std::env::var("OVERLAY_TEST_SCRATCH").unwrap()); + let lower = scratch.join("lower"); + let state = scratch.join("state"); + std::fs::create_dir_all(&lower).unwrap(); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write(lower.join("base.txt"), b"base\n").unwrap(); + + // --- capa 1 (abajo) --- + let id1 = try_overlay(&[lower.clone()], &state).expect("try 1"); + std::fs::write(lower.join("uno.txt"), b"de la capa 1\n").unwrap(); + + // --- capa 2 (arriba), apilada sobre el mismo target --- + let id2 = try_overlay(&[lower.clone()], &state).expect("try 2"); + assert_ne!(id1, id2, "los ids deben diferir aunque caigan en el mismo segundo"); + // La capa 2 ve lo que escribió la capa 1: el stack toma el merged previo como lower. + assert_eq!( + std::fs::read_to_string(lower.join("uno.txt")).unwrap(), + "de la capa 1\n", + "la capa de arriba debe ver las escrituras de la de abajo" + ); + std::fs::write(lower.join("dos.txt"), b"de la capa 2\n").unwrap(); + + assert_eq!(status(&state).unwrap().len(), 2, "dos overlays activos"); + + // --- guard LIFO: no se puede commitear la capa de abajo con la de arriba encima --- + match commit(&id1, &state) { + Err(hammer_overlay::Error::Shadowed { id, shadowed_by }) => { + assert_eq!(id, id1); + assert_eq!(shadowed_by, vec![id2.clone()]); + } + other => panic!("esperaba Shadowed al commitear la capa de abajo, vino {other:?}"), + } + + // --- commit de arriba hacia abajo (LIFO correcto) --- + let r2 = commit(&id2, &state).expect("commit capa 2"); + assert!(r2.copied.iter().any(|p| p.ends_with("dos.txt"))); + // Ahora id1 es la cima: ya se puede commitear. + let r1 = commit(&id1, &state).expect("commit capa 1"); + assert!(r1.copied.iter().any(|p| p.ends_with("uno.txt"))); + + // El lower real tiene la fusión de ambas capas + el base original. + assert!(status(&state).unwrap().is_empty(), "state limpio tras ambos commits"); + assert_eq!(std::fs::read_to_string(lower.join("base.txt")).unwrap(), "base\n"); + assert_eq!(std::fs::read_to_string(lower.join("uno.txt")).unwrap(), "de la capa 1\n"); + assert_eq!(std::fs::read_to_string(lower.join("dos.txt")).unwrap(), "de la capa 2\n"); +} + +#[test] +fn overlay_nested_stack_inside_userns() { + if std::env::var("HAMMER_OVERLAY_IN_SANDBOX").is_ok() { + body_nested_stack(); + return; + } + let tmp = tempfile::tempdir().unwrap(); + if run_inside_userns_or_skip("overlay_nested_stack_inside_userns", tmp.path()) { + return; + } + body_nested_stack(); +} + #[test] fn discard_unknown_id_errors() { // No requiere sandbox. diff --git a/docs/04-overlay.md b/docs/04-overlay.md index 952ded40..6a222bec 100644 --- a/docs/04-overlay.md +++ b/docs/04-overlay.md @@ -61,7 +61,14 @@ diferencia entre "probar en una maqueta" y "probar en la casa real con un seguro Reglas: - `commit`/`discard` sin overlay activo: no-op con aviso. -- Un `try` anidado crea un overlay nuevo apilado; se desmontan en orden LIFO. +- Un `try` anidado sobre un target ya cubierto crea un overlay nuevo **apilado**: el kernel + toma el merged view de la capa de abajo como `lowerdir` de la nueva. El orden de + apilamiento es total y determinista (`created_at`, desempatado por `id`, que ahora lleva un + `seq` por proceso para que ráfagas en el mismo segundo no colisionen). `commit`/`discard` + **exigen LIFO**: si una capa más joven aún sombrea alguno de tus targets, la operación + falla con `Error::Shadowed` listándolas — resuelve primero las de arriba. (La unicidad de + orden entre procesos concurrentes apilando sobre el mismo target en el mismo segundo no la + garantiza el `seq`; queda para el track posterior junto al init propio.) - Si el sistema se apaga con overlays activos, al arranque `hammerd` los reporta y deja que el humano decida (no auto-commit, no auto-discard — eso sería declarativo). diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index c6088b9f..e12be9a2 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -44,7 +44,15 @@ pre-requisito de validación. - [x] Subcomandos del CLI: `hammer try [targets…]`, `commit `, `discard `, `status`. - [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente). - [x] Hook al diario en `commit` (Fase 3 lo añadió vía `commit_with_journal`). -- [ ] Anidación real de overlays (un solo overlay activo por target hoy). +- [x] Anidación real de overlays. Un `try` sobre un target ya cubierto apila un overlay nuevo + (el kernel usa el merged view inferior como `lowerdir`). Orden de apilamiento total y + determinista vía `stack_key` (`created_at` + `id`, con `seq` por proceso en `fresh_id` + para evitar colisiones en ráfaga). `commit`/`discard` exigen **LIFO**: `blocking_overlays` + detecta capas más jóvenes que solapan targets (igualdad o ancestro de path) y la operación + falla con `Error::Shadowed` si las hay. Guard cubierto por 6 unit tests; el stack real + (capa 2 ve la 1, LIFO rechaza commit de abajo, commit arriba→abajo fusiona) por + `overlay_nested_stack_inside_userns`, gated en `HAMMER_OVERLAY_TESTS`. Pendiente menor: + unicidad de orden entre procesos concurrentes (track posterior). ### Fase 3 — Diario de mutaciones ▶ *en progreso* - [x] Crate `hammer-journal`: `MutationEvent`, `Source` (HammerHydrate/HammerCommit/External),