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:
co-authored by
Claude Opus 4.7
parent
3ab2b68e06
commit
fe1601e669
@@ -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 }
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user