gioser

sergio/takana

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

TOML 1574Shell 204Rust 111Markdown 993763 Definiciones

⎇ 9142cd05650376627da4839c61ed8e63a9481510 crates/hammer-upgrade/src/lib.rs Rust · 1291 líneas · 55.3 KB Historia Quién tocó qué crudo

upgrade: el mismo agujero que hydrate, en el proyector de generaciones 9142cd056 · Sergio ·

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/// La ruta REAL de `target_root`, resolviendo los symlinks del tramo que ya existe y dejando
460/// pegado el tramo que todavía no (que `apply` va a crear como directorios de verdad). Sin esto no
461/// habría con qué comparar cuando el root aún no existe — el caso de una imagen nueva.
462fn raiz_real(target_root: &Path) -> PathBuf {
463    let mut faltan: Vec<std::ffi::OsString> = Vec::new();
464    let mut p = target_root.to_path_buf();
465    loop {
466        if let Ok(mut r) = p.canonicalize() {
467            for c in faltan.iter().rev() {
468                r.push(c);
469            }
470            return r;
471        }
472        match (p.file_name().map(|n| n.to_os_string()), p.parent().map(|x| x.to_path_buf())) {
473            (Some(n), Some(par)) => {
474                faltan.push(n);
475                p = par;
476            }
477            _ => return target_root.to_path_buf(),
478        }
479    }
480}
481
482/// El destino no puede salirse del root. **Se mide el DIRECTORIO padre, no el destino**: un
483/// `Replaced` sobre un symlink que apunta afuera es legítimo —`rename` pisa el symlink, no escribe
484/// a través de él— y medir el destino lo rechazaría por error.
485///
486/// EL FALLO QUE TAPA (medido 2026-08-30, `apply_no_escribe_fuera_del_root_por_un_symlink`): igual
487/// que en `hammer-build/hydrate.rs`, proyectar es `target_root.join(rel)` + escribir por ruta, así
488/// que un symlink de DIRECTORIO ya presente en el root se sigue. Una generación que deja puesto
489/// `usr/share/pkg → /algún/lado` hacía que la siguiente escribiera `usr/share/pkg/archivo` FUERA
490/// del root, con `ApplyReport` en verde. Con `--root /` no cambia nada (todo empieza por `/`); con
491/// `--root /mnt/imagen`, que es el caso real de armar una imagen, sí.
492fn dentro_de_raiz(raiz: &Path, dst: &Path) -> Result<()> {
493    let padre = match dst.parent() {
494        Some(p) => p,
495        None => return Ok(()),
496    };
497    let real = raiz_real(padre);
498    if !real.starts_with(raiz) {
499        return Err(Error::Io(std::io::Error::other(format!(
500            "la proyección se saldría del root: {} lleva a {}, fuera de {}.              Es un symlink de directorio ya presente en el root (probablemente de otra generación);              no se escribe a través de él.",
501            padre.display(),
502            real.display(),
503            raiz.display()
504        ))));
505    }
506    Ok(())
507}
508
509/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
510/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
511/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
512/// **re-entrante**: correrlo dos veces deja el mismo resultado.
513fn project_plan(
514    tree_path: &Path,
515    manifest: &GenerationManifest,
516    target_root: &Path,
517    backup_root: &Path,
518    journal: Option<&Journal>,
519) -> Result<()> {
520    let entries = enumerate_tree(tree_path)?;
521    let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
522        entries.iter().map(|e| (&e.rel, e)).collect();
523    let raiz = raiz_real(target_root);
524    for ch in &manifest.changes {
525        let dst = target_root.join(&ch.path);
526        dentro_de_raiz(&raiz, &dst)?;
527        match ch.op {
528            ChangeOp::Added | ChangeOp::Replaced => {
529                if ch.op == ChangeOp::Replaced {
530                    backup_existing_once(&dst, &backup_root.join(&ch.path))?;
531                }
532                if let Some(e) = by_rel.get(&ch.path) {
533                    project_entry(tree_path, e, &dst)?;
534                }
535            }
536            ChangeOp::Removed => {
537                backup_existing_once(&dst, &backup_root.join(&ch.path))?;
538                remove_path(&dst)?;
539            }
540        }
541        journal_change(journal, &manifest.tree_dir, &dst, ch.op);
542    }
543    Ok(())
544}
545
546/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
547/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
548fn undo_changes(
549    manifest: &GenerationManifest,
550    target_root: &Path,
551    backup_root: &Path,
552    journal: Option<&Journal>,
553    report_restored: &mut Vec<PathBuf>,
554    report_deleted: &mut Vec<PathBuf>,
555) -> Result<()> {
556    let raiz = raiz_real(target_root);
557    for ch in manifest.changes.iter().rev() {
558        let dst = target_root.join(&ch.path);
559        dentro_de_raiz(&raiz, &dst)?;
560        match ch.op {
561            ChangeOp::Added => {
562                if std::fs::symlink_metadata(&dst).is_ok() {
563                    remove_path(&dst)?;
564                    report_deleted.push(ch.path.clone());
565                    journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
566                }
567            }
568            ChangeOp::Replaced | ChangeOp::Removed => {
569                restore_from_backup(&backup_root.join(&ch.path), &dst)?;
570                report_restored.push(ch.path.clone());
571                journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
572            }
573        }
574    }
575    Ok(())
576}
577
578// ---------------------------------------------------------------------------------------------------
579// Rollback.
580// ---------------------------------------------------------------------------------------------------
581
582/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo
583/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados.
584pub fn rollback(
585    target_root: &Path,
586    state_root: &Path,
587    journal: Option<&Journal>,
588) -> Result<RollbackReport> {
589    let cur = current(state_root)?.ok_or(Error::NothingToRollback)?;
590    let m = get(state_root, cur)?
591        .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?;
592    let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
593
594    let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
595    undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
596    set_current(state_root, m.parent)?;
597    Ok(report)
598}
599
600/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
601///
602/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
603///   — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
604///   el plan registró (defensa ante un store que cambió bajo los pies).
605/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
606///   generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
607///
608/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
609pub fn recover(
610    store_root: &Path,
611    target_root: &Path,
612    state_root: &Path,
613    journal: Option<&Journal>,
614    rollback: bool,
615) -> Result<RecoverReport> {
616    let manifest = match pending(state_root)? {
617        Some(m) => m,
618        None => return Ok(RecoverReport::default()),
619    };
620    let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
621
622    if rollback {
623        let (mut restored, mut deleted) = (Vec::new(), Vec::new());
624        undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
625        // El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
626        if current(state_root)? == Some(manifest.id) {
627            set_current(state_root, manifest.parent)?;
628        }
629        let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
630        clear_pending(state_root)?;
631        return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
632    }
633
634    // roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
635    let store = Store::open(store_root)?;
636    let tree_path = store.root().join(&manifest.tree_dir);
637    if !tree_path.is_dir() {
638        return Err(Error::Other(format!(
639            "recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
640            manifest.tree_dir
641        )));
642    }
643    let got = ArtifactHash::of_tree(&tree_path)?;
644    if got.as_str() != manifest.tree_content {
645        return Err(Error::Verify {
646            dir: manifest.tree_dir.clone(),
647            want: manifest.tree_content.clone(),
648            got: got.as_str().to_string(),
649        });
650    }
651    project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
652    write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
653    set_current(state_root, Some(manifest.id))?;
654    clear_pending(state_root)?;
655    Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
656}
657
658// ---------------------------------------------------------------------------------------------------
659// Primitivas de filesystem.
660// ---------------------------------------------------------------------------------------------------
661
662/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico).
663fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> {
664    if let Some(parent) = dst.parent() {
665        std::fs::create_dir_all(parent)?;
666    }
667    let tmp = tmp_sibling(dst);
668    let _ = remove_path(&tmp);
669    match &e.kind {
670        EntryKind::File { exec } => {
671            let src = tree_root.join(&e.rel);
672            std::fs::copy(&src, &tmp)?;
673            if *exec {
674                use std::os::unix::fs::PermissionsExt;
675                let mode = std::fs::metadata(&src)?.permissions().mode();
676                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
677            }
678        }
679        EntryKind::Symlink(target) => {
680            std::os::unix::fs::symlink(target, &tmp)?;
681        }
682    }
683    // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS).
684    std::fs::rename(&tmp, dst)?;
685    Ok(())
686}
687
688/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo.
689fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
690    if let Some(parent) = bak.parent() {
691        std::fs::create_dir_all(parent)?;
692    }
693    let meta = std::fs::symlink_metadata(src)?;
694    if meta.file_type().is_symlink() {
695        let _ = remove_path(bak);
696        std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?;
697    } else if meta.is_file() {
698        let _ = remove_path(bak);
699        std::fs::copy(src, bak)?;
700        std::fs::set_permissions(bak, meta.permissions())?;
701    } else {
702        return Err(Error::Other(format!(
703            "no sé respaldar {} (no es fichero ni symlink)",
704            src.display()
705        )));
706    }
707    Ok(())
708}
709
710/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
711/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
712/// proyección sin sobrescribir el backup con la versión ya proyectada).
713fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
714    if std::fs::symlink_metadata(bak).is_ok() {
715        return Ok(()); // ya respaldado
716    }
717    if std::fs::symlink_metadata(src).is_err() {
718        return Ok(()); // nada que respaldar
719    }
720    backup_existing(src, bak)
721}
722
723/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
724/// (idempotencia: ya restaurado o nunca existió).
725fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
726    let meta = match std::fs::symlink_metadata(bak) {
727        Ok(m) => m,
728        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
729        Err(e) => return Err(e.into()),
730    };
731    if let Some(parent) = dst.parent() {
732        std::fs::create_dir_all(parent)?;
733    }
734    let tmp = tmp_sibling(dst);
735    let _ = remove_path(&tmp);
736    if meta.file_type().is_symlink() {
737        std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?;
738    } else {
739        std::fs::copy(bak, &tmp)?;
740        std::fs::set_permissions(&tmp, meta.permissions())?;
741    }
742    std::fs::rename(&tmp, dst)?;
743    Ok(())
744}
745
746fn remove_path(p: &Path) -> Result<()> {
747    match std::fs::symlink_metadata(p) {
748        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?,
749        Ok(_) => std::fs::remove_file(p)?,
750        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
751        Err(e) => return Err(e.into()),
752    }
753    Ok(())
754}
755
756fn tmp_sibling(dst: &Path) -> PathBuf {
757    let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
758    dst.with_file_name(format!(".hammer-upgrade-tmp-{name}"))
759}
760
761/// Escribe `bytes` en `path` de forma atómica (temporal + rename).
762fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
763    if let Some(parent) = path.parent() {
764        std::fs::create_dir_all(parent)?;
765    }
766    let tmp = tmp_sibling(path);
767    std::fs::write(&tmp, bytes)?;
768    std::fs::rename(&tmp, path)?;
769    Ok(())
770}
771
772/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades).
773fn set_current(state_root: &Path, id: Option<u64>) -> Result<()> {
774    let f = current_file(state_root);
775    match id {
776        Some(id) => write_atomic(&f, id.to_string().as_bytes())?,
777        None => {
778            let _ = std::fs::remove_file(&f);
779        }
780    }
781    Ok(())
782}
783
784fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) {
785    let Some(j) = journal else { return };
786    let mop = match op {
787        ChangeOp::Added => MutationOp::Create,
788        ChangeOp::Replaced => MutationOp::Replace,
789        ChangeOp::Removed => MutationOp::Delete,
790    };
791    let ev = MutationEvent {
792        ts: hammer_journal::now_rfc3339(),
793        op: mop,
794        path: dst.to_path_buf(),
795        by: Actor {
796            // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store.
797            source: Source::HammerHydrate { artifact: tree_dir.to_string() },
798            pid: Some(std::process::id()),
799            uid: None,
800        },
801        content_hash: None,
802        note: Some("hammer upgrade".to_string()),
803    };
804    if let Err(e) = j.record(&ev) {
805        tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario");
806    }
807}
808
809fn now_secs() -> u64 {
810    std::time::SystemTime::now()
811        .duration_since(std::time::UNIX_EPOCH)
812        .map(|d| d.as_secs())
813        .unwrap_or(0)
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    /// Sella un árbol sintético en `store_root` y devuelve su `<hash>-<name>`.
821    fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
822        let store = Store::open(store_root).unwrap();
823        let h = ArtifactHash::from_hex(hex);
824        let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name));
825        let _ = std::fs::remove_dir_all(&staging);
826        std::fs::create_dir_all(&staging).unwrap();
827        build(&staging);
828        store.seal(&staging, &h, name).unwrap();
829        h.store_dir_name(name)
830    }
831
832    fn h64(prefix: &str) -> String {
833        format!("{prefix}{}", "0".repeat(64 - prefix.len()))
834    }
835
836    fn write(p: &Path, bytes: &[u8]) {
837        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
838        std::fs::write(p, bytes).unwrap();
839    }
840
841    /// EVIDENCIA (2026-08-30): ¿el proyector de `apply` escribe a través de un symlink de
842    /// directorio que sale del root? Es el mismo patrón que se midió en `hydrate` — `root.join(rel)`
843    /// y escribir por ruta — y con `--root /mnt/imagen` el root NO es `/`.
844    #[test]
845    fn apply_no_escribe_fuera_del_root_por_un_symlink() {
846        let tmp = tempfile::tempdir().unwrap();
847        let store = tmp.path().join("store");
848        let root = tmp.path().join("root");
849        let state = tmp.path().join("state");
850        let fuera = tmp.path().join("fuera");
851        std::fs::create_dir_all(&fuera).unwrap();
852
853        // Generación 1: deja puesto `usr/share/pkg → <fuera>` (los symlinks se proyectan literales).
854        let g1 = seal_tree(&store, &h64("a1"), "v1", |w| {
855            std::fs::create_dir_all(w.join("usr/share")).unwrap();
856            std::os::unix::fs::symlink(&fuera, w.join("usr/share/pkg")).unwrap();
857        });
858        apply(&store, &g1, None, &root, &state, None).unwrap();
859
860        // Generación 2: trae un fichero DEBAJO de esa ruta.
861        let g2 = seal_tree(&store, &h64("a2"), "v2", |w| {
862            write(&w.join("usr/share/pkg/archivo"), b"contenido");
863        });
864        let r = apply(&store, &g2, None, &root, &state, None);
865
866        assert!(
867            !fuera.join("archivo").exists(),
868            "ESCAPÓ: apply escribió fuera del root a través del symlink ({r:?})"
869        );
870        let err = format!("{}", r.unwrap_err());
871        assert!(err.contains("se saldría del root"), "el error tiene que decir qué pasó: {err}");
872    }
873
874    /// El contrapeso del guardián anterior: un symlink de directorio que se queda DENTRO del root
875    /// tiene que seguir proyectando. Es el caso de usr-merge (`/lib → usr/lib`) y el de los temas de
876    /// iconos; romperlo sería peor que el agujero que se tapa.
877    #[test]
878    fn apply_sigue_proyectando_por_un_symlink_que_no_sale() {
879        let tmp = tempfile::tempdir().unwrap();
880        let store = tmp.path().join("store");
881        let root = tmp.path().join("root");
882        let state = tmp.path().join("state");
883
884        // Generación 1: `usr/share/pkg → ../otro`, relativo y dentro del propio root.
885        let g1 = seal_tree(&store, &h64("b1"), "v1", |w| {
886            write(&w.join("usr/otro/.marca"), b"x");
887            std::fs::create_dir_all(w.join("usr/share")).unwrap();
888            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
889        });
890        apply(&store, &g1, None, &root, &state, None).unwrap();
891
892        // Generación 2: un fichero debajo de esa ruta + lo de la 1 (si no, la 2 la retira).
893        let g2 = seal_tree(&store, &h64("b2"), "v2", |w| {
894            write(&w.join("usr/otro/.marca"), b"x");
895            std::fs::create_dir_all(w.join("usr/share")).unwrap();
896            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
897            write(&w.join("usr/share/pkg/archivo"), b"contenido");
898        });
899        apply(&store, &g2, None, &root, &state, None).unwrap();
900
901        assert!(
902            root.join("usr/otro/archivo").exists(),
903            "el fichero tiene que aterrizar donde apunta el symlink, dentro del root"
904        );
905    }
906
907    #[test]
908    fn apply_projects_tree_and_records_generation() {
909        let tmp = tempfile::tempdir().unwrap();
910        let store = tmp.path().join("store");
911        let root = tmp.path().join("root");
912        let state = tmp.path().join("state");
913        std::fs::create_dir_all(&root).unwrap();
914        // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará.
915        write(&root.join("usr/bin/ls"), b"OLD-ls");
916
917        let dir = seal_tree(&store, &h64("aa"), "v1", |w| {
918            write(&w.join("usr/bin/ls"), b"NEW-ls");
919            write(&w.join("usr/bin/cat"), b"NEW-cat");
920            std::fs::create_dir_all(w.join("bin")).unwrap();
921            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
922        });
923
924        let r = apply(&store, &dir, None, &root, &state, None).unwrap();
925        assert_eq!(r.generation, 1);
926        assert!(!r.already_current);
927        assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]);
928        assert!(r.added.contains(&PathBuf::from("usr/bin/cat")));
929        assert!(r.added.contains(&PathBuf::from("bin/sh")));
930
931        // los bytes nuevos están en el root vivo.
932        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
933        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
934        assert_eq!(std::fs::read_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox"));
935
936        assert_eq!(current(&state).unwrap(), Some(1));
937        assert_eq!(list(&state).unwrap().len(), 1);
938    }
939
940    #[test]
941    fn apply_is_idempotent_for_same_tree() {
942        let tmp = tempfile::tempdir().unwrap();
943        let store = tmp.path().join("store");
944        let root = tmp.path().join("root");
945        let state = tmp.path().join("state");
946        let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A"));
947        let r1 = apply(&store, &dir, None, &root, &state, None).unwrap();
948        assert!(!r1.already_current && r1.generation == 1);
949        let r2 = apply(&store, &dir, None, &root, &state, None).unwrap();
950        assert!(r2.already_current, "mismo árbol ⇒ no-op");
951        assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva");
952    }
953
954    #[test]
955    fn rollback_restores_previous_tree_exactly() {
956        let tmp = tempfile::tempdir().unwrap();
957        let store = tmp.path().join("store");
958        let root = tmp.path().join("root");
959        let state = tmp.path().join("state");
960
961        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| {
962            write(&w.join("usr/bin/ls"), b"LS-v1");
963            write(&w.join("usr/bin/old"), b"OLD-only-in-v1");
964        });
965        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| {
966            write(&w.join("usr/bin/ls"), b"LS-v2");
967            write(&w.join("usr/bin/new"), b"NEW-only-in-v2");
968            // `old` desaparece ⇒ apply de v2 debe retirarlo.
969        });
970
971        apply(&store, &v1, None, &root, &state, None).unwrap();
972        let r2 = apply(&store, &v2, None, &root, &state, None).unwrap();
973        assert_eq!(r2.generation, 2);
974        assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]);
975        // estado tras v2.
976        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2");
977        assert!(root.join("usr/bin/new").exists());
978        assert!(!root.join("usr/bin/old").exists());
979
980        // rollback ⇒ vuelve EXACTAMENTE a v1.
981        let rb = rollback(&root, &state, None).unwrap();
982        assert_eq!(rb.reverted, 2);
983        assert_eq!(rb.now_current, Some(1));
984        assert_eq!(current(&state).unwrap(), Some(1));
985        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido");
986        assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró");
987        assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró");
988    }
989
990    #[test]
991    fn rollback_to_pre_upgrade_state() {
992        let tmp = tempfile::tempdir().unwrap();
993        let store = tmp.path().join("store");
994        let root = tmp.path().join("root");
995        let state = tmp.path().join("state");
996        std::fs::create_dir_all(&root).unwrap();
997        write(&root.join("etc/motd"), b"PRISTINE");
998
999        let v1 = seal_tree(&store, &h64("dd"), "v1", |w| {
1000            write(&w.join("etc/motd"), b"UPGRADED");
1001            write(&w.join("usr/bin/brand-new"), b"X");
1002        });
1003        apply(&store, &v1, None, &root, &state, None).unwrap();
1004        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED");
1005
1006        let rb = rollback(&root, &state, None).unwrap();
1007        assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades");
1008        assert_eq!(current(&state).unwrap(), None);
1009        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada");
1010        assert!(!root.join("usr/bin/brand-new").exists());
1011    }
1012
1013    #[test]
1014    fn rollback_without_generation_errors() {
1015        let tmp = tempfile::tempdir().unwrap();
1016        let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err();
1017        assert!(matches!(err, Error::NothingToRollback), "vino {err}");
1018    }
1019
1020    #[test]
1021    fn apply_rejects_tree_that_does_not_match_expected() {
1022        // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el
1023        // árbol del store no casa, apply rechaza ANTES de tocar el root.
1024        let tmp = tempfile::tempdir().unwrap();
1025        let store_root = tmp.path().join("store");
1026        let root = tmp.path().join("root");
1027        let state = tmp.path().join("state");
1028        let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A"));
1029
1030        // expected MENTIROSO ⇒ Verify, y nada se aplicó.
1031        let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err();
1032        assert!(matches!(err, Error::Verify { .. }), "vino {err}");
1033        assert_eq!(current(&state).unwrap(), None, "no se creó generación");
1034        assert!(!root.join("a").exists(), "el root quedó intacto");
1035
1036        // expected CORRECTO (el of_tree real) ⇒ aplica.
1037        let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string();
1038        let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap();
1039        assert_eq!(r.generation, 1);
1040    }
1041
1042    #[test]
1043    fn journal_records_each_change() {
1044        let tmp = tempfile::tempdir().unwrap();
1045        let store = tmp.path().join("store");
1046        let root = tmp.path().join("root");
1047        let state = tmp.path().join("state");
1048        let j = Journal::open(tmp.path().join("journal")).unwrap();
1049        let dir = seal_tree(&store, &h64("ff"), "v1", |w| {
1050            write(&w.join("usr/bin/a"), b"A");
1051            write(&w.join("usr/bin/b"), b"B");
1052        });
1053        apply(&store, &dir, None, &root, &state, Some(&j)).unwrap();
1054        let evs = j.read_all().unwrap();
1055        assert_eq!(evs.len(), 2);
1056        assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
1057    }
1058
1059    /// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
1060    /// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
1061    fn interrupt_apply(
1062        store: &Path,
1063        tree_dir: &str,
1064        target_root: &Path,
1065        state_root: &Path,
1066        partial: usize,
1067    ) -> GenerationManifest {
1068        let store_h = Store::open(store).unwrap();
1069        let tree_path = store_h.root().join(tree_dir);
1070        let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
1071        let entries = enumerate_tree(&tree_path).unwrap();
1072        let mut changes = Vec::new();
1073        for e in &entries {
1074            let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
1075            changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
1076        }
1077        let id = next_id(state_root).unwrap();
1078        let manifest = GenerationManifest {
1079            id, tree_dir: tree_dir.to_string(), tree_content,
1080            parent: current(state_root).unwrap(), created_at: 0,
1081            files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
1082        };
1083        let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
1084        std::fs::create_dir_all(&backup_root).unwrap();
1085        write_pending(state_root, &manifest).unwrap();
1086        // proyectar SÓLO `partial` entradas (corte a media proyección).
1087        for e in entries.iter().take(partial) {
1088            let dst = target_root.join(&e.rel);
1089            if std::fs::symlink_metadata(&dst).is_ok() {
1090                backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
1091            }
1092            project_entry(&tree_path, e, &dst).unwrap();
1093        }
1094        manifest
1095    }
1096
1097    #[test]
1098    fn apply_refuses_when_a_pending_apply_exists() {
1099        let tmp = tempfile::tempdir().unwrap();
1100        let store = tmp.path().join("store");
1101        let root = tmp.path().join("root");
1102        let state = tmp.path().join("state");
1103        let v1 = seal_tree(&store, &h64("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
1104        interrupt_apply(&store, &v1, &root, &state, 1);
1105        // con un intento pendiente, apply se NIEGA (exige recover).
1106        let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
1107        assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
1108        assert!(pending(&state).unwrap().is_some());
1109    }
1110
1111    #[test]
1112    fn recover_roll_forward_completes_interrupted_apply() {
1113        let tmp = tempfile::tempdir().unwrap();
1114        let store = tmp.path().join("store");
1115        let root = tmp.path().join("root");
1116        let state = tmp.path().join("state");
1117        std::fs::create_dir_all(&root).unwrap();
1118        write(&root.join("etc/keep"), b"PRE");
1119        let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
1120            write(&w.join("usr/bin/ls"), b"NEW-ls");
1121            write(&w.join("usr/bin/cat"), b"NEW-cat");
1122            write(&w.join("usr/bin/grep"), b"NEW-grep");
1123        });
1124        // corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
1125        interrupt_apply(&store, &v1, &root, &state, 1);
1126        assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
1127        assert!(pending(&state).unwrap().is_some());
1128
1129        let r = recover(&store, &root, &state, None, false).unwrap();
1130        assert!(r.recovered && r.rolled_forward && r.generation == 1);
1131        // el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
1132        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
1133        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
1134        assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
1135        assert_eq!(current(&state).unwrap(), Some(1));
1136        assert!(pending(&state).unwrap().is_none());
1137        // y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
1138        rollback(&root, &state, None).unwrap();
1139        assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
1140        assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
1141    }
1142
1143    #[test]
1144    fn recover_roll_back_undoes_interrupted_apply() {
1145        let tmp = tempfile::tempdir().unwrap();
1146        let store = tmp.path().join("store");
1147        let root = tmp.path().join("root");
1148        let state = tmp.path().join("state");
1149        std::fs::create_dir_all(&root).unwrap();
1150        write(&root.join("usr/bin/ls"), b"ORIG-ls");   // existirá ⇒ Replaced (con backup)
1151        let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
1152            write(&w.join("usr/bin/ls"), b"NEW-ls");
1153            write(&w.join("usr/bin/new"), b"NEW-only"); // Added
1154        });
1155        // corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
1156        interrupt_apply(&store, &v1, &root, &state, 1);
1157        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
1158
1159        let r = recover(&store, &root, &state, None, true).unwrap();
1160        assert!(r.recovered && !r.rolled_forward);
1161        // el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
1162        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
1163        assert!(!root.join("usr/bin/new").exists());
1164        assert_eq!(current(&state).unwrap(), None);
1165        assert!(pending(&state).unwrap().is_none());
1166        assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
1167    }
1168
1169    #[test]
1170    fn recover_is_noop_without_pending() {
1171        let tmp = tempfile::tempdir().unwrap();
1172        let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
1173        assert!(!r.recovered);
1174    }
1175
1176    #[test]
1177    fn apply_clears_pending_on_success() {
1178        let tmp = tempfile::tempdir().unwrap();
1179        let store = tmp.path().join("store");
1180        let root = tmp.path().join("root");
1181        let state = tmp.path().join("state");
1182        let v1 = seal_tree(&store, &h64("e4"), "v1", |w| write(&w.join("a"), b"A"));
1183        apply(&store, &v1, None, &root, &state, None).unwrap();
1184        assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
1185    }
1186
1187    #[test]
1188    fn prune_removes_orphans_but_keeps_live_chain() {
1189        let tmp = tempfile::tempdir().unwrap();
1190        let store = tmp.path().join("store");
1191        let root = tmp.path().join("root");
1192        let state = tmp.path().join("state");
1193        let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1"));
1194        let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2"));
1195        let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3"));
1196
1197        apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1
1198        apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1)
1199        apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2)
1200        // rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza).
1201        rollback(&root, &state, None).unwrap();
1202        rollback(&root, &state, None).unwrap();
1203        assert_eq!(current(&state).unwrap(), Some(1));
1204        assert_eq!(live_chain(&state).unwrap(), vec![1]);
1205
1206        let r = prune(&state, None).unwrap();
1207        assert_eq!(r.removed, vec![2, 3], "huérfanas borradas");
1208        assert_eq!(r.kept, vec![1], "cadena viva conservada");
1209        assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![1]);
1210        // idempotente.
1211        assert!(prune(&state, None).unwrap().removed.is_empty());
1212    }
1213
1214    #[test]
1215    fn boot_graph_builds_dag_and_activate_rolls_back_to_ancestor() {
1216        use crate::boot_graph::{activate, build, recovery_id, NodeKind};
1217
1218        let tmp = tempfile::tempdir().unwrap();
1219        let store = tmp.path().join("store");
1220        let root = tmp.path().join("root");
1221        let state = tmp.path().join("state");
1222        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| write(&w.join("f"), b"1"));
1223        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| write(&w.join("f"), b"2"));
1224        let v3 = seal_tree(&store, &h64("c3"), "v3", |w| write(&w.join("f"), b"3"));
1225        apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 (Base)
1226        apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2
1227        apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (viva)
1228
1229        let g = build(&state).unwrap();
1230        assert_eq!(g.version, 1);
1231        // 3 generaciones + el nodo de recuperación sintético.
1232        assert_eq!(g.nodes.len(), 4);
1233        let gen1 = &g.nodes[0];
1234        assert_eq!(gen1.kind, NodeKind::Base, "la primera generación es la raíz del linaje");
1235        assert!(gen1.parents.is_empty());
1236        let gen2 = &g.nodes[1];
1237        assert_eq!(gen2.kind, NodeKind::Generation);
1238        assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre");
1239        // current/default = la generación viva (gen 3).
1240        let gen3 = &g.nodes[2];
1241        assert_eq!(g.current, gen3.id);
1242        assert_eq!(g.default, g.current);
1243        // el nodo de recuperación cuelga de la viva.
1244        let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap();
1245        assert_eq!(rec.id, recovery_id());
1246        assert_eq!(rec.parents, vec![gen3.id.clone()]);
1247
1248        // activar la viva ⇒ no-op.
1249        let r = activate(&root, &state, None, &gen3.id).unwrap();
1250        assert!(r.already_current);
1251        assert_eq!(current(&state).unwrap(), Some(3));
1252
1253        // activar la gen 1 (ancestro) ⇒ rollback paso a paso hasta ella.
1254        let r = activate(&root, &state, None, &gen1.id).unwrap();
1255        assert!(!r.already_current);
1256        assert_eq!(r.rolled_back, vec![3, 2], "revierte de la más nueva a la más vieja");
1257        assert_eq!(current(&state).unwrap(), Some(1));
1258        assert_eq!(std::fs::read(root.join("f")).unwrap(), b"1", "el FHS vivo es el de la gen 1");
1259
1260        // ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto.
1261        let g2 = build(&state).unwrap();
1262        assert_eq!(g2.current, gen1.id);
1263        let err = activate(&root, &state, None, &gen3.id).unwrap_err();
1264        assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");
1265
1266        // recuperación desde la gen 1 ⇒ rollback al estado pre-upgrades (sin current).
1267        let r = activate(&root, &state, None, &recovery_id()).unwrap();
1268        assert_eq!(r.kind, NodeKind::Recovery);
1269        assert_eq!(r.rolled_back, vec![1]);
1270        assert_eq!(current(&state).unwrap(), None);
1271    }
1272
1273    #[test]
1274    fn prune_with_keep_trims_chain_depth() {
1275        let tmp = tempfile::tempdir().unwrap();
1276        let store = tmp.path().join("store");
1277        let root = tmp.path().join("root");
1278        let state = tmp.path().join("state");
1279        for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] {
1280            let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by));
1281            apply(&store, &d, None, &root, &state, None).unwrap();
1282        }
1283        // current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1.
1284        assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]);
1285        let r = prune(&state, Some(2)).unwrap();
1286        assert_eq!(r.kept, vec![2, 3]);
1287        assert_eq!(r.removed, vec![1]);
1288        // gen 1 borrada del disco; 2 y 3 siguen.
1289        assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![2, 3]);
1290    }
1291}