Files
takana/crates/takana-overlay/src/lib.rs
T
Sergio 26c153c149 overlay: el commit no podía desmontar /bin en una máquina viva — y el código prometía el perezoso sin hacerlo
La primera instalación real de un paquete en la caja (`takana install zsh`, sin --prefix) dejó el
sistema A MEDIAS: `commit` desmontó 5 de 7 targets y murió con `target is busy` en /bin, con dos
overlays montados y el estado sin promocionar. La causa no es un fd abierto: los procesos tienen su
EJECUTABLE mapeado desde /bin y /usr/bin —empezando por PID 1, /usr/bin/arje-zero— y un ejecutable
mapeado pin-ea el montaje. En un FHS vivo eso no se puede evitar esperando.

El comentario de `do_umount_lenient` prometía el desmontaje perezoso «desde la Fase 2» y el código
NUNCA pasaba `-l`. Ahora, ante `busy`, reintenta perezoso: desengancha el montaje ya mismo (las
rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el
último proceso que lo miraba se muere; los vivos ven el mismo contenido porque el commit copia el
upper al lower justo después.

Probado en la caja con un paquete nuevo: install tree ⇒ overlay; commit ⇒ WARN del perezoso sobre
/usr/bin y «commit OK — 1 archivo(s) promocionados»; 0 overlays, /usr/bin/tree real, tree v2.3.2.

El SDD 28 §5.10 anota las otras cuatro cosas que ese `sudo takana install zsh` destapó: el repo por
defecto que no existía, el lab que no se encontraba desde /root, el binario del sistema incapaz de
leer los .tkn nuevos, y —la peor— que la instalación queda PARTIDA porque el overlay cubre siete
directorios y /usr/share no es uno: 1296 de los 1331 ficheros fueron directos al FHS real mientras
2 binarios esperaban el commit, y el mensaje decía `apply OK` igual.
2026-09-21 20:20:54 +00:00

728 lines
27 KiB
Rust

//! 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 `takana 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),
#[error(
"overlay {id} tiene overlays más jóvenes apilados encima ({}); \
commitea/descarta primero los de arriba (LIFO)",
.shadowed_by.iter().map(|o| o.as_str()).collect::<Vec<_>>().join(", ")
)]
Shadowed { id: OverlayId, shadowed_by: Vec<OverlayId> },
}
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>-<seq>`. El `seq` es un contador atómico
/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo
/// segundo desde el mismo PID — el caso de la anidación real, donde un orquestador apila
/// varios overlays en ráfaga. El padding mantiene el orden lexicográfico del id alineado con
/// el orden de creación, que es justo lo que [`stack_key`] usa como desempate del apilamiento.
/// (La unicidad estricta entre procesos concurrentes no la cubre el seq; ver `stack_key`.)
fn fresh_id() -> OverlayId {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
OverlayId(format!("{ts}-{}-{seq:06}", 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)?;
// LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima.
let blockers = blocking_overlays(state_root, id)?;
if !blockers.is_empty() {
return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
}
// Desmonta en orden inverso al de montaje dentro de este overlay.
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. Sin diario.
///
/// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
/// [`commit_with_journal`].
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
commit_inner(id, state_root, None)
}
/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
pub fn commit_with_journal(
id: &OverlayId,
state_root: &Path,
journal: &takana_journal::Journal,
) -> Result<CommitReport> {
commit_inner(id, state_root, Some(journal))
}
fn commit_inner(
id: &OverlayId,
state_root: &Path,
journal: Option<&takana_journal::Journal>,
) -> 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)?;
// LIFO: commitear una capa con overlays más jóvenes encima promocionaría el upper a un
// target todavía sombreado por otro mount. Rechazamos hasta que se resuelvan los de arriba.
let blockers = blocking_overlays(state_root, id)?;
if !blockers.is_empty() {
return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
}
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)?;
}
if let Some(j) = journal {
record_commit_to_journal(j, id, &report);
}
std::fs::remove_dir_all(&base)?;
Ok(report)
}
fn record_commit_to_journal(
j: &takana_journal::Journal,
id: &OverlayId,
report: &CommitReport,
) {
use takana_journal::{Actor, MutationEvent, MutationOp, Source};
let actor = Actor {
source: Source::HammerCommit {
overlay: id.as_str().to_string(),
artifact: None,
},
pid: Some(std::process::id()),
uid: Some(unsafe { libc::getuid() }),
};
let ts = takana_journal::now_rfc3339();
for p in &report.copied {
let ev = MutationEvent {
ts: ts.clone(),
op: MutationOp::Replace,
path: p.clone(),
by: actor.clone(),
content_hash: None,
note: None,
};
if let Err(e) = j.record(&ev) {
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
}
}
for p in &report.removed {
let ev = MutationEvent {
ts: ts.clone(),
op: MutationOp::Delete,
path: p.clone(),
by: actor.clone(),
content_hash: None,
note: None,
};
if let Err(e) = j.record(&ev) {
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
}
}
}
/// Clave de orden de apilamiento total y determinista: primero por instante de creación,
/// desempatado por el `id` (que lleva ts+pid). Un overlay con clave mayor es "más joven" y,
/// si comparte targets, queda apilado *encima*.
fn stack_key(s: &OverlayState) -> (u64, &str) {
(s.created_at, s.id.as_str())
}
/// `true` si dos targets se solapan en el árbol del FHS: iguales, o uno ancestro del otro
/// (p. ej. un overlay sobre `/usr` y otro sobre `/usr/bin` comparten el mountpoint efectivo).
fn targets_overlap(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)
}
/// Overlays más jóvenes que `id` que solapan alguno de sus targets — es decir, los que
/// están apilados *encima* en el FHS. Mientras existan, `commit`/`discard` de `id` violaría
/// el orden LIFO (desmontaría la capa equivocada del target compartido), así que los
/// rechazamos. Es inspección pura del estado, sin tocar mounts.
fn blocking_overlays(state_root: &Path, id: &OverlayId) -> Result<Vec<OverlayId>> {
let all = status(state_root)?;
let Some(me) = all.iter().find(|s| &s.id == id) else {
return Ok(Vec::new());
};
let me_key = stack_key(me);
let mut blockers: Vec<OverlayId> = all
.iter()
.filter(|other| &other.id != id && stack_key(other) > me_key)
.filter(|other| {
other.mounts.iter().any(|om| {
me.mounts.iter().any(|mm| targets_overlap(&om.target, &mm.target))
})
})
.map(|other| other.id.clone())
.collect();
blockers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
Ok(blockers)
}
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".
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(());
}
// ⚠ `target is busy` NO es un error acá, y esto es medido (2026-09-21, caja `takana`): un
// `commit` sobre el FHS VIVO falla siempre en `/bin` y `/usr/bin`, porque los procesos de la
// máquina —empezando por PID 1, `/usr/bin/arje-zero`— tienen su ejecutable mapeado desde ahí,
// y un ejecutable mapeado pin-ea el montaje. Pasó con la primera instalación real de un
// paquete en la caja: desmontó 5 de 7 targets, murió en `/bin`, y dejó el sistema **a medias**
// —dos overlays montados y el estado sin promocionar— que es el peor de los finales.
//
// El desmontaje PEREZOSO es la salida correcta y la que este mismo comentario prometía desde
// la Fase 2 sin que nadie la hubiera cableado: desengancha el montaje del árbol ya mismo (las
// rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera
// cuando el último proceso que lo mira se muera. Los que siguen vivos ven el mismo contenido,
// porque lo primero que hace el commit después es copiar el upper al lower.
if stderr.contains("busy") {
let lazy = Command::new("umount")
.arg("-l")
.arg(target)
.output()
.map_err(|e| Error::Umount(format!("spawn umount -l: {e}")))?;
if lazy.status.success() {
tracing::warn!(
target = %target.display(),
"umount: el target estaba ocupado (procesos con ejecutables mapeados ahí, \
normal en un FHS vivo) ⇒ desmontaje perezoso"
);
return Ok(());
}
return Err(Error::Umount(format!(
"umount {} falló ocupado, y el perezoso tampoco pudo: {}",
target.display(),
String::from_utf8_lossy(&lazy.stderr).trim()
)));
}
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}");
}
/// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de
/// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría.
fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) {
let base = state_root.join(id);
std::fs::create_dir_all(&base).unwrap();
let mounts = targets
.iter()
.map(|t| OverlayMount {
target: PathBuf::from(t),
upper: base.join("upper"),
work: base.join("work"),
})
.collect();
let st = OverlayState { id: OverlayId(id.into()), mounts, created_at };
std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
}
#[test]
fn no_blockers_for_disjoint_targets() {
let d = tempfile::tempdir().unwrap();
plant(d.path(), "100-1", 100, &["/usr/bin"]);
plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto
assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty());
}
#[test]
fn younger_overlapping_overlay_blocks() {
let d = tempfile::tempdir().unwrap();
plant(d.path(), "100-1", 100, &["/usr/bin"]);
plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target
let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
assert_eq!(blockers, vec![OverlayId("200-1".into())]);
// El de arriba no tiene a nadie por encima: se puede resolver primero.
assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty());
}
#[test]
fn ancestor_target_counts_as_overlap() {
let d = tempfile::tempdir().unwrap();
plant(d.path(), "100-1", 100, &["/usr"]);
plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr
let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
assert_eq!(blockers, vec![OverlayId("200-1".into())]);
}
#[test]
fn same_second_ties_broken_by_id() {
let d = tempfile::tempdir().unwrap();
// Mismo created_at: el desempate por id define quién está encima.
plant(d.path(), "100-1", 100, &["/etc"]);
plant(d.path(), "100-2", 100, &["/etc"]);
assert_eq!(
blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(),
vec![OverlayId("100-2".into())]
);
assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty());
}
#[test]
fn commit_refuses_when_shadowed() {
let d = tempfile::tempdir().unwrap();
plant(d.path(), "100-1", 100, &["/usr/bin"]);
plant(d.path(), "200-1", 200, &["/usr/bin"]);
let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err();
match err {
Error::Shadowed { id, shadowed_by } => {
assert_eq!(id.as_str(), "100-1");
assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]);
}
other => panic!("esperaba Shadowed, vino {other:?}"),
}
}
#[test]
fn discard_refuses_when_shadowed() {
let d = tempfile::tempdir().unwrap();
plant(d.path(), "100-1", 100, &["/etc"]);
plant(d.path(), "200-1", 200, &["/etc"]);
let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err();
assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}");
}
#[test]
fn journal_hook_records_copied_and_removed() {
// No tocamos mount real: ejercitamos directamente la función privada que el commit
// delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
let d = tempfile::tempdir().unwrap();
let journal = takana_journal::Journal::open(d.path().join("journal")).unwrap();
let report = CommitReport {
copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
removed: vec![PathBuf::from("/bin/obsoleto")],
};
super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
let events = journal.read_all().unwrap();
assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
// Los promocionados van como Replace, los removidos como Delete.
let ops_paths: Vec<_> = events
.iter()
.map(|e| (e.op, e.path.display().to_string()))
.collect();
assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/etc/hosts".into())));
assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/bin/grep".into())));
assert!(ops_paths.contains(&(takana_journal::MutationOp::Delete, "/bin/obsoleto".into())));
// Todos comparten el mismo source con overlay id.
for e in &events {
match &e.by.source {
takana_journal::Source::HammerCommit { overlay, .. } => {
assert_eq!(overlay, "1234-1");
}
other => panic!("source inesperado: {other:?}"),
}
}
}
}