//! 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), #[error("bootstrap: {0}")] Other(String), } pub type Result = std::result::Result; /// 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-`. 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 { 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 { 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" ); } }