> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
Fase 2 — overlay try/commit/discard/status sobre overlayfs
Nuevo crate hammer-overlay + integración al CLI. Diseño:
- API: try_overlay(targets, state_root) → OverlayId, status, discard, commit.
- Layout: <state>/<id>/{state.json, mounts/<slug>/{upper,work}}. El manifiesto
permite a `status` enumerar overlays sin estado en memoria.
- commit: desmonta primero, luego cp -a upper→lower por archivo, procesa
whiteouts (char dev 0/0) como remove en el lower. Sin diario aún (Fase 3).
- discard: umount LIFO + rm del state. Idempotente sobre `not mounted`.
- Privilegios: la librería no asume; emite `mount`/`umount` y deja que el
caller los tenga. CLI sin sudo falla con mensaje claro. El daemon de
Fase 5 los obtendrá desde contexto privilegiado.
Tests:
- 6 unit tests (slug, defaults, manifest, errores).
- E2E bajo bwrap+user-ns con CAP_SYS_ADMIN: ciclo try → escribir → commit →
verificar persiste; try → escribir → discard → verificar revertido;
remove en merged → commit → whiteout aplicado al lower. Gateado en
HAMMER_OVERLAY_TESTS=1: por defecto, los kernels deniegan el mount
overlayfs en user-ns no-privilegiado (sysctl/AppArmor); el dev habilita
cuando su entorno lo permite o corre con sudo.
CLI: hammer try [targets…] --state-root, commit <id>, discard <id>, status.
Refactor mínimo: las cmds que no tocan artefactos ya no abren (ni crean) el
store. `hammer status --state-root /tmp` no toca /store.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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<PathBuf>,
+ /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
+ #[arg(long)]
+ state_root: Option<PathBuf>,
+ },
+ /// [Fase 2] Fusiona un overlay al FHS real y desmonta. Sin diario aún (Fase 3).
+ Commit {
+ id: String,
+ #[arg(long)]
+ state_root: Option<PathBuf>,
+ },
+ /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
+ Discard {
+ id: String,
+ #[arg(long)]
+ state_root: Option<PathBuf>,
+ },
+ /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
+ Status {
+ #[arg(long)]
+ state_root: Option<PathBuf>,
+ },
/// [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_root>/
+//! <id>/
+//! state.json manifiesto: targets, hora de creación, etc.
+//! mounts/
+//! <target_slug>/
+//! 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<PathBuf> {
+ ["/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 `<state>/<id>/mounts/<slug>/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<OverlayMount>,
+ /// 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<PathBuf>,
+ pub removed: Vec<PathBuf>,
+}
+
+#[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<T> = std::result::Result<T, Error>;
+
+/// 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::<Vec<_>>()
+ .join("-");
+ 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).
+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 `<state_root>/<id>/`. Si `targets` está vacío usa [`default_targets`].
+pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result<OverlayId> {
+ let targets: Vec<PathBuf> = 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<OverlayMount> = 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<Vec<OverlayState>> {
+ 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<CommitReport> {
+ 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_root>/<id>/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 <id>`, `discard <id>`, `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`.