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:
Sergio
2026-06-11 11:53:34 +00:00
co-authored by Claude Opus 4.8
parent 974504e068
commit 79ad2cad69
3 changed files with 175 additions and 4 deletions
+127
View File
@@ -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<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`.
///
/// 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