tawasuyu

sergio/takana

> 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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

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>

fe1601e669c584cd46f2d584416bc932f47b5a6f · by Sergio · (4 months ago) · parent 3ab2b68e0 · browse the tree at this commit

Semantic delta · 36

crates/hammer-cli/src/main.rs

  • signature changed enum Cmd
  • body changed fn main

crates/hammer-overlay/src/lib.rs

8 files · +752 −13

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`.