//! 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 takana_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(takana_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}"); }