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),
|
Mount(String),
|
||||||
#[error("umount falló: {0}")]
|
#[error("umount falló: {0}")]
|
||||||
Umount(String),
|
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>;
|
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 }
|
if s.is_empty() { "root".to_string() } else { s }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genera un `OverlayId` legible: `<unix_ts>-<pid>`. Determinista por proceso, único en la
|
/// Genera un `OverlayId` legible: `<unix_ts>-<pid>-<seq>`. El `seq` es un contador atómico
|
||||||
/// práctica cuando varios `try` no se llaman en el mismo segundo desde el mismo PID
|
/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo
|
||||||
/// (suficiente para Fase 2; si llegamos a anidación real, añadir nonce).
|
/// 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 {
|
fn fresh_id() -> OverlayId {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
let ts = std::time::SystemTime::now()
|
let ts = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.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.
|
/// 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 bytes = std::fs::read(&state_file)?;
|
||||||
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
||||||
// Desmonta en orden inverso al de montaje — mismo principio LIFO que descarga bash
|
// LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima.
|
||||||
// anidaba `try`s, por si alguien apila overlays sobre los mismos paths.
|
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() {
|
for m in state.mounts.iter().rev() {
|
||||||
do_umount_lenient(&m.target)?;
|
do_umount_lenient(&m.target)?;
|
||||||
}
|
}
|
||||||
@@ -253,6 +269,12 @@ fn commit_inner(
|
|||||||
}
|
}
|
||||||
let bytes = std::fs::read(&state_file)?;
|
let bytes = std::fs::read(&state_file)?;
|
||||||
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
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();
|
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<()> {
|
fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
|
||||||
// overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
|
// 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
|
// 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}");
|
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]
|
#[test]
|
||||||
fn journal_hook_records_copied_and_removed() {
|
fn journal_hook_records_copied_and_removed() {
|
||||||
// No tocamos mount real: ejercitamos directamente la función privada que el commit
|
// 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();
|
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]
|
#[test]
|
||||||
fn discard_unknown_id_errors() {
|
fn discard_unknown_id_errors() {
|
||||||
// No requiere sandbox.
|
// 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:
|
Reglas:
|
||||||
- `commit`/`discard` sin overlay activo: no-op con aviso.
|
- `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
|
- 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).
|
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] 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] 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`).
|
- [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*
|
### Fase 3 — Diario de mutaciones ▶ *en progreso*
|
||||||
- [x] Crate `hammer-journal`: `MutationEvent`, `Source` (HammerHydrate/HammerCommit/External),
|
- [x] Crate `hammer-journal`: `MutationEvent`, `Source` (HammerHydrate/HammerCommit/External),
|
||||||
|
|||||||
Reference in New Issue
Block a user