tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ fe9a2d50cf65ea675cd0d2928f832f0059699a43 crates/hammer-overlay/src/lib.rs Rust · 694 líneas · 25.2 KB Historia Quién tocó qué crudo

Fase 2: anidación real de overlays con guard LIFO fe9a2d50c · Sergio ·

1//! El overlay de experimentación. Ver `docs/04-overlay.md`.
2//!
3//! Layout en disco bajo `state_root` (default `/var/lib/hammer/overlays/`):
4//!
5//! ```text
6//! <state_root>/
7//!   <id>/
8//!     state.json                 manifiesto: targets, hora de creación, etc.
9//!     mounts/
10//!       <target_slug>/
11//!         upper/                 escrituras
12//!         work/                  workdir requerido por overlayfs (mismo FS que upper)
13//! ```
14//!
15//! Diseño: la librería no asume privilegios — emite los comandos `mount`/`umount` y deja
16//! que el proceso que la invoca tenga (o no) CAP_SYS_ADMIN. Esto permite:
17//! - El CLI `hammer try` lo invoca con sudo (o setcap) en una máquina real.
18//! - `hammerd` lo invoca desde su contexto privilegiado (Fase 5).
19//! - Los tests lo ejercitan dentro de `bwrap --unshare-user-try --unshare-pid` que da
20//!   CAP_SYS_ADMIN en un user-ns aislado.
21
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24
25use serde::{Deserialize, Serialize};
26
27pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/overlays";
28
29/// Targets por defecto cuando `try` se invoca sin argumentos. Cubren el FHS clásico de
30/// userland (binarios + libs + config); el store y `/home` quedan fuera a propósito.
31pub fn default_targets() -> Vec<PathBuf> {
32    ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/lib", "/lib", "/etc"]
33        .into_iter()
34        .map(PathBuf::from)
35        .collect()
36}
37
38/// Identificador de un overlay activo. Por ahora es una cadena corta legible
39/// (timestamp + nonce) generada por el caller; mantenemos el tipo opaco para poder
40/// cambiar el formato sin tocar consumidores.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct OverlayId(pub String);
43
44impl OverlayId {
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48}
49
50impl std::fmt::Display for OverlayId {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(&self.0)
53    }
54}
55
56/// Una mount concreta dentro de un overlay: un único target (p. ej. `/usr/bin`) con sus
57/// `upper`/`work` dedicados.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct OverlayMount {
60    /// Punto de montaje real: el path del FHS. También sirve como `lowerdir`.
61    pub target: PathBuf,
62    /// Donde caen las escrituras. Vive bajo `<state>/<id>/mounts/<slug>/upper`.
63    pub upper: PathBuf,
64    /// Workdir que overlayfs necesita (mismo FS que `upper`, vacío al montar).
65    pub work: PathBuf,
66}
67
68/// Manifiesto persistente del overlay — se serializa a `state.json` para que `status`
69/// pueda enumerarlos sin depender de procesos anteriores.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct OverlayState {
72    pub id: OverlayId,
73    pub mounts: Vec<OverlayMount>,
74    /// Segundos desde UNIX epoch al `try`. No tomamos `chrono` por ahora.
75    pub created_at: u64,
76}
77
78/// Reporte de un `commit`. Lista cada archivo que se promocionó del upper al lower.
79#[derive(Debug, Default)]
80pub struct CommitReport {
81    pub copied: Vec<PathBuf>,
82    pub removed: Vec<PathBuf>,
83}
84
85#[derive(Debug, thiserror::Error)]
86pub enum Error {
87    #[error("io: {0}")]
88    Io(#[from] std::io::Error),
89    #[error("json: {0}")]
90    Json(#[from] serde_json::Error),
91    #[error("overlay: {0}")]
92    Overlay(String),
93    #[error("mount falló: {0}")]
94    Mount(String),
95    #[error("umount falló: {0}")]
96    Umount(String),
97    #[error(
98        "overlay {id} tiene overlays más jóvenes apilados encima ({}); \
99         commitea/descarta primero los de arriba (LIFO)",
100        .shadowed_by.iter().map(|o| o.as_str()).collect::<Vec<_>>().join(", ")
101    )]
102    Shadowed { id: OverlayId, shadowed_by: Vec<OverlayId> },
103}
104
105pub type Result<T> = std::result::Result<T, Error>;
106
107/// Convierte un target absoluto en un slug usable como nombre de directorio.
108/// `/usr/bin` → `usr-bin`, `/etc` → `etc`. Por diseño NO uses sufijo: queremos colisión
109/// si alguien intenta el mismo target dos veces dentro del mismo overlay.
110fn slug_for(target: &Path) -> String {
111    let s: String = target
112        .components()
113        .filter_map(|c| match c {
114            std::path::Component::Normal(n) => Some(n.to_string_lossy().to_string()),
115            _ => None,
116        })
117        .collect::<Vec<_>>()
118        .join("-");
119    if s.is_empty() { "root".to_string() } else { s }
120}
121
122/// Genera un `OverlayId` legible: `<unix_ts>-<pid>-<seq>`. El `seq` es un contador atómico
123/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo
124/// segundo desde el mismo PID — el caso de la anidación real, donde un orquestador apila
125/// varios overlays en ráfaga. El padding mantiene el orden lexicográfico del id alineado con
126/// el orden de creación, que es justo lo que [`stack_key`] usa como desempate del apilamiento.
127/// (La unicidad estricta entre procesos concurrentes no la cubre el seq; ver `stack_key`.)
128fn fresh_id() -> OverlayId {
129    use std::sync::atomic::{AtomicU64, Ordering};
130    static SEQ: AtomicU64 = AtomicU64::new(0);
131    let ts = std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .map(|d| d.as_secs())
134        .unwrap_or(0);
135    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
136    OverlayId(format!("{ts}-{}-{seq:06}", std::process::id()))
137}
138
139/// Monta un overlay nuevo sobre `targets` y persiste su manifiesto.
140///
141/// `state_root` es donde viven los `upper`/`work` y el `state.json`. Cada overlay queda
142/// bajo `<state_root>/<id>/`. Si `targets` está vacío usa [`default_targets`].
143pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result<OverlayId> {
144    let targets: Vec<PathBuf> = if targets.is_empty() {
145        default_targets()
146    } else {
147        targets.to_vec()
148    };
149
150    let id = fresh_id();
151    let base = state_root.join(id.as_str());
152    let mounts_root = base.join("mounts");
153    std::fs::create_dir_all(&mounts_root)?;
154
155    let mut mounts: Vec<OverlayMount> = Vec::with_capacity(targets.len());
156    for target in &targets {
157        if !target.is_dir() {
158            return Err(Error::Overlay(format!(
159                "target {} no existe o no es directorio",
160                target.display()
161            )));
162        }
163        let slot = mounts_root.join(slug_for(target));
164        let upper = slot.join("upper");
165        let work = slot.join("work");
166        std::fs::create_dir_all(&upper)?;
167        std::fs::create_dir_all(&work)?;
168        do_mount(target, &upper, &work)?;
169        mounts.push(OverlayMount {
170            target: target.clone(),
171            upper,
172            work,
173        });
174    }
175
176    let state = OverlayState {
177        id: id.clone(),
178        mounts,
179        created_at: std::time::SystemTime::now()
180            .duration_since(std::time::UNIX_EPOCH)
181            .map(|d| d.as_secs())
182            .unwrap_or(0),
183    };
184    let state_file = base.join("state.json");
185    std::fs::write(&state_file, serde_json::to_vec_pretty(&state)?)?;
186    Ok(id)
187}
188
189/// Lista todos los overlays con manifiesto bajo `state_root`. No verifica que sus mounts
190/// estén vivos; eso lo añade un consumidor que parsea `/proc/mounts` si lo necesita.
191pub fn status(state_root: &Path) -> Result<Vec<OverlayState>> {
192    if !state_root.is_dir() {
193        return Ok(Vec::new());
194    }
195    let mut out = Vec::new();
196    for entry in std::fs::read_dir(state_root)? {
197        let entry = entry?;
198        let state_file = entry.path().join("state.json");
199        if !state_file.is_file() {
200            continue;
201        }
202        let bytes = std::fs::read(&state_file)?;
203        let state: OverlayState = serde_json::from_slice(&bytes)?;
204        out.push(state);
205    }
206    out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
207    Ok(out)
208}
209
210/// Desmonta todos los targets de un overlay y borra su árbol de estado. Idempotente sobre
211/// mounts ya desmontados (errores de umount "not mounted" se ignoran).
212pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> {
213    let base = state_root.join(id.as_str());
214    let state_file = base.join("state.json");
215    if !state_file.is_file() {
216        return Err(Error::Overlay(format!(
217            "no encuentro overlay {} bajo {}",
218            id,
219            state_root.display()
220        )));
221    }
222    let bytes = std::fs::read(&state_file)?;
223    let state: OverlayState = serde_json::from_slice(&bytes)?;
224    // LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima.
225    let blockers = blocking_overlays(state_root, id)?;
226    if !blockers.is_empty() {
227        return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
228    }
229    // Desmonta en orden inverso al de montaje dentro de este overlay.
230    for m in state.mounts.iter().rev() {
231        do_umount_lenient(&m.target)?;
232    }
233    std::fs::remove_dir_all(&base)?;
234    Ok(())
235}
236
237/// Fusiona los `upper`s al FHS y desmonta. Sin diario.
238///
239/// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
240/// [`commit_with_journal`].
241pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
242    commit_inner(id, state_root, None)
243}
244
245/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
246/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
247/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
248pub fn commit_with_journal(
249    id: &OverlayId,
250    state_root: &Path,
251    journal: &hammer_journal::Journal,
252) -> Result<CommitReport> {
253    commit_inner(id, state_root, Some(journal))
254}
255
256fn commit_inner(
257    id: &OverlayId,
258    state_root: &Path,
259    journal: Option<&hammer_journal::Journal>,
260) -> Result<CommitReport> {
261    let base = state_root.join(id.as_str());
262    let state_file = base.join("state.json");
263    if !state_file.is_file() {
264        return Err(Error::Overlay(format!(
265            "no encuentro overlay {} bajo {}",
266            id,
267            state_root.display()
268        )));
269    }
270    let bytes = std::fs::read(&state_file)?;
271    let state: OverlayState = serde_json::from_slice(&bytes)?;
272    // LIFO: commitear una capa con overlays más jóvenes encima promocionaría el upper a un
273    // target todavía sombreado por otro mount. Rechazamos hasta que se resuelvan los de arriba.
274    let blockers = blocking_overlays(state_root, id)?;
275    if !blockers.is_empty() {
276        return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
277    }
278
279    let mut report = CommitReport::default();
280
281    // Desmonta primero — necesitamos el upper "crudo".
282    for m in state.mounts.iter().rev() {
283        do_umount_lenient(&m.target)?;
284    }
285
286    for m in &state.mounts {
287        promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
288    }
289
290    if let Some(j) = journal {
291        record_commit_to_journal(j, id, &report);
292    }
293    std::fs::remove_dir_all(&base)?;
294    Ok(report)
295}
296
297fn record_commit_to_journal(
298    j: &hammer_journal::Journal,
299    id: &OverlayId,
300    report: &CommitReport,
301) {
302    use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
303    let actor = Actor {
304        source: Source::HammerCommit {
305            overlay: id.as_str().to_string(),
306            artifact: None,
307        },
308        pid: Some(std::process::id()),
309        uid: Some(unsafe { libc::getuid() }),
310    };
311    let ts = hammer_journal::now_rfc3339();
312    for p in &report.copied {
313        let ev = MutationEvent {
314            ts: ts.clone(),
315            op: MutationOp::Replace,
316            path: p.clone(),
317            by: actor.clone(),
318            content_hash: None,
319            note: None,
320        };
321        if let Err(e) = j.record(&ev) {
322            tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
323        }
324    }
325    for p in &report.removed {
326        let ev = MutationEvent {
327            ts: ts.clone(),
328            op: MutationOp::Delete,
329            path: p.clone(),
330            by: actor.clone(),
331            content_hash: None,
332            note: None,
333        };
334        if let Err(e) = j.record(&ev) {
335            tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
336        }
337    }
338}
339
340/// Clave de orden de apilamiento total y determinista: primero por instante de creación,
341/// desempatado por el `id` (que lleva ts+pid). Un overlay con clave mayor es "más joven" y,
342/// si comparte targets, queda apilado *encima*.
343fn stack_key(s: &OverlayState) -> (u64, &str) {
344    (s.created_at, s.id.as_str())
345}
346
347/// `true` si dos targets se solapan en el árbol del FHS: iguales, o uno ancestro del otro
348/// (p. ej. un overlay sobre `/usr` y otro sobre `/usr/bin` comparten el mountpoint efectivo).
349fn targets_overlap(a: &Path, b: &Path) -> bool {
350    a.starts_with(b) || b.starts_with(a)
351}
352
353/// Overlays más jóvenes que `id` que solapan alguno de sus targets — es decir, los que
354/// están apilados *encima* en el FHS. Mientras existan, `commit`/`discard` de `id` violaría
355/// el orden LIFO (desmontaría la capa equivocada del target compartido), así que los
356/// rechazamos. Es inspección pura del estado, sin tocar mounts.
357fn blocking_overlays(state_root: &Path, id: &OverlayId) -> Result<Vec<OverlayId>> {
358    let all = status(state_root)?;
359    let Some(me) = all.iter().find(|s| &s.id == id) else {
360        return Ok(Vec::new());
361    };
362    let me_key = stack_key(me);
363    let mut blockers: Vec<OverlayId> = all
364        .iter()
365        .filter(|other| &other.id != id && stack_key(other) > me_key)
366        .filter(|other| {
367            other.mounts.iter().any(|om| {
368                me.mounts.iter().any(|mm| targets_overlap(&om.target, &mm.target))
369            })
370        })
371        .map(|other| other.id.clone())
372        .collect();
373    blockers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
374    Ok(blockers)
375}
376
377fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
378    // overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
379    // overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
380    // lugar de adivinar (en el FHS clásico no aparece nunca).
381    for p in [lower, upper, work] {
382        let s = p.to_string_lossy();
383        if s.contains(',') || s.contains(':') {
384            return Err(Error::Mount(format!(
385                "path con ',' o ':' no soportado por overlayfs: {}",
386                p.display()
387            )));
388        }
389    }
390    let opts = format!(
391        "lowerdir={},upperdir={},workdir={}",
392        lower.display(),
393        upper.display(),
394        work.display()
395    );
396    let st = Command::new("mount")
397        .args(["-t", "overlay", "overlay", "-o"])
398        .arg(&opts)
399        .arg(lower)
400        .stdout(Stdio::inherit())
401        .stderr(Stdio::inherit())
402        .status()
403        .map_err(|e| Error::Mount(format!("spawn mount: {e}")))?;
404    if !st.success() {
405        return Err(Error::Mount(format!(
406            "mount overlay sobre {} falló (exit {:?})",
407            lower.display(),
408            st.code()
409        )));
410    }
411    Ok(())
412}
413
414fn do_umount_lenient(target: &Path) -> Result<()> {
415    // `umount` retorna 32 para "not mounted"; el flag --lazy fuerza el desmontaje incluso
416    // si hay procesos con file descriptors abiertos sobre el merged. Para Fase 2 nos vale.
417    let out = Command::new("umount")
418        .arg(target)
419        .output()
420        .map_err(|e| Error::Umount(format!("spawn umount: {e}")))?;
421    if out.status.success() {
422        return Ok(());
423    }
424    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
425    if stderr.contains("not mounted") || stderr.contains("not found") {
426        return Ok(());
427    }
428    Err(Error::Umount(format!(
429        "umount {} falló (exit {:?}): {}",
430        target.display(),
431        out.status.code(),
432        stderr.trim()
433    )))
434}
435
436/// Camina el `upper` y replica cada entrada al `lower`, anotando en el reporte. Maneja:
437///
438/// - Archivos regulares → `cp -a` (preserva metadata + hardlinks dentro del upper).
439/// - Directorios → `mkdir -p` con los permisos del upper.
440/// - Symlinks → recreación literal.
441/// - "Whiteouts" overlayfs (char dev 0/0) → `rm -rf` el correspondiente en el lower.
442fn promote_upper_to_lower(
443    upper: &Path,
444    lower: &Path,
445    report: &mut CommitReport,
446) -> Result<()> {
447    use std::os::unix::fs::FileTypeExt;
448    walk(upper, &mut |path: &Path| -> Result<()> {
449        let rel = path.strip_prefix(upper).expect("rel ⊂ upper");
450        if rel.as_os_str().is_empty() {
451            return Ok(());
452        }
453        let dst = lower.join(rel);
454        let meta = std::fs::symlink_metadata(path)?;
455        let ft = meta.file_type();
456        if ft.is_char_device() && rdev_is_zero(&meta) {
457            // Whiteout: el upper marca "borra esto del lower".
458            if dst.exists() || std::fs::symlink_metadata(&dst).is_ok() {
459                if dst.is_dir() && !dst.is_symlink() {
460                    std::fs::remove_dir_all(&dst)?;
461                } else {
462                    std::fs::remove_file(&dst)?;
463                }
464                report.removed.push(dst);
465            }
466        } else if ft.is_dir() {
467            std::fs::create_dir_all(&dst)?;
468        } else if ft.is_symlink() {
469            let tgt = std::fs::read_link(path)?;
470            let _ = std::fs::remove_file(&dst);
471            std::os::unix::fs::symlink(&tgt, &dst)?;
472            report.copied.push(dst);
473        } else if ft.is_file() {
474            if let Some(parent) = dst.parent() {
475                std::fs::create_dir_all(parent)?;
476            }
477            // `cp -a` para preservar perms/timestamps/ownership; copy_file_range para
478            // archivos grandes se queda en futuro si lo necesitamos.
479            let st = Command::new("cp")
480                .arg("-a")
481                .arg("--remove-destination")
482                .arg(path)
483                .arg(&dst)
484                .status()?;
485            if !st.success() {
486                return Err(Error::Overlay(format!(
487                    "cp -a {} → {} falló (exit {:?})",
488                    path.display(),
489                    dst.display(),
490                    st.code()
491                )));
492            }
493            report.copied.push(dst);
494        }
495        Ok(())
496    })
497}
498
499fn rdev_is_zero(meta: &std::fs::Metadata) -> bool {
500    use std::os::unix::fs::MetadataExt;
501    meta.rdev() == 0
502}
503
504fn walk(root: &Path, f: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
505    f(root)?;
506    let rd = match std::fs::read_dir(root) {
507        Ok(r) => r,
508        Err(_) => return Ok(()),
509    };
510    for entry in rd {
511        let entry = entry?;
512        let p = entry.path();
513        let ft = entry.file_type()?;
514        if ft.is_dir() && !ft.is_symlink() {
515            walk(&p, f)?;
516        } else {
517            f(&p)?;
518        }
519    }
520    Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn slug_basic() {
529        assert_eq!(slug_for(Path::new("/usr/bin")), "usr-bin");
530        assert_eq!(slug_for(Path::new("/etc")), "etc");
531        assert_eq!(slug_for(Path::new("/")), "root");
532    }
533
534    #[test]
535    fn default_targets_are_fhs_userland() {
536        let t = default_targets();
537        assert!(t.iter().any(|p| p == Path::new("/usr/bin")));
538        assert!(t.iter().any(|p| p == Path::new("/etc")));
539        assert!(!t.iter().any(|p| p == Path::new("/")));
540    }
541
542    #[test]
543    fn status_empty_dir_returns_empty() {
544        let d = tempfile::tempdir().unwrap();
545        let v = status(d.path()).unwrap();
546        assert!(v.is_empty());
547    }
548
549    #[test]
550    fn status_missing_dir_returns_empty() {
551        let d = tempfile::tempdir().unwrap();
552        let v = status(&d.path().join("no-existe")).unwrap();
553        assert!(v.is_empty());
554    }
555
556    #[test]
557    fn status_reads_planted_manifest() {
558        let d = tempfile::tempdir().unwrap();
559        let id = OverlayId("9999-1".into());
560        let base = d.path().join(id.as_str());
561        std::fs::create_dir_all(&base).unwrap();
562        let st = OverlayState {
563            id: id.clone(),
564            mounts: vec![],
565            created_at: 1234,
566        };
567        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
568        let v = status(d.path()).unwrap();
569        assert_eq!(v.len(), 1);
570        assert_eq!(v[0].id, id);
571        assert_eq!(v[0].created_at, 1234);
572    }
573
574    #[test]
575    fn discard_on_unknown_id_errors_clearly() {
576        let d = tempfile::tempdir().unwrap();
577        let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
578        assert!(err.contains("no encuentro overlay"), "msg = {err}");
579    }
580
581    /// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de
582    /// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría.
583    fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) {
584        let base = state_root.join(id);
585        std::fs::create_dir_all(&base).unwrap();
586        let mounts = targets
587            .iter()
588            .map(|t| OverlayMount {
589                target: PathBuf::from(t),
590                upper: base.join("upper"),
591                work: base.join("work"),
592            })
593            .collect();
594        let st = OverlayState { id: OverlayId(id.into()), mounts, created_at };
595        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
596    }
597
598    #[test]
599    fn no_blockers_for_disjoint_targets() {
600        let d = tempfile::tempdir().unwrap();
601        plant(d.path(), "100-1", 100, &["/usr/bin"]);
602        plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto
603        assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty());
604    }
605
606    #[test]
607    fn younger_overlapping_overlay_blocks() {
608        let d = tempfile::tempdir().unwrap();
609        plant(d.path(), "100-1", 100, &["/usr/bin"]);
610        plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target
611        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
612        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
613        // El de arriba no tiene a nadie por encima: se puede resolver primero.
614        assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty());
615    }
616
617    #[test]
618    fn ancestor_target_counts_as_overlap() {
619        let d = tempfile::tempdir().unwrap();
620        plant(d.path(), "100-1", 100, &["/usr"]);
621        plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr
622        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
623        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
624    }
625
626    #[test]
627    fn same_second_ties_broken_by_id() {
628        let d = tempfile::tempdir().unwrap();
629        // Mismo created_at: el desempate por id define quién está encima.
630        plant(d.path(), "100-1", 100, &["/etc"]);
631        plant(d.path(), "100-2", 100, &["/etc"]);
632        assert_eq!(
633            blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(),
634            vec![OverlayId("100-2".into())]
635        );
636        assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty());
637    }
638
639    #[test]
640    fn commit_refuses_when_shadowed() {
641        let d = tempfile::tempdir().unwrap();
642        plant(d.path(), "100-1", 100, &["/usr/bin"]);
643        plant(d.path(), "200-1", 200, &["/usr/bin"]);
644        let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err();
645        match err {
646            Error::Shadowed { id, shadowed_by } => {
647                assert_eq!(id.as_str(), "100-1");
648                assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]);
649            }
650            other => panic!("esperaba Shadowed, vino {other:?}"),
651        }
652    }
653
654    #[test]
655    fn discard_refuses_when_shadowed() {
656        let d = tempfile::tempdir().unwrap();
657        plant(d.path(), "100-1", 100, &["/etc"]);
658        plant(d.path(), "200-1", 200, &["/etc"]);
659        let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err();
660        assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}");
661    }
662
663    #[test]
664    fn journal_hook_records_copied_and_removed() {
665        // No tocamos mount real: ejercitamos directamente la función privada que el commit
666        // delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
667        let d = tempfile::tempdir().unwrap();
668        let journal = hammer_journal::Journal::open(d.path().join("journal")).unwrap();
669        let report = CommitReport {
670            copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
671            removed: vec![PathBuf::from("/bin/obsoleto")],
672        };
673        super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
674        let events = journal.read_all().unwrap();
675        assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
676        // Los promocionados van como Replace, los removidos como Delete.
677        let ops_paths: Vec<_> = events
678            .iter()
679            .map(|e| (e.op, e.path.display().to_string()))
680            .collect();
681        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/etc/hosts".into())));
682        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/bin/grep".into())));
683        assert!(ops_paths.contains(&(hammer_journal::MutationOp::Delete, "/bin/obsoleto".into())));
684        // Todos comparten el mismo source con overlay id.
685        for e in &events {
686            match &e.by.source {
687                hammer_journal::Source::HammerCommit { overlay, .. } => {
688                    assert_eq!(overlay, "1234-1");
689                }
690                other => panic!("source inesperado: {other:?}"),
691            }
692        }
693    }
694}