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…
⎇ 26c153c149c81663140768bfda7dafb19c010e4f
crates/takana-overlay/src/lib.rs
Historia
Quién tocó qué
crudo
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}