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>
313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! 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.
|
|
//!
|
|
//! 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::process::Command;
|
|
|
|
use hammer_build::download;
|
|
use hammer_core::{ArtifactHash, Store};
|
|
|
|
pub use manifest::{BootstrapManifest, StageEntry};
|
|
|
|
#[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)");
|
|
} 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?;
|
|
}
|
|
|
|
// La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
|
|
// receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
|
|
manifest::append_line(
|
|
store,
|
|
StageEntry {
|
|
stage: 0,
|
|
recipe_hash: None,
|
|
artifact_hash: h.clone(),
|
|
seed_hash: Some(h.clone()),
|
|
ts: now_unix(),
|
|
},
|
|
)?;
|
|
Ok(h)
|
|
}
|
|
|
|
/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
|
|
/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
|
|
/// la reproducibilidad.
|
|
fn now_unix() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn stage0_into(
|
|
seed: &SeedSpec,
|
|
store: &Store,
|
|
h: &ArtifactHash,
|
|
name: &str,
|
|
work: &Path,
|
|
) -> Result<ArtifactHash> {
|
|
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");
|
|
Ok(h.clone())
|
|
}
|
|
|
|
/// 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_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]
|
|
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"
|
|
);
|
|
// Tampoco debe dejar staging a medio descargar bajo el store.
|
|
assert!(
|
|
!store.root().join(".bootstrap-tmp").exists()
|
|
|| std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
|
|
"el staging debe quedar limpio tras el fallo"
|
|
);
|
|
}
|
|
}
|