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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1731e7ef23
commit
ba4d520c44
Generated
+1
@@ -426,6 +426,7 @@ dependencies = [
|
|||||||
"hammer-core",
|
"hammer-core",
|
||||||
"hex",
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ anyhow.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes)
|
//! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes)
|
||||||
//! llegan en entregas siguientes y reusan este mismo store.
|
//! 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::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -15,6 +20,8 @@ use std::process::Command;
|
|||||||
use hammer_build::download;
|
use hammer_build::download;
|
||||||
use hammer_core::{ArtifactHash, Store};
|
use hammer_core::{ArtifactHash, Store};
|
||||||
|
|
||||||
|
pub use manifest::{BootstrapManifest, StageEntry};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
@@ -88,20 +95,44 @@ pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
|
|||||||
let name = seed.store_name();
|
let name = seed.store_name();
|
||||||
if store.has(&h, &name) {
|
if store.has(&h, &name) {
|
||||||
tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
|
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
|
// La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
|
||||||
// hacer el rename atómico.
|
// receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
|
||||||
let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
|
manifest::append_line(
|
||||||
let _ = std::fs::remove_dir_all(&work);
|
store,
|
||||||
std::fs::create_dir_all(&work)?;
|
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
|
/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
|
||||||
// no debe dejar el tarball a medio descargar bajo el store).
|
/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
|
||||||
let result = stage0_into(seed, store, &h, &name, &work);
|
/// la reproducibilidad.
|
||||||
let _ = std::fs::remove_dir_all(&work);
|
fn now_unix() -> u64 {
|
||||||
result
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage0_into(
|
fn stage0_into(
|
||||||
@@ -224,6 +255,37 @@ mod tests {
|
|||||||
assert_eq!(h1, h2);
|
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]
|
#[test]
|
||||||
fn stage0_rejects_bad_sha256_and_seals_nothing() {
|
fn stage0_rejects_bad_sha256_and_seals_nothing() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -98,12 +98,18 @@ hash(stage1) vs hash(stage1')
|
|||||||
- **Distintos** ⇒ hay no-determinismo (timestamps, paths embebidos, orden de enlace). Se
|
- **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.
|
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
|
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
|
`bootstrap.json` versionado (en la raíz del store). Ese manifiesto **es** la semilla del log de
|
||||||
([SDD 09 §4](09-trust-model.md)): un tercero reproduce el bootstrap y verifica que sus hashes
|
transparencia ([SDD 09 §4](09-trust-model.md)): un tercero reproduce el bootstrap y verifica que
|
||||||
coinciden con los publicados, sin confiar en el binario del autor.
|
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
|
## 5. Interfaz
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user