diff --git a/Cargo.lock b/Cargo.lock index 7a101133..a0eec2a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ dependencies = [ "clap", "hammer-build", "hammer-core", + "hammer-overlay", "tracing", "tracing-subscriber", ] @@ -311,6 +312,20 @@ dependencies = [ "toml", ] +[[package]] +name = "hammer-overlay" +version = "0.0.1" +dependencies = [ + "anyhow", + "hammer-core", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", +] + [[package]] name = "hammerd" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2a9fb4b7..87619dbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/hammer-core", "crates/hammer-build", + "crates/hammer-overlay", "crates/hammer-cli", "crates/hammerd", ] @@ -17,6 +18,7 @@ repository = "https://gitea.gioser.net/sergio/hammer" [workspace.dependencies] hammer-core = { path = "crates/hammer-core" } hammer-build = { path = "crates/hammer-build" } +hammer-overlay = { path = "crates/hammer-overlay" } # shared third-party deps, pinned at the workspace level anyhow = "1" diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index ec6e3fb9..5f88e163 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] hammer-core.workspace = true hammer-build.workspace = true +hammer-overlay.workspace = true anyhow.workspace = true clap.workspace = true tracing.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index cc02cfd9..0b6cc918 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -4,6 +4,8 @@ //! no están implementados devuelven un error claro indicando su fase, para que el esqueleto //! sea navegable desde el día uno. +use std::path::PathBuf; + use clap::{Parser, Subcommand}; const DEFAULT_STORE: &str = "/store"; @@ -42,13 +44,31 @@ enum Cmd { link: String, }, /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema. - Try, - /// [Fase 2] Fusiona el overlay activo al FHS real (registra en el diario). - Commit, - /// [Fase 2] Descarta el overlay activo; vuelve al estado base. - Discard, - /// [Fase 2] Muestra el estado de overlays activos. - Status, + /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …). + Try { + /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto. + targets: Vec, + /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`. + #[arg(long)] + state_root: Option, + }, + /// [Fase 2] Fusiona un overlay al FHS real y desmonta. Sin diario aún (Fase 3). + Commit { + id: String, + #[arg(long)] + state_root: Option, + }, + /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base. + Discard { + id: String, + #[arg(long)] + state_root: Option, + }, + /// [Fase 2] Lista los overlays activos según los manifiestos persistidos. + Status { + #[arg(long)] + state_root: Option, + }, /// [Fase 3] Ver/seguir el diario de mutaciones. Journal, /// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay). @@ -72,16 +92,19 @@ fn main() -> anyhow::Result<()> { .init(); let cli = Cli::parse(); - let store = hammer_core::Store::open(&cli.store)?; + // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store. + // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía. match cli.cmd { Cmd::Build { recipe } => { + let store = hammer_core::Store::open(&cli.store)?; let recipe = hammer_core::Recipe::load_from_path(&recipe)?; let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); let hash = hammer_build::build(&recipe, &cfg, &store)?; println!("{hash}"); } Cmd::Hydrate { hash, into, link } => { + let store = hammer_core::Store::open(&cli.store)?; let mode = match link.as_str() { "static" => hammer_core::LinkMode::Static, "dynamic" => hammer_core::LinkMode::Dynamic, @@ -104,8 +127,48 @@ fn main() -> anyhow::Result<()> { println!(" {}", f.dst.display()); } } - Cmd::Try | Cmd::Commit | Cmd::Discard | Cmd::Status => { - println!("[fase 2 pendiente] overlay — ver docs/04-overlay.md"); + Cmd::Try { targets, state_root } => { + let root = state_root + .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); + std::fs::create_dir_all(&root)?; + let id = hammer_overlay::try_overlay(&targets, &root)?; + println!("{id}"); + } + Cmd::Commit { id, state_root } => { + let root = state_root + .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); + let report = hammer_overlay::commit(&hammer_overlay::OverlayId(id), &root)?; + println!( + "commit OK — {} archivo(s) promocionados, {} eliminado(s)", + report.copied.len(), + report.removed.len() + ); + } + Cmd::Discard { id, state_root } => { + let root = state_root + .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); + hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?; + println!("discard OK"); + } + Cmd::Status { state_root } => { + let root = state_root + .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); + let overlays = hammer_overlay::status(&root)?; + if overlays.is_empty() { + println!("(sin overlays activos)"); + } else { + for o in overlays { + println!( + "{} created={} mounts={}", + o.id, + o.created_at, + o.mounts.len() + ); + for m in &o.mounts { + println!(" └─ {} upper={}", m.target.display(), m.upper.display()); + } + } + } } Cmd::Journal => { println!("[fase 3 pendiente] diario — ver docs/05-journal.md"); diff --git a/crates/hammer-overlay/Cargo.toml b/crates/hammer-overlay/Cargo.toml new file mode 100644 index 00000000..2862e6ac --- /dev/null +++ b/crates/hammer-overlay/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hammer-overlay" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "El overlay de experimentación de hammer: try/commit/discard/status sobre overlayfs." + +[dependencies] +hammer-core.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tracing-subscriber = { workspace = true } diff --git a/crates/hammer-overlay/src/lib.rs b/crates/hammer-overlay/src/lib.rs new file mode 100644 index 00000000..3541ff93 --- /dev/null +++ b/crates/hammer-overlay/src/lib.rs @@ -0,0 +1,456 @@ +//! El overlay de experimentación. Ver `docs/04-overlay.md`. +//! +//! Layout en disco bajo `state_root` (default `/var/lib/hammer/overlays/`): +//! +//! ```text +//! / +//! / +//! state.json manifiesto: targets, hora de creación, etc. +//! mounts/ +//! / +//! upper/ escrituras +//! work/ workdir requerido por overlayfs (mismo FS que upper) +//! ``` +//! +//! Diseño: la librería no asume privilegios — emite los comandos `mount`/`umount` y deja +//! que el proceso que la invoca tenga (o no) CAP_SYS_ADMIN. Esto permite: +//! - El CLI `hammer try` lo invoca con sudo (o setcap) en una máquina real. +//! - `hammerd` lo invoca desde su contexto privilegiado (Fase 5). +//! - Los tests lo ejercitan dentro de `bwrap --unshare-user-try --unshare-pid` que da +//! CAP_SYS_ADMIN en un user-ns aislado. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/overlays"; + +/// Targets por defecto cuando `try` se invoca sin argumentos. Cubren el FHS clásico de +/// userland (binarios + libs + config); el store y `/home` quedan fuera a propósito. +pub fn default_targets() -> Vec { + ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/lib", "/lib", "/etc"] + .into_iter() + .map(PathBuf::from) + .collect() +} + +/// Identificador de un overlay activo. Por ahora es una cadena corta legible +/// (timestamp + nonce) generada por el caller; mantenemos el tipo opaco para poder +/// cambiar el formato sin tocar consumidores. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayId(pub String); + +impl OverlayId { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for OverlayId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Una mount concreta dentro de un overlay: un único target (p. ej. `/usr/bin`) con sus +/// `upper`/`work` dedicados. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OverlayMount { + /// Punto de montaje real: el path del FHS. También sirve como `lowerdir`. + pub target: PathBuf, + /// Donde caen las escrituras. Vive bajo `//mounts//upper`. + pub upper: PathBuf, + /// Workdir que overlayfs necesita (mismo FS que `upper`, vacío al montar). + pub work: PathBuf, +} + +/// Manifiesto persistente del overlay — se serializa a `state.json` para que `status` +/// pueda enumerarlos sin depender de procesos anteriores. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OverlayState { + pub id: OverlayId, + pub mounts: Vec, + /// Segundos desde UNIX epoch al `try`. No tomamos `chrono` por ahora. + pub created_at: u64, +} + +/// Reporte de un `commit`. Lista cada archivo que se promocionó del upper al lower. +#[derive(Debug, Default)] +pub struct CommitReport { + pub copied: Vec, + pub removed: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("overlay: {0}")] + Overlay(String), + #[error("mount falló: {0}")] + Mount(String), + #[error("umount falló: {0}")] + Umount(String), +} + +pub type Result = std::result::Result; + +/// Convierte un target absoluto en un slug usable como nombre de directorio. +/// `/usr/bin` → `usr-bin`, `/etc` → `etc`. Por diseño NO uses sufijo: queremos colisión +/// si alguien intenta el mismo target dos veces dentro del mismo overlay. +fn slug_for(target: &Path) -> String { + let s: String = target + .components() + .filter_map(|c| match c { + std::path::Component::Normal(n) => Some(n.to_string_lossy().to_string()), + _ => None, + }) + .collect::>() + .join("-"); + if s.is_empty() { "root".to_string() } else { s } +} + +/// Genera un `OverlayId` legible: `-`. 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). +fn fresh_id() -> OverlayId { + 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())) +} + +/// Monta un overlay nuevo sobre `targets` y persiste su manifiesto. +/// +/// `state_root` es donde viven los `upper`/`work` y el `state.json`. Cada overlay queda +/// bajo `//`. Si `targets` está vacío usa [`default_targets`]. +pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result { + let targets: Vec = if targets.is_empty() { + default_targets() + } else { + targets.to_vec() + }; + + let id = fresh_id(); + let base = state_root.join(id.as_str()); + let mounts_root = base.join("mounts"); + std::fs::create_dir_all(&mounts_root)?; + + let mut mounts: Vec = Vec::with_capacity(targets.len()); + for target in &targets { + if !target.is_dir() { + return Err(Error::Overlay(format!( + "target {} no existe o no es directorio", + target.display() + ))); + } + let slot = mounts_root.join(slug_for(target)); + let upper = slot.join("upper"); + let work = slot.join("work"); + std::fs::create_dir_all(&upper)?; + std::fs::create_dir_all(&work)?; + do_mount(target, &upper, &work)?; + mounts.push(OverlayMount { + target: target.clone(), + upper, + work, + }); + } + + let state = OverlayState { + id: id.clone(), + mounts, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + }; + let state_file = base.join("state.json"); + std::fs::write(&state_file, serde_json::to_vec_pretty(&state)?)?; + Ok(id) +} + +/// Lista todos los overlays con manifiesto bajo `state_root`. No verifica que sus mounts +/// estén vivos; eso lo añade un consumidor que parsea `/proc/mounts` si lo necesita. +pub fn status(state_root: &Path) -> Result> { + if !state_root.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in std::fs::read_dir(state_root)? { + let entry = entry?; + let state_file = entry.path().join("state.json"); + if !state_file.is_file() { + continue; + } + let bytes = std::fs::read(&state_file)?; + let state: OverlayState = serde_json::from_slice(&bytes)?; + out.push(state); + } + out.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(out) +} + +/// Desmonta todos los targets de un overlay y borra su árbol de estado. Idempotente sobre +/// mounts ya desmontados (errores de umount "not mounted" se ignoran). +pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> { + let base = state_root.join(id.as_str()); + let state_file = base.join("state.json"); + if !state_file.is_file() { + return Err(Error::Overlay(format!( + "no encuentro overlay {} bajo {}", + id, + state_root.display() + ))); + } + 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. + for m in state.mounts.iter().rev() { + do_umount_lenient(&m.target)?; + } + std::fs::remove_dir_all(&base)?; + Ok(()) +} + +/// Fusiona los `upper`s al FHS y desmonta. Cero diario por ahora — Fase 3 lo añade. +/// +/// Estrategia: por cada mount, `rsync -a --delete-after` desde el upperdir al target. +/// Antes del rsync desmontamos el overlay; si no, rsync estaría leyendo de la vista +/// "merged" en lugar del upper puro (y promocionaríamos todo, no sólo los cambios). +pub fn commit(id: &OverlayId, state_root: &Path) -> Result { + let base = state_root.join(id.as_str()); + let state_file = base.join("state.json"); + if !state_file.is_file() { + return Err(Error::Overlay(format!( + "no encuentro overlay {} bajo {}", + id, + state_root.display() + ))); + } + let bytes = std::fs::read(&state_file)?; + let state: OverlayState = serde_json::from_slice(&bytes)?; + + let mut report = CommitReport::default(); + + // Desmonta primero — necesitamos el upper "crudo". + for m in state.mounts.iter().rev() { + do_umount_lenient(&m.target)?; + } + + for m in &state.mounts { + promote_upper_to_lower(&m.upper, &m.target, &mut report)?; + } + std::fs::remove_dir_all(&base)?; + Ok(report) +} + +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 + // lugar de adivinar (en el FHS clásico no aparece nunca). + for p in [lower, upper, work] { + let s = p.to_string_lossy(); + if s.contains(',') || s.contains(':') { + return Err(Error::Mount(format!( + "path con ',' o ':' no soportado por overlayfs: {}", + p.display() + ))); + } + } + let opts = format!( + "lowerdir={},upperdir={},workdir={}", + lower.display(), + upper.display(), + work.display() + ); + let st = Command::new("mount") + .args(["-t", "overlay", "overlay", "-o"]) + .arg(&opts) + .arg(lower) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| Error::Mount(format!("spawn mount: {e}")))?; + if !st.success() { + return Err(Error::Mount(format!( + "mount overlay sobre {} falló (exit {:?})", + lower.display(), + st.code() + ))); + } + Ok(()) +} + +fn do_umount_lenient(target: &Path) -> Result<()> { + // `umount` retorna 32 para "not mounted"; el flag --lazy fuerza el desmontaje incluso + // si hay procesos con file descriptors abiertos sobre el merged. Para Fase 2 nos vale. + let out = Command::new("umount") + .arg(target) + .output() + .map_err(|e| Error::Umount(format!("spawn umount: {e}")))?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + if stderr.contains("not mounted") || stderr.contains("not found") { + return Ok(()); + } + Err(Error::Umount(format!( + "umount {} falló (exit {:?}): {}", + target.display(), + out.status.code(), + stderr.trim() + ))) +} + +/// Camina el `upper` y replica cada entrada al `lower`, anotando en el reporte. Maneja: +/// +/// - Archivos regulares → `cp -a` (preserva metadata + hardlinks dentro del upper). +/// - Directorios → `mkdir -p` con los permisos del upper. +/// - Symlinks → recreación literal. +/// - "Whiteouts" overlayfs (char dev 0/0) → `rm -rf` el correspondiente en el lower. +fn promote_upper_to_lower( + upper: &Path, + lower: &Path, + report: &mut CommitReport, +) -> Result<()> { + use std::os::unix::fs::FileTypeExt; + walk(upper, &mut |path: &Path| -> Result<()> { + let rel = path.strip_prefix(upper).expect("rel ⊂ upper"); + if rel.as_os_str().is_empty() { + return Ok(()); + } + let dst = lower.join(rel); + let meta = std::fs::symlink_metadata(path)?; + let ft = meta.file_type(); + if ft.is_char_device() && rdev_is_zero(&meta) { + // Whiteout: el upper marca "borra esto del lower". + if dst.exists() || std::fs::symlink_metadata(&dst).is_ok() { + if dst.is_dir() && !dst.is_symlink() { + std::fs::remove_dir_all(&dst)?; + } else { + std::fs::remove_file(&dst)?; + } + report.removed.push(dst); + } + } else if ft.is_dir() { + std::fs::create_dir_all(&dst)?; + } else if ft.is_symlink() { + let tgt = std::fs::read_link(path)?; + let _ = std::fs::remove_file(&dst); + std::os::unix::fs::symlink(&tgt, &dst)?; + report.copied.push(dst); + } else if ft.is_file() { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + // `cp -a` para preservar perms/timestamps/ownership; copy_file_range para + // archivos grandes se queda en futuro si lo necesitamos. + let st = Command::new("cp") + .arg("-a") + .arg("--remove-destination") + .arg(path) + .arg(&dst) + .status()?; + if !st.success() { + return Err(Error::Overlay(format!( + "cp -a {} → {} falló (exit {:?})", + path.display(), + dst.display(), + st.code() + ))); + } + report.copied.push(dst); + } + Ok(()) + }) +} + +fn rdev_is_zero(meta: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + meta.rdev() == 0 +} + +fn walk(root: &Path, f: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> { + f(root)?; + let rd = match std::fs::read_dir(root) { + Ok(r) => r, + Err(_) => return Ok(()), + }; + for entry in rd { + let entry = entry?; + let p = entry.path(); + let ft = entry.file_type()?; + if ft.is_dir() && !ft.is_symlink() { + walk(&p, f)?; + } else { + f(&p)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_basic() { + assert_eq!(slug_for(Path::new("/usr/bin")), "usr-bin"); + assert_eq!(slug_for(Path::new("/etc")), "etc"); + assert_eq!(slug_for(Path::new("/")), "root"); + } + + #[test] + fn default_targets_are_fhs_userland() { + let t = default_targets(); + assert!(t.iter().any(|p| p == Path::new("/usr/bin"))); + assert!(t.iter().any(|p| p == Path::new("/etc"))); + assert!(!t.iter().any(|p| p == Path::new("/"))); + } + + #[test] + fn status_empty_dir_returns_empty() { + let d = tempfile::tempdir().unwrap(); + let v = status(d.path()).unwrap(); + assert!(v.is_empty()); + } + + #[test] + fn status_missing_dir_returns_empty() { + let d = tempfile::tempdir().unwrap(); + let v = status(&d.path().join("no-existe")).unwrap(); + assert!(v.is_empty()); + } + + #[test] + fn status_reads_planted_manifest() { + let d = tempfile::tempdir().unwrap(); + let id = OverlayId("9999-1".into()); + let base = d.path().join(id.as_str()); + std::fs::create_dir_all(&base).unwrap(); + let st = OverlayState { + id: id.clone(), + mounts: vec![], + created_at: 1234, + }; + std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap(); + let v = status(d.path()).unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].id, id); + assert_eq!(v[0].created_at, 1234); + } + + #[test] + fn discard_on_unknown_id_errors_clearly() { + let d = tempfile::tempdir().unwrap(); + let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string(); + assert!(err.contains("no encuentro overlay"), "msg = {err}"); + } +} diff --git a/crates/hammer-overlay/tests/overlayfs_e2e.rs b/crates/hammer-overlay/tests/overlayfs_e2e.rs new file mode 100644 index 00000000..f5022f17 --- /dev/null +++ b/crates/hammer-overlay/tests/overlayfs_e2e.rs @@ -0,0 +1,177 @@ +//! 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(); +} + +#[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}"); +} diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 0d5a8996..8173e4c2 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -37,9 +37,14 @@ Alpine vía `bwrap`** — confirma el corazón "fábrica funcional → FHS mutab substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como pre-requisito de validación. -### Fase 2 — Overlay de experimentación -- [ ] `try` / `commit` / `discard` / `status` sobre `overlayfs`. -- **Hecho cuando:** puedes romper `/bin` en un overlay y revertir con `discard`. +### Fase 2 — Overlay de experimentación ▶ *en progreso* +- [x] Crate `hammer-overlay`: `try` / `commit` / `discard` / `status` sobre `overlayfs`. +- [x] Manifiesto persistente en `//state.json`; `status` enumera. +- [x] `commit` promociona archivos y procesa whiteouts (char dev 0/0) como remove. +- [x] Subcomandos del CLI: `hammer try [targets…]`, `commit `, `discard `, `status`. +- [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente). +- [ ] Hook al diario en `commit` (queda para Fase 3). +- [ ] Anidación real de overlays (un solo overlay activo por target hoy). ### Fase 3 — Diario de mutaciones - [ ] `hammerd` con `fanotify` sobre `/bin`,`/sbin`,`/lib`,`/etc`.