gioser

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 188Rust 55Shell 30Markdown 281211 Definiciones

⎇ 26c153c149c81663140768bfda7dafb19c010e4f crates/takana-overlay/src/lib.rs Rust · 727 líneas · 26.9 KB Historia Quién tocó qué crudo

overlay: el commit no podía desmontar /bin en una máquina viva — y el código prometía el perezoso sin hacerlo 26c153c14 · 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 `takana 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: &takana_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<&takana_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: &takana_journal::Journal,
299    id: &OverlayId,
300    report: &CommitReport,
301) {
302    use takana_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 = takana_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".
416    let out = Command::new("umount")
417        .arg(target)
418        .output()
419        .map_err(|e| Error::Umount(format!("spawn umount: {e}")))?;
420    if out.status.success() {
421        return Ok(());
422    }
423    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
424    if stderr.contains("not mounted") || stderr.contains("not found") {
425        return Ok(());
426    }
427
428    // ⚠ `target is busy` NO es un error acá, y esto es medido (2026-09-21, caja `takana`): un
429    // `commit` sobre el FHS VIVO falla siempre en `/bin` y `/usr/bin`, porque los procesos de la
430    // máquina —empezando por PID 1, `/usr/bin/arje-zero`— tienen su ejecutable mapeado desde ahí,
431    // y un ejecutable mapeado pin-ea el montaje. Pasó con la primera instalación real de un
432    // paquete en la caja: desmontó 5 de 7 targets, murió en `/bin`, y dejó el sistema **a medias**
433    // —dos overlays montados y el estado sin promocionar— que es el peor de los finales.
434    //
435    // El desmontaje PEREZOSO es la salida correcta y la que este mismo comentario prometía desde
436    // la Fase 2 sin que nadie la hubiera cableado: desengancha el montaje del árbol ya mismo (las
437    // rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera
438    // cuando el último proceso que lo mira se muera. Los que siguen vivos ven el mismo contenido,
439    // porque lo primero que hace el commit después es copiar el upper al lower.
440    if stderr.contains("busy") {
441        let lazy = Command::new("umount")
442            .arg("-l")
443            .arg(target)
444            .output()
445            .map_err(|e| Error::Umount(format!("spawn umount -l: {e}")))?;
446        if lazy.status.success() {
447            tracing::warn!(
448                target = %target.display(),
449                "umount: el target estaba ocupado (procesos con ejecutables mapeados ahí, \
450                 normal en un FHS vivo) ⇒ desmontaje perezoso"
451            );
452            return Ok(());
453        }
454        return Err(Error::Umount(format!(
455            "umount {} falló ocupado, y el perezoso tampoco pudo: {}",
456            target.display(),
457            String::from_utf8_lossy(&lazy.stderr).trim()
458        )));
459    }
460
461    Err(Error::Umount(format!(
462        "umount {} falló (exit {:?}): {}",
463        target.display(),
464        out.status.code(),
465        stderr.trim()
466    )))
467}
468
469/// Camina el `upper` y replica cada entrada al `lower`, anotando en el reporte. Maneja:
470///
471/// - Archivos regulares → `cp -a` (preserva metadata + hardlinks dentro del upper).
472/// - Directorios → `mkdir -p` con los permisos del upper.
473/// - Symlinks → recreación literal.
474/// - "Whiteouts" overlayfs (char dev 0/0) → `rm -rf` el correspondiente en el lower.
475fn promote_upper_to_lower(
476    upper: &Path,
477    lower: &Path,
478    report: &mut CommitReport,
479) -> Result<()> {
480    use std::os::unix::fs::FileTypeExt;
481    walk(upper, &mut |path: &Path| -> Result<()> {
482        let rel = path.strip_prefix(upper).expect("rel ⊂ upper");
483        if rel.as_os_str().is_empty() {
484            return Ok(());
485        }
486        let dst = lower.join(rel);
487        let meta = std::fs::symlink_metadata(path)?;
488        let ft = meta.file_type();
489        if ft.is_char_device() && rdev_is_zero(&meta) {
490            // Whiteout: el upper marca "borra esto del lower".
491            if dst.exists() || std::fs::symlink_metadata(&dst).is_ok() {
492                if dst.is_dir() && !dst.is_symlink() {
493                    std::fs::remove_dir_all(&dst)?;
494                } else {
495                    std::fs::remove_file(&dst)?;
496                }
497                report.removed.push(dst);
498            }
499        } else if ft.is_dir() {
500            std::fs::create_dir_all(&dst)?;
501        } else if ft.is_symlink() {
502            let tgt = std::fs::read_link(path)?;
503            let _ = std::fs::remove_file(&dst);
504            std::os::unix::fs::symlink(&tgt, &dst)?;
505            report.copied.push(dst);
506        } else if ft.is_file() {
507            if let Some(parent) = dst.parent() {
508                std::fs::create_dir_all(parent)?;
509            }
510            // `cp -a` para preservar perms/timestamps/ownership; copy_file_range para
511            // archivos grandes se queda en futuro si lo necesitamos.
512            let st = Command::new("cp")
513                .arg("-a")
514                .arg("--remove-destination")
515                .arg(path)
516                .arg(&dst)
517                .status()?;
518            if !st.success() {
519                return Err(Error::Overlay(format!(
520                    "cp -a {} → {} falló (exit {:?})",
521                    path.display(),
522                    dst.display(),
523                    st.code()
524                )));
525            }
526            report.copied.push(dst);
527        }
528        Ok(())
529    })
530}
531
532fn rdev_is_zero(meta: &std::fs::Metadata) -> bool {
533    use std::os::unix::fs::MetadataExt;
534    meta.rdev() == 0
535}
536
537fn walk(root: &Path, f: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
538    f(root)?;
539    let rd = match std::fs::read_dir(root) {
540        Ok(r) => r,
541        Err(_) => return Ok(()),
542    };
543    for entry in rd {
544        let entry = entry?;
545        let p = entry.path();
546        let ft = entry.file_type()?;
547        if ft.is_dir() && !ft.is_symlink() {
548            walk(&p, f)?;
549        } else {
550            f(&p)?;
551        }
552    }
553    Ok(())
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn slug_basic() {
562        assert_eq!(slug_for(Path::new("/usr/bin")), "usr-bin");
563        assert_eq!(slug_for(Path::new("/etc")), "etc");
564        assert_eq!(slug_for(Path::new("/")), "root");
565    }
566
567    #[test]
568    fn default_targets_are_fhs_userland() {
569        let t = default_targets();
570        assert!(t.iter().any(|p| p == Path::new("/usr/bin")));
571        assert!(t.iter().any(|p| p == Path::new("/etc")));
572        assert!(!t.iter().any(|p| p == Path::new("/")));
573    }
574
575    #[test]
576    fn status_empty_dir_returns_empty() {
577        let d = tempfile::tempdir().unwrap();
578        let v = status(d.path()).unwrap();
579        assert!(v.is_empty());
580    }
581
582    #[test]
583    fn status_missing_dir_returns_empty() {
584        let d = tempfile::tempdir().unwrap();
585        let v = status(&d.path().join("no-existe")).unwrap();
586        assert!(v.is_empty());
587    }
588
589    #[test]
590    fn status_reads_planted_manifest() {
591        let d = tempfile::tempdir().unwrap();
592        let id = OverlayId("9999-1".into());
593        let base = d.path().join(id.as_str());
594        std::fs::create_dir_all(&base).unwrap();
595        let st = OverlayState {
596            id: id.clone(),
597            mounts: vec![],
598            created_at: 1234,
599        };
600        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
601        let v = status(d.path()).unwrap();
602        assert_eq!(v.len(), 1);
603        assert_eq!(v[0].id, id);
604        assert_eq!(v[0].created_at, 1234);
605    }
606
607    #[test]
608    fn discard_on_unknown_id_errors_clearly() {
609        let d = tempfile::tempdir().unwrap();
610        let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
611        assert!(err.contains("no encuentro overlay"), "msg = {err}");
612    }
613
614    /// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de
615    /// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría.
616    fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) {
617        let base = state_root.join(id);
618        std::fs::create_dir_all(&base).unwrap();
619        let mounts = targets
620            .iter()
621            .map(|t| OverlayMount {
622                target: PathBuf::from(t),
623                upper: base.join("upper"),
624                work: base.join("work"),
625            })
626            .collect();
627        let st = OverlayState { id: OverlayId(id.into()), mounts, created_at };
628        std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
629    }
630
631    #[test]
632    fn no_blockers_for_disjoint_targets() {
633        let d = tempfile::tempdir().unwrap();
634        plant(d.path(), "100-1", 100, &["/usr/bin"]);
635        plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto
636        assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty());
637    }
638
639    #[test]
640    fn younger_overlapping_overlay_blocks() {
641        let d = tempfile::tempdir().unwrap();
642        plant(d.path(), "100-1", 100, &["/usr/bin"]);
643        plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target
644        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
645        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
646        // El de arriba no tiene a nadie por encima: se puede resolver primero.
647        assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty());
648    }
649
650    #[test]
651    fn ancestor_target_counts_as_overlap() {
652        let d = tempfile::tempdir().unwrap();
653        plant(d.path(), "100-1", 100, &["/usr"]);
654        plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr
655        let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
656        assert_eq!(blockers, vec![OverlayId("200-1".into())]);
657    }
658
659    #[test]
660    fn same_second_ties_broken_by_id() {
661        let d = tempfile::tempdir().unwrap();
662        // Mismo created_at: el desempate por id define quién está encima.
663        plant(d.path(), "100-1", 100, &["/etc"]);
664        plant(d.path(), "100-2", 100, &["/etc"]);
665        assert_eq!(
666            blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(),
667            vec![OverlayId("100-2".into())]
668        );
669        assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty());
670    }
671
672    #[test]
673    fn commit_refuses_when_shadowed() {
674        let d = tempfile::tempdir().unwrap();
675        plant(d.path(), "100-1", 100, &["/usr/bin"]);
676        plant(d.path(), "200-1", 200, &["/usr/bin"]);
677        let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err();
678        match err {
679            Error::Shadowed { id, shadowed_by } => {
680                assert_eq!(id.as_str(), "100-1");
681                assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]);
682            }
683            other => panic!("esperaba Shadowed, vino {other:?}"),
684        }
685    }
686
687    #[test]
688    fn discard_refuses_when_shadowed() {
689        let d = tempfile::tempdir().unwrap();
690        plant(d.path(), "100-1", 100, &["/etc"]);
691        plant(d.path(), "200-1", 200, &["/etc"]);
692        let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err();
693        assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}");
694    }
695
696    #[test]
697    fn journal_hook_records_copied_and_removed() {
698        // No tocamos mount real: ejercitamos directamente la función privada que el commit
699        // delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
700        let d = tempfile::tempdir().unwrap();
701        let journal = takana_journal::Journal::open(d.path().join("journal")).unwrap();
702        let report = CommitReport {
703            copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
704            removed: vec![PathBuf::from("/bin/obsoleto")],
705        };
706        super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
707        let events = journal.read_all().unwrap();
708        assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
709        // Los promocionados van como Replace, los removidos como Delete.
710        let ops_paths: Vec<_> = events
711            .iter()
712            .map(|e| (e.op, e.path.display().to_string()))
713            .collect();
714        assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/etc/hosts".into())));
715        assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/bin/grep".into())));
716        assert!(ops_paths.contains(&(takana_journal::MutationOp::Delete, "/bin/obsoleto".into())));
717        // Todos comparten el mismo source con overlay id.
718        for e in &events {
719            match &e.by.source {
720                takana_journal::Source::HammerCommit { overlay, .. } => {
721                    assert_eq!(overlay, "1234-1");
722                }
723                other => panic!("source inesperado: {other:?}"),
724            }
725        }
726    }
727}