bootstrap: stage2() — ancla y verifica reproducibilidad (pre-Stage 2)
Esqueleto de Stage 2 (SDD 11 §3): la verificación de auto-alojamiento.
- VerifyReport + Reproducibility {Reproducible|Divergent|RebuildPending}
- artifact_content_hash(store, hash, name): of_tree del artefacto sellado
- stage2(stage1, store): ancla el content-hash del rootfs (los BYTES reales, no
el hash input-addressed del store) como referencia y lo anota en el manifiesto
(línea stage 2); verdict = RebuildPending
- verify_against(report, rebuilt): compara stage1 vs stage1' → Reproducible/Divergent
- CLI: `hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH]`
Validado sobre el rootfs real: content-hash b3:d0d5669f… (≠ store hash 73d7a9be…,
confirma que of_tree hashea bytes); --verify igual ⇒ ✓ REPRODUCIBLE, distinto ⇒
✗ DIVERGENTE. +2 tests. El rebuild nativo DENTRO del rootfs (produce stage1')
corre en la VM destino — el único sub-ítem que queda de Stage 2.
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
974504e068
commit
79ad2cad69
@@ -386,6 +386,89 @@ fn assemble_rootfs(
|
|||||||
Ok(())
|
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<ArtifactHash> {
|
||||||
|
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<VerifyReport> {
|
||||||
|
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`.
|
/// **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
|
/// 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]
|
#[test]
|
||||||
fn all_shipped_recipes_parse_and_hash() {
|
fn all_shipped_recipes_parse_and_hash() {
|
||||||
// Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y
|
// Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y
|
||||||
|
|||||||
@@ -263,6 +263,17 @@ enum BootstrapCmd {
|
|||||||
#[arg(long, default_value = "recipes")]
|
#[arg(long, default_value = "recipes")]
|
||||||
recipes: String,
|
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 <hash>` 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<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
|
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)?;
|
let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
|
||||||
println!("{hash}");
|
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(())
|
Ok(())
|
||||||
|
|||||||
+12
-4
@@ -121,7 +121,7 @@ No introduce mecanismo nuevo de build: encadena recetas y persiste el manifiesto
|
|||||||
pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash>; // ✅ toolchain semilla
|
pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash>; // ✅ toolchain semilla
|
||||||
pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store)
|
pub fn stage1(spec: &Stage1Spec, cfg: &BuildConfig, store: &Store)
|
||||||
-> Result<RootfsHash>; // ◑ musl+busybox+hammerd; init arje ☐
|
-> Result<RootfsHash>; // ◑ musl+busybox+hammerd; init arje ☐
|
||||||
pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>; // ☐ rebuild + diff
|
pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport>; // ◑ ancla+verifica; rebuild in-rootfs en VM
|
||||||
pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>; // ☐
|
pub fn all(seed: &SeedSpec, store: &Store) -> Result<BootstrapManifest>; // ☐
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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`):
|
(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
|
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
|
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()`
|
`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
|
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 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 stage1 --seed-hash HASH [--seed zig] [--recipes DIR] # ✅ booteado en QEMU
|
||||||
hammer bootstrap stage2 --verify # ☐
|
hammer bootstrap stage2 --rootfs HASH [--verify CONTENT_HASH] # ◑ ancla+verifica; rebuild en VM
|
||||||
hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
|
hammer bootstrap --all # las tres + reporte de reproducibilidad # ☐
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user