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:
co-authored by
Claude Opus 4.8
parent
303dfa2b58
commit
fe9a2d50cf
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user