gioser

sergio/takana

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

TOML 188Rust 55Shell 30Markdown 281211 Sutichaykuna

⎇ 68d052136c06b74f858a8a33d1963f45cf244fa0 crates/takana-upgrade/src/lib.rs Rust · 1382 siq'ikuna · 59.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

upgrade: `write_atomic` era atómico pero NO DURABLE — un corte lo dejaba en 0 bytes 68d052136 · 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](takana_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 takana_core::{ArtifactHash, Store};
39use takana_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] takana_core::Error),
52    #[error("journal: {0}")]
53    Journal(#[from] takana_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(
69        "upgrade: el registro del apply pendiente ({path}) está VACÍO. Es la huella de un CORTE a \
70         mitad de escritura: el `rename` llegó al disco y los bytes no. El plan se perdió, así que \
71         no se puede completar ni deshacer automáticamente — pero el árbol de respaldos sigue en \
72         {backups}: restaurá desde ahí, y borrá el `pending.json` y la generación a medias"
73    )]
74    PendingCorrupt { path: String, backups: String },
75    #[error("upgrade: {0}")]
76    Other(String),
77}
78
79pub type Result<T> = std::result::Result<T, Error>;
80
81/// Qué le pasó a un path concreto al aplicar una generación. Determina cómo se deshace.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum ChangeOp {
85    /// El path no existía antes ⇒ rollback lo **borra**.
86    Added,
87    /// El path existía y fue pisado ⇒ `backup/` guarda los bytes previos ⇒ rollback los **restaura**.
88    Replaced,
89    /// El path existía en el árbol previo y el nuevo no lo tiene ⇒ apply lo borra; `backup/` guarda
90    /// los bytes previos ⇒ rollback los **restaura**.
91    Removed,
92}
93
94/// Un cambio atómico sobre un path del FHS, relativo a `target_root`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Change {
97    pub path: PathBuf,
98    pub op: ChangeOp,
99}
100
101/// Manifiesto persistente de una generación — la unidad de upgrade/rollback.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GenerationManifest {
104    pub id: u64,
105    /// Directorio del artefacto en el store (`<hash>-<name>`) cuyo árbol esta generación proyectó.
106    pub tree_dir: String,
107    /// `of_tree` (`b3:…`) del árbol aplicado: ancla de integridad y desempate vs el store.
108    pub tree_content: String,
109    /// Generación anterior (a la que el rollback vuelve). `None` en la primera.
110    pub parent: Option<u64>,
111    /// Segundos desde UNIX epoch al apply.
112    pub created_at: u64,
113    /// Lista relativa (ordenada) de los ficheros que el árbol aporta — sirve para que el SIGUIENTE
114    /// apply sepa qué retirar (paths del árbol previo ausentes en el nuevo).
115    pub files: Vec<PathBuf>,
116    /// Cada path tocado y cómo, para deshacer.
117    pub changes: Vec<Change>,
118}
119
120/// Reporte de un [`apply`].
121#[derive(Debug, Clone, Default)]
122pub struct ApplyReport {
123    pub generation: u64,
124    pub added: Vec<PathBuf>,
125    pub replaced: Vec<PathBuf>,
126    pub removed: Vec<PathBuf>,
127    /// `true` si el árbol pedido ya era el vivo (apply idempotente, no se creó generación nueva).
128    pub already_current: bool,
129}
130
131/// Reporte de un [`recover`].
132#[derive(Debug, Clone, Default)]
133pub struct RecoverReport {
134    /// `false` si no había nada que recuperar (no había intento pendiente).
135    pub recovered: bool,
136    /// `true` = se completó el apply (roll-forward); `false` = se deshizo (roll-back).
137    pub rolled_forward: bool,
138    /// Generación afectada por el intento pendiente.
139    pub generation: u64,
140}
141
142/// Reporte de un [`rollback`].
143#[derive(Debug, Clone, Default)]
144pub struct RollbackReport {
145    /// Generación que se revirtió (la que estaba viva).
146    pub reverted: u64,
147    /// Generación que quedó viva tras revertir (`None` si se volvió al estado pre-upgrades).
148    pub now_current: Option<u64>,
149    pub restored: Vec<PathBuf>,
150    pub deleted: Vec<PathBuf>,
151}
152
153// ---------------------------------------------------------------------------------------------------
154// Registro de generaciones (lectura del estado).
155// ---------------------------------------------------------------------------------------------------
156
157fn generations_dir(state_root: &Path) -> PathBuf {
158    state_root.join("generations")
159}
160
161fn current_file(state_root: &Path) -> PathBuf {
162    state_root.join("current")
163}
164
165fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
166    generations_dir(state_root).join(id.to_string()).join("manifest.json")
167}
168
169/// El **journal de intención**: el plan de un apply en curso. Existe sólo entre el inicio de la
170/// proyección y el commit; si sobrevive a un reinicio, un apply quedó a medias y hay que [`recover`].
171fn pending_path(state_root: &Path) -> PathBuf {
172    state_root.join("pending.json")
173}
174
175/// Lee el plan pendiente (apply interrumpido), o `None` si no hay ninguno.
176pub fn pending(state_root: &Path) -> Result<Option<GenerationManifest>> {
177    let p = pending_path(state_root);
178    if !p.is_file() {
179        return Ok(None);
180    }
181    let bytes = std::fs::read(&p)?;
182    // Un `pending.json` VACÍO o ilegible es la huella de un corte a mitad de escritura — justo el
183    // caso para el que existe `recover`. Un `EOF while parsing a value` crudo no dice eso: dice que
184    // el JSON está mal, y manda a mirar al lugar equivocado.
185    if bytes.iter().all(|b| b.is_ascii_whitespace()) {
186        return Err(Error::PendingCorrupt {
187            path: p.display().to_string(),
188            backups: generations_dir(state_root).display().to_string(),
189        });
190    }
191    Ok(Some(serde_json::from_slice(&bytes)?))
192}
193
194fn write_pending(state_root: &Path, manifest: &GenerationManifest) -> Result<()> {
195    write_atomic(&pending_path(state_root), &serde_json::to_vec_pretty(manifest)?)
196}
197
198fn clear_pending(state_root: &Path) -> Result<()> {
199    match std::fs::remove_file(pending_path(state_root)) {
200        Ok(()) => Ok(()),
201        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
202        Err(e) => Err(e.into()),
203    }
204}
205
206/// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
207pub fn current(state_root: &Path) -> Result<Option<u64>> {
208    let f = current_file(state_root);
209    if !f.is_file() {
210        return Ok(None);
211    }
212    let s = std::fs::read_to_string(&f)?;
213    let s = s.trim();
214    if s.is_empty() {
215        return Ok(None);
216    }
217    s.parse::<u64>()
218        .map(Some)
219        .map_err(|_| Error::Other(format!("`current` ilegible: {s:?}")))
220}
221
222/// Lee el manifiesto de una generación concreta.
223pub fn get(state_root: &Path, id: u64) -> Result<Option<GenerationManifest>> {
224    let p = manifest_path(state_root, id);
225    if !p.is_file() {
226        return Ok(None);
227    }
228    Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
229}
230
231/// Lista todas las generaciones registradas, ordenadas por id ascendente.
232pub fn list(state_root: &Path) -> Result<Vec<GenerationManifest>> {
233    let dir = generations_dir(state_root);
234    if !dir.is_dir() {
235        return Ok(Vec::new());
236    }
237    let mut out: Vec<GenerationManifest> = Vec::new();
238    for entry in std::fs::read_dir(&dir)? {
239        let entry = entry?;
240        let mp = entry.path().join("manifest.json");
241        if mp.is_file() {
242            out.push(serde_json::from_slice(&std::fs::read(&mp)?)?);
243        }
244    }
245    out.sort_by_key(|m| m.id);
246    Ok(out)
247}
248
249fn next_id(state_root: &Path) -> Result<u64> {
250    Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
251}
252
253/// La **cadena viva**: la generación actual seguida de sus ancestros vía `parent`, de la más nueva a
254/// la más vieja. Son las únicas generaciones que el `rollback` puede necesitar (no hay "redo": tras un
255/// rollback las generaciones más nuevas quedan **huérfanas**). Vacía si no hay `current`.
256pub fn live_chain(state_root: &Path) -> Result<Vec<u64>> {
257    let mut chain = Vec::new();
258    let mut cur = current(state_root)?;
259    let mut guard = 0usize; // anti-ciclo defensivo (un manifiesto manipulado no debe colgar el proceso)
260    while let Some(id) = cur {
261        if chain.contains(&id) || guard > 100_000 {
262            break;
263        }
264        chain.push(id);
265        guard += 1;
266        cur = get(state_root, id)?.and_then(|m| m.parent);
267    }
268    Ok(chain)
269}
270
271/// Reporte de un [`prune`].
272#[derive(Debug, Clone, Default)]
273pub struct PruneReport {
274    pub removed: Vec<u64>,
275    pub kept: Vec<u64>,
276}
277
278/// Recupera espacio borrando generaciones (manifiesto + `backup/`) que el rollback ya no puede
279/// alcanzar. **Siempre** se conservan las de la [cadena viva](live_chain); el resto (huérfanas tras un
280/// rollback) se borran. Si `keep` es `Some(n)`, además se **recorta la cadena** a sus `n` generaciones
281/// más nuevas — esto limita la profundidad de rollback disponible (como `delete-generations` en NixOS),
282/// así que `None` (conservar toda la cadena) es lo seguro por defecto. Idempotente.
283pub fn prune(state_root: &Path, keep: Option<usize>) -> Result<PruneReport> {
284    let chain = live_chain(state_root)?;
285    let keep_set: std::collections::BTreeSet<u64> = match keep {
286        Some(n) => chain.iter().take(n).copied().collect(),
287        None => chain.iter().copied().collect(),
288    };
289    let mut report = PruneReport::default();
290    for m in list(state_root)? {
291        if keep_set.contains(&m.id) {
292            report.kept.push(m.id);
293            continue;
294        }
295        let gen_base = generations_dir(state_root).join(m.id.to_string());
296        std::fs::remove_dir_all(&gen_base)?;
297        report.removed.push(m.id);
298    }
299    report.removed.sort_unstable();
300    report.kept.sort_unstable();
301    Ok(report)
302}
303
304// ---------------------------------------------------------------------------------------------------
305// Enumeración de árboles.
306// ---------------------------------------------------------------------------------------------------
307
308/// Un fichero de un árbol: su path relativo y su tipo de contenido (lo que `of_tree` distingue).
309#[derive(Debug, Clone)]
310struct TreeEntry {
311    rel: PathBuf,
312    kind: EntryKind,
313}
314
315#[derive(Debug, Clone)]
316enum EntryKind {
317    /// Fichero regular. `exec` = bit de ejecución (de_tree lo mira).
318    File { exec: bool },
319    /// Symlink → target literal (sin resolver).
320    Symlink(PathBuf),
321}
322
323/// Camina `root` y devuelve sus ficheros (regulares + symlinks) con path relativo, ordenados. Los
324/// directorios no se listan: se recrean al proyectar según haga falta.
325fn enumerate_tree(root: &Path) -> Result<Vec<TreeEntry>> {
326    let mut out = Vec::new();
327    fn walk(root: &Path, dir: &Path, out: &mut Vec<TreeEntry>) -> Result<()> {
328        use std::os::unix::fs::PermissionsExt;
329        for entry in std::fs::read_dir(dir)? {
330            let entry = entry?;
331            let p = entry.path();
332            let meta = std::fs::symlink_metadata(&p)?;
333            let ft = meta.file_type();
334            let rel = p.strip_prefix(root).expect("p ⊂ root").to_path_buf();
335            if ft.is_symlink() {
336                out.push(TreeEntry { rel, kind: EntryKind::Symlink(std::fs::read_link(&p)?) });
337            } else if ft.is_dir() {
338                walk(root, &p, out)?;
339            } else if ft.is_file() {
340                let exec = meta.permissions().mode() & 0o111 != 0;
341                out.push(TreeEntry { rel, kind: EntryKind::File { exec } });
342            }
343        }
344        Ok(())
345    }
346    walk(root, root, &mut out)?;
347    out.sort_by(|a, b| a.rel.cmp(&b.rel));
348    Ok(out)
349}
350
351// ---------------------------------------------------------------------------------------------------
352// Apply.
353// ---------------------------------------------------------------------------------------------------
354
355/// Aplica el árbol `tree_dir` del store sobre `target_root`, dejando una generación nueva.
356///
357/// Pasos: (1) resuelve y **verifica** el árbol del store (su `of_tree` debe casar el del índice);
358/// (2) diferencia contra el árbol de la generación viva ⇒ qué se añade/pisa/retira; (3) **respalda**
359/// el estado previo de cada path tocado; (4) proyecta el árbol nuevo (escritura a temporal + rename)
360/// y retira los ficheros sobrantes, registrando cada cambio en el diario; (5) escribe el manifiesto y
361/// **avanza `current`** (commit-point).
362pub fn apply(
363    store_root: &Path,
364    tree_dir: &str,
365    expected: Option<&str>,
366    target_root: &Path,
367    state_root: &Path,
368    journal: Option<&Journal>,
369) -> Result<ApplyReport> {
370    let store = Store::open(store_root)?;
371    let tree_path = store.root().join(tree_dir);
372    if !tree_path.is_dir() {
373        return Err(Error::Other(format!(
374            "el árbol '{tree_dir}' no existe en el store {}",
375            store.root().display()
376        )));
377    }
378    // El of_tree real ancla la identidad del árbol. Si el llamador anuncia uno esperado (de un índice
379    // de mirror o una release firmada), lo exigimos: defiende de un store corrupto/manipulado entre el
380    // sellado y el apply. Sin `expected`, el store local se confía (sellado read-only) y el of_tree
381    // sólo se registra en el manifiesto.
382    let got = ArtifactHash::of_tree(&tree_path)?;
383    let tree_content = got.as_str().to_string();
384    if let Some(want) = expected {
385        if tree_content != want {
386            return Err(Error::Verify {
387                dir: tree_dir.to_string(),
388                want: want.to_string(),
389                got: tree_content,
390            });
391        }
392    }
393
394    // Un apply previo interrumpido deja un `pending.json` ⇒ exigimos `recover` explícito antes de tocar
395    // nada (no pisamos un FHS a medias). El recover lo completa (roll-forward) o lo deshace (roll-back).
396    if let Some(p) = pending(state_root)? {
397        return Err(Error::PendingExists { generation: p.id });
398    }
399
400    // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
401    if let Some(cur) = current(state_root)? {
402        if let Some(m) = get(state_root, cur)? {
403            if m.tree_content == tree_content {
404                return Ok(ApplyReport { generation: cur, already_current: true, ..Default::default() });
405            }
406        }
407    }
408
409    // --- PLAN completo ANTES de tocar nada: entradas del árbol nuevo + ficheros a retirar + op/path ---
410    let new_entries = enumerate_tree(&tree_path)?;
411    let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
412    let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
413    let prev_id = current(state_root)?;
414    let prev_files: Vec<PathBuf> = match prev_id {
415        Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
416        None => Vec::new(),
417    };
418    let removed_files: Vec<PathBuf> =
419        prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
420
421    let mut changes: Vec<Change> = Vec::with_capacity(new_entries.len() + removed_files.len());
422    for e in &new_entries {
423        let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
424        changes.push(Change {
425            path: e.rel.clone(),
426            op: if existed { ChangeOp::Replaced } else { ChangeOp::Added },
427        });
428    }
429    for rel in &removed_files {
430        if std::fs::symlink_metadata(target_root.join(rel)).is_ok() {
431            changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
432        }
433    }
434
435    let id = next_id(state_root)?;
436    let manifest = GenerationManifest {
437        id,
438        tree_dir: tree_dir.to_string(),
439        tree_content,
440        parent: prev_id,
441        created_at: now_secs(),
442        files: new_files,
443        changes,
444    };
445
446    let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
447    std::fs::create_dir_all(&backup_root)?;
448
449    // (INTENTO) escribir el plan ANTES de proyectar: el commit-point sigue siendo `current`, pero ahora
450    // un corte a media proyección es recuperable — `recover` re-ejecuta el plan (idempotente) o lo deshace.
451    write_pending(state_root, &manifest)?;
452
453    // (PROYECTAR) ejecutar el plan: backups idempotentes (no pisan el original) + escritura temporal+rename.
454    project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
455
456    // (COMMIT) manifiesto definitivo + avance de `current` + limpiar el intento.
457    write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
458    set_current(state_root, Some(id))?;
459    clear_pending(state_root)?;
460    Ok(report_from_changes(id, &manifest.changes))
461}
462
463/// Construye el [`ApplyReport`] a partir de la lista de cambios del plan.
464fn report_from_changes(id: u64, changes: &[Change]) -> ApplyReport {
465    let mut r = ApplyReport { generation: id, ..Default::default() };
466    for c in changes {
467        match c.op {
468            ChangeOp::Added => r.added.push(c.path.clone()),
469            ChangeOp::Replaced => r.replaced.push(c.path.clone()),
470            ChangeOp::Removed => r.removed.push(c.path.clone()),
471        }
472    }
473    r
474}
475
476/// La ruta REAL de `target_root`, resolviendo los symlinks del tramo que ya existe y dejando
477/// pegado el tramo que todavía no (que `apply` va a crear como directorios de verdad). Sin esto no
478/// habría con qué comparar cuando el root aún no existe — el caso de una imagen nueva.
479fn raiz_real(target_root: &Path) -> PathBuf {
480    let mut faltan: Vec<std::ffi::OsString> = Vec::new();
481    let mut p = target_root.to_path_buf();
482    loop {
483        if let Ok(mut r) = p.canonicalize() {
484            for c in faltan.iter().rev() {
485                r.push(c);
486            }
487            return r;
488        }
489        match (p.file_name().map(|n| n.to_os_string()), p.parent().map(|x| x.to_path_buf())) {
490            (Some(n), Some(par)) => {
491                faltan.push(n);
492                p = par;
493            }
494            _ => return target_root.to_path_buf(),
495        }
496    }
497}
498
499/// El destino no puede salirse del root. **Se mide el DIRECTORIO padre, no el destino**: un
500/// `Replaced` sobre un symlink que apunta afuera es legítimo —`rename` pisa el symlink, no escribe
501/// a través de él— y medir el destino lo rechazaría por error.
502///
503/// EL FALLO QUE TAPA (medido 2026-08-30, `apply_no_escribe_fuera_del_root_por_un_symlink`): igual
504/// que en `takana-build/hydrate.rs`, proyectar es `target_root.join(rel)` + escribir por ruta, así
505/// que un symlink de DIRECTORIO ya presente en el root se sigue. Una generación que deja puesto
506/// `usr/share/pkg → /algún/lado` hacía que la siguiente escribiera `usr/share/pkg/archivo` FUERA
507/// del root, con `ApplyReport` en verde. Con `--root /` no cambia nada (todo empieza por `/`); con
508/// `--root /mnt/imagen`, que es el caso real de armar una imagen, sí.
509fn dentro_de_raiz(raiz: &Path, dst: &Path) -> Result<()> {
510    let padre = match dst.parent() {
511        Some(p) => p,
512        None => return Ok(()),
513    };
514    let real = raiz_real(padre);
515    if !real.starts_with(raiz) {
516        return Err(Error::Io(std::io::Error::other(format!(
517            "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.",
518            padre.display(),
519            real.display(),
520            raiz.display()
521        ))));
522    }
523    Ok(())
524}
525
526/// Ejecuta el plan de una generación sobre `target_root`: proyecta los Added/Replaced del árbol y
527/// retira los Removed, respaldando el ORIGINAL de cada path tocado **idempotentemente** (no pisa un
528/// backup ya existente — clave para que `recover` pueda re-ejecutar sin perder el estado previo). Es
529/// **re-entrante**: correrlo dos veces deja el mismo resultado.
530fn project_plan(
531    tree_path: &Path,
532    manifest: &GenerationManifest,
533    target_root: &Path,
534    backup_root: &Path,
535    journal: Option<&Journal>,
536) -> Result<()> {
537    let entries = enumerate_tree(tree_path)?;
538    let by_rel: std::collections::BTreeMap<&PathBuf, &TreeEntry> =
539        entries.iter().map(|e| (&e.rel, e)).collect();
540    let raiz = raiz_real(target_root);
541    for ch in &manifest.changes {
542        let dst = target_root.join(&ch.path);
543        dentro_de_raiz(&raiz, &dst)?;
544        match ch.op {
545            ChangeOp::Added | ChangeOp::Replaced => {
546                if ch.op == ChangeOp::Replaced {
547                    backup_existing_once(&dst, &backup_root.join(&ch.path))?;
548                }
549                if let Some(e) = by_rel.get(&ch.path) {
550                    project_entry(tree_path, e, &dst)?;
551                }
552            }
553            ChangeOp::Removed => {
554                backup_existing_once(&dst, &backup_root.join(&ch.path))?;
555                remove_path(&dst)?;
556            }
557        }
558        journal_change(journal, &manifest.tree_dir, &dst, ch.op);
559    }
560    Ok(())
561}
562
563/// Deshace el plan de un manifiesto sobre `target_root` (orden inverso): borra los Added, restaura los
564/// Replaced/Removed desde `backup_root`. Idempotente. Compartido por [`rollback`] y `recover --rollback`.
565fn undo_changes(
566    manifest: &GenerationManifest,
567    target_root: &Path,
568    backup_root: &Path,
569    journal: Option<&Journal>,
570    report_restored: &mut Vec<PathBuf>,
571    report_deleted: &mut Vec<PathBuf>,
572) -> Result<()> {
573    let raiz = raiz_real(target_root);
574    for ch in manifest.changes.iter().rev() {
575        let dst = target_root.join(&ch.path);
576        dentro_de_raiz(&raiz, &dst)?;
577        match ch.op {
578            ChangeOp::Added => {
579                if std::fs::symlink_metadata(&dst).is_ok() {
580                    remove_path(&dst)?;
581                    report_deleted.push(ch.path.clone());
582                    journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Removed);
583                }
584            }
585            ChangeOp::Replaced | ChangeOp::Removed => {
586                restore_from_backup(&backup_root.join(&ch.path), &dst)?;
587                report_restored.push(ch.path.clone());
588                journal_change(journal, &manifest.tree_dir, &dst, ChangeOp::Replaced);
589            }
590        }
591    }
592    Ok(())
593}
594
595// ---------------------------------------------------------------------------------------------------
596// Rollback.
597// ---------------------------------------------------------------------------------------------------
598
599/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo
600/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados.
601pub fn rollback(
602    target_root: &Path,
603    state_root: &Path,
604    journal: Option<&Journal>,
605) -> Result<RollbackReport> {
606    let cur = current(state_root)?.ok_or(Error::NothingToRollback)?;
607    let m = get(state_root, cur)?
608        .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?;
609    let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
610
611    let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
612    undo_changes(&m, target_root, &backup_root, journal, &mut report.restored, &mut report.deleted)?;
613    set_current(state_root, m.parent)?;
614    Ok(report)
615}
616
617/// Completa o deshace un apply interrumpido (un `pending.json` que sobrevivió a un corte/reinicio).
618///
619/// - **roll-forward** (`rollback=false`, default): re-ejecuta el plan pendiente (idempotente) y commitea
620///   — el FHS queda como si el apply hubiera terminado. Re-verifica el `of_tree` del árbol contra el que
621///   el plan registró (defensa ante un store que cambió bajo los pies).
622/// - **roll-back** (`rollback=true`): deshace el plan (restaura backups, borra lo añadido), elimina la
623///   generación a medias y vuelve `current` al padre — el FHS queda como ANTES del apply.
624///
625/// No-op (sin error) si no hay intento pendiente. Idempotente: re-correrlo es seguro.
626pub fn recover(
627    store_root: &Path,
628    target_root: &Path,
629    state_root: &Path,
630    journal: Option<&Journal>,
631    rollback: bool,
632) -> Result<RecoverReport> {
633    let manifest = match pending(state_root)? {
634        Some(m) => m,
635        None => return Ok(RecoverReport::default()),
636    };
637    let backup_root = generations_dir(state_root).join(manifest.id.to_string()).join("backup");
638
639    if rollback {
640        let (mut restored, mut deleted) = (Vec::new(), Vec::new());
641        undo_changes(&manifest, target_root, &backup_root, journal, &mut restored, &mut deleted)?;
642        // El apply nunca avanzó `current` (eso era el commit-point); por las dudas, asegurarlo en el padre.
643        if current(state_root)? == Some(manifest.id) {
644            set_current(state_root, manifest.parent)?;
645        }
646        let _ = std::fs::remove_dir_all(generations_dir(state_root).join(manifest.id.to_string()));
647        clear_pending(state_root)?;
648        return Ok(RecoverReport { recovered: true, rolled_forward: false, generation: manifest.id });
649    }
650
651    // roll-forward: re-resolver y re-verificar el árbol del store, completar la proyección, commitear.
652    let store = Store::open(store_root)?;
653    let tree_path = store.root().join(&manifest.tree_dir);
654    if !tree_path.is_dir() {
655        return Err(Error::Other(format!(
656            "recover: el árbol '{}' del intento ya no existe en el store; usá --rollback",
657            manifest.tree_dir
658        )));
659    }
660    let got = ArtifactHash::of_tree(&tree_path)?;
661    if got.as_str() != manifest.tree_content {
662        return Err(Error::Verify {
663            dir: manifest.tree_dir.clone(),
664            want: manifest.tree_content.clone(),
665            got: got.as_str().to_string(),
666        });
667    }
668    project_plan(&tree_path, &manifest, target_root, &backup_root, journal)?;
669    write_atomic(&manifest_path(state_root, manifest.id), &serde_json::to_vec_pretty(&manifest)?)?;
670    set_current(state_root, Some(manifest.id))?;
671    clear_pending(state_root)?;
672    Ok(RecoverReport { recovered: true, rolled_forward: true, generation: manifest.id })
673}
674
675// ---------------------------------------------------------------------------------------------------
676// Primitivas de filesystem.
677// ---------------------------------------------------------------------------------------------------
678
679/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico).
680fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> {
681    if let Some(parent) = dst.parent() {
682        std::fs::create_dir_all(parent)?;
683    }
684    let tmp = tmp_sibling(dst);
685    let _ = remove_path(&tmp);
686    match &e.kind {
687        EntryKind::File { exec } => {
688            let src = tree_root.join(&e.rel);
689            std::fs::copy(&src, &tmp)?;
690            if *exec {
691                use std::os::unix::fs::PermissionsExt;
692                let mode = std::fs::metadata(&src)?.permissions().mode();
693                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
694            }
695        }
696        EntryKind::Symlink(target) => {
697            std::os::unix::fs::symlink(target, &tmp)?;
698        }
699    }
700    // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS).
701    std::fs::rename(&tmp, dst)?;
702    Ok(())
703}
704
705/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo.
706fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
707    if let Some(parent) = bak.parent() {
708        std::fs::create_dir_all(parent)?;
709    }
710    let meta = std::fs::symlink_metadata(src)?;
711    if meta.file_type().is_symlink() {
712        let _ = remove_path(bak);
713        std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?;
714    } else if meta.is_file() {
715        let _ = remove_path(bak);
716        std::fs::copy(src, bak)?;
717        std::fs::set_permissions(bak, meta.permissions())?;
718    } else {
719        return Err(Error::Other(format!(
720            "no sé respaldar {} (no es fichero ni symlink)",
721            src.display()
722        )));
723    }
724    Ok(())
725}
726
727/// Respalda el original de `src` en `bak` **sólo si aún no hay backup** y `src` existe. Idempotente:
728/// preserva el primer original capturado (clave para que un `recover` roll-forward re-ejecute la
729/// proyección sin sobrescribir el backup con la versión ya proyectada).
730fn backup_existing_once(src: &Path, bak: &Path) -> Result<()> {
731    if std::fs::symlink_metadata(bak).is_ok() {
732        return Ok(()); // ya respaldado
733    }
734    if std::fs::symlink_metadata(src).is_err() {
735        return Ok(()); // nada que respaldar
736    }
737    backup_existing(src, bak)
738}
739
740/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
741/// (idempotencia: ya restaurado o nunca existió).
742fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
743    let meta = match std::fs::symlink_metadata(bak) {
744        Ok(m) => m,
745        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
746        Err(e) => return Err(e.into()),
747    };
748    if let Some(parent) = dst.parent() {
749        std::fs::create_dir_all(parent)?;
750    }
751    let tmp = tmp_sibling(dst);
752    let _ = remove_path(&tmp);
753    if meta.file_type().is_symlink() {
754        std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?;
755    } else {
756        std::fs::copy(bak, &tmp)?;
757        std::fs::set_permissions(&tmp, meta.permissions())?;
758    }
759    std::fs::rename(&tmp, dst)?;
760    Ok(())
761}
762
763fn remove_path(p: &Path) -> Result<()> {
764    match std::fs::symlink_metadata(p) {
765        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?,
766        Ok(_) => std::fs::remove_file(p)?,
767        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
768        Err(e) => return Err(e.into()),
769    }
770    Ok(())
771}
772
773fn tmp_sibling(dst: &Path) -> PathBuf {
774    let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
775    dst.with_file_name(format!(".hammer-upgrade-tmp-{name}"))
776}
777
778/// Escribe `bytes` en `path` de forma atómica **y DURABLE** (temporal + fsync + rename + fsync del dir).
779///
780/// ⚠ Los dos `fsync` no son prolijidad: sin ellos «atómico» vale sólo frente a OTROS PROCESOS, no
781/// frente a un corte. `rename` sobre un fichero cuyos datos siguen en la caché de página deja, tras
782/// un corte de luz, la entrada nueva apuntando a bloques que nunca se escribieron — o sea un fichero
783/// de **CERO BYTES**.
784///
785/// Medido el 2026-09-11 (SDD 28 §6.14): tras un `hcloud server reset` —que es un corte duro, no un
786/// apagado limpio— la caja volvió con `pending.json` y `generations/2/manifest.json` en 0 bytes, el
787/// upgrade perdido, y `upgrade recover` —cuyo único propósito es sobrevivir exactamente a eso—
788/// abortando con `json: EOF while parsing a value`. La máquina de estados que promete resistir un
789/// corte no puede depender de escrituras que no resisten un corte.
790///
791/// `fsync` del fichero garantiza los datos; `fsync` del DIRECTORIO garantiza que la entrada del
792/// rename sobreviva. Hacen falta los dos.
793fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
794    if let Some(parent) = path.parent() {
795        std::fs::create_dir_all(parent)?;
796    }
797    let tmp = tmp_sibling(path);
798    {
799        let mut f = std::fs::File::create(&tmp)?;
800        use std::io::Write;
801        f.write_all(bytes)?;
802        f.sync_all()?; // datos + metadatos del temporal, ANTES del rename
803    }
804    std::fs::rename(&tmp, path)?;
805    if let Some(parent) = path.parent() {
806        // La entrada de directorio que el rename creó también necesita llegar al disco.
807        if let Ok(d) = std::fs::File::open(parent) {
808            let _ = d.sync_all();
809        }
810    }
811    Ok(())
812}
813
814/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades).
815fn set_current(state_root: &Path, id: Option<u64>) -> Result<()> {
816    let f = current_file(state_root);
817    match id {
818        Some(id) => write_atomic(&f, id.to_string().as_bytes())?,
819        None => {
820            let _ = std::fs::remove_file(&f);
821        }
822    }
823    Ok(())
824}
825
826fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) {
827    let Some(j) = journal else { return };
828    let mop = match op {
829        ChangeOp::Added => MutationOp::Create,
830        ChangeOp::Replaced => MutationOp::Replace,
831        ChangeOp::Removed => MutationOp::Delete,
832    };
833    let ev = MutationEvent {
834        ts: takana_journal::now_rfc3339(),
835        op: mop,
836        path: dst.to_path_buf(),
837        by: Actor {
838            // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store.
839            source: Source::HammerHydrate { artifact: tree_dir.to_string() },
840            pid: Some(std::process::id()),
841            uid: None,
842        },
843        content_hash: None,
844        note: Some("hammer upgrade".to_string()),
845    };
846    if let Err(e) = j.record(&ev) {
847        tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario");
848    }
849}
850
851fn now_secs() -> u64 {
852    std::time::SystemTime::now()
853        .duration_since(std::time::UNIX_EPOCH)
854        .map(|d| d.as_secs())
855        .unwrap_or(0)
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    /// Sella un árbol sintético en `store_root` y devuelve su `<hash>-<name>`.
863    fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
864        let store = Store::open(store_root).unwrap();
865        let h = ArtifactHash::from_hex(hex);
866        let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name));
867        let _ = std::fs::remove_dir_all(&staging);
868        std::fs::create_dir_all(&staging).unwrap();
869        build(&staging);
870        store.seal(&staging, &h, name).unwrap();
871        h.store_dir_name(name)
872    }
873
874    /// Un `pending.json` de CERO BYTES es la huella de un corte a mitad de escritura — el caso para
875    /// el que `recover` existe. Antes reventaba con `json: EOF while parsing a value`, que manda a
876    /// mirar el JSON en vez del corte. Medido en la caja de producción el 2026-09-11 tras un
877    /// `hcloud server reset` (corte duro): `pending.json` y el manifiesto de la generación, los dos
878    /// en 0 bytes, y `upgrade recover` abortando.
879    #[test]
880    fn pending_vacio_se_reporta_como_corte_no_como_json_roto() {
881        let tmp = tempfile::tempdir().unwrap();
882        let state = tmp.path();
883        std::fs::create_dir_all(state).unwrap();
884
885        // (1) sin fichero ⇒ no hay pendiente, sin error
886        assert!(matches!(pending(state), Ok(None)), "sin pending.json no hay intento pendiente");
887
888        // (2) fichero VACÍO ⇒ error de CORTE, nombrando el árbol de respaldos
889        std::fs::write(pending_path(state), b"").unwrap();
890        match pending(state) {
891            Err(Error::PendingCorrupt { path, backups }) => {
892                assert!(path.contains("pending.json"), "el error nombra el fichero: {path}");
893                assert!(!backups.is_empty(), "y dónde están los respaldos");
894            }
895            otro => panic!("esperaba PendingCorrupt, obtuve {otro:?}"),
896        }
897
898        // (3) sólo espacios ⇒ lo mismo (una escritura truncada puede dejar basura en blanco)
899        std::fs::write(pending_path(state), b"  \n").unwrap();
900        assert!(matches!(pending(state), Err(Error::PendingCorrupt { .. })));
901    }
902
903    /// El control que hace que el test de arriba valga: un `pending.json` VÁLIDO se lee igual que
904    /// siempre. Si esto se rompiera, el chequeo nuevo estaría tragándose intentos reales.
905    #[test]
906    fn pending_valido_sigue_leyendose() {
907        let tmp = tempfile::tempdir().unwrap();
908        let state = tmp.path();
909        let m = GenerationManifest {
910            id: 7,
911            tree_dir: "aaaa-x".into(),
912            tree_content: "b3:aaaa".into(),
913            parent: None,
914            created_at: 0,
915            files: vec![],
916            changes: vec![],
917        };
918        write_pending(state, &m).unwrap();
919        let leido = pending(state).unwrap().expect("un pending válido se lee");
920        assert_eq!(leido.id, 7);
921    }
922
923    fn h64(prefix: &str) -> String {
924        format!("{prefix}{}", "0".repeat(64 - prefix.len()))
925    }
926
927    fn write(p: &Path, bytes: &[u8]) {
928        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
929        std::fs::write(p, bytes).unwrap();
930    }
931
932    /// EVIDENCIA (2026-08-30): ¿el proyector de `apply` escribe a través de un symlink de
933    /// directorio que sale del root? Es el mismo patrón que se midió en `hydrate` — `root.join(rel)`
934    /// y escribir por ruta — y con `--root /mnt/imagen` el root NO es `/`.
935    #[test]
936    fn apply_no_escribe_fuera_del_root_por_un_symlink() {
937        let tmp = tempfile::tempdir().unwrap();
938        let store = tmp.path().join("store");
939        let root = tmp.path().join("root");
940        let state = tmp.path().join("state");
941        let fuera = tmp.path().join("fuera");
942        std::fs::create_dir_all(&fuera).unwrap();
943
944        // Generación 1: deja puesto `usr/share/pkg → <fuera>` (los symlinks se proyectan literales).
945        let g1 = seal_tree(&store, &h64("a1"), "v1", |w| {
946            std::fs::create_dir_all(w.join("usr/share")).unwrap();
947            std::os::unix::fs::symlink(&fuera, w.join("usr/share/pkg")).unwrap();
948        });
949        apply(&store, &g1, None, &root, &state, None).unwrap();
950
951        // Generación 2: trae un fichero DEBAJO de esa ruta.
952        let g2 = seal_tree(&store, &h64("a2"), "v2", |w| {
953            write(&w.join("usr/share/pkg/archivo"), b"contenido");
954        });
955        let r = apply(&store, &g2, None, &root, &state, None);
956
957        assert!(
958            !fuera.join("archivo").exists(),
959            "ESCAPÓ: apply escribió fuera del root a través del symlink ({r:?})"
960        );
961        let err = format!("{}", r.unwrap_err());
962        assert!(err.contains("se saldría del root"), "el error tiene que decir qué pasó: {err}");
963    }
964
965    /// El contrapeso del guardián anterior: un symlink de directorio que se queda DENTRO del root
966    /// tiene que seguir proyectando. Es el caso de usr-merge (`/lib → usr/lib`) y el de los temas de
967    /// iconos; romperlo sería peor que el agujero que se tapa.
968    #[test]
969    fn apply_sigue_proyectando_por_un_symlink_que_no_sale() {
970        let tmp = tempfile::tempdir().unwrap();
971        let store = tmp.path().join("store");
972        let root = tmp.path().join("root");
973        let state = tmp.path().join("state");
974
975        // Generación 1: `usr/share/pkg → ../otro`, relativo y dentro del propio root.
976        let g1 = seal_tree(&store, &h64("b1"), "v1", |w| {
977            write(&w.join("usr/otro/.marca"), b"x");
978            std::fs::create_dir_all(w.join("usr/share")).unwrap();
979            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
980        });
981        apply(&store, &g1, None, &root, &state, None).unwrap();
982
983        // Generación 2: un fichero debajo de esa ruta + lo de la 1 (si no, la 2 la retira).
984        let g2 = seal_tree(&store, &h64("b2"), "v2", |w| {
985            write(&w.join("usr/otro/.marca"), b"x");
986            std::fs::create_dir_all(w.join("usr/share")).unwrap();
987            std::os::unix::fs::symlink("../otro", w.join("usr/share/pkg")).unwrap();
988            write(&w.join("usr/share/pkg/archivo"), b"contenido");
989        });
990        apply(&store, &g2, None, &root, &state, None).unwrap();
991
992        assert!(
993            root.join("usr/otro/archivo").exists(),
994            "el fichero tiene que aterrizar donde apunta el symlink, dentro del root"
995        );
996    }
997
998    #[test]
999    fn apply_projects_tree_and_records_generation() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        let store = tmp.path().join("store");
1002        let root = tmp.path().join("root");
1003        let state = tmp.path().join("state");
1004        std::fs::create_dir_all(&root).unwrap();
1005        // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará.
1006        write(&root.join("usr/bin/ls"), b"OLD-ls");
1007
1008        let dir = seal_tree(&store, &h64("aa"), "v1", |w| {
1009            write(&w.join("usr/bin/ls"), b"NEW-ls");
1010            write(&w.join("usr/bin/cat"), b"NEW-cat");
1011            std::fs::create_dir_all(w.join("bin")).unwrap();
1012            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
1013        });
1014
1015        let r = apply(&store, &dir, None, &root, &state, None).unwrap();
1016        assert_eq!(r.generation, 1);
1017        assert!(!r.already_current);
1018        assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]);
1019        assert!(r.added.contains(&PathBuf::from("usr/bin/cat")));
1020        assert!(r.added.contains(&PathBuf::from("bin/sh")));
1021
1022        // los bytes nuevos están en el root vivo.
1023        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
1024        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
1025        assert_eq!(std::fs::read_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox"));
1026
1027        assert_eq!(current(&state).unwrap(), Some(1));
1028        assert_eq!(list(&state).unwrap().len(), 1);
1029    }
1030
1031    #[test]
1032    fn apply_is_idempotent_for_same_tree() {
1033        let tmp = tempfile::tempdir().unwrap();
1034        let store = tmp.path().join("store");
1035        let root = tmp.path().join("root");
1036        let state = tmp.path().join("state");
1037        let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A"));
1038        let r1 = apply(&store, &dir, None, &root, &state, None).unwrap();
1039        assert!(!r1.already_current && r1.generation == 1);
1040        let r2 = apply(&store, &dir, None, &root, &state, None).unwrap();
1041        assert!(r2.already_current, "mismo árbol ⇒ no-op");
1042        assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva");
1043    }
1044
1045    #[test]
1046    fn rollback_restores_previous_tree_exactly() {
1047        let tmp = tempfile::tempdir().unwrap();
1048        let store = tmp.path().join("store");
1049        let root = tmp.path().join("root");
1050        let state = tmp.path().join("state");
1051
1052        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| {
1053            write(&w.join("usr/bin/ls"), b"LS-v1");
1054            write(&w.join("usr/bin/old"), b"OLD-only-in-v1");
1055        });
1056        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| {
1057            write(&w.join("usr/bin/ls"), b"LS-v2");
1058            write(&w.join("usr/bin/new"), b"NEW-only-in-v2");
1059            // `old` desaparece ⇒ apply de v2 debe retirarlo.
1060        });
1061
1062        apply(&store, &v1, None, &root, &state, None).unwrap();
1063        let r2 = apply(&store, &v2, None, &root, &state, None).unwrap();
1064        assert_eq!(r2.generation, 2);
1065        assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]);
1066        // estado tras v2.
1067        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2");
1068        assert!(root.join("usr/bin/new").exists());
1069        assert!(!root.join("usr/bin/old").exists());
1070
1071        // rollback ⇒ vuelve EXACTAMENTE a v1.
1072        let rb = rollback(&root, &state, None).unwrap();
1073        assert_eq!(rb.reverted, 2);
1074        assert_eq!(rb.now_current, Some(1));
1075        assert_eq!(current(&state).unwrap(), Some(1));
1076        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido");
1077        assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró");
1078        assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró");
1079    }
1080
1081    #[test]
1082    fn rollback_to_pre_upgrade_state() {
1083        let tmp = tempfile::tempdir().unwrap();
1084        let store = tmp.path().join("store");
1085        let root = tmp.path().join("root");
1086        let state = tmp.path().join("state");
1087        std::fs::create_dir_all(&root).unwrap();
1088        write(&root.join("etc/motd"), b"PRISTINE");
1089
1090        let v1 = seal_tree(&store, &h64("dd"), "v1", |w| {
1091            write(&w.join("etc/motd"), b"UPGRADED");
1092            write(&w.join("usr/bin/brand-new"), b"X");
1093        });
1094        apply(&store, &v1, None, &root, &state, None).unwrap();
1095        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED");
1096
1097        let rb = rollback(&root, &state, None).unwrap();
1098        assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades");
1099        assert_eq!(current(&state).unwrap(), None);
1100        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada");
1101        assert!(!root.join("usr/bin/brand-new").exists());
1102    }
1103
1104    #[test]
1105    fn rollback_without_generation_errors() {
1106        let tmp = tempfile::tempdir().unwrap();
1107        let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err();
1108        assert!(matches!(err, Error::NothingToRollback), "vino {err}");
1109    }
1110
1111    #[test]
1112    fn apply_rejects_tree_that_does_not_match_expected() {
1113        // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el
1114        // árbol del store no casa, apply rechaza ANTES de tocar el root.
1115        let tmp = tempfile::tempdir().unwrap();
1116        let store_root = tmp.path().join("store");
1117        let root = tmp.path().join("root");
1118        let state = tmp.path().join("state");
1119        let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A"));
1120
1121        // expected MENTIROSO ⇒ Verify, y nada se aplicó.
1122        let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err();
1123        assert!(matches!(err, Error::Verify { .. }), "vino {err}");
1124        assert_eq!(current(&state).unwrap(), None, "no se creó generación");
1125        assert!(!root.join("a").exists(), "el root quedó intacto");
1126
1127        // expected CORRECTO (el of_tree real) ⇒ aplica.
1128        let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string();
1129        let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap();
1130        assert_eq!(r.generation, 1);
1131    }
1132
1133    #[test]
1134    fn journal_records_each_change() {
1135        let tmp = tempfile::tempdir().unwrap();
1136        let store = tmp.path().join("store");
1137        let root = tmp.path().join("root");
1138        let state = tmp.path().join("state");
1139        let j = Journal::open(tmp.path().join("journal")).unwrap();
1140        let dir = seal_tree(&store, &h64("ff"), "v1", |w| {
1141            write(&w.join("usr/bin/a"), b"A");
1142            write(&w.join("usr/bin/b"), b"B");
1143        });
1144        apply(&store, &dir, None, &root, &state, Some(&j)).unwrap();
1145        let evs = j.read_all().unwrap();
1146        assert_eq!(evs.len(), 2);
1147        assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
1148    }
1149
1150    /// Simula un apply INTERRUMPIDO a media proyección: escribe el `pending.json` con el plan y deja el
1151    /// FHS a medias (proyecta sólo el primer cambio), sin commitear. Devuelve el manifiesto del plan.
1152    fn interrupt_apply(
1153        store: &Path,
1154        tree_dir: &str,
1155        target_root: &Path,
1156        state_root: &Path,
1157        partial: usize,
1158    ) -> GenerationManifest {
1159        let store_h = Store::open(store).unwrap();
1160        let tree_path = store_h.root().join(tree_dir);
1161        let tree_content = ArtifactHash::of_tree(&tree_path).unwrap().as_str().to_string();
1162        let entries = enumerate_tree(&tree_path).unwrap();
1163        let mut changes = Vec::new();
1164        for e in &entries {
1165            let existed = std::fs::symlink_metadata(target_root.join(&e.rel)).is_ok();
1166            changes.push(Change { path: e.rel.clone(), op: if existed { ChangeOp::Replaced } else { ChangeOp::Added } });
1167        }
1168        let id = next_id(state_root).unwrap();
1169        let manifest = GenerationManifest {
1170            id, tree_dir: tree_dir.to_string(), tree_content,
1171            parent: current(state_root).unwrap(), created_at: 0,
1172            files: entries.iter().map(|e| e.rel.clone()).collect(), changes,
1173        };
1174        let backup_root = generations_dir(state_root).join(id.to_string()).join("backup");
1175        std::fs::create_dir_all(&backup_root).unwrap();
1176        write_pending(state_root, &manifest).unwrap();
1177        // proyectar SÓLO `partial` entradas (corte a media proyección).
1178        for e in entries.iter().take(partial) {
1179            let dst = target_root.join(&e.rel);
1180            if std::fs::symlink_metadata(&dst).is_ok() {
1181                backup_existing_once(&dst, &backup_root.join(&e.rel)).unwrap();
1182            }
1183            project_entry(&tree_path, e, &dst).unwrap();
1184        }
1185        manifest
1186    }
1187
1188    #[test]
1189    fn apply_refuses_when_a_pending_apply_exists() {
1190        let tmp = tempfile::tempdir().unwrap();
1191        let store = tmp.path().join("store");
1192        let root = tmp.path().join("root");
1193        let state = tmp.path().join("state");
1194        let v1 = seal_tree(&store, &h64("e1"), "v1", |w| { write(&w.join("a"), b"A"); write(&w.join("b"), b"B"); });
1195        interrupt_apply(&store, &v1, &root, &state, 1);
1196        // con un intento pendiente, apply se NIEGA (exige recover).
1197        let err = apply(&store, &v1, None, &root, &state, None).unwrap_err();
1198        assert!(matches!(err, Error::PendingExists { .. }), "vino {err}");
1199        assert!(pending(&state).unwrap().is_some());
1200    }
1201
1202    #[test]
1203    fn recover_roll_forward_completes_interrupted_apply() {
1204        let tmp = tempfile::tempdir().unwrap();
1205        let store = tmp.path().join("store");
1206        let root = tmp.path().join("root");
1207        let state = tmp.path().join("state");
1208        std::fs::create_dir_all(&root).unwrap();
1209        write(&root.join("etc/keep"), b"PRE");
1210        let v1 = seal_tree(&store, &h64("e2"), "v1", |w| {
1211            write(&w.join("usr/bin/ls"), b"NEW-ls");
1212            write(&w.join("usr/bin/cat"), b"NEW-cat");
1213            write(&w.join("usr/bin/grep"), b"NEW-grep");
1214        });
1215        // corte tras proyectar 1 de 3 ⇒ FHS a medias, sin generación viva.
1216        interrupt_apply(&store, &v1, &root, &state, 1);
1217        assert_eq!(current(&state).unwrap(), None, "el apply nunca commiteó");
1218        assert!(pending(&state).unwrap().is_some());
1219
1220        let r = recover(&store, &root, &state, None, false).unwrap();
1221        assert!(r.recovered && r.rolled_forward && r.generation == 1);
1222        // el apply quedó COMPLETO: los 3 binarios, generación viva, intento limpio.
1223        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
1224        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
1225        assert_eq!(std::fs::read(root.join("usr/bin/grep")).unwrap(), b"NEW-grep");
1226        assert_eq!(current(&state).unwrap(), Some(1));
1227        assert!(pending(&state).unwrap().is_none());
1228        // y como quedó committeado, un rollback posterior restaura el estado pre-apply EXACTO.
1229        rollback(&root, &state, None).unwrap();
1230        assert!(!root.join("usr/bin/ls").exists(), "lo añadido se revierte");
1231        assert_eq!(std::fs::read(root.join("etc/keep")).unwrap(), b"PRE");
1232    }
1233
1234    #[test]
1235    fn recover_roll_back_undoes_interrupted_apply() {
1236        let tmp = tempfile::tempdir().unwrap();
1237        let store = tmp.path().join("store");
1238        let root = tmp.path().join("root");
1239        let state = tmp.path().join("state");
1240        std::fs::create_dir_all(&root).unwrap();
1241        write(&root.join("usr/bin/ls"), b"ORIG-ls");   // existirá ⇒ Replaced (con backup)
1242        let v1 = seal_tree(&store, &h64("e3"), "v1", |w| {
1243            write(&w.join("usr/bin/ls"), b"NEW-ls");
1244            write(&w.join("usr/bin/new"), b"NEW-only"); // Added
1245        });
1246        // corte tras proyectar el 1er cambio (orden alfabético: usr/bin/ls Replaced primero).
1247        interrupt_apply(&store, &v1, &root, &state, 1);
1248        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls", "ls ya pisado a medias");
1249
1250        let r = recover(&store, &root, &state, None, true).unwrap();
1251        assert!(r.recovered && !r.rolled_forward);
1252        // el FHS volvió al estado PRE-apply: ls original restaurado, nada de la generación a medias.
1253        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"ORIG-ls", "ls restaurado del backup");
1254        assert!(!root.join("usr/bin/new").exists());
1255        assert_eq!(current(&state).unwrap(), None);
1256        assert!(pending(&state).unwrap().is_none());
1257        assert!(!generations_dir(&state).join("1").exists(), "la generación a medias se borró");
1258    }
1259
1260    #[test]
1261    fn recover_is_noop_without_pending() {
1262        let tmp = tempfile::tempdir().unwrap();
1263        let r = recover(&tmp.path().join("store"), &tmp.path().join("root"), &tmp.path().join("state"), None, false).unwrap();
1264        assert!(!r.recovered);
1265    }
1266
1267    #[test]
1268    fn apply_clears_pending_on_success() {
1269        let tmp = tempfile::tempdir().unwrap();
1270        let store = tmp.path().join("store");
1271        let root = tmp.path().join("root");
1272        let state = tmp.path().join("state");
1273        let v1 = seal_tree(&store, &h64("e4"), "v1", |w| write(&w.join("a"), b"A"));
1274        apply(&store, &v1, None, &root, &state, None).unwrap();
1275        assert!(pending(&state).unwrap().is_none(), "el apply exitoso limpia el intento");
1276    }
1277
1278    #[test]
1279    fn prune_removes_orphans_but_keeps_live_chain() {
1280        let tmp = tempfile::tempdir().unwrap();
1281        let store = tmp.path().join("store");
1282        let root = tmp.path().join("root");
1283        let state = tmp.path().join("state");
1284        let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1"));
1285        let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2"));
1286        let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3"));
1287
1288        apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1
1289        apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1)
1290        apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2)
1291        // rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza).
1292        rollback(&root, &state, None).unwrap();
1293        rollback(&root, &state, None).unwrap();
1294        assert_eq!(current(&state).unwrap(), Some(1));
1295        assert_eq!(live_chain(&state).unwrap(), vec![1]);
1296
1297        let r = prune(&state, None).unwrap();
1298        assert_eq!(r.removed, vec![2, 3], "huérfanas borradas");
1299        assert_eq!(r.kept, vec![1], "cadena viva conservada");
1300        assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![1]);
1301        // idempotente.
1302        assert!(prune(&state, None).unwrap().removed.is_empty());
1303    }
1304
1305    #[test]
1306    fn boot_graph_builds_dag_and_activate_rolls_back_to_ancestor() {
1307        use crate::boot_graph::{activate, build, recovery_id, NodeKind};
1308
1309        let tmp = tempfile::tempdir().unwrap();
1310        let store = tmp.path().join("store");
1311        let root = tmp.path().join("root");
1312        let state = tmp.path().join("state");
1313        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| write(&w.join("f"), b"1"));
1314        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| write(&w.join("f"), b"2"));
1315        let v3 = seal_tree(&store, &h64("c3"), "v3", |w| write(&w.join("f"), b"3"));
1316        apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1 (Base)
1317        apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2
1318        apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (viva)
1319
1320        let g = build(&state).unwrap();
1321        assert_eq!(g.version, 1);
1322        // 3 generaciones + el nodo de recuperación sintético.
1323        assert_eq!(g.nodes.len(), 4);
1324        let gen1 = &g.nodes[0];
1325        assert_eq!(gen1.kind, NodeKind::Base, "la primera generación es la raíz del linaje");
1326        assert!(gen1.parents.is_empty());
1327        let gen2 = &g.nodes[1];
1328        assert_eq!(gen2.kind, NodeKind::Generation);
1329        assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre");
1330        // current/default = la generación viva (gen 3).
1331        let gen3 = &g.nodes[2];
1332        assert_eq!(g.current, gen3.id);
1333        assert_eq!(g.default, g.current);
1334        // el nodo de recuperación cuelga de la viva.
1335        let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap();
1336        assert_eq!(rec.id, recovery_id());
1337        assert_eq!(rec.parents, vec![gen3.id.clone()]);
1338
1339        // activar la viva ⇒ no-op.
1340        let r = activate(&root, &state, None, &gen3.id).unwrap();
1341        assert!(r.already_current);
1342        assert_eq!(current(&state).unwrap(), Some(3));
1343
1344        // activar la gen 1 (ancestro) ⇒ rollback paso a paso hasta ella.
1345        let r = activate(&root, &state, None, &gen1.id).unwrap();
1346        assert!(!r.already_current);
1347        assert_eq!(r.rolled_back, vec![3, 2], "revierte de la más nueva a la más vieja");
1348        assert_eq!(current(&state).unwrap(), Some(1));
1349        assert_eq!(std::fs::read(root.join("f")).unwrap(), b"1", "el FHS vivo es el de la gen 1");
1350
1351        // ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto.
1352        let g2 = build(&state).unwrap();
1353        assert_eq!(g2.current, gen1.id);
1354        let err = activate(&root, &state, None, &gen3.id).unwrap_err();
1355        assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");
1356
1357        // recuperación desde la gen 1 ⇒ rollback al estado pre-upgrades (sin current).
1358        let r = activate(&root, &state, None, &recovery_id()).unwrap();
1359        assert_eq!(r.kind, NodeKind::Recovery);
1360        assert_eq!(r.rolled_back, vec![1]);
1361        assert_eq!(current(&state).unwrap(), None);
1362    }
1363
1364    #[test]
1365    fn prune_with_keep_trims_chain_depth() {
1366        let tmp = tempfile::tempdir().unwrap();
1367        let store = tmp.path().join("store");
1368        let root = tmp.path().join("root");
1369        let state = tmp.path().join("state");
1370        for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] {
1371            let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by));
1372            apply(&store, &d, None, &root, &state, None).unwrap();
1373        }
1374        // current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1.
1375        assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]);
1376        let r = prune(&state, Some(2)).unwrap();
1377        assert_eq!(r.kept, vec![2, 3]);
1378        assert_eq!(r.removed, vec![1]);
1379        // gen 1 borrada del disco; 2 y 3 siguen.
1380        assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![2, 3]);
1381    }
1382}