diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index b32b7773..6316a690 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -386,6 +386,89 @@ fn assemble_rootfs( Ok(()) } +// ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ────────────────────── +// +// El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando +// **sólo** las herramientas de Stage 1, y se compara el **content-hash** (`of_tree`, no el hash +// input-addressed del store) de stage1 vs stage1'. Iguales ⇒ el sistema se compila a sí mismo bit +// a bit (auto-alojado y reproducible). El rebuild **dentro del rootfs** (bwrap/chroot/VM) corre en +// la VM destino; lo que vive en hammer es la **referencia** (content-hash de stage1) y la +// comparación. Ver `docs/runbooks/stage1-vm-boot.md`. + +/// Veredicto de la verificación de auto-alojamiento de Stage 2. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Reproducibility { + /// stage1 y stage1' tienen el mismo content-hash: el sistema se reconstruye bit-idéntico. + Reproducible, + /// Difieren: hay no-determinismo que cazar ([SDD 09 §2](../../docs/09-trust-model.md)). + Divergent { stage1: ArtifactHash, rebuilt: ArtifactHash }, + /// El rebuild nativo dentro del rootfs aún no corrió (se hace en la VM); sólo se ancló el + /// content-hash de stage1 como referencia a reproducir. + RebuildPending, +} + +/// Reporte de Stage 2: la referencia (content-hash de stage1) y el veredicto. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VerifyReport { + /// Hash input-addressed del rootfs en el store (el que devolvió `stage1`). + pub stage1_rootfs: RootfsHash, + /// Content-hash (`of_tree`) de stage1: los bytes reales que stage1' debe reproducir. + pub stage1_content: ArtifactHash, + pub verdict: Reproducibility, +} + +/// Content-hash (`ArtifactHash::of_tree`) del artefacto sellado `(hash, name)` en el store. +pub fn artifact_content_hash( + store: &Store, + hash: &ArtifactHash, + name: &str, +) -> Result { + let dir = store.path_of(hash, name); + if !dir.is_dir() { + return Err(Error::Other(format!( + "artefacto '{name}' no sellado en {}", + dir.display() + ))); + } + ArtifactHash::of_tree(&dir).map_err(Error::Io) +} + +/// **Stage 2** (referencia + manifiesto). Ancla el **content-hash** del rootfs de Stage 1 como la +/// referencia a reproducir y lo anota en el manifiesto (línea stage 2). El rebuild nativo DENTRO +/// del rootfs corre en la VM (SDD 11 §3); cuando produzca stage1', se compara con [`verify_against`]. +pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result { + let content = artifact_content_hash(store, stage1, "stage1-rootfs")?; + manifest::append_line( + store, + StageEntry { + stage: 2, + recipe_hash: None, + artifact_hash: content.clone(), + seed_hash: None, + ts: now_unix(), + }, + )?; + tracing::info!(content = %content, "stage2: content-hash de stage1 anclado (referencia)"); + Ok(VerifyReport { + stage1_rootfs: stage1.clone(), + stage1_content: content, + verdict: Reproducibility::RebuildPending, + }) +} + +/// Compara la referencia (`report.stage1_content`) con el content-hash de un rebuild stage1' +/// producido en la VM, y emite el veredicto de reproducibilidad. +pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> Reproducibility { + if report.stage1_content == rebuilt_content { + Reproducibility::Reproducible + } else { + Reproducibility::Divergent { + stage1: report.stage1_content.clone(), + rebuilt: rebuilt_content, + } + } +} + /// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`. /// /// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a @@ -801,6 +884,50 @@ mod tests { } } + #[test] + fn stage2_anchors_content_hash_and_verifies() { + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + // Sella un "rootfs" sintético con el nombre que espera stage2. + let h = seal_component(&store, "stage1-rootfs", "abcd", |w| { + std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake-init").unwrap(); + }); + + let report = stage2(&h, &store).unwrap(); + assert_eq!(report.stage1_rootfs, h); + assert!(report.stage1_content.as_str().starts_with("b3:")); + assert_eq!(report.verdict, Reproducibility::RebuildPending); + + // El manifiesto anotó la línea stage 2 con el content-hash como artefacto. + let m = BootstrapManifest::load(&store).unwrap(); + let e2 = m.entries.iter().find(|e| e.stage == 2).expect("línea stage 2"); + assert_eq!(e2.artifact_hash, report.stage1_content); + + // verify_against: un rebuild idéntico ⇒ Reproducible; distinto ⇒ Divergent. + assert_eq!( + verify_against(&report, report.stage1_content.clone()), + Reproducibility::Reproducible + ); + match verify_against(&report, ArtifactHash::from_hex("ffff")) { + Reproducibility::Divergent { stage1, rebuilt } => { + assert_eq!(stage1, report.stage1_content); + assert_eq!(rebuilt, ArtifactHash::from_hex("ffff")); + } + v => panic!("esperaba Divergent, vino {v:?}"), + } + } + + #[test] + fn artifact_content_hash_errors_when_unsealed() { + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + let err = artifact_content_hash(&store, &ArtifactHash::from_hex("00"), "stage1-rootfs") + .unwrap_err() + .to_string(); + assert!(err.contains("no sellado"), "{err}"); + } + #[test] fn all_shipped_recipes_parse_and_hash() { // Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 0a58e5c6..c882eb1c 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -263,6 +263,17 @@ enum BootstrapCmd { #[arg(long, default_value = "recipes")] recipes: String, }, + /// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad + /// (anota la línea del manifiesto). Con `--verify ` compara contra un rebuild `stage1'` + /// (producido en la VM) y emite el veredicto de auto-alojamiento. + Stage2 { + /// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`). + #[arg(long)] + rootfs: String, + /// Content-hash de un rebuild `stage1'` para comparar. Sin él, sólo ancla la referencia. + #[arg(long)] + verify: Option, + }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { @@ -494,6 +505,31 @@ fn main() -> anyhow::Result<()> { let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?; println!("{hash}"); } + BootstrapCmd::Stage2 { rootfs, verify } => { + let store = hammer_core::Store::open(&cli.store)?; + let rootfs_hash = + hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:")); + let report = hammer_bootstrap::stage2(&rootfs_hash, &store)?; + println!("stage1 content-hash: {}", report.stage1_content); + match verify { + None => println!("referencia anclada (rebuild nativo de stage1': correr en la VM)"), + Some(rebuilt) => { + let rb = + hammer_core::ArtifactHash::from_hex(rebuilt.trim_start_matches("b3:")); + match hammer_bootstrap::verify_against(&report, rb) { + hammer_bootstrap::Reproducibility::Reproducible => { + println!("✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)"); + } + hammer_bootstrap::Reproducibility::Divergent { stage1, rebuilt } => { + anyhow::bail!( + "✗ DIVERGENTE: stage1={stage1} rebuilt={rebuilt} — hay no-determinismo que cazar (SDD 09 §2)" + ); + } + hammer_bootstrap::Reproducibility::RebuildPending => unreachable!(), + } + } + } + } }, } Ok(()) diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index f9d87aff..3b33a6bd 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -121,7 +121,7 @@ No introduce mecanismo nuevo de build: encadena recetas y persiste el manifiesto pub fn stage0(seed: &SeedSpec, store: &Store) -> Result; // ✅ toolchain semilla pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store) -> Result; // ◑ musl+busybox+hammerd; init arje ☐ -pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result; // ☐ rebuild + diff +pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result; // ◑ ancla+verifica; rebuild in-rootfs en VM pub fn all(seed: &SeedSpec, store: &Store) -> Result; // ☐ ``` @@ -134,7 +134,15 @@ receta Cargo (repo pinned + deps vendoreadas en el fetch para build `--offline`) (Cargo, fuente = monorepo tawasuyu pinned al commit de la migración del CAS a BLAKE3, `-p arje-zero`): prueba que el lab construye el init, pero todavía **no** es PID 1. El paso "init real" —arje-zero como PID 1 del rootfs con su seed card, `arje-bus` y hammerd como Card de servicio supervisada, que -entrega el `CRASHED` real— está diseñado en el [SDD 12](12-init-real.md). +entrega el `CRASHED` real— está diseñado en el [SDD 12](12-init-real.md) y **validado en QEMU** +(ver [runbook](runbooks/stage1-vm-boot.md)). + +`stage2` ancla el **content-hash** del rootfs de Stage 1 (`ArtifactHash::of_tree` — los bytes +reales, no el hash input-addressed del store) como la referencia a reproducir, y la anota en el +manifiesto. `verify_against` la compara con un rebuild `stage1'` producido **dentro** del rootfs (en +la VM, recompilando con sólo las herramientas de Stage 1): iguales ⇒ auto-alojado bit a bit. El +determinismo necesario lo dan las rutas fijas (`/src`) + `SOURCE_DATE_EPOCH` del sandbox. El rebuild +in-rootfs es el sub-ítem que queda (corre en la VM destino). `SeedSpec { kind, version, url, sha256 }` es la identidad pinned de la semilla; `seed_hash()` deriva el `ArtifactHash` de `(kind, version, sha256)` — no del `url` ni del host, así que @@ -144,8 +152,8 @@ CLI (implementado lo de Stage 0; el resto pendiente): ``` hammer bootstrap stage0 --url URL --sha256 HEX --version V [--seed zig|musl-cross-make] # ✅ -hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR] # ◑ musl+busybox+hammerd -hammer bootstrap stage2 --verify # ☐ +hammer bootstrap stage1 --seed-hash HASH [--seed zig] [--recipes DIR] # ✅ booteado en QEMU +hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH] # ◑ ancla+verifica; rebuild en VM hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐ ```