tawasuyu

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 604Rust 56Shell 36Markdown 281227 Definiciones

⎇ d23946fa3d45e29f42e0ea707d612d5477ab04f3 crates/hammer-upgrade/src/lib.rs Rust · 718 líneas · 28.7 KB Historia Quién tocó qué crudo

hammer-upgrade: upgrades atómicos con generaciones y rollback (Etapa E4, primer corte) d23946fa3 · 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 const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/upgrades";
43
44#[derive(Debug, thiserror::Error)]
45pub enum Error {
46    #[error("io: {0}")]
47    Io(#[from] std::io::Error),
48    #[error("core: {0}")]
49    Core(#[from] hammer_core::Error),
50    #[error("journal: {0}")]
51    Journal(#[from] hammer_journal::Error),
52    #[error("json: {0}")]
53    Json(#[from] serde_json::Error),
54    #[error(
55        "integridad: el árbol '{dir}' del store hashea a {got} pero se esperaba {want} \
56         — store corrupto o árbol equivocado; NO se aplica"
57    )]
58    Verify { dir: String, want: String, got: String },
59    #[error("upgrade: no hay generación viva que revertir (sistema sin upgrades aplicados)")]
60    NothingToRollback,
61    #[error("upgrade: {0}")]
62    Other(String),
63}
64
65pub type Result<T> = std::result::Result<T, Error>;
66
67/// Qué le pasó a un path concreto al aplicar una generación. Determina cómo se deshace.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "kebab-case")]
70pub enum ChangeOp {
71    /// El path no existía antes ⇒ rollback lo **borra**.
72    Added,
73    /// El path existía y fue pisado ⇒ `backup/` guarda los bytes previos ⇒ rollback los **restaura**.
74    Replaced,
75    /// El path existía en el árbol previo y el nuevo no lo tiene ⇒ apply lo borra; `backup/` guarda
76    /// los bytes previos ⇒ rollback los **restaura**.
77    Removed,
78}
79
80/// Un cambio atómico sobre un path del FHS, relativo a `target_root`.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Change {
83    pub path: PathBuf,
84    pub op: ChangeOp,
85}
86
87/// Manifiesto persistente de una generación — la unidad de upgrade/rollback.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct GenerationManifest {
90    pub id: u64,
91    /// Directorio del artefacto en el store (`<hash>-<name>`) cuyo árbol esta generación proyectó.
92    pub tree_dir: String,
93    /// `of_tree` (`b3:…`) del árbol aplicado: ancla de integridad y desempate vs el store.
94    pub tree_content: String,
95    /// Generación anterior (a la que el rollback vuelve). `None` en la primera.
96    pub parent: Option<u64>,
97    /// Segundos desde UNIX epoch al apply.
98    pub created_at: u64,
99    /// Lista relativa (ordenada) de los ficheros que el árbol aporta — sirve para que el SIGUIENTE
100    /// apply sepa qué retirar (paths del árbol previo ausentes en el nuevo).
101    pub files: Vec<PathBuf>,
102    /// Cada path tocado y cómo, para deshacer.
103    pub changes: Vec<Change>,
104}
105
106/// Reporte de un [`apply`].
107#[derive(Debug, Clone, Default)]
108pub struct ApplyReport {
109    pub generation: u64,
110    pub added: Vec<PathBuf>,
111    pub replaced: Vec<PathBuf>,
112    pub removed: Vec<PathBuf>,
113    /// `true` si el árbol pedido ya era el vivo (apply idempotente, no se creó generación nueva).
114    pub already_current: bool,
115}
116
117/// Reporte de un [`rollback`].
118#[derive(Debug, Clone, Default)]
119pub struct RollbackReport {
120    /// Generación que se revirtió (la que estaba viva).
121    pub reverted: u64,
122    /// Generación que quedó viva tras revertir (`None` si se volvió al estado pre-upgrades).
123    pub now_current: Option<u64>,
124    pub restored: Vec<PathBuf>,
125    pub deleted: Vec<PathBuf>,
126}
127
128// ---------------------------------------------------------------------------------------------------
129// Registro de generaciones (lectura del estado).
130// ---------------------------------------------------------------------------------------------------
131
132fn generations_dir(state_root: &Path) -> PathBuf {
133    state_root.join("generations")
134}
135
136fn current_file(state_root: &Path) -> PathBuf {
137    state_root.join("current")
138}
139
140fn manifest_path(state_root: &Path, id: u64) -> PathBuf {
141    generations_dir(state_root).join(id.to_string()).join("manifest.json")
142}
143
144/// Id de la generación viva, o `None` si nunca se aplicó un upgrade.
145pub fn current(state_root: &Path) -> Result<Option<u64>> {
146    let f = current_file(state_root);
147    if !f.is_file() {
148        return Ok(None);
149    }
150    let s = std::fs::read_to_string(&f)?;
151    let s = s.trim();
152    if s.is_empty() {
153        return Ok(None);
154    }
155    s.parse::<u64>()
156        .map(Some)
157        .map_err(|_| Error::Other(format!("`current` ilegible: {s:?}")))
158}
159
160/// Lee el manifiesto de una generación concreta.
161pub fn get(state_root: &Path, id: u64) -> Result<Option<GenerationManifest>> {
162    let p = manifest_path(state_root, id);
163    if !p.is_file() {
164        return Ok(None);
165    }
166    Ok(Some(serde_json::from_slice(&std::fs::read(&p)?)?))
167}
168
169/// Lista todas las generaciones registradas, ordenadas por id ascendente.
170pub fn list(state_root: &Path) -> Result<Vec<GenerationManifest>> {
171    let dir = generations_dir(state_root);
172    if !dir.is_dir() {
173        return Ok(Vec::new());
174    }
175    let mut out: Vec<GenerationManifest> = Vec::new();
176    for entry in std::fs::read_dir(&dir)? {
177        let entry = entry?;
178        let mp = entry.path().join("manifest.json");
179        if mp.is_file() {
180            out.push(serde_json::from_slice(&std::fs::read(&mp)?)?);
181        }
182    }
183    out.sort_by_key(|m| m.id);
184    Ok(out)
185}
186
187fn next_id(state_root: &Path) -> Result<u64> {
188    Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
189}
190
191// ---------------------------------------------------------------------------------------------------
192// Enumeración de árboles.
193// ---------------------------------------------------------------------------------------------------
194
195/// Un fichero de un árbol: su path relativo y su tipo de contenido (lo que `of_tree` distingue).
196#[derive(Debug, Clone)]
197struct TreeEntry {
198    rel: PathBuf,
199    kind: EntryKind,
200}
201
202#[derive(Debug, Clone)]
203enum EntryKind {
204    /// Fichero regular. `exec` = bit de ejecución (de_tree lo mira).
205    File { exec: bool },
206    /// Symlink → target literal (sin resolver).
207    Symlink(PathBuf),
208}
209
210/// Camina `root` y devuelve sus ficheros (regulares + symlinks) con path relativo, ordenados. Los
211/// directorios no se listan: se recrean al proyectar según haga falta.
212fn enumerate_tree(root: &Path) -> Result<Vec<TreeEntry>> {
213    let mut out = Vec::new();
214    fn walk(root: &Path, dir: &Path, out: &mut Vec<TreeEntry>) -> Result<()> {
215        use std::os::unix::fs::PermissionsExt;
216        for entry in std::fs::read_dir(dir)? {
217            let entry = entry?;
218            let p = entry.path();
219            let meta = std::fs::symlink_metadata(&p)?;
220            let ft = meta.file_type();
221            let rel = p.strip_prefix(root).expect("p ⊂ root").to_path_buf();
222            if ft.is_symlink() {
223                out.push(TreeEntry { rel, kind: EntryKind::Symlink(std::fs::read_link(&p)?) });
224            } else if ft.is_dir() {
225                walk(root, &p, out)?;
226            } else if ft.is_file() {
227                let exec = meta.permissions().mode() & 0o111 != 0;
228                out.push(TreeEntry { rel, kind: EntryKind::File { exec } });
229            }
230        }
231        Ok(())
232    }
233    walk(root, root, &mut out)?;
234    out.sort_by(|a, b| a.rel.cmp(&b.rel));
235    Ok(out)
236}
237
238// ---------------------------------------------------------------------------------------------------
239// Apply.
240// ---------------------------------------------------------------------------------------------------
241
242/// Aplica el árbol `tree_dir` del store sobre `target_root`, dejando una generación nueva.
243///
244/// Pasos: (1) resuelve y **verifica** el árbol del store (su `of_tree` debe casar el del índice);
245/// (2) diferencia contra el árbol de la generación viva ⇒ qué se añade/pisa/retira; (3) **respalda**
246/// el estado previo de cada path tocado; (4) proyecta el árbol nuevo (escritura a temporal + rename)
247/// y retira los ficheros sobrantes, registrando cada cambio en el diario; (5) escribe el manifiesto y
248/// **avanza `current`** (commit-point).
249pub fn apply(
250    store_root: &Path,
251    tree_dir: &str,
252    expected: Option<&str>,
253    target_root: &Path,
254    state_root: &Path,
255    journal: Option<&Journal>,
256) -> Result<ApplyReport> {
257    let store = Store::open(store_root)?;
258    let tree_path = store.root().join(tree_dir);
259    if !tree_path.is_dir() {
260        return Err(Error::Other(format!(
261            "el árbol '{tree_dir}' no existe en el store {}",
262            store.root().display()
263        )));
264    }
265    // El of_tree real ancla la identidad del árbol. Si el llamador anuncia uno esperado (de un índice
266    // de mirror o una release firmada), lo exigimos: defiende de un store corrupto/manipulado entre el
267    // sellado y el apply. Sin `expected`, el store local se confía (sellado read-only) y el of_tree
268    // sólo se registra en el manifiesto.
269    let got = ArtifactHash::of_tree(&tree_path)?;
270    let tree_content = got.as_str().to_string();
271    if let Some(want) = expected {
272        if tree_content != want {
273            return Err(Error::Verify {
274                dir: tree_dir.to_string(),
275                want: want.to_string(),
276                got: tree_content,
277            });
278        }
279    }
280
281    // Idempotencia: si el árbol vivo ya es éste, no hacemos nada.
282    if let Some(cur) = current(state_root)? {
283        if let Some(m) = get(state_root, cur)? {
284            if m.tree_content == tree_content {
285                return Ok(ApplyReport { generation: cur, already_current: true, ..Default::default() });
286            }
287        }
288    }
289
290    let new_entries = enumerate_tree(&tree_path)?;
291    let new_files: Vec<PathBuf> = new_entries.iter().map(|e| e.rel.clone()).collect();
292    let new_set: std::collections::BTreeSet<&PathBuf> = new_files.iter().collect();
293
294    // Ficheros que la generación viva aportaba y el árbol nuevo NO ⇒ se retiran.
295    let prev_files: Vec<PathBuf> = match current(state_root)? {
296        Some(cur) => get(state_root, cur)?.map(|m| m.files).unwrap_or_default(),
297        None => Vec::new(),
298    };
299    let removed_files: Vec<PathBuf> =
300        prev_files.into_iter().filter(|p| !new_set.contains(p)).collect();
301
302    let id = next_id(state_root)?;
303    let gen_base = generations_dir(state_root).join(id.to_string());
304    let backup_root = gen_base.join("backup");
305    std::fs::create_dir_all(&backup_root)?;
306
307    let mut report = ApplyReport { generation: id, ..Default::default() };
308    let mut changes: Vec<Change> = Vec::new();
309
310    // (3)+(4) proyectar el árbol nuevo.
311    for e in &new_entries {
312        let dst = target_root.join(&e.rel);
313        let existed = std::fs::symlink_metadata(&dst).is_ok();
314        if existed {
315            backup_existing(&dst, &backup_root.join(&e.rel))?;
316        }
317        project_entry(&tree_path, e, &dst)?;
318        let op = if existed { ChangeOp::Replaced } else { ChangeOp::Added };
319        match op {
320            ChangeOp::Added => report.added.push(e.rel.clone()),
321            ChangeOp::Replaced => report.replaced.push(e.rel.clone()),
322            ChangeOp::Removed => unreachable!(),
323        }
324        changes.push(Change { path: e.rel.clone(), op });
325        journal_change(journal, tree_dir, &dst, op);
326    }
327
328    // Retirar los sobrantes del árbol previo.
329    for rel in &removed_files {
330        let dst = target_root.join(rel);
331        if std::fs::symlink_metadata(&dst).is_ok() {
332            backup_existing(&dst, &backup_root.join(rel))?;
333            remove_path(&dst)?;
334            report.removed.push(rel.clone());
335            changes.push(Change { path: rel.clone(), op: ChangeOp::Removed });
336            journal_change(journal, tree_dir, &dst, ChangeOp::Removed);
337        }
338    }
339
340    // (5) manifiesto + avance de `current` (commit-point).
341    let manifest = GenerationManifest {
342        id,
343        tree_dir: tree_dir.to_string(),
344        tree_content,
345        parent: current(state_root)?,
346        created_at: now_secs(),
347        files: new_files,
348        changes,
349    };
350    write_atomic(&manifest_path(state_root, id), &serde_json::to_vec_pretty(&manifest)?)?;
351    set_current(state_root, Some(id))?;
352    Ok(report)
353}
354
355// ---------------------------------------------------------------------------------------------------
356// Rollback.
357// ---------------------------------------------------------------------------------------------------
358
359/// Revierte la generación viva: deshace sus cambios (borra lo añadido, restaura desde `backup/` lo
360/// pisado/retirado) y retrocede `current` al padre. Idempotente respecto a paths ya restaurados.
361pub fn rollback(
362    target_root: &Path,
363    state_root: &Path,
364    journal: Option<&Journal>,
365) -> Result<RollbackReport> {
366    let cur = current(state_root)?.ok_or(Error::NothingToRollback)?;
367    let m = get(state_root, cur)?
368        .ok_or_else(|| Error::Other(format!("manifiesto de la generación {cur} ausente")))?;
369    let backup_root = generations_dir(state_root).join(cur.to_string()).join("backup");
370
371    let mut report = RollbackReport { reverted: cur, now_current: m.parent, ..Default::default() };
372
373    // Deshacer en orden inverso al apply (los Added al final, por si un Replaced creó su dir padre).
374    for ch in m.changes.iter().rev() {
375        let dst = target_root.join(&ch.path);
376        match ch.op {
377            ChangeOp::Added => {
378                if std::fs::symlink_metadata(&dst).is_ok() {
379                    remove_path(&dst)?;
380                    report.deleted.push(ch.path.clone());
381                    journal_change(journal, &m.tree_dir, &dst, ChangeOp::Removed);
382                }
383            }
384            ChangeOp::Replaced | ChangeOp::Removed => {
385                let bak = backup_root.join(&ch.path);
386                restore_from_backup(&bak, &dst)?;
387                report.restored.push(ch.path.clone());
388                journal_change(journal, &m.tree_dir, &dst, ChangeOp::Replaced);
389            }
390        }
391    }
392
393    set_current(state_root, m.parent)?;
394    Ok(report)
395}
396
397// ---------------------------------------------------------------------------------------------------
398// Primitivas de filesystem.
399// ---------------------------------------------------------------------------------------------------
400
401/// Proyecta una entrada del árbol sobre `dst` (escritura a temporal hermano + `rename` atómico).
402fn project_entry(tree_root: &Path, e: &TreeEntry, dst: &Path) -> Result<()> {
403    if let Some(parent) = dst.parent() {
404        std::fs::create_dir_all(parent)?;
405    }
406    let tmp = tmp_sibling(dst);
407    let _ = remove_path(&tmp);
408    match &e.kind {
409        EntryKind::File { exec } => {
410            let src = tree_root.join(&e.rel);
411            std::fs::copy(&src, &tmp)?;
412            if *exec {
413                use std::os::unix::fs::PermissionsExt;
414                let mode = std::fs::metadata(&src)?.permissions().mode();
415                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
416            }
417        }
418        EntryKind::Symlink(target) => {
419            std::os::unix::fs::symlink(target, &tmp)?;
420        }
421    }
422    // rename pisa el destino atómicamente (mismo dir ⇒ mismo FS).
423    std::fs::rename(&tmp, dst)?;
424    Ok(())
425}
426
427/// Respalda el estado PREVIO de `src` (fichero o symlink) en `bak`, preservando el tipo.
428fn backup_existing(src: &Path, bak: &Path) -> Result<()> {
429    if let Some(parent) = bak.parent() {
430        std::fs::create_dir_all(parent)?;
431    }
432    let meta = std::fs::symlink_metadata(src)?;
433    if meta.file_type().is_symlink() {
434        let _ = remove_path(bak);
435        std::os::unix::fs::symlink(std::fs::read_link(src)?, bak)?;
436    } else if meta.is_file() {
437        let _ = remove_path(bak);
438        std::fs::copy(src, bak)?;
439        std::fs::set_permissions(bak, meta.permissions())?;
440    } else {
441        return Err(Error::Other(format!(
442            "no sé respaldar {} (no es fichero ni symlink)",
443            src.display()
444        )));
445    }
446    Ok(())
447}
448
449/// Restaura `dst` desde su backup (atómico vía temporal). Si el backup no existe, no hace nada
450/// (idempotencia: ya restaurado o nunca existió).
451fn restore_from_backup(bak: &Path, dst: &Path) -> Result<()> {
452    let meta = match std::fs::symlink_metadata(bak) {
453        Ok(m) => m,
454        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
455        Err(e) => return Err(e.into()),
456    };
457    if let Some(parent) = dst.parent() {
458        std::fs::create_dir_all(parent)?;
459    }
460    let tmp = tmp_sibling(dst);
461    let _ = remove_path(&tmp);
462    if meta.file_type().is_symlink() {
463        std::os::unix::fs::symlink(std::fs::read_link(bak)?, &tmp)?;
464    } else {
465        std::fs::copy(bak, &tmp)?;
466        std::fs::set_permissions(&tmp, meta.permissions())?;
467    }
468    std::fs::rename(&tmp, dst)?;
469    Ok(())
470}
471
472fn remove_path(p: &Path) -> Result<()> {
473    match std::fs::symlink_metadata(p) {
474        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => std::fs::remove_dir_all(p)?,
475        Ok(_) => std::fs::remove_file(p)?,
476        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
477        Err(e) => return Err(e.into()),
478    }
479    Ok(())
480}
481
482fn tmp_sibling(dst: &Path) -> PathBuf {
483    let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
484    dst.with_file_name(format!(".hammer-upgrade-tmp-{name}"))
485}
486
487/// Escribe `bytes` en `path` de forma atómica (temporal + rename).
488fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
489    if let Some(parent) = path.parent() {
490        std::fs::create_dir_all(parent)?;
491    }
492    let tmp = tmp_sibling(path);
493    std::fs::write(&tmp, bytes)?;
494    std::fs::rename(&tmp, path)?;
495    Ok(())
496}
497
498/// Avanza/retrocede `current` de forma atómica. `None` ⇒ borra el puntero (estado pre-upgrades).
499fn set_current(state_root: &Path, id: Option<u64>) -> Result<()> {
500    let f = current_file(state_root);
501    match id {
502        Some(id) => write_atomic(&f, id.to_string().as_bytes())?,
503        None => {
504            let _ = std::fs::remove_file(&f);
505        }
506    }
507    Ok(())
508}
509
510fn journal_change(journal: Option<&Journal>, tree_dir: &str, dst: &Path, op: ChangeOp) {
511    let Some(j) = journal else { return };
512    let mop = match op {
513        ChangeOp::Added => MutationOp::Create,
514        ChangeOp::Replaced => MutationOp::Replace,
515        ChangeOp::Removed => MutationOp::Delete,
516    };
517    let ev = MutationEvent {
518        ts: hammer_journal::now_rfc3339(),
519        op: mop,
520        path: dst.to_path_buf(),
521        by: Actor {
522            // El upgrade es replay-able: el contenido viene del árbol `tree_dir` del store.
523            source: Source::HammerHydrate { artifact: tree_dir.to_string() },
524            pid: Some(std::process::id()),
525            uid: None,
526        },
527        content_hash: None,
528        note: Some("hammer upgrade".to_string()),
529    };
530    if let Err(e) = j.record(&ev) {
531        tracing::warn!(error = %e, path = %dst.display(), "upgrade: fallo al registrar en el diario");
532    }
533}
534
535fn now_secs() -> u64 {
536    std::time::SystemTime::now()
537        .duration_since(std::time::UNIX_EPOCH)
538        .map(|d| d.as_secs())
539        .unwrap_or(0)
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    /// Sella un árbol sintético en `store_root` y devuelve su `<hash>-<name>`.
547    fn seal_tree(store_root: &Path, hex: &str, name: &str, build: impl Fn(&Path)) -> String {
548        let store = Store::open(store_root).unwrap();
549        let h = ArtifactHash::from_hex(hex);
550        let staging = store_root.join(".bootstrap-tmp").join(h.store_dir_name(name));
551        let _ = std::fs::remove_dir_all(&staging);
552        std::fs::create_dir_all(&staging).unwrap();
553        build(&staging);
554        store.seal(&staging, &h, name).unwrap();
555        h.store_dir_name(name)
556    }
557
558    fn h64(prefix: &str) -> String {
559        format!("{prefix}{}", "0".repeat(64 - prefix.len()))
560    }
561
562    fn write(p: &Path, bytes: &[u8]) {
563        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
564        std::fs::write(p, bytes).unwrap();
565    }
566
567    #[test]
568    fn apply_projects_tree_and_records_generation() {
569        let tmp = tempfile::tempdir().unwrap();
570        let store = tmp.path().join("store");
571        let root = tmp.path().join("root");
572        let state = tmp.path().join("state");
573        std::fs::create_dir_all(&root).unwrap();
574        // root ya tiene un /usr/bin/ls "viejo" que el upgrade pisará.
575        write(&root.join("usr/bin/ls"), b"OLD-ls");
576
577        let dir = seal_tree(&store, &h64("aa"), "v1", |w| {
578            write(&w.join("usr/bin/ls"), b"NEW-ls");
579            write(&w.join("usr/bin/cat"), b"NEW-cat");
580            std::fs::create_dir_all(w.join("bin")).unwrap();
581            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
582        });
583
584        let r = apply(&store, &dir, None, &root, &state, None).unwrap();
585        assert_eq!(r.generation, 1);
586        assert!(!r.already_current);
587        assert_eq!(r.replaced, vec![PathBuf::from("usr/bin/ls")]);
588        assert!(r.added.contains(&PathBuf::from("usr/bin/cat")));
589        assert!(r.added.contains(&PathBuf::from("bin/sh")));
590
591        // los bytes nuevos están en el root vivo.
592        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"NEW-ls");
593        assert_eq!(std::fs::read(root.join("usr/bin/cat")).unwrap(), b"NEW-cat");
594        assert_eq!(std::fs::read_link(root.join("bin/sh")).unwrap(), PathBuf::from("busybox"));
595
596        assert_eq!(current(&state).unwrap(), Some(1));
597        assert_eq!(list(&state).unwrap().len(), 1);
598    }
599
600    #[test]
601    fn apply_is_idempotent_for_same_tree() {
602        let tmp = tempfile::tempdir().unwrap();
603        let store = tmp.path().join("store");
604        let root = tmp.path().join("root");
605        let state = tmp.path().join("state");
606        let dir = seal_tree(&store, &h64("bb"), "v1", |w| write(&w.join("a"), b"A"));
607        let r1 = apply(&store, &dir, None, &root, &state, None).unwrap();
608        assert!(!r1.already_current && r1.generation == 1);
609        let r2 = apply(&store, &dir, None, &root, &state, None).unwrap();
610        assert!(r2.already_current, "mismo árbol ⇒ no-op");
611        assert_eq!(list(&state).unwrap().len(), 1, "no se creó generación nueva");
612    }
613
614    #[test]
615    fn rollback_restores_previous_tree_exactly() {
616        let tmp = tempfile::tempdir().unwrap();
617        let store = tmp.path().join("store");
618        let root = tmp.path().join("root");
619        let state = tmp.path().join("state");
620
621        let v1 = seal_tree(&store, &h64("c1"), "v1", |w| {
622            write(&w.join("usr/bin/ls"), b"LS-v1");
623            write(&w.join("usr/bin/old"), b"OLD-only-in-v1");
624        });
625        let v2 = seal_tree(&store, &h64("c2"), "v2", |w| {
626            write(&w.join("usr/bin/ls"), b"LS-v2");
627            write(&w.join("usr/bin/new"), b"NEW-only-in-v2");
628            // `old` desaparece ⇒ apply de v2 debe retirarlo.
629        });
630
631        apply(&store, &v1, None, &root, &state, None).unwrap();
632        let r2 = apply(&store, &v2, None, &root, &state, None).unwrap();
633        assert_eq!(r2.generation, 2);
634        assert_eq!(r2.removed, vec![PathBuf::from("usr/bin/old")]);
635        // estado tras v2.
636        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v2");
637        assert!(root.join("usr/bin/new").exists());
638        assert!(!root.join("usr/bin/old").exists());
639
640        // rollback ⇒ vuelve EXACTAMENTE a v1.
641        let rb = rollback(&root, &state, None).unwrap();
642        assert_eq!(rb.reverted, 2);
643        assert_eq!(rb.now_current, Some(1));
644        assert_eq!(current(&state).unwrap(), Some(1));
645        assert_eq!(std::fs::read(root.join("usr/bin/ls")).unwrap(), b"LS-v1", "ls revertido");
646        assert!(!root.join("usr/bin/new").exists(), "lo añadido por v2 se borró");
647        assert_eq!(std::fs::read(root.join("usr/bin/old")).unwrap(), b"OLD-only-in-v1", "lo retirado por v2 se restauró");
648    }
649
650    #[test]
651    fn rollback_to_pre_upgrade_state() {
652        let tmp = tempfile::tempdir().unwrap();
653        let store = tmp.path().join("store");
654        let root = tmp.path().join("root");
655        let state = tmp.path().join("state");
656        std::fs::create_dir_all(&root).unwrap();
657        write(&root.join("etc/motd"), b"PRISTINE");
658
659        let v1 = seal_tree(&store, &h64("dd"), "v1", |w| {
660            write(&w.join("etc/motd"), b"UPGRADED");
661            write(&w.join("usr/bin/brand-new"), b"X");
662        });
663        apply(&store, &v1, None, &root, &state, None).unwrap();
664        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"UPGRADED");
665
666        let rb = rollback(&root, &state, None).unwrap();
667        assert_eq!(rb.now_current, None, "se volvió al estado pre-upgrades");
668        assert_eq!(current(&state).unwrap(), None);
669        assert_eq!(std::fs::read(root.join("etc/motd")).unwrap(), b"PRISTINE", "config original restaurada");
670        assert!(!root.join("usr/bin/brand-new").exists());
671    }
672
673    #[test]
674    fn rollback_without_generation_errors() {
675        let tmp = tempfile::tempdir().unwrap();
676        let err = rollback(&tmp.path().join("root"), &tmp.path().join("state"), None).unwrap_err();
677        assert!(matches!(err, Error::NothingToRollback), "vino {err}");
678    }
679
680    #[test]
681    fn apply_rejects_tree_that_does_not_match_expected() {
682        // Si el llamador anuncia un of_tree esperado (de un índice de mirror / release firmada) y el
683        // árbol del store no casa, apply rechaza ANTES de tocar el root.
684        let tmp = tempfile::tempdir().unwrap();
685        let store_root = tmp.path().join("store");
686        let root = tmp.path().join("root");
687        let state = tmp.path().join("state");
688        let dir = seal_tree(&store_root, &h64("ee"), "v1", |w| write(&w.join("a"), b"A"));
689
690        // expected MENTIROSO ⇒ Verify, y nada se aplicó.
691        let err = apply(&store_root, &dir, Some("b3:deadbeef"), &root, &state, None).unwrap_err();
692        assert!(matches!(err, Error::Verify { .. }), "vino {err}");
693        assert_eq!(current(&state).unwrap(), None, "no se creó generación");
694        assert!(!root.join("a").exists(), "el root quedó intacto");
695
696        // expected CORRECTO (el of_tree real) ⇒ aplica.
697        let want = ArtifactHash::of_tree(&store_root.join(&dir)).unwrap().as_str().to_string();
698        let r = apply(&store_root, &dir, Some(&want), &root, &state, None).unwrap();
699        assert_eq!(r.generation, 1);
700    }
701
702    #[test]
703    fn journal_records_each_change() {
704        let tmp = tempfile::tempdir().unwrap();
705        let store = tmp.path().join("store");
706        let root = tmp.path().join("root");
707        let state = tmp.path().join("state");
708        let j = Journal::open(tmp.path().join("journal")).unwrap();
709        let dir = seal_tree(&store, &h64("ff"), "v1", |w| {
710            write(&w.join("usr/bin/a"), b"A");
711            write(&w.join("usr/bin/b"), b"B");
712        });
713        apply(&store, &dir, None, &root, &state, Some(&j)).unwrap();
714        let evs = j.read_all().unwrap();
715        assert_eq!(evs.len(), 2);
716        assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
717    }
718}