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>
243 lines
9.7 KiB
Rust
243 lines
9.7 KiB
Rust
//! End-to-end de `try`/`commit`/`discard` ejercitado dentro de un user namespace creado por
|
|
//! bwrap. El namespace nos da CAP_SYS_ADMIN local para poder `mount -t overlay` sin sudo.
|
|
//!
|
|
//! Caveat de kernel: por defecto, overlayfs **no se deja montar en un user-ns sin
|
|
//! privilegios reales** salvo que el kernel esté configurado para permitirlo
|
|
//! (`kernel.unprivileged_userns_clone=1` + sysctl `kernel.apparmor_restrict_unprivileged_userns=0`
|
|
//! en distros con AppArmor; algunos kernels también requieren la opción `userxattr` en el
|
|
//! mount). Por eso este test está doblemente gateado:
|
|
//! - Requiere `bwrap` y overlayfs en el kernel.
|
|
//! - Requiere `HAMMER_OVERLAY_TESTS=1` — el dev confirma que su entorno admite el
|
|
//! mount unprivilegiado, o corre el test bajo `sudo`.
|
|
//!
|
|
//! Sin esos requisitos el test salta limpio; los aspectos puros (layout, slug, parsing del
|
|
//! manifiesto, errores de id desconocido) ya quedan cubiertos en los unit tests del crate.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use hammer_overlay::{commit, discard, status, try_overlay, OverlayId};
|
|
|
|
fn have_bwrap() -> bool {
|
|
Command::new("bwrap")
|
|
.arg("--version")
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn overlayfs_supported() -> bool {
|
|
std::fs::read_to_string("/proc/filesystems")
|
|
.map(|s| s.contains("overlay"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Reejecuta este binario dentro de bwrap con un namespace de usuario y un FS efímero,
|
|
/// reenviando los args originales. Si ya estamos dentro del sandbox (env marker set),
|
|
/// no re-anidamos.
|
|
fn run_inside_userns_or_skip(phase: &str, scratch: &Path) -> bool {
|
|
if std::env::var("HAMMER_OVERLAY_IN_SANDBOX").is_ok() {
|
|
// Ya estamos dentro — el caller ejecuta la lógica real.
|
|
return false;
|
|
}
|
|
if std::env::var("HAMMER_OVERLAY_TESTS").ok().as_deref() != Some("1") {
|
|
eprintln!(
|
|
"SKIP: HAMMER_OVERLAY_TESTS != 1 (overlayfs en user-ns suele estar denegado por \
|
|
el kernel; setea la var cuando tu host lo permita o corre con sudo)."
|
|
);
|
|
return true;
|
|
}
|
|
if !have_bwrap() {
|
|
eprintln!("SKIP: bwrap no disponible");
|
|
return true;
|
|
}
|
|
if !overlayfs_supported() {
|
|
eprintln!("SKIP: overlay no listado en /proc/filesystems");
|
|
return true;
|
|
}
|
|
|
|
let self_exe = std::env::current_exe().expect("current_exe");
|
|
let st = Command::new("bwrap")
|
|
.args([
|
|
"--unshare-user-try",
|
|
"--unshare-pid",
|
|
"--unshare-net",
|
|
"--uid", "0", "--gid", "0",
|
|
"--die-with-parent",
|
|
"--bind", "/", "/",
|
|
"--proc", "/proc",
|
|
"--dev", "/dev",
|
|
"--bind",
|
|
])
|
|
.arg(scratch)
|
|
.arg(scratch)
|
|
.arg("--setenv")
|
|
.arg("HAMMER_OVERLAY_IN_SANDBOX")
|
|
.arg("1")
|
|
.arg("--setenv")
|
|
.arg("OVERLAY_TEST_SCRATCH")
|
|
.arg(scratch)
|
|
.arg(&self_exe)
|
|
.arg("--exact")
|
|
.arg(phase)
|
|
.arg("--nocapture")
|
|
.status()
|
|
.expect("spawn bwrap");
|
|
if !st.success() {
|
|
panic!("test interior falló dentro de bwrap (exit {:?})", st.code());
|
|
}
|
|
true // ya corrimos la versión sandboxed, el host-side return.
|
|
}
|
|
|
|
/// Lógica que corre DENTRO del sandbox (con CAP_SYS_ADMIN en el user-ns).
|
|
fn body_try_commit_discard() {
|
|
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();
|
|
|
|
// Lower con un archivo base.
|
|
std::fs::write(lower.join("base.txt"), b"contenido base\n").unwrap();
|
|
|
|
// --- try ---
|
|
let id = try_overlay(&[lower.clone()], &state).expect("try_overlay");
|
|
// El base sigue visible en el merged (que es el propio target).
|
|
assert_eq!(
|
|
std::fs::read_to_string(lower.join("base.txt")).unwrap(),
|
|
"contenido base\n"
|
|
);
|
|
// Escritura nueva.
|
|
std::fs::write(lower.join("nuevo.txt"), b"hola desde upper\n").unwrap();
|
|
|
|
// status lista 1.
|
|
let ss = status(&state).expect("status");
|
|
assert_eq!(ss.len(), 1);
|
|
assert_eq!(ss[0].id, id);
|
|
|
|
// --- commit: la escritura debe persistir tras desmontar ---
|
|
let report = commit(&id, &state).expect("commit");
|
|
assert!(report.copied.iter().any(|p| p.ends_with("nuevo.txt")),
|
|
"commit debe haber promocionado nuevo.txt; copied={:?}", report.copied);
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(lower.join("nuevo.txt")).unwrap(),
|
|
"hola desde upper\n",
|
|
"commit debe haber escrito el archivo en el lower real"
|
|
);
|
|
// status ya no debe listar nada.
|
|
assert!(status(&state).unwrap().is_empty(), "tras commit el state debe quedar limpio");
|
|
|
|
// --- segundo ciclo: try + discard NO debe persistir cambios ---
|
|
let id2 = try_overlay(&[lower.clone()], &state).expect("try 2");
|
|
std::fs::write(lower.join("efimero.txt"), b"transitorio\n").unwrap();
|
|
assert!(lower.join("efimero.txt").exists(), "merged debe ver el archivo");
|
|
discard(&id2, &state).expect("discard");
|
|
assert!(!lower.join("efimero.txt").exists(),
|
|
"discard debe haber descartado la escritura del upper");
|
|
assert!(lower.join("base.txt").is_file(), "el base sigue ahí");
|
|
assert!(lower.join("nuevo.txt").is_file(),
|
|
"el commit anterior sigue persistido");
|
|
|
|
// --- whiteout: borrar un archivo del lower desde el upper debería propagarlo en commit ---
|
|
let id3 = try_overlay(&[lower.clone()], &state).expect("try 3");
|
|
std::fs::remove_file(lower.join("base.txt")).expect("remove a través del overlay");
|
|
assert!(!lower.join("base.txt").exists(), "merged no debe verlo");
|
|
let rep = commit(&id3, &state).expect("commit del whiteout");
|
|
assert!(
|
|
rep.removed.iter().any(|p| p.ends_with("base.txt")),
|
|
"commit debe haber registrado el remove; removed={:?}", rep.removed
|
|
);
|
|
assert!(!lower.join("base.txt").exists(),
|
|
"tras commit el lower real ya no tiene base.txt");
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_try_commit_discard_inside_userns() {
|
|
if std::env::var("HAMMER_OVERLAY_IN_SANDBOX").is_ok() {
|
|
body_try_commit_discard();
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
if run_inside_userns_or_skip("overlay_try_commit_discard_inside_userns", tmp.path()) {
|
|
return;
|
|
}
|
|
// unreachable salvo cuando run_inside_userns_or_skip devuelve false (sandbox).
|
|
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.
|
|
let d = tempfile::tempdir().unwrap();
|
|
let err = discard(&OverlayId("inexistente".into()), d.path())
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("no encuentro overlay"), "msg = {err}");
|
|
}
|