//! Manifiesto de bootstrap — el embrión del log de transparencia ([SDD 11 §4](../../docs/11-bootstrap.md)). //! //! Cada etapa del bootstrap anota una línea inmutable `(stage, recipe_hash, artifact_hash, //! seed_hash, ts)` en un `bootstrap.json` versionado en la raíz del store. Un tercero reproduce //! el bootstrap y verifica que sus hashes coinciden con los publicados, sin confiar en el //! binario del autor ([SDD 09 §4](../../docs/09-trust-model.md)). //! //! El manifiesto es **append-only e idempotente**: re-correr una etapa (Stage 0 es idempotente //! por diseño) no duplica su línea. El `ts` es puramente informativo y no entra en ningún hash. use std::path::PathBuf; use hammer_core::{ArtifactHash, Store}; use serde::{Deserialize, Serialize}; use crate::{Error, Result}; /// Versión del formato del manifiesto. Subir si cambia el esquema de [`StageEntry`]. pub const MANIFEST_VERSION: u32 = 1; /// Nombre del archivo del manifiesto, en la raíz del store. Los directorios del store llevan /// nombre `-`, así que un `bootstrap.json` plano nunca colisiona con un artefacto. pub const MANIFEST_FILE: &str = "bootstrap.json"; /// Una línea del manifiesto: el registro inmutable de lo que produjo una etapa del bootstrap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StageEntry { /// 0 = semilla ingerida, 1 = userland mínimo, 2 = rebuild nativo. pub stage: u8, /// Hash de la receta que produjo el artefacto. `None` en Stage 0: la semilla se **ingiere** /// (fuente fijada por sha256), no se compila desde una receta. #[serde(default, skip_serializing_if = "Option::is_none")] pub recipe_hash: Option, /// Artefacto que produjo la etapa (la semilla en Stage 0, el rootfs sellado en Stage 1). pub artifact_hash: ArtifactHash, /// Semilla de la que depende la etapa. En Stage 0 coincide con `artifact_hash` (la semilla /// es a la vez insumo y producto). #[serde(default, skip_serializing_if = "Option::is_none")] pub seed_hash: Option, /// Segundos Unix en que se anotó la línea. Solo informativo: **no** entra en ningún hash, de /// modo que dos reproducciones del mismo bootstrap difieren a lo sumo en este campo. pub ts: u64, } /// El manifiesto completo: una secuencia ordenada de líneas, una por etapa ejecutada. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BootstrapManifest { pub version: u32, #[serde(default)] pub entries: Vec, } impl Default for BootstrapManifest { fn default() -> Self { Self { version: MANIFEST_VERSION, entries: Vec::new() } } } impl BootstrapManifest { /// Ruta del manifiesto para un store dado. pub fn path_for(store: &Store) -> PathBuf { store.root().join(MANIFEST_FILE) } /// Carga el manifiesto del store; devuelve uno vacío si aún no existe (el caso normal antes /// de la primera etapa). pub fn load(store: &Store) -> Result { let path = Self::path_for(store); match std::fs::read(&path) { Ok(bytes) => serde_json::from_slice(&bytes) .map_err(|e| Error::Other(format!("bootstrap.json corrupto: {e}"))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(Error::Io(e)), } } /// Inserta una línea si no estaba ya (de-dup por `(stage, artifact_hash)`), preservando el /// orden de inserción. Devuelve `true` si añadió algo nuevo. Idempotente: re-correr una /// etapa que produjo el mismo artefacto no duplica su línea. pub fn record(&mut self, entry: StageEntry) -> bool { let dup = self .entries .iter() .any(|e| e.stage == entry.stage && e.artifact_hash == entry.artifact_hash); if dup { return false; } self.entries.push(entry); true } /// Persiste el manifiesto al store de forma atómica (write a `.tmp` + rename), para que un /// fallo a media escritura no deje un `bootstrap.json` truncado. pub fn save(&self, store: &Store) -> Result<()> { let path = Self::path_for(store); let tmp = path.with_extension("json.tmp"); let json = serde_json::to_vec_pretty(self) .map_err(|e| Error::Other(format!("serializar bootstrap.json: {e}")))?; std::fs::write(&tmp, &json)?; std::fs::rename(&tmp, &path)?; Ok(()) } } /// Anota una línea en el manifiesto del store (load → record → save), de forma idempotente: si /// la línea ya estaba no reescribe el archivo. Lo usan las etapas tras sellar su artefacto. pub(crate) fn append_line(store: &Store, entry: StageEntry) -> Result<()> { let mut m = BootstrapManifest::load(store)?; if m.record(entry) { m.save(store)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; fn h(hex: &str) -> ArtifactHash { ArtifactHash::from_hex(hex) } fn entry(stage: u8, artifact: &str, ts: u64) -> StageEntry { StageEntry { stage, recipe_hash: None, artifact_hash: h(artifact), seed_hash: Some(h(artifact)), ts, } } #[test] fn load_missing_returns_empty_versioned() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let m = BootstrapManifest::load(&store).unwrap(); assert_eq!(m.version, MANIFEST_VERSION); assert!(m.entries.is_empty()); } #[test] fn record_dedups_by_stage_and_artifact() { let mut m = BootstrapManifest::default(); assert!(m.record(entry(0, "aaaa", 1))); assert!(!m.record(entry(0, "aaaa", 999)), "mismo (stage, artifact) ⇒ no re-anota"); assert_eq!(m.entries.len(), 1); // El ts del intento duplicado se descarta: gana la primera línea. assert_eq!(m.entries[0].ts, 1); // Mismo artefacto pero otra etapa sí es una línea distinta. assert!(m.record(entry(1, "aaaa", 2))); assert_eq!(m.entries.len(), 2); } #[test] fn record_preserves_insertion_order() { let mut m = BootstrapManifest::default(); m.record(entry(0, "aaaa", 1)); m.record(entry(1, "bbbb", 2)); m.record(entry(2, "cccc", 3)); let stages: Vec = m.entries.iter().map(|e| e.stage).collect(); assert_eq!(stages, vec![0, 1, 2]); } #[test] fn save_then_load_roundtrips() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let mut m = BootstrapManifest::default(); m.record(entry(0, "aaaa", 10)); m.record(entry(1, "bbbb", 20)); m.save(&store).unwrap(); let loaded = BootstrapManifest::load(&store).unwrap(); assert_eq!(loaded, m); } #[test] fn append_line_is_idempotent_on_disk() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); append_line(&store, entry(0, "aaaa", 1)).unwrap(); append_line(&store, entry(0, "aaaa", 2)).unwrap(); // mismo (stage, artifact) let m = BootstrapManifest::load(&store).unwrap(); assert_eq!(m.entries.len(), 1, "la segunda anotación no debe duplicar"); assert_eq!(m.entries[0].ts, 1, "gana la primera línea"); } }