From ba4d520c44ef442f0c2cb29c3fb578fd9e5e3b7d Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 23:54:28 +0000 Subject: [PATCH] =?UTF-8?q?Bootstrap:=20manifiesto=20versionado=20(bootstr?= =?UTF-8?q?ap.json)=20+=20Stage=200=20anota=20su=20l=C3=ADnea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lote 1 del track posterior. Implementa el embrión del log de transparencia (SDD 11 §4): crate hammer-bootstrap::manifest con BootstrapManifest/StageEntry. - append-only e idempotente (de-dup por (stage, artifact_hash)) - escritura atómica (tmp + rename); ts informativo, fuera de todo hash - stage0() anota su línea en ambos caminos (sello nuevo e idempotente); un sha erróneo no genera línea - 9 tests nuevos (4 de manifest + 5 de stage0/manifest), workspace verde Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/hammer-bootstrap/Cargo.toml | 1 + crates/hammer-bootstrap/src/lib.rs | 84 +++++++++-- crates/hammer-bootstrap/src/manifest.rs | 189 ++++++++++++++++++++++++ docs/11-bootstrap.md | 14 +- 5 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 crates/hammer-bootstrap/src/manifest.rs diff --git a/Cargo.lock b/Cargo.lock index dda8ddc3..d00f3ccb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ dependencies = [ "hammer-core", "hex", "serde", + "serde_json", "sha2", "tempfile", "thiserror", diff --git a/crates/hammer-bootstrap/Cargo.toml b/crates/hammer-bootstrap/Cargo.toml index fdf7d9e9..3a1db0a0 100644 --- a/crates/hammer-bootstrap/Cargo.toml +++ b/crates/hammer-bootstrap/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true thiserror.workspace = true tracing.workspace = true serde.workspace = true +serde_json.workspace = true sha2.workspace = true hex.workspace = true diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 3da67157..a2064b68 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -8,6 +8,11 @@ //! //! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes) //! llegan en entregas siguientes y reusan este mismo store. +//! +//! Cada etapa anota su línea en el [`manifest`] de bootstrap (`bootstrap.json`), el embrión del +//! log de transparencia ([SDD 11 §4](../../docs/11-bootstrap.md)). + +pub mod manifest; use std::path::Path; use std::process::Command; @@ -15,6 +20,8 @@ use std::process::Command; use hammer_build::download; use hammer_core::{ArtifactHash, Store}; +pub use manifest::{BootstrapManifest, StageEntry}; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("io: {0}")] @@ -88,20 +95,44 @@ pub fn stage0(seed: &SeedSpec, store: &Store) -> Result { let name = seed.store_name(); if store.has(&h, &name) { tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)"); - return Ok(h); + } else { + // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda + // hacer el rename atómico. + let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name)); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work)?; + + // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo + // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de + // anotar el manifiesto: una semilla que no se selló no genera línea. + let result = stage0_into(seed, store, &h, &name, &work); + let _ = std::fs::remove_dir_all(&work); + result?; } - // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda - // hacer el rename atómico. - let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name)); - let _ = std::fs::remove_dir_all(&work); - std::fs::create_dir_all(&work)?; + // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin + // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea. + manifest::append_line( + store, + StageEntry { + stage: 0, + recipe_hash: None, + artifact_hash: h.clone(), + seed_hash: Some(h.clone()), + ts: now_unix(), + }, + )?; + Ok(h) +} - // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo - // no debe dejar el tarball a medio descargar bajo el store). - let result = stage0_into(seed, store, &h, &name, &work); - let _ = std::fs::remove_dir_all(&work); - result +/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época +/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a +/// la reproducibilidad. +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } fn stage0_into( @@ -224,6 +255,37 @@ mod tests { assert_eq!(h1, h2); } + #[test] + fn stage0_records_one_manifest_line() { + let tmp = tempfile::tempdir().unwrap(); + let (url, sha) = make_seed_tarball(tmp.path()); + let store = Store::open(tmp.path().join("store")).unwrap(); + let seed = spec(&url, &sha); + + let h = stage0(&seed, &store).unwrap(); + stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea + + let m = BootstrapManifest::load(&store).unwrap(); + assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0"); + let e = &m.entries[0]; + assert_eq!(e.stage, 0); + assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila"); + assert_eq!(e.artifact_hash, h); + assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto"); + } + + #[test] + fn stage0_bad_sha_records_no_manifest_line() { + let tmp = tempfile::tempdir().unwrap(); + let (url, _real_sha) = make_seed_tarball(tmp.path()); + let store = Store::open(tmp.path().join("store")).unwrap(); + let seed = spec(&url, &"0".repeat(64)); + + assert!(stage0(&seed, &store).is_err()); + // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto. + assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json"); + } + #[test] fn stage0_rejects_bad_sha256_and_seals_nothing() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hammer-bootstrap/src/manifest.rs b/crates/hammer-bootstrap/src/manifest.rs new file mode 100644 index 00000000..8062421d --- /dev/null +++ b/crates/hammer-bootstrap/src/manifest.rs @@ -0,0 +1,189 @@ +//! 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"); + } +} diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index 0cf474be..15f7c641 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -98,12 +98,18 @@ hash(stage1) vs hash(stage1') - **Distintos** ⇒ hay no-determinismo (timestamps, paths embebidos, orden de enlace). Se caza y se elimina; es exactamente el trabajo que [SDD 09](09-trust-model.md) §2 exige. -## 4. Manifiesto de bootstrap (embrión del log de transparencia) +## 4. Manifiesto de bootstrap (embrión del log de transparencia) ✅ Cada etapa anota una línea `(stage, recipe_hash, artifact_hash, seed_hash, ts)` en un -`bootstrap.json` versionado. Ese manifiesto **es** la semilla del log de transparencia -([SDD 09 §4](09-trust-model.md)): un tercero reproduce el bootstrap y verifica que sus hashes -coinciden con los publicados, sin confiar en el binario del autor. +`bootstrap.json` versionado (en la raíz del store). Ese manifiesto **es** la semilla del log de +transparencia ([SDD 09 §4](09-trust-model.md)): un tercero reproduce el bootstrap y verifica que +sus hashes coinciden con los publicados, sin confiar en el binario del autor. + +Implementado en `hammer-bootstrap::manifest` (`BootstrapManifest` / `StageEntry`): append-only e +**idempotente** (de-dup por `(stage, artifact_hash)`), escritura atómica, y `ts` que **no** entra +en ningún hash (dos reproducciones del mismo bootstrap difieren a lo sumo en ese campo). Stage 0 +ya anota su línea: `recipe_hash = None` (la semilla se ingiere, no se compila) y +`artifact_hash == seed_hash` (la semilla es a la vez insumo y producto). ## 5. Interfaz