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 1182Shell 87Rust 63Markdown 541592 Definiciones

Bootstrap: manifiesto versionado (bootstrap.json) + Stage 0 anota su línea

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) <noreply@anthropic.com>

ba4d520c44ef442f0c2cb29c3fb578fd9e5e3b7d · por Sergio · (hace 4 meses) · padre 1731e7ef2 · ver el árbol en este commit

Delta semántico · 23

crates/hammer-bootstrap/src/lib.rs

crates/hammer-bootstrap/src/manifest.rs

5 archivos · +275 −16

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<ArtifactHash> {
     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)?;
-
-    // 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
+    // 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)
+}
+
+/// 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 `<hash>-<name>`, 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<ArtifactHash>,
+    /// 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<ArtifactHash>,
+    /// 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<StageEntry>,
+}
+
+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<Self> {
+        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<u8> = 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