//! 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, PathBuf}; use std::process::Command; use hammer_build::download; use hammer_core::{ArtifactHash, Recipe, 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()) } /// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo /// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin /// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable /// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico /// top-level versionado `zig-linux-x86_64-/`—. Falla si la semilla no está sellada o si /// el ejecutable no aparece (o es ambiguo). pub fn toolchain_dir(&self, store: &Store) -> Result { seed_toolchain_dir(store, &self.seed_hash(), self.kind) } } /// Igual que [`SeedSpec::toolchain_dir`] pero sólo desde la identidad mínima `(seed_hash, kind)`: /// lo que basta para localizar la semilla ya sellada sin re-pasar `url`/`sha256`. Lo usa Stage 1, /// que conoce el `seed_hash` (de Stage 0 o del manifiesto) pero no necesariamente el `SeedSpec`. pub fn seed_toolchain_dir( store: &Store, seed_hash: &ArtifactHash, kind: SeedKind, ) -> Result { let base = store.path_of(seed_hash, &format!("seed-{}", kind.as_str())); if !base.is_dir() { return Err(Error::Other(format!( "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", kind.as_str(), base.display() ))); } match kind { SeedKind::Zig => locate_tool_dir(&base, "zig"), SeedKind::MuslCrossMake => Err(Error::Other( "toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(), )), } } /// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`. /// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no /// elegir a ciegas entre layouts ambiguos. fn locate_tool_dir(base: &Path, tool: &str) -> Result { if base.join(tool).is_file() { return Ok(base.to_path_buf()); } let mut hits = Vec::new(); for entry in std::fs::read_dir(base)? { let path = entry?.path(); if path.is_dir() && path.join(tool).is_file() { hits.push(path); } } hits.sort(); match hits.len() { 1 => Ok(hits.pop().unwrap()), 0 => Err(Error::Other(format!( "no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}", base.display() ))), _ => Err(Error::Other(format!( "ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}", base.display() ))), } } /// Deriva una [`hammer_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla /// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el /// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto /// de la config (rootfs, work, cache) se hereda intacto. pub fn seed_build_config( base: &hammer_build::BuildConfig, seed: &SeedSpec, store: &Store, ) -> Result { let zig_dir = seed.toolchain_dir(store)?; Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() }) } /// Hash de un rootfs sellado por Stage 1. Es un `ArtifactHash` derivado del **contenido lógico** /// (los hashes de sus componentes + el init), no del árbol en disco: así dos ensamblados de los /// mismos artefactos producen el mismo hash aunque difieran en inodes o timestamps. pub type RootfsHash = ArtifactHash; /// Los componentes del userland mínimo, en orden de ensamblado. `musl` primero (libc), luego /// `busybox` (coreutils + sh + init). `hammerd` (Rust, vía receta Cargo) y el init `arje` entran /// en un lote posterior (ADR 0008): hasta entonces el PID 1 provisional es el `init` de busybox. const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox"]; /// `inittab` del init provisional (busybox). Monta los pseudo-FS y abre una shell. Cuando /// `hammerd` entre como servicio supervisado se añadirá `::respawn:/sbin/hammerd`; cuando entre /// `arje` como PID 1 (ADR 0007) este archivo se reemplaza por su supervisión real. const PROVISIONAL_INITTAB: &str = "\ # Stage 1 — init provisional (busybox). Reemplazado por arje (ADR 0007) más adelante. ::sysinit:/bin/mount -t proc proc /proc ::sysinit:/bin/mount -t sysfs sysfs /sys ::sysinit:/bin/mount -t devtmpfs dev /dev ::sysinit:/bin/mount -o remount,rw / ::respawn:/bin/sh ::ctrlaltdel:/bin/umount -a -r ::shutdown:/bin/umount -a -r "; /// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio /// de recetas donde viven `musl.toml` / `busybox.toml`. #[derive(Debug, Clone)] pub struct Stage1Spec { pub seed_hash: ArtifactHash, pub seed_kind: SeedKind, pub recipes_dir: PathBuf, } /// **Stage 1** — cross-compila el userland mínimo con la semilla de Stage 0, ensambla un rootfs /// FHS y lo sella. Devuelve el [`RootfsHash`] y anota la línea del manifiesto. /// /// Idempotente: si el rootfs (mismo hash de contenido) ya está sellado, no reconstruye. El /// cross-compile real requiere el rootfs base + toolchain del lab; en una máquina sin `.dev-fs` /// `hammer_build::build` falla limpio antes de ensamblar nada. pub fn stage1( spec: &Stage1Spec, base_cfg: &hammer_build::BuildConfig, store: &Store, ) -> Result { // Toolchain = la semilla sellada, no el zig del host. let zig_dir = seed_toolchain_dir(store, &spec.seed_hash, spec.seed_kind)?; let cfg = hammer_build::BuildConfig { zig_dir, ..base_cfg.clone() }; // 1) Construir cada componente (cacheado por el lab si ya existe). let mut components: Vec<(String, ArtifactHash)> = Vec::with_capacity(STAGE1_COMPONENTS.len()); for name in STAGE1_COMPONENTS { let recipe_path = spec.recipes_dir.join(format!("{name}.toml")); let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) })?; let h = hammer_build::build(&recipe, &cfg, store)?; components.push((name.to_string(), h)); } // 2) Hash del rootfs = función pura de sus componentes (reproducible, sin tocar disco). let rootfs_hash = rootfs_hash(&components); let store_name = "stage1-rootfs"; // 3) Ensamblar + sellar (si no estaba ya). Staging bajo el store ⇒ rename atómico en seal. if !store.has(&rootfs_hash, store_name) { let staging = store .root() .join(".bootstrap-tmp") .join(rootfs_hash.store_dir_name(store_name)); let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)?; let result = assemble_and_seal(store, &components, &rootfs_hash, store_name, &staging); let _ = std::fs::remove_dir_all(&staging); result?; } else { tracing::info!(hash = %rootfs_hash, "stage1: rootfs ya sellado (idempotente)"); } // 4) Línea del manifiesto. `recipe_hash = None`: el rootfs es un ensamblado de varias recetas, // no de una sola; el `seed_hash` ancla de qué toolchain salió. manifest::append_line( store, StageEntry { stage: 1, recipe_hash: None, artifact_hash: rootfs_hash.clone(), seed_hash: Some(spec.seed_hash.clone()), ts: now_unix(), }, )?; Ok(rootfs_hash) } /// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + el /// init provisional. Cambiar un componente, el orden o el inittab re-hashea el rootfs. fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash { let mut inputs: Vec> = vec![b"hammer-stage1-rootfs-v1".to_vec()]; for (name, h) in components { inputs.push(name.as_bytes().to_vec()); inputs.push(h.as_str().as_bytes().to_vec()); } inputs.push(PROVISIONAL_INITTAB.as_bytes().to_vec()); let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); ArtifactHash::of_inputs(&refs) } fn assemble_and_seal( store: &Store, components: &[(String, ArtifactHash)], rootfs_hash: &RootfsHash, store_name: &str, staging: &Path, ) -> Result<()> { assemble_rootfs(store, components, staging)?; store.seal(staging, rootfs_hash, store_name)?; tracing::info!(hash = %rootfs_hash, "stage1: rootfs sellado"); Ok(()) } /// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada /// componente sellado (hardlinks) y el init provisional. Es la pieza testeable sin build real. fn assemble_rootfs( store: &Store, components: &[(String, ArtifactHash)], staging: &Path, ) -> Result<()> { // Esqueleto FHS mínimo. Los pseudo-FS quedan como puntos de montaje vacíos. for d in ["proc", "sys", "dev", "tmp", "run", "etc", "root", "var", "usr/bin", "bin", "sbin"] { std::fs::create_dir_all(staging.join(d))?; } // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem). for (name, h) in components { let dir = store.path_of(h, name); hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static)?; } // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs // ejecutable: lo quitamos para que la imagen quede limpia y su hash no dependa de cuál // componente lo escribió último. let _ = std::fs::remove_dir_all(staging.join(".hammer")); // Init provisional (busybox lo lee de /etc/inittab). std::fs::write(staging.join("etc/inittab"), PROVISIONAL_INITTAB)?; Ok(()) } /// **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" ); } /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain. fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) { let src = dir.join("src"); let zig = src.join(zig_rel); std::fs::create_dir_all(zig.parent().unwrap()).unwrap(); std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap(); let tarball = dir.join("seed.tar.gz"); let st = Command::new("tar") .args(["-czf"]) .arg(&tarball) .arg("-C") .arg(&src) .arg(".") .status() .unwrap(); assert!(st.success()); let sha = sha256_hex(&std::fs::read(&tarball).unwrap()); let store = Store::open(dir.join("store")).unwrap(); let seed = spec(&format!("file://{}", tarball.display()), &sha); stage0(&seed, &store).unwrap(); (store, seed) } #[test] fn toolchain_dir_finds_zig_in_versioned_subdir() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); let dir = seed.toolchain_dir(&store).unwrap(); assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display()); assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display()); } #[test] fn toolchain_dir_finds_zig_at_root() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig"); let dir = seed.toolchain_dir(&store).unwrap(); let base = store.path_of(&seed.seed_hash(), &seed.store_name()); assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla"); } #[test] fn toolchain_dir_errors_when_unsealed() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada let err = seed.toolchain_dir(&store).unwrap_err().to_string(); assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}"); } #[test] fn toolchain_dir_errors_when_ambiguous() { let tmp = tempfile::tempdir().unwrap(); // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas. let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig"); let base = store.path_of(&seed.seed_hash(), &seed.store_name()); // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test. let b = base.join("b"); let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status(); std::fs::create_dir_all(&b).unwrap(); std::fs::write(b.join("zig"), b"x").unwrap(); let err = seed.toolchain_dir(&store).unwrap_err().to_string(); assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}"); } #[test] fn seed_build_config_points_zig_dir_at_seed() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); let base = hammer_build::BuildConfig::defaults_for_store(store.root()); let cfg = seed_build_config(&base, &seed, &store).unwrap(); assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap()); // El resto de la config se hereda sin cambios. assert_eq!(cfg.rootfs, base.rootfs); assert_eq!(cfg.work_root, base.work_root); } // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real --- /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol /// antes del `seal` (que lo marca read-only). fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash { let h = ArtifactHash::from_hex(hex); let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name)); std::fs::create_dir_all(&work).unwrap(); build(&work); store.seal(&work, &h, name).unwrap(); h } #[test] fn rootfs_hash_is_deterministic_and_sensitive() { let base = [ ("musl".to_string(), ArtifactHash::from_hex("11")), ("busybox".to_string(), ArtifactHash::from_hex("22")), ]; let a = rootfs_hash(&base); assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash"); let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))]; assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea"); let reordered = [base[1].clone(), base[0].clone()]; assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa"); } #[test] fn assemble_rootfs_hydrates_and_writes_provisional_init() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let hmusl = seal_component(&store, "musl", "aa11", |w| { std::fs::create_dir_all(w.join("usr/lib")).unwrap(); std::fs::write(w.join("usr/lib/libc.a"), b"!\n").unwrap(); }); let hbb = seal_component(&store, "busybox", "bb22", |w| { std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap(); std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); // Sidecar de provenance que NO debe terminar en el rootfs. std::fs::create_dir_all(w.join(".hammer")).unwrap(); std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap(); }); let staging = store.root().join("staging"); std::fs::create_dir_all(&staging).unwrap(); let components = [("musl".to_string(), hmusl), ("busybox".to_string(), hbb)]; assemble_rootfs(&store, &components, &staging).unwrap(); assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado"); assert!(staging.join("bin/busybox").is_file(), "busybox hidratado"); assert_eq!( std::fs::read_link(staging.join("bin/sh")).unwrap(), std::path::PathBuf::from("busybox"), "el symlink de applet se replica literal" ); assert!(staging.join("proc").is_dir() && staging.join("sbin").is_dir(), "esqueleto FHS"); let inittab = std::fs::read_to_string(staging.join("etc/inittab")).unwrap(); assert!(inittab.contains("/bin/sh"), "init provisional abre una shell"); assert!(inittab.contains("mount -t proc"), "monta los pseudo-FS"); assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs"); } #[test] fn shipped_stage1_recipes_parse_and_hash() { // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK). let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); for name in STAGE1_COMPONENTS { let path = recipes_dir.join(format!("{name}.toml")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); assert_eq!(&r.name, name); let h = hammer_build::artifact_hash(&r, &store).expect("hash"); assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:"); } } }