//! El overlay de experimentación. Ver `docs/04-overlay.md`. //! //! Layout en disco bajo `state_root` (default `/var/lib/hammer/overlays/`): //! //! ```text //! / //! / //! state.json manifiesto: targets, hora de creación, etc. //! mounts/ //! / //! 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 { ["/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 `//mounts//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, /// 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, pub removed: Vec, } #[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::>().join(", ") )] Shadowed { id: OverlayId, shadowed_by: Vec }, } pub type Result = std::result::Result; /// 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::>() .join("-"); if s.is_empty() { "root".to_string() } else { s } } /// Genera un `OverlayId` legible: `--`. 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 `//`. Si `targets` está vacío usa [`default_targets`]. pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result { let targets: Vec = 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 = 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> { 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 { 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 { commit_inner(id, state_root, Some(journal)) } fn commit_inner( id: &OverlayId, state_root: &Path, journal: Option<&takana_journal::Journal>, ) -> 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: 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> { 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 = 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:?}"), } } } }