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…
⎇ fe9a2d50cf65ea675cd0d2928f832f0059699a43
crates/hammer-overlay/src/lib.rs
Historia
Quién tocó qué
crudo
Fase 2: anidación real de overlays con guard LIFO
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}