Track posterior: Stage 0 del bootstrap (crate hammer-bootstrap) + ADR 0008

Primer eslabón ejecutable del bootstrap from-scratch (SDD 11). Stage 0 ingiere un
toolchain semilla pinned al store sin compilarlo:

- SeedSpec{kind,version,url,sha256}. seed_hash() deriva el ArtifactHash de la
  identidad pinned (clase+versión+sha256), no de rutas del host ni del url → el
  mismo tarball desde otro espejo produce el mismo artefacto.
- stage0(seed, store): descarga (curl, file:// offline-ok), verifica sha256
  ANTES de extraer, untar y seal en el store. Idempotente si ya está sellado;
  un sha erróneo no sella nada.
- Reusa el lab: hammer_build::download (+ nuevo verify_sha256 público), Store::seal.
  Staging bajo el root del store para que el rename de seal sea atómico.

ADR 0008 fija la decisión (3 stages, zig semilla primaria, musl-cross-make
escotilla, ingerir-no-compilar el Stage 0). Renumerado a 0008 porque el 0007 lo
tomó la decisión de adoptar arje como init; SDD 11 y refs alineados con arje
(init supervisado que entrega el CRASHED real, ADR 0007).

4 unit tests (hash por identidad, ingest+seal+readonly, idempotencia, rechazo de
sha) sin red (file://). Workspace 218 verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-10 21:03:09 +00:00
co-authored by Claude Opus 4.8
parent ea10c9f716
commit 3d6d25661c
8 changed files with 348 additions and 6 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "hammer-bootstrap"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Bootstrap from-scratch de hammer: Stage 0/1/2 hacia el auto-alojamiento (SDD 11)."
[dependencies]
hammer-core.workspace = true
hammer-build.workspace = true
anyhow.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
sha2.workspace = true
hex.workspace = true
[dev-dependencies]
tempfile.workspace = true
+233
View File
@@ -0,0 +1,233 @@
//! Bootstrap from-scratch de hammer — el track posterior del [SDD 11](../../docs/11-bootstrap.md).
//!
//! Esta primera entrega cubre el **Stage 0**: ingerir un toolchain semilla ya construido
//! (no se compila; ver [ADR 0008](../../docs/adr/0008-bootstrap-stages.md)) como una *fuente
//! fijada por sha256* y sellarlo en el `Store`. El `ArtifactHash` resultante se deriva de la
//! **identidad pinned** de la semilla (clase + versión + sha256), no de rutas del host, así
//! que el resto del bootstrap depende de un artefacto reproducible y auditable.
//!
//! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes)
//! llegan en entregas siguientes y reusan este mismo store.
use std::path::Path;
use std::process::Command;
use hammer_build::download;
use hammer_core::{ArtifactHash, Store};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("core: {0}")]
Core(#[from] hammer_core::Error),
#[error("tar -x de la semilla falló (exit {0:?})")]
Untar(Option<i32>),
#[error("bootstrap: {0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Qué toolchain semilla usamos. `zig` es la semilla primaria (ADR 0003/0007); `musl-cross-make`
/// (gcc + musl) es la escotilla para paquetes con gcc-ismos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeedKind {
Zig,
MuslCrossMake,
}
impl SeedKind {
pub fn as_str(self) -> &'static str {
match self {
SeedKind::Zig => "zig",
SeedKind::MuslCrossMake => "musl-cross-make",
}
}
}
/// Especificación de una semilla: su identidad pinned. La integridad la garantiza `sha256`
/// (fijado, [ADR 0006](../../docs/adr/0006-pinned-commits.md)); `url` puede ser cualquier
/// esquema que entienda curl, incluido `file://` para ingestión offline.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SeedSpec {
pub kind: SeedKind,
pub version: String,
pub url: String,
/// sha256 hex del tarball, fijado. Se verifica **antes** de extraer y sellar.
pub sha256: String,
}
impl SeedSpec {
/// Hash del artefacto del store para esta semilla. Función pura de la identidad pinned:
/// misma `(kind, version, sha256)` ⇒ mismo hash, en cualquier máquina. El `url` queda
/// fuera a propósito (un espejo distinto del mismo tarball produce el mismo artefacto).
pub fn seed_hash(&self) -> ArtifactHash {
ArtifactHash::of_inputs(&[
b"hammer-seed-v1",
self.kind.as_str().as_bytes(),
self.version.as_bytes(),
self.sha256.as_bytes(),
])
}
/// Nombre del artefacto en el store: `seed-<kind>`.
pub fn store_name(&self) -> String {
format!("seed-{}", self.kind.as_str())
}
}
/// **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
/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
/// llega al árbol que se sella.
pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
let h = seed.seed_hash();
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);
}
// 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)?;
let tarball = work.join("seed.tar");
download::fetch_url_to_file(&seed.url, &tarball)?;
// Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
download::verify_sha256(&tarball, &seed.sha256)?;
let tree = work.join("tree");
std::fs::create_dir_all(&tree)?;
untar(&tarball, &tree)?;
let sealed = store.seal(&tree, &h, &name)?;
tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
// Limpieza del staging (el árbol ya se movió por rename; queda el tarball + el dir padre).
let _ = std::fs::remove_dir_all(&work);
Ok(h)
}
/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
fn untar(tarball: &Path, into: &Path) -> Result<()> {
let st = Command::new("tar")
.arg("-xf")
.arg(tarball)
.arg("-C")
.arg(into)
.status()
.map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
if !st.success() {
return Err(Error::Untar(st.code()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(url: &str, sha256: &str) -> SeedSpec {
SeedSpec {
kind: SeedKind::Zig,
version: "0.13.0".into(),
url: url.into(),
sha256: sha256.into(),
}
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
hex::encode(h.finalize())
}
/// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
fn make_seed_tarball(dir: &Path) -> (String, String) {
let src = dir.join("src");
std::fs::create_dir_all(src.join("bin")).unwrap();
std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
let tarball = dir.join("seed.tar.gz");
let st = Command::new("tar")
.arg("-czf")
.arg(&tarball)
.arg("-C")
.arg(&src)
.arg(".")
.status()
.unwrap();
assert!(st.success(), "tar -c falló");
let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
(format!("file://{}", tarball.display()), sha)
}
#[test]
fn seed_hash_is_identity_based() {
let a = spec("file:///mirror-a.tar", "abc123");
let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
let mut d = spec("file:///mirror-a.tar", "abc123");
d.kind = SeedKind::MuslCrossMake;
assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
}
#[test]
fn stage0_ingests_and_seals() {
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).expect("stage0");
assert_eq!(h, seed.seed_hash());
assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
// El contenido extraído está presente y de sólo-lectura (sellado).
let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
}
#[test]
fn stage0_is_idempotent() {
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 h1 = stage0(&seed, &store).unwrap();
let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
assert_eq!(h1, h2);
}
#[test]
fn stage0_rejects_bad_sha256_and_seals_nothing() {
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)); // sha intencionalmente equivocado
let err = stage0(&seed, &store).unwrap_err();
assert!(
err.to_string().contains("sha256 mismatch"),
"esperaba mismatch de sha256, vino: {err}"
);
assert!(
!store.has(&seed.seed_hash(), &seed.store_name()),
"un sha erróneo no debe sellar nada"
);
}
}