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>
This commit is contained in:
Sergio
2026-06-09 14:26:17 +00:00
co-authored by Claude Opus 4.7
parent 3ab2b68e06
commit fe1601e669
8 changed files with 752 additions and 13 deletions
+20
View File
@@ -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 }
+456
View File
@@ -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}");
}
}
@@ -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}");
}