Fase 2: anidación real de overlays con guard LIFO

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>
This commit is contained in:
Sergio
2026-06-10 20:39:32 +00:00
co-authored by Claude Opus 4.8
parent 303dfa2b58
commit fe9a2d50cf
4 changed files with 229 additions and 8 deletions
+147 -6
View File
@@ -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
@@ -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.
+8 -1
View File
@@ -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).
+9 -1
View File
@@ -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),