> 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…
Un `try` sobre un target ya cubierto apila un overlay nuevo: el kernel toma el
merged view de la capa inferior como lowerdir. Para que esto sea seguro y no un
footgun:
- fresh_id añade un `seq` atómico por proceso (`<ts>-<pid>-<seq>` zero-padded),
eliminando la colisión de ids cuando un orquestador apila varios overlays en
el mismo segundo — el caso real de la anidación.
- stack_key define un orden de apilamiento total y determinista (created_at,
desempatado por id).
- blocking_overlays detecta capas más jóvenes que solapan targets (igualdad o
ancestro de path). commit/discard fallan con Error::Shadowed si existen,
exigiendo resolver LIFO de arriba hacia abajo — antes se desmontaba la capa
equivocada del target compartido en silencio.
Tests: 6 unit del guard (disjuntos / solape / ancestro / desempate / commit y
discard rechazados) sin privilegios; e2e overlay_nested_stack_inside_userns
prueba el stack real (capa 2 ve la 1, rechazo LIFO, fusión arriba→abajo),
gated en HAMMER_OVERLAY_TESTS. Docs 04 y roadmap actualizados.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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::<Vec<_>>().join(", ")
+ )]
+ Shadowed { id: OverlayId, shadowed_by: Vec<OverlayId> },
}
pub type Result<T> = std::result::Result<T, Error>;
@@ -113,15 +119,21 @@ fn slug_for(target: &Path) -> String {
if s.is_empty() { "root".to_string() } else { s }
}
-/// Genera un `OverlayId` legible: `<unix_ts>-<pid>`. 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: `<unix_ts>-<pid>-<seq>`. 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<Vec<OverlayId>> {
+ 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<OverlayId> = 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 <id>`, `discard <id>`, `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),