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…
⎇ 08510c042412e6a408d531c5d125dce7eac588bd
crates/hammer-upgrade/src/lib.rs
Historia
Quién tocó qué
crudo
arranque-grafo: current/default siempre presentes — interop con el lector de mirada
1//! **E4 — upgrades atómicos con rollback** ([SDD 13 §E4](../../docs/13-release-engineering.md)).
2//!
3//! Aplicar un árbol Stage1/producto **nuevo** al root vivo sin reinstalar, de forma atómica y con
4//! **rollback** al árbol anterior. El árbol nuevo es un artefacto sellado del store (CAS BLAKE3); el
5//! "apply" lo proyecta sobre el FHS vivo (`target_root`, normalmente `/`), registra cada cambio en el
6//! [diario](hammer_journal) y deja una **generación** nueva. Si algo sale mal, `rollback` revierte
7//! exactamente al árbol anterior.
8//!
9//! ## Modelo de generaciones (inspirado en NixOS, aplicado in-place al FHS)
10//!
11//! El sistema booteado lee el userland directamente del root (vda2), no de un symlink indirecto. Así
12//! que el "switch" no es un único symlink: es la proyección de los ficheros del árbol nuevo sobre el
13//! root, fichero a fichero (escritura a temporal + `rename` ⇒ cada fichero conmuta atómicamente), más
14//! un **registro de generación** que es la fuente de verdad de "qué árbol está vivo" y guarda lo
15//! necesario para deshacer.
16//!
17//! ```text
18//! <state_root>/ (default /var/lib/hammer/upgrades)
19//! generations/
20//! <N>/manifest.json id, tree_dir (store), tree_content (of_tree), parent, cambios
21//! <N>/backup/<rel...> bytes PREVIOS de cada fichero que el apply pisó o borró
22//! current fichero con el id de la generación viva (escritura atómica)
23//! ```
24//!
25//! **Atomicidad y rollback.** El apply respalda el estado previo de todo path que toca *antes* de
26//! tocarlo; el commit-point es el avance de `current`. Un corte a mitad deja `current` en la
27//! generación vieja y los backups intactos ⇒ `rollback` (o un re-apply) restaura. El rollback deshace
28//! en orden inverso: borra lo que el apply creó, restaura desde `backup/` lo que pisó o borró, y
29//! retrocede `current` al padre.
30//!
31//! **Alcance del primer corte.** Maneja ficheros regulares y symlinks (los dos tipos de contenido que
32//! `of_tree` hashea); crea los directorios intermedios que haga falta. El cableado del init de boot
33//! para que el rollback sobreviva un kernel-panic a media escritura (journal de intención + replay) es
34//! endurecimiento posterior, ortogonal a esta lógica.
35
36use std::path::{Path, PathBuf};
37
38use hammer_core::{ArtifactHash, Store};
39use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
40use serde::{Deserialize, Serialize};
41
42pub mod boot_graph;
43
44pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades";
45
46#[derive(Debug, thiserror::Error)]
47pub enum Error {
48 #[error("io: {0}")]
49 Io(#[from] std::io::Error),
50 #[error("core: {0}")]
51 Core(#[from] hammer_core::Error),
52 #[error("journal: {0}")]
53 Journal(#[from] hammer_journal::Error),
54 #[error("json: {0}")]
55 Json(#[from] serde_json::Error),
56 #[error(
57 "integridad: el árbol '{dir}' del store hashea a {got} pero se esperaba {want} \
58 — store corrupto o árbol equivocado; NO se aplica"
59 )]
60 Verify { dir: String, want: String, got: String },
61 #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
62 NothingToRollback,
63 #[error(
64 "upgrade: hay un apply interrumpido pendiente (generación {generation}) — corré \
65 `hammer upgrade recover` (completa) o `hammer upgrade recover --rollback` (deshace) antes de seguir"
66 )]
67 PendingExists { generation: u64 },
68 #[error("upgrade: {0}")]
69 Other(String),
70}
71
72pub type Result<T> = std::result::Result<T, Error>;
73
74/// Qué le pasó a un path concreto al aplicar una generación. Determina cómo se deshace.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum ChangeOp {
78 /// El path no existía antes ⇒ rollback lo **borra**.
79 Added,
80 /// El path existía y fue pisado ⇒ `backup/` guarda los bytes previos ⇒ rollback los **restaura**.
81 Replaced,
82 /// El path existía en el árbol previo y el nuevo no lo tiene ⇒ apply lo borra; `backup/` guarda
83 /// los bytes previos ⇒ rollback los **restaura**.
84 Removed,
85}
86
87/// Un cambio atómico sobre un path del FHS, relativo a `target_root`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Change {
90 pub path: PathBuf,
91 pub op: ChangeOp,
92}
93
94/// Manifiesto persistente de una generación — la unidad de upgrade/rollback.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct GenerationManifest {
97 pub id: u64,
98 /// Directorio del artefacto en el store (`<hash>-<name>`) cuyo árbol esta generación proyectó.
99 pub tree_dir: String,
100 /// `of_tree` (`b3:…`) del árbol aplicado: ancla de integridad y desempate vs el store.
101 pub tree_content: String,
102 /// Generación anterior (a la que el rollback vuelve). `None` en la primera.
103 pub parent: Option<u64>,
104 /// Segundos desde UNIX epoch al apply.
105 pub created_at: u64,
106 /// Lista relativa (ordenada) de los ficheros que el árbol aporta — sirve para que el SIGUIENTE
107 /// apply sepa qué retirar (paths del árbol previo ausentes en el nuevo).
108 pub files: Vec<PathBuf>,
109 /// Cada path tocado y cómo, para deshacer.
110 pub changes: Vec<Change>,
111}
112
113/// Reporte de un [`apply`].
114#[derive(Debug, Clone, Default)]
115pub struct ApplyReport {
116 pub generation: u64,
117 pub added: Vec<PathBuf>,
118 pub replaced: Vec<PathBuf>,
119 pub removed: Vec<PathBuf>,
120 /// `true` si el árbol pedido ya era el vivo (apply idempotente, no se creó generación nueva).
121 pub already_current: bool,
122}
123
124/// Reporte de un [`recover`].
125#[derive(Debug, Clone, Default)]
126pub struct RecoverReport {
127 /// `false` si no había nada que recuperar (no había intento pendiente).
128 pub recovered: bool,
129 /// `true` = se completó el apply (roll-forward); `false` = se deshizo (roll-back).
130 pub rolled_forward: bool,
131 /// Generación afectada por el intento pendiente.
132 pub generation: u64,
133}
134
135/// Reporte de un [`rollback`].
136#[derive(Debug, Clone, Default)]
137pub struct RollbackReport {
138 /// Generación que se revirtió (la que estaba viva).
139 pub reverted: u64,
140 /// Generación que quedó viva tras revertir (`None` si se volvió al estado pre-upgrades).
141 pub now_current: Option<u64>,
142 pub restored: Vec<PathBuf>,
143 pub deleted: Vec<PathBuf>,
144}
145
146// ---------------------------------------------------------------------------------------------------
147// Registro de generaciones (lectura del estado).
148// ---------------------------------------------------------------------------------------------------
149
150fn generations_dir(state_root: &Path) -> PathBuf {
151 state_root.join("generations")
152}
153
154fn current_file(state_root: &Path) -> PathBuf {
155 state_root.join("current")
156}
157
158fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
159 generations_dir(state_root).join(id.to_string()).join("manifest.json")
160}
161
162/// El **journal de intención**: el plan de un apply en curso. Existe sólo entre el inicio de la
163/// proyección y el commit; si sobrevive a un reinicio, un apply quedó a medias y hay que [`recover`].
164fn pending_path(state_root: &Path) -> PathBuf {
165 state_root.join("pending.json")
166}
167
168/// Lee el plan pendiente (apply interrumpido), o `None` si no hay ninguno.
169pub fn pending(state_root: &Path) -> Result<Option<GenerationManifest>> {
170 let p = pending_path(state_root);
171 if !p.is_file() {
172 return Ok(None);
173 }
174 Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
175}
176
177fn write_pending(state_root: &Path, manifest: &GenerationManifest) -> Result<()> {
178 write_atomic(&pending_path(state_root), &serde_json::to_vec_pretty(manifest)?)
179}
180
181fn clear_pending(state_root: &Path) -> Result<()> {
182 match std::fs::remove_file(pending_path(state_root)) {
183 Ok(()) => Ok(()),
184 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
185 Err(e) => Err(e.into()),
186 }
187}
188
189/// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
190pub fn current(state_root: &Path) -> Result<Option<u64>> {
191 let f = current_file(state_root);
192 if !f.is_file() {
193 return Ok(None);
194 }
195 let s = std::fs::read_to_string(&f)?;
196 let s = s.trim();
197 if s.is_empty() {
198 return Ok(None);
199 }
200 s.parse::<u64>()
201 .map(Some)
202 .map_err(|_| Error::Other(format!("`current` ilegible: {s:?}")))
203}
204
205/// Lee el manifiesto de una generación concreta.
206pub fn get(state_root: &Path, id: u64) -> Result<Option<GenerationManifest>> {
207 let p = manifest_path(state_root, id);
208 if !p.is_file() {
209 return Ok(None);
210 }
211 Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
212}
213
214/// Lista todas las generaciones registradas, ordenadas por id ascendente.
215pub fn list(state_root: &Path) -> Result<Vec<GenerationManifest>> {
216 let dir = generations_dir(state_root);
217 if !dir.is_dir() {
218 return Ok(Vec::new());
219 }
220 let mut out: Vec<GenerationManifest> = Vec::new();
221 for entry in std::fs::read_dir(&dir)? {
222 let entry = entry?;
223 let mp = entry.path().join("manifest.json");
224 if mp.is_file() {
225 out.push(serde_json::from_slice(&std::fs::read(&mp)?)?);
226 }
227 }
228 out.sort_by_key(|m| m.id);
229 Ok(out)
230}
231
232fn next_id(state_root: &Path) -> Result<u64> {
233 Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
234}
235
236/// La **cadena viva**: la generación actual seguida de sus ancestros vía `parent`, de la más nueva a
237/// la más vieja. Son las únicas generaciones que el `rollback` puede necesitar (no hay "redo": tras un
238/// rollback las generaciones más nuevas quedan **huérfanas**). Vacía si no hay `current`.
239pub fn live_chain(state_root: &Path) -> Result<Vec<u64>> {
240 let mut chain = Vec::new();
241 let mut cur = current(state_root)?;
242 let mut guard = 0usize; // anti-ciclo defensivo (un manifiesto manipulado no debe colgar el proceso)
243 while let Some(id) = cur {
244 if chain.contains(&id) || guard > 100_000 {
245 break;
246 }
247 chain.push(id);
248 guard += 1;
249 cur = get(state_root, id)?.and_then(|m| m.parent);
250 }
251 Ok(chain)
252}
253
254/// Reporte de un [`prune`].
255#[derive(Debug, Clone, Default)]
256pub struct PruneReport {
257 pub removed: Vec<u64>,
258 pub kept: Vec<u64>,
259}
260
261/// Recupera espacio borrando generaciones (manifiesto + `backup/`) que el rollback ya no puede
262/// alcanzar. **Siempre** se conservan las de la [cadena viva](live_chain); el resto (huérfanas tras un
263/// rollback) se borran. Si `keep` es `Some(n)`, además se **recorta la cadena** a sus `n` generaciones
264/// más nuevas — esto limita la profundidad de rollback disponible (como `delete-generations` en NixOS),
265/// así que `None` (conservar toda la cadena) es lo seguro por defecto. Idempotente.
266pub fn prune(state_root: &Path, keep: Option<usize>) -> Result<PruneReport> {
267 let chain = live_chain(state_root)?;
268 let keep_set: std::collections::BTreeSet<u64> = match keep {
269 Some(n) => chain.iter().take(n).copied().collect(),
270 None => chain.iter().copied().collect(),
271 };
272 let mut report = PruneReport::default();
273 for m in list(state_root)? {
274 if keep_set.contains(&m.id) {
275 report.kept.push(m.id);
276 continue;
277 }
278 let gen_base = generations_dir(state_root).join(m.id.to_string());
279 std::fs::remove_dir_all(&gen_base)?;
280 report.removed.push(m.id);
281 }
282 report.removed.sort_unstable();
283 report.kept.sort_unstable();
284 Ok(report)
285}
286
287// ---------------------------------------------------------------------------------------------------
288// Enumeración de árboles.
289// ---------------------------------------------------------------------------------------------------
290
291/// Un fichero de un árbol: su path relativo y su tipo de contenido (lo que `of_tree` distingue).
292#[derive(Debug, Clone)]
293struct TreeEntry {
294 rel: PathBuf,
295 kind: EntryKind,
296}
297
298#[derive(Debug, Clone)]
299enum EntryKind {
300 /// Fichero regular. `exec` = bit de ejecución (de_tree lo mira).
301 File { exec: bool },
302 /// Symlink → target literal (sin resolver).
303 Symlink(PathBuf),
304}
305
306/// Camina `root` y devuelve sus ficheros (regulares + symlinks) con path relativo, ordenados. Los
307/// directorios no se listan: se recrean al proyectar según haga falta.
308fn enumerate_tree(root: &Path) -> Result<Vec<TreeEntry>> {
309 let mut out = Vec::new();
310 fn walk(root: &Path, dir: &Path, out: &mut Vec<TreeEntry>) -> Result<()> {
311 use std::os::unix::fs::PermissionsExt;
312 for entry in std::fs::read_dir(dir)? {
313 let entry = entry?;
314 let p = entry.path();
315 let meta = std::fs::symlink_metadata(&p)?;
316 let ft = meta.file_type();
317 let rel = p.strip_prefix(root).expect("p ⊂ root").to_path_buf();
318 if ft.is_symlink() {
319 out.push(TreeEntry { rel, kind: EntryKind::Symlink(std::fs::read_link(&p)?) });
320 } else if ft.is_dir() {
321 walk(root, &p, out)?;
322 } else if ft.is_file() {
323 let exec = meta.permissions().mode() & 0o111 != 0;
324 out.push(TreeEntry { rel, kind: EntryKind::File { exec } });
325 }
326 }
327 Ok(())
328 }
329 walk(root, root, &mut out)?;
330 out.sort_by(|a, b| a.rel.cmp(&b.rel));
331 Ok(out)
332}
333
334// ---------------------------------------------------------------------------------------------------
335// Apply.
336// ---------------------------------------------------------------------------------------------------
337
338/// Aplica el árbol `tree_dir` del store sobre `target_root`, dejando una generación nueva.
339///
340/// Pasos: (1) resuelve y **verifica** el árbol del store (su `of_tree` debe casar el del índice);
341/// (2) diferencia contra el árbol de la generación viva ⇒ qué se añade/pisa/retira; (3) **respalda**
342/// el estado previo de cada path tocado; (4) proyecta el árbol nuevo (escritura a temporal + rename)
343/// y retira los ficheros sobrantes, registrando cada cambio en el diario; (5) escribe el manifiesto y
344/// **avanza `current`** (commit-point).
345pub fn apply(
346 store_root: &Path,
347 tree_dir: &str,
348 expected: Option<&str>,
349 target_root: &Path,
350 state_root: &Path,
351 journal: Option<&Journal>,
352) -> Result<ApplyReport> {
353 let store = Store::open(store_root)?;
354 let tree_path = store.root().join(tree_dir);
355 if !tree_path.is_dir() {
356 return Err(Error::Other(format!(
357 "el árbol '{tree_dir}' no existe en el store {}",
358 store.root().display()
359 )));
360 }
361 // El of_tree real ancla la identidad del árbol. Si el llamador anuncia uno esperado (de un índice
362 // de mirror o una release firmada), lo exigimos: defiende de un store corrupto/manipulado entre el
363 // sellado y el apply. Sin `expected`, el store local se confía (sellado read-only) y el of_tree
364 // sólo se registra en el manifiesto.
365 let got = ArtifactHash::of_tree(&tree_path)?;
366 let tree_content = got.as_str().to_string();
367 if let Some(want) = expected {
368 if tree_content != want {
369 return Err(Error::Verify {
370 dir: tree_dir.to_string(),
371 want: want.to_string(),
372 got: tree_content,
373 });
374 }
375 }
376
377 // Un apply previo interrumpido deja un `pending.json` ⇒ exigimos `recover` explícito antes de tocar
378 // nada (no pisamos un FHS a medias). El recover lo completa (roll-forward) o lo deshace (roll-back).
379 if let Some(p) = pending(state_root)? {
380 return Err(Error::PendingExists { generation: p.id });
381 }
382
383 // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
384 if let Some(cur) = current(state_root)? {
385 if let Some(m) = get(state_root, cur)? {
386 if m.tree_content == tree_content {
387 return Ok(ApplyReport { generation: cur, already_current: true, ..Default::default() });
388 }
389 }
390 }
391
392 // --- PLAN completo ANTES de tocar nada: entradas del árbol nuevo + ficheros a retirar + op/path ---
393 let new_entries = enumerate_tree(&tree_path)?;
394 let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
395 let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
396 let prev_id = current(state_root)?;
397 let prev_files: Vec<PathBuf> = match prev_id {
398 Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
399 None => Vec::new(),
400 };
401 let removed_files: Vec<PathBuf> =
402 prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
403
404 let mut changes: Vec<Change> = Vec::with_capacity(new_entries.len() + removed_files.len());
405 for e in &new_entries {
406 let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
407 changes.push(Change {
408 path: e.rel.clone(),
409 op: if existed { ChangeOp::Replaced } else { ChangeOp::Added },
410 });
411 }
412 for rel in &removed_files {
413 if std::fs::symlink_metadata(target_root.join(rel)).is_ok() {
414 changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
415 }
416 }
417
418 let id = next_id(state_root)?;
419 let manifest = GenerationManifest {
420 id,
421 tree_dir: tree_dir.to_string(),
422 tree_content,
423 parent: prev_id,
424 created_at: now_secs(),
425 files: new_files,
426 changes,
427 };
428
429 let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
430 std::fs::create_dir_all(&backup_root)?;
431
432 // (INTENTO) escribir el plan ANTES de proyectar: el commit-point sigue siendo `current`, pero ahora
433 // un corte a media proyección es recuperable — `recover` re-ejecuta el plan (idempotente) o lo deshace.
434 write_pending(state_root, &manifest)?;
435
436 // (PROYECTAR) ejecutar el plan: backups idempotentes (no pisan el original) + escritura temporal+rename.
437 project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
438
439 // (COMMIT) manifiesto definitivo + avance de `current` + limpiar el intento.
440 write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
441 set_current(state_root, Some(id))?;
442 clear_pending(state_root)?;
443 Ok(report_from_changes(id, &manifest.changes))
444}
445
446/// Construye el [`ApplyReport`] a partir de la lista de cambios del plan.
447fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
448 let mut r = ApplyReport { generation: id, ..Default::default() };
449 for c in changes {
450 match c.op {
451 ChangeOp::Added => r.added.push(c.path.clone()),
452 ChangeOp::Replaced => r.replaced.push(c.path.clone()),
453 ChangeOp::Removed => r.removed.push(c.path.clone()),
454 }
455 }
456 r
457}
458
459/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
460/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
461/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
462/// **re-entrante**: correrlo dos veces deja el mismo resultado.
463fn project_plan(
464 tree_path: &Path,
465 manifest: &GenerationManifest,
466 target_root: &Path,
467 backup_root: &Path,
468 journal: Option<&Journal>,
469) -> Result<()> {
470 let entries = enumerate_tree(tree_path)?;
471 let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
472 entries.iter().map(|e| (&e.rel, e)).collect();
473 for ch in &manifest.changes {
474 let dst = target_root.join(&ch.path);
475 match ch.op {
476 ChangeOp::Added | ChangeOp::Replaced => {
477 if ch.op == ChangeOp::Replaced {
478 backup_existing_once(&dst, &backup_root.join(&ch.path))?;
479 }
480 if let Some(e) = by_rel.get(&ch.path) {
481 project_entry(tree_path, e, &dst)?;
482 }
483 }
484 ChangeOp::Removed => {
485 backup_existing_once(&dst, &backup_root.join(&ch.path))?;
486 remove_path(&dst)?;
487 }
488 }
489 journal_change(journal, &manifest.tree_dir, &dst, ch.op);
490 }
491 Ok(())
492}
493
494/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
495/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
496fn undo_changes(
497 manifest: &GenerationManifest,
498 target_root: &Path,
499 backup_root: &Path,
500 journal: Option<&Journal>,
501 report_restored: &mut Vec<PathBuf>,
502 report_deleted: &mut Vec<PathBuf>,
503) -> Result<()> {
504 for ch in manifest.changes.iter().rev() {
505 let dst = target_root.join(&ch.path);
506 match ch.op {
507 ChangeOp::Added => {
508 if std::fs::symlink_metadata(&dst).is_ok() {
509 remove_path(&dst)?;
510 report_deleted.push(ch.path.clone());
511 journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
512 }
513 }
514 ChangeOp::Replaced | ChangeOp::Removed => {
515 restore_from_backup(&backup_root.join(&ch.path), &dst)?;
516 report_restored.push(ch.path.clone());
517 journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
518 }
519 }
520 }
521 Ok(())
522}
523
524// ---------------------------------------------------------------------------------------------------
525// Rollback.
526// ---------------------------------------------------------------------------------------------------
527
528/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo
529/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados.
530pub fn rollback(
531 target_root: &Path,
532 state_root: &Path,
533 journal: Option<&Journal>,
534) -> Result<RollbackReport> {
535 let cur = current(state_root)?.ok_or(Error::NothingToRollback)?;
536 let m = get(state_root, cur)?
537 .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?;
538 let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
539
540 let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
541 undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
542 set_current(state_root, m.parent)?;
543 Ok(report)
544}
545
546/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
547///
548/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
549/// — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
550/// el plan registró (defensa ante un store que cambió bajo los pies).
551/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
552/// generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
553///
554/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
555pub fn recover(
556 store_root: &Path,
557 target_root: &Path,
558 state_root: &Path,
559 journal: Option<&Journal>,
560 rollback: bool,
561) -> Result<RecoverReport> {
562 let manifest = match pending(state_root)? {
563 Some(m) => m,
564 None => return Ok(RecoverReport::default()),
565 };
566 let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
567
568 if rollback {
569 let (mut restored, mut deleted) = (Vec::new(), Vec::new());
570 undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
571 // El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
572 if current(state_root)? == Some(manifest.id) {
573 set_current(state_root, manifest.parent)?;
574 }
575 let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
576 clear_pending(state_root)?;
577 return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
578 }
579
580 // roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
581 let store = Store::open(store_root)?;
582 let tree_path = store.root().join(&manifest.tree_dir);
583 if !tree_path.is_dir() {
584 return Err(Error::Other(format!(
585 "recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
586 manifest.tree_dir
587 )));
588 }
589 let got = ArtifactHash::of_tree(&tree_path)?;
590 if got.as_str() != manifest.tree_content {
591 return Err(Error::Verify {
592 dir: manifest.tree_dir.clone(),
593 want: manifest.tree_content.clone(),
594 got: got.as_str().to_string(),
595 });
596 }
597 project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
598 write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
599 set_current(state_root, Some(manifest.id))?;
600 clear_pending(state_root)?;
601 Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
602}
603
604// ---------------------------------------------------------------------------------------------------
605// Primitivas de filesystem.
606// ---------------------------------------------------------------------------------------------------
607
608/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico).
609fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> {
610 if let Some(parent) = dst.parent() {
611 std::fs::create_dir_all(parent)?;
612 }
613 let tmp = tmp_sibling(dst);
614 let _ = remove_path(&tmp);
615 match &e.kind {
616 EntryKind::File { exec } => {
617 let src = tree_root.join(&e.rel);
618 std::fs::copy(&src, &tmp)?;
619 if *exec {
620 use std::os::unix::fs::PermissionsExt;
621 let mode = std::fs::metadata(&src)?.permissions().mode();
622 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
623 }
624 }
625 EntryKind::Symlink(target) => {
626 std::os::unix::fs::symlink(target, &tmp)?;
627 }
628 }
629 // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS).
630 std::fs::rename(&tmp, dst)?;
631 Ok(())
632}
633
634/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo.
635fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
636 if let Some(parent) = bak.parent() {
637 std::fs::create_dir_all(parent)?;
638 }
639 let meta = std::fs::symlink_metadata(src)?;
640 if meta.file_type().is_symlink() {
641 let _ = remove_path(bak);
642 std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?;
643 } else if meta.is_file() {
644 let _ = remove_path(bak);
645 std::fs::copy(src, bak)?;
646 std::fs::set_permissions(bak, meta.permissions())?;
647 } else {
648 return Err(Error::Other(format!(
649 "no sé respaldar {} (no es fichero ni symlink)",
650 src.display()
651 )));
652 }
653 Ok(())
654}
655
656/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
657/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
658/// proyección sin sobrescribir el backup con la versión ya proyectada).
659fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
660 if std::fs::symlink_metadata(bak).is_ok() {
661 return Ok(()); // ya respaldado
662 }
663 if std::fs::symlink_metadata(src).is_err() {
664 return Ok(()); // nada que respaldar
665 }
666 backup_existing(src, bak)
667}
668
669/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
670/// (idempotencia: ya restaurado o nunca existió).
671fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
672 let meta = match std::fs::symlink_metadata(bak) {
673 Ok(m) => m,
674 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
675 Err(e) => return Err(e.into()),
676 };
677 if let Some(parent) = dst.parent() {
678 std::fs::create_dir_all(parent)?;
679 }
680 let tmp = tmp_sibling(dst);
681 let _ = remove_path(&tmp);
682 if meta.file_type().is_symlink() {
683 std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?;
684 } else {
685 std::fs::copy(bak, &tmp)?;
686 std::fs::set_permissions(&tmp, meta.permissions())?;
687 }
688 std::fs::rename(&tmp, dst)?;
689 Ok(())
690}
691
692fn remove_path(p: &Path) -> Result<()> {
693 match std::fs::symlink_metadata(p) {
694 Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?,
695 Ok(_) => std::fs::remove_file(p)?,
696 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
697 Err(e) => return Err(e.into()),
698 }
699 Ok(())
700}
701
702fn tmp_sibling(dst: &Path) -> PathBuf {
703 let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
704 dst.with_file_name(format!(".hammer-upgrade-tmp-{name}"))
705}
706
707/// Escribe `bytes` en `path` de forma atómica (temporal + rename).
708fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
709 if let Some(parent) = path.parent() {
710 std::fs::create_dir_all(parent)?;
711 }
712 let tmp = tmp_sibling(path);
713 std::fs::write(&tmp, bytes)?;
714 std::fs::rename(&tmp, path)?;
715 Ok(())
716}
717
718/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades).
719fn set_current(state_root: &Path, id: Option<u64>) -> Result<()> {
720 let f = current_file(state_root);
721 match id {
722 Some(id) => write_atomic(&f, id.to_string().as_bytes())?,
723 None => {
724 let _ = std::fs::remove_file(&f);
725 }
726 }
727 Ok(())
728}
729
730fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) {
731 let Some(j) = journal else { return };
732 let mop = match op {
733 ChangeOp::Added => MutationOp::Create,
734 ChangeOp::Replaced => MutationOp::Replace,
735 ChangeOp::Removed => MutationOp::Delete,
736 };
737 let ev = MutationEvent {
738 ts: hammer_journal::now_rfc3339(),
739 op: mop,
740 path: dst.to_path_buf(),
741 by: Actor {
742 // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store.
743 source: Source::HammerHydrate { artifact: tree_dir.to_string() },
744 pid: Some(std::process::id()),
745 uid: None,
746 },
747 content_hash: None,
748 note: Some("hammer upgrade".to_string()),
749 };
750 if let Err(e) = j.record(&ev) {
751 tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario");
752 }
753}
754
755fn now_secs() -> u64 {
756 std::time::SystemTime::now()
757 .duration_since(std::time::UNIX_EPOCH)
758 .map(|d| d.as_secs())
759 .unwrap_or(0)
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 /// Sella un árbol sintético en `store_root` y devuelve su `<hash>-<name>`.
767 fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
768 let store = Store::open(store_root).unwrap();
769 let h = ArtifactHash::from_hex(hex);
770 let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name));
771 let _ = std::fs::remove_dir_all(&staging);
772 std::fs::create_dir_all(&staging).unwrap();
773 build(&staging);
774 store.seal(&staging, &h, name).unwrap();
775 h.store_dir_name(name)
776 }
777
778 fn h64(prefix: &str) -> String {
779 format!("{prefix}{}", "0".repeat(64 - prefix.len()))
780 }
781
782 fn write(p: &Path, bytes: &[u8]) {
783 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
784 std::fs::write(p, bytes).unwrap();
785 }
786
787 #[test]
788 fn apply_projects_tree_and_records_generation() {
789 let tmp = tempfile::tempdir().unwrap();
790 let store = tmp.path().join("store");
791 let root = tmp.path().join("root");
792 let state = tmp.path().join("state");
793 std::fs::create_dir_all(&root).unwrap();
794 // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará.
795 write(&root.join("usr/bin/ls"), b"OLD-ls");
796
797 let dir = seal_tree(&store, &h64("aa"), "v1", |w| {
798 write(&w.join("usr/bin/ls"), b"NEW-ls");
799 write(&w.join("usr/bin/cat"), b"NEW-cat");
800 std::fs::create_dir_all(w.join("bin")).unwrap();
801 std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
802 });
803
804 let r = apply(&store, &dir, None, &root, &state, None).unwrap();
805 assert_eq!(r.generation, 1);
806 assert!(!r.already_current);
807 assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]);
808 assert!(r.added.contains(&PathBuf::from("usr/bin/cat")));
809 assert!(r.added.contains(&PathBuf::from("bin/sh")));
810
811 // los bytes nuevos están en el root vivo.
812 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
813 assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
814 assert_eq!(std::fs::read_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox"));
815
816 assert_eq!(current(&state).unwrap(), Some(1));
817 assert_eq!(list(&state).unwrap().len(), 1);
818 }
819
820 #[test]
821 fn apply_is_idempotent_for_same_tree() {
822 let tmp = tempfile::tempdir().unwrap();
823 let store = tmp.path().join("store");
824 let root = tmp.path().join("root");
825 let state = tmp.path().join("state");
826 let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A"));
827 let r1 = apply(&store, &dir, None, &root, &state, None).unwrap();
828 assert!(!r1.already_current && r1.generation == 1);
829 let r2 = apply(&store, &dir, None, &root, &state, None).unwrap();
830 assert!(r2.already_current, "mismo árbol ⇒ no-op");
831 assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva");
832 }
833
834 #[test]
835 fn rollback_restores_previous_tree_exactly() {
836 let tmp = tempfile::tempdir().unwrap();
837 let store = tmp.path().join("store");
838 let root = tmp.path().join("root");
839 let state = tmp.path().join("state");
840
841 let v1 = seal_tree(&store, &h64("c1"), "v1", |w| {
842 write(&w.join("usr/bin/ls"), b"LS-v1");
843 write(&w.join("usr/bin/old"), b"OLD-only-in-v1");
844 });
845 let v2 = seal_tree(&store, &h64("c2"), "v2", |w| {
846 write(&w.join("usr/bin/ls"), b"LS-v2");
847 write(&w.join("usr/bin/new"), b"NEW-only-in-v2");
848 // `old` desaparece ⇒ apply de v2 debe retirarlo.
849 });
850
851 apply(&store, &v1, None, &root, &state, None).unwrap();
852 let r2 = apply(&store, &v2, None, &root, &state, None).unwrap();
853 assert_eq!(r2.generation, 2);
854 assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]);
855 // estado tras v2.
856 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2");
857 assert!(root.join("usr/bin/new").exists());
858 assert!(!root.join("usr/bin/old").exists());
859
860 // rollback ⇒ vuelve EXACTAMENTE a v1.
861 let rb = rollback(&root, &state, None).unwrap();
862 assert_eq!(rb.reverted, 2);
863 assert_eq!(rb.now_current, Some(1));
864 assert_eq!(current(&state).unwrap(), Some(1));
865 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido");
866 assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró");
867 assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró");
868 }
869
870 #[test]
871 fn rollback_to_pre_upgrade_state() {
872 let tmp = tempfile::tempdir().unwrap();
873 let store = tmp.path().join("store");
874 let root = tmp.path().join("root");
875 let state = tmp.path().join("state");
876 std::fs::create_dir_all(&root).unwrap();
877 write(&root.join("etc/motd"), b"PRISTINE");
878
879 let v1 = seal_tree(&store, &h64("dd"), "v1", |w| {
880 write(&w.join("etc/motd"), b"UPGRADED");
881 write(&w.join("usr/bin/brand-new"), b"X");
882 });
883 apply(&store, &v1, None, &root, &state, None).unwrap();
884 assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED");
885
886 let rb = rollback(&root, &state, None).unwrap();
887 assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades");
888 assert_eq!(current(&state).unwrap(), None);
889 assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada");
890 assert!(!root.join("usr/bin/brand-new").exists());
891 }
892
893 #[test]
894 fn rollback_without_generation_errors() {
895 let tmp = tempfile::tempdir().unwrap();
896 let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err();
897 assert!(matches!(err, Error::NothingToRollback), "vino {err}");
898 }
899
900 #[test]
901 fn apply_rejects_tree_that_does_not_match_expected() {
902 // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el
903 // árbol del store no casa, apply rechaza ANTES de tocar el root.
904 let tmp = tempfile::tempdir().unwrap();
905 let store_root = tmp.path().join("store");
906 let root = tmp.path().join("root");
907 let state = tmp.path().join("state");
908 let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A"));
909
910 // expected MENTIROSO ⇒ Verify, y nada se aplicó.
911 let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err();
912 assert!(matches!(err, Error::Verify { .. }), "vino {err}");
913 assert_eq!(current(&state).unwrap(), None, "no se creó generación");
914 assert!(!root.join("a").exists(), "el root quedó intacto");
915
916 // expected CORRECTO (el of_tree real) ⇒ aplica.
917 let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string();
918 let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap();
919 assert_eq!(r.generation, 1);
920 }
921
922 #[test]
923 fn journal_records_each_change() {
924 let tmp = tempfile::tempdir().unwrap();
925 let store = tmp.path().join("store");
926 let root = tmp.path().join("root");
927 let state = tmp.path().join("state");
928 let j = Journal::open(tmp.path().join("journal")).unwrap();
929 let dir = seal_tree(&store, &h64("ff"), "v1", |w| {
930 write(&w.join("usr/bin/a"), b"A");
931 write(&w.join("usr/bin/b"), b"B");
932 });
933 apply(&store, &dir, None, &root, &state, Some(&j)).unwrap();
934 let evs = j.read_all().unwrap();
935 assert_eq!(evs.len(), 2);
936 assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
937 }
938
939 /// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
940 /// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
941 fn interrupt_apply(
942 store: &Path,
943 tree_dir: &str,
944 target_root: &Path,
945 state_root: &Path,
946 partial: usize,
947 ) -> GenerationManifest {
948 let store_h = Store::open(store).unwrap();
949 let tree_path = store_h.root().join(tree_dir);
950 let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
951 let entries = enumerate_tree(&tree_path).unwrap();
952 let mut changes = Vec::new();
953 for e in &entries {
954 let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
955 changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
956 }
957 let id = next_id(state_root).unwrap();
958 let manifest = GenerationManifest {
959 id, tree_dir: tree_dir.to_string(), tree_content,
960 parent: current(state_root).unwrap(), created_at: 0,
961 files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
962 };
963 let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
964 std::fs::create_dir_all(&backup_root).unwrap();
965 write_pending(state_root, &manifest).unwrap();
966 // proyectar SÓLO `partial` entradas (corte a media proyección).
967 for e in entries.iter().take(partial) {
968 let dst = target_root.join(&e.rel);
969 if std::fs::symlink_metadata(&dst).is_ok() {
970 backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
971 }
972 project_entry(&tree_path, e, &dst).unwrap();
973 }
974 manifest
975 }
976
977 #[test]
978 fn apply_refuses_when_a_pending_apply_exists() {
979 let tmp = tempfile::tempdir().unwrap();
980 let store = tmp.path().join("store");
981 let root = tmp.path().join("root");
982 let state = tmp.path().join("state");
983 let v1 = seal_tree(&store, &h64("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
984 interrupt_apply(&store, &v1, &root, &state, 1);
985 // con un intento pendiente, apply se NIEGA (exige recover).
986 let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
987 assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
988 assert!(pending(&state).unwrap().is_some());
989 }
990
991 #[test]
992 fn recover_roll_forward_completes_interrupted_apply() {
993 let tmp = tempfile::tempdir().unwrap();
994 let store = tmp.path().join("store");
995 let root = tmp.path().join("root");
996 let state = tmp.path().join("state");
997 std::fs::create_dir_all(&root).unwrap();
998 write(&root.join("etc/keep"), b"PRE");
999 let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
1000 write(&w.join("usr/bin/ls"), b"NEW-ls");
1001 write(&w.join("usr/bin/cat"), b"NEW-cat");
1002 write(&w.join("usr/bin/grep"), b"NEW-grep");
1003 });
1004 // corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
1005 interrupt_apply(&store, &v1, &root, &state, 1);
1006 assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
1007 assert!(pending(&state).unwrap().is_some());
1008
1009 let r = recover(&store, &root, &state, None, false).unwrap();
1010 assert!(r.recovered && r.rolled_forward && r.generation == 1);
1011 // el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
1012 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
1013 assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
1014 assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
1015 assert_eq!(current(&state).unwrap(), Some(1));
1016 assert!(pending(&state).unwrap().is_none());
1017 // y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
1018 rollback(&root, &state, None).unwrap();
1019 assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
1020 assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
1021 }
1022
1023 #[test]
1024 fn recover_roll_back_undoes_interrupted_apply() {
1025 let tmp = tempfile::tempdir().unwrap();
1026 let store = tmp.path().join("store");
1027 let root = tmp.path().join("root");
1028 let state = tmp.path().join("state");
1029 std::fs::create_dir_all(&root).unwrap();
1030 write(&root.join("usr/bin/ls"), b"ORIG-ls"); // existirá ⇒ Replaced (con backup)
1031 let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
1032 write(&w.join("usr/bin/ls"), b"NEW-ls");
1033 write(&w.join("usr/bin/new"), b"NEW-only"); // Added
1034 });
1035 // corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
1036 interrupt_apply(&store, &v1, &root, &state, 1);
1037 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
1038
1039 let r = recover(&store, &root, &state, None, true).unwrap();
1040 assert!(r.recovered && !r.rolled_forward);
1041 // el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
1042 assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
1043 assert!(!root.join("usr/bin/new").exists());
1044 assert_eq!(current(&state).unwrap(), None);
1045 assert!(pending(&state).unwrap().is_none());
1046 assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
1047 }
1048
1049 #[test]
1050 fn recover_is_noop_without_pending() {
1051 let tmp = tempfile::tempdir().unwrap();
1052 let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
1053 assert!(!r.recovered);
1054 }
1055
1056 #[test]
1057 fn apply_clears_pending_on_success() {
1058 let tmp = tempfile::tempdir().unwrap();
1059 let store = tmp.path().join("store");
1060 let root = tmp.path().join("root");
1061 let state = tmp.path().join("state");
1062 let v1 = seal_tree(&store, &h64("e4"), "v1", |w| write(&w.join("a"), b"A"));
1063 apply(&store, &v1, None, &root, &state, None).unwrap();
1064 assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
1065 }
1066
1067 #[test]
1068 fn prune_removes_orphans_but_keeps_live_chain() {
1069 let tmp = tempfile::tempdir().unwrap();
1070 let store = tmp.path().join("store");
1071 let root = tmp.path().join("root");
1072 let state = tmp.path().join("state");
1073 let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1"));
1074 let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2"));
1075 let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3"));
1076
1077 apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1
1078 apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1)
1079 apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2)
1080 // rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza).
1081 rollback(&root, &state, None).unwrap();
1082 rollback(&root, &state, None).unwrap();
1083 assert_eq!(current(&state).unwrap(), Some(1));
1084 assert_eq!(live_chain(&state).unwrap(), vec![1]);
1085
1086 let r = prune(&state, None).unwrap();
1087 assert_eq!(r.removed, vec![2, 3], "huérfanas borradas");
1088 assert_eq!(r.kept, vec![1], "cadena viva conservada");
1089 assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![1]);
1090 // idempotente.
1091 assert!(prune(&state, None).unwrap().removed.is_empty());
1092 }
1093
1094 #[test]
1095 fn boot_graph_builds_dag_and_activate_rolls_back_to_ancestor() {
1096 use crate::boot_graph::{activate, build, recovery_id, NodeKind};
1097
1098 let tmp = tempfile::tempdir().unwrap();
1099 let store = tmp.path().join("store");
1100 let root = tmp.path().join("root");
1101 let state = tmp.path().join("state");
1102 let v1 = seal_tree(&store, &h64("c1"), "v1", |w| write(&w.join("f"), b"1"));
1103 let v2 = seal_tree(&store, &h64("c2"), "v2", |w| write(&w.join("f"), b"2"));
1104 let v3 = seal_tree(&store, &h64("c3"), "v3", |w| write(&w.join("f"), b"3"));
1105 apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 (Base)
1106 apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2
1107 apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (viva)
1108
1109 let g = build(&state).unwrap();
1110 assert_eq!(g.version, 1);
1111 // 3 generaciones + el nodo de recuperación sintético.
1112 assert_eq!(g.nodes.len(), 4);
1113 let gen1 = &g.nodes[0];
1114 assert_eq!(gen1.kind, NodeKind::Base, "la primera generación es la raíz del linaje");
1115 assert!(gen1.parents.is_empty());
1116 let gen2 = &g.nodes[1];
1117 assert_eq!(gen2.kind, NodeKind::Generation);
1118 assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre");
1119 // current/default = la generación viva (gen 3).
1120 let gen3 = &g.nodes[2];
1121 assert_eq!(g.current, gen3.id);
1122 assert_eq!(g.default, g.current);
1123 // el nodo de recuperación cuelga de la viva.
1124 let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap();
1125 assert_eq!(rec.id, recovery_id());
1126 assert_eq!(rec.parents, vec![gen3.id.clone()]);
1127
1128 // activar la viva ⇒ no-op.
1129 let r = activate(&root, &state, None, &gen3.id).unwrap();
1130 assert!(r.already_current);
1131 assert_eq!(current(&state).unwrap(), Some(3));
1132
1133 // activar la gen 1 (ancestro) ⇒ rollback paso a paso hasta ella.
1134 let r = activate(&root, &state, None, &gen1.id).unwrap();
1135 assert!(!r.already_current);
1136 assert_eq!(r.rolled_back, vec![3, 2], "revierte de la más nueva a la más vieja");
1137 assert_eq!(current(&state).unwrap(), Some(1));
1138 assert_eq!(std::fs::read(root.join("f")).unwrap(), b"1", "el FHS vivo es el de la gen 1");
1139
1140 // ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto.
1141 let g2 = build(&state).unwrap();
1142 assert_eq!(g2.current, gen1.id);
1143 let err = activate(&root, &state, None, &gen3.id).unwrap_err();
1144 assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");
1145
1146 // recuperación desde la gen 1 ⇒ rollback al estado pre-upgrades (sin current).
1147 let r = activate(&root, &state, None, &recovery_id()).unwrap();
1148 assert_eq!(r.kind, NodeKind::Recovery);
1149 assert_eq!(r.rolled_back, vec![1]);
1150 assert_eq!(current(&state).unwrap(), None);
1151 }
1152
1153 #[test]
1154 fn prune_with_keep_trims_chain_depth() {
1155 let tmp = tempfile::tempdir().unwrap();
1156 let store = tmp.path().join("store");
1157 let root = tmp.path().join("root");
1158 let state = tmp.path().join("state");
1159 for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] {
1160 let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by));
1161 apply(&store, &d, None, &root, &state, None).unwrap();
1162 }
1163 // current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1.
1164 assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]);
1165 let r = prune(&state, Some(2)).unwrap();
1166 assert_eq!(r.kept, vec![2, 3]);
1167 assert_eq!(r.removed, vec![1]);
1168 // gen 1 borrada del disco; 2 y 3 siguen.
1169 assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![2, 3]);
1170 }
1171}