Files
takana/crates/hammer-bootstrap/src/lib.rs
T
sergioandClaude Opus 4.8 98f0b2d228 bootstrap: orquestador all + export del manifiesto (Etapa A)
Cierra la Etapa A del camino a la distro (SDD 11 §5):

- `hammer_bootstrap::all(seed, recipes_dir, base_cfg, store) -> AllReport`
  encadena stage0→stage1→stage2 en una corrida del host y deja el
  `bootstrap.json` poblado. El veredicto ✓ REPRODUCIBLE lo sigue sellando el
  rebuild in-VM (selfhost-verify.sh); `all` ancla la referencia para comparar.
- CLI: `hammer bootstrap all --url … --sha256 … --version …` y
  `hammer bootstrap manifest` (imprime el log de transparencia, §4).
- Refactor: `parse_seed_kind` factoriza el match de `--seed` (estaba duplicado
  en 4 sitios del despacho).
- hammer-build: arregla el test stale `detect_cargo` — la fase Cargo evolucionó
  a `RF=…`+`-C link-self-contained=no` condicional, pero la aserción esperaba la
  forma vieja `RUSTFLAGS="-C linker=…`. Restaura el workspace en verde (43/43).

Tests: cargo test --workspace verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:11:56 -04:00

1651 lines
74 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, 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<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())
}
/// 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-<ver>/`—. Falla si la semilla no está sellada o si
/// el ejecutable no aparece (o es ambiguo).
pub fn toolchain_dir(&self, store: &Store) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<hammer_build::BuildConfig> {
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` (libc), `busybox`
/// (coreutils + sh), `hammerd` (daemon, receta Cargo) y `arje-zero` (el init PID 1, ADR 0007).
/// arje-zero es PID 1 vía el symlink `/sbin/init`; supervisa a `hammerd` y a la getty por la
/// seed card ([SDD 12](../../docs/12-init-real.md)) — su muerte es el `CRASHED` real.
const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"];
/// La **seed card** que arje-zero lee en `/ente/seed.card.json` al boot ([SDD 12 §4](../../docs/12-init-real.md)):
/// un `Card` `Virtual` cuyos `genesis` son `hammerd` (servicio `Native` con `Restart` — el
/// `CRASHED` real) y una `console-getty` supervisada. La forma replica una seed real de arje
/// (`seeds/arje-qemu.card.json`) para que `Card::validate()` la acepte; los ULID son fijos para que
/// el `RootfsHash` sea reproducible. Template autocontenido (opción A del SDD): hammer no depende de
/// `card-core` como librería; el pin a arje-zero + el boot en VM cubren el drift de schema.
const STAGE1_SEED_CARD: &str = r#"{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA00",
"lineage": null,
"label": "hammer-stage1",
"provides": ["Spawn", "Journal"],
"requires": [],
"permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": "Virtual",
"supervision": "OneShot",
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": [
{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA01",
"lineage": null,
"label": "hammerd",
"provides": [],
"requires": [],
"permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/hammerd", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": { "Native": {
"exec": "/usr/bin/hammerd",
"argv": ["--store", "/store", "--journal", "/var/lib/hammer/journal"],
"envp": [["RUST_LOG", "info"], ["ENTE_BUS_SOCK", "/run/ente-bus.sock"]]
}},
"supervision": { "Restart": { "initial": 200, "max": 10000 } },
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": []
},
{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA02",
"lineage": null,
"label": "console-getty",
"provides": [],
"requires": [],
"permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": { "Native": {
"exec": "/bin/busybox",
"argv": ["getty", "-n", "-l", "/bin/sh", "115200", "console"],
"envp": []
}},
"supervision": { "Restart": { "initial": 200, "max": 10000 } },
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": []
}
]
}
"#;
/// 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<RootfsHash> {
// 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 + la
/// seed card del init. Cambiar un componente, el orden o la seed re-hashea el rootfs. El tag `v2`
/// marca el corte respecto al rootfs con init provisional (`v1`).
fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash {
let mut inputs: Vec<Vec<u8>> = vec![b"hammer-stage1-rootfs-v2".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(STAGE1_SEED_CARD.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), la seed card de arje y `/sbin/init`→arje-zero. Es la pieza
/// testeable sin build real ([SDD 12](../../docs/12-init-real.md)).
fn assemble_rootfs(
store: &Store,
components: &[(String, ArtifactHash)],
staging: &Path,
) -> Result<()> {
// Esqueleto FHS. arje-zero monta él mismo los pseudo-FS (arje-kernel), así que sólo deben
// existir como puntos de montaje vacíos; `/ente` lleva la seed, `/var/lib/hammer` el diario.
for d in [
"proc", "sys", "sys/fs/cgroup", "dev", "dev/pts", "dev/shm", "run", "tmp", "etc", "root",
"var", "var/lib/hammer", "ente", "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, None)?;
}
// 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"));
// La seed card que arje-zero lee al boot.
std::fs::write(staging.join("ente/seed.card.json"), STAGE1_SEED_CARD)?;
// arje-zero es PID 1 vía `/sbin/init`. Symlink absoluto: el kernel lo sigue sea cual sea el
// cmdline, sin exigir `init=`. Idempotente: si ya existe (re-ensamblado), lo reemplazamos.
let init_link = staging.join("sbin/init");
let _ = std::fs::remove_file(&init_link);
std::os::unix::fs::symlink("/usr/bin/arje-zero", &init_link)?;
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,
}
}
}
/// Reporte de [`all`]: los tres hashes de las etapas + el manifiesto ya poblado. El veredicto de
/// auto-alojamiento NO va aquí (es `RebuildPending` en el host): el rebuild nativo que lo decide corre
/// **dentro** del rootfs, en la VM destino (`scripts/selfhost-verify.sh`, runbook §8).
#[derive(Debug, Clone)]
pub struct AllReport {
pub seed_hash: ArtifactHash,
pub stage1_rootfs: RootfsHash,
pub stage2: VerifyReport,
pub manifest: BootstrapManifest,
}
/// **`bootstrap --all`** ([SDD 11 §5](../../docs/11-bootstrap.md)) — encadena las tres etapas en una
/// sola corrida del host: ingiere la semilla (Stage 0), construye y sella el rootfs del userland mínimo
/// (Stage 1) y ancla su content-hash como referencia de reproducibilidad (Stage 2). Devuelve los tres
/// hashes y el [`BootstrapManifest`] (`bootstrap.json`) resultante: el embrión del log de transparencia
/// ([SDD 11 §4](../../docs/11-bootstrap.md)), listo para publicar.
///
/// El **corte del cordón** (el veredicto `✓ REPRODUCIBLE`) no se emite aquí: el rebuild nativo que lo
/// decide corre con las herramientas de **dentro** del rootfs (en la VM), no las del host. `all` deja la
/// referencia anclada (`stage2.stage1_content`) lista para que ese rebuild la compare vía `stage2
/// --verify` (ver [`verify_against`] y `scripts/selfhost-verify.sh`).
///
/// Idempotente componiendo idempotencias: cada etapa no rehace lo ya sellado ni duplica su línea de
/// manifiesto, así que re-correr `all` sobre un store ya poblado sólo recolecta los hashes.
pub fn all(
seed: &SeedSpec,
recipes_dir: &Path,
base_cfg: &hammer_build::BuildConfig,
store: &Store,
) -> Result<AllReport> {
let seed_hash = stage0(seed, store)?;
let stage1_spec = Stage1Spec {
seed_hash: seed_hash.clone(),
seed_kind: seed.kind,
recipes_dir: recipes_dir.to_path_buf(),
};
let stage1_rootfs = stage1(&stage1_spec, base_cfg, store)?;
let stage2 = stage2(&stage1_rootfs, store)?;
let manifest = BootstrapManifest::load(store)?;
Ok(AllReport { seed_hash, stage1_rootfs, stage2, manifest })
}
// ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ────────────────────
//
// El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no
// puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que,
// booteada en la VM, corre `hammer bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara
// su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit.
//
// Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por
// hammer). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el
// auto-alojamiento *puro* (variante b: el toolchain construido por hammer desde fuente, incremental).
//
// El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se
// ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es
// reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario +
// tag del toolchain + el propio driver), apto para el log de transparencia.
/// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee
/// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al
/// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va
/// vacío, sólo imprime el content-hash de `stage1'` para comparación manual.
///
/// El toolchain es Alpine **dinámico**: sus binarios (bwrap/git/curl/cargo/make) no corren desde el
/// userland Stage 1, que es **estático** (no hay loader en `/lib`). El driver instala un *shim* del
/// loader musl (`cp` a `/lib`) + `LD_LIBRARY_PATH`/`PATH` al toolchain, y a partir de ahí esas
/// herramientas son ejecutables; el `hammer` estático vive en `/usr/bin/hammer`. El sandbox de build
/// (bwrap) anida en `/toolchain`. (El shim sólo se usa para *invocar* el toolchain desde fuera del
/// sandbox: fetch git/tarball; dentro del sandbox el loader está en su sitio.)
const REBUILD_DRIVER: &str = r#"#!/bin/sh
# rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7).
# Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y
# compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera.
set -eu
. /etc/hammer/rebuild.env # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío)
# Shim del loader musl: deja correr los binarios *dinámicos* del toolchain (bwrap/git/curl/cargo/make)
# desde el root Stage 1 estático. Usa el busybox estático (rutas absolutas) ANTES de tocar el PATH.
/bin/mkdir -p /lib
/bin/cp -f /toolchain/lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
export LD_LIBRARY_PATH=/toolchain/lib:/toolchain/usr/lib
export PATH=/toolchain/usr/bin:/toolchain/usr/sbin:/toolchain/bin:/toolchain/sbin:/usr/bin:/bin:/sbin
# overlay es módulo (CONFIG_OVERLAY_FS=m) y el initramfs arranca sin módulos cargados: el sandbox de
# build (bwrap --tmp-overlay) lo exige ⇒ ENODEV sin esto. Lo cargamos del toolchain (insmod=kmod,
# vermagic del kernel destino). Idempotente: si ya está, insmod falla y lo ignoramos.
insmod /toolchain/lib/overlay.ko 2>/dev/null || true
# Red para el vendoring de crates (los componentes Rust hacen `cargo vendor`, que baja de crates.io).
# NIC e1000 (qemu user-mode 10.0.2.0/24, módulo del kernel) + config estática + DNS de slirp. La VM
# sin NIC ⇒ todo esto es no-op (los componentes C no necesitan red). `ip` sale del toolchain (shim).
insmod /toolchain/lib/e1000.ko 2>/dev/null || true
if ip link show eth0 >/dev/null 2>&1; then
ip link set eth0 up
ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || true
ip route add default via 10.0.2.2 2>/dev/null || true
printf 'nameserver 10.0.2.3\n' > /etc/resolv.conf
echo ">> red eth0: $(ip -4 -o addr show eth0 2>/dev/null | grep -o 'inet [0-9.]*' || echo down)"
# CA bundle para el TLS de cargo/curl/git (crates.io). El toolchain trae el bundle de Alpine;
# lo exponemos en la ruta estándar y por env (cargo usa libcurl/openssl según el build).
mkdir -p /etc/ssl/certs
cp -f /toolchain/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt
fi
# NOTA: el initramfs debe empaquetarse con `cpio --owner=root:root`. Si los ficheros del toolchain
# quedan con el uid del que armó el builder (p. ej. 1000), ese uid no está mapeado en el userns de
# bwrap (que mapea sólo 0→0) y el copy-up de overlay falla con EACCES al crear /opt/zig. Ver §8c.
export HAMMER_ROOTFS=/toolchain # el sandbox de build: el toolchain (variante a, desde Alpine)
export HAMMER_WORK=/work
export HAMMER_CACHE=
echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)"
PRIME=$(/usr/bin/hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes)
echo ">> stage1' = $PRIME"
if [ -n "${REF_CONTENT:-}" ]; then
/usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT"
else
/usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME"
fi
"#;
/// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7).
#[derive(Debug, Clone)]
pub struct BuilderSpec {
/// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base
/// runtime sobre la que se hidrata el toolchain.
pub stage1_rootfs: RootfsHash,
/// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `hammer`
/// de adentro resuelva el toolchain por hash, igual que afuera.
pub seed_hash: ArtifactHash,
pub seed_kind: SeedKind,
/// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild).
pub recipes_dir: PathBuf,
/// Binario `hammer` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que hammer
/// construye desde fuente.
pub hammer_bin: PathBuf,
/// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`).
pub toolchain_src: PathBuf,
/// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del
/// builder sin tener que hashear el árbol Alpine entero.
pub toolchain_tag: String,
/// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se
/// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`.
pub ref_content: Option<ArtifactHash>,
/// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y
/// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`).
pub work_cache: Option<PathBuf>,
/// Piezas del toolchain **construidas por hammer desde fuente** (variante b, SDD 11 §7.2b) que se
/// montan sobre el path Alpine en `/toolchain`. Vacío ⇒ variante (a) pura-Alpine. Cada swap entra
/// al hash lógico del builder: la procedencia deja de ser "todo Alpine" y se vuelve auditable.
pub swaps: Vec<ToolchainSwap>,
}
/// Una herramienta del toolchain reemplazada por su build hammer desde fuente (SDD 11 §7.2b). El
/// binario sellado se monta sobre `rel_path` dentro de `/toolchain`, pisando la versión Alpine; su
/// hash sellado se ancla en el hash lógico del builder para que el swap sea reproducible y auditable.
#[derive(Debug, Clone)]
pub struct ToolchainSwap {
/// Nombre del componente/receta (p. ej. `make`); también el nombre store del artefacto sellado.
pub name: String,
/// `of_tree` del build hammer (el artefacto sellado del que sale el binario).
pub artifact: ArtifactHash,
/// Path relativo del binario dentro del artefacto **y** dentro de `/toolchain` (p. ej.
/// `usr/bin/make`). El mismo en origen y destino: el layout del artefacto imita al de Alpine.
pub rel_path: String,
}
/// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado.
#[derive(Debug, Clone)]
pub struct BuilderReport {
pub builder_hash: ArtifactHash,
pub out_dir: PathBuf,
}
/// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain
/// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder.
fn builder_hash(
stage1: &RootfsHash,
seed: &ArtifactHash,
toolchain_tag: &str,
recipes_digest: &str,
hammer_digest: &str,
swaps_digest: &str,
) -> ArtifactHash {
ArtifactHash::of_inputs(&[
b"hammer-builder-rootfs-v1",
stage1.as_str().as_bytes(),
seed.as_str().as_bytes(),
toolchain_tag.as_bytes(),
recipes_digest.as_bytes(),
hammer_digest.as_bytes(),
swaps_digest.as_bytes(),
REBUILD_DRIVER.as_bytes(),
])
}
/// Digest de los swaps del toolchain (variante b): sha256 sobre los `(name, artifact, rel_path)` en
/// orden por nombre. Sin swaps ⇒ string vacío, así el hash lógico de un builder pura-Alpine no cambia
/// respecto a antes de existir el mecanismo (compatibilidad hacia atrás de la línea de manifiesto).
fn swaps_digest(swaps: &[ToolchainSwap]) -> String {
if swaps.is_empty() {
return String::new();
}
use sha2::{Digest, Sha256};
let mut sorted: Vec<&ToolchainSwap> = swaps.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let mut h = Sha256::new();
for s in sorted {
for field in [s.name.as_str(), s.artifact.as_str(), s.rel_path.as_str()] {
h.update(field.as_bytes());
h.update([0u8]);
}
}
hex::encode(h.finalize())
}
/// sha256 hex del contenido de un archivo.
fn sha256_file(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path)?;
let mut h = Sha256::new();
h.update(&bytes);
Ok(hex::encode(h.finalize()))
}
/// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden.
/// Cambiar una receta, añadir o quitar una, re-hashea el builder.
fn recipes_digest(dir: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut tomls: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
.collect();
tomls.sort();
let mut h = Sha256::new();
for p in &tomls {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
h.update(name.as_bytes());
h.update([0u8]);
h.update(std::fs::read(p)?);
h.update([0u8]);
}
Ok(hex::encode(h.finalize()))
}
/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y
/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder
/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`).
///
/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej.
/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el
/// propio destino en una recursión infinita. Se compara por path canónico.
fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> {
if let Some(skip) = skip {
if src.canonicalize().ok().as_deref() == Some(skip) {
return Ok(());
}
}
let meta = std::fs::symlink_metadata(src)?;
let ft = meta.file_type();
if ft.is_symlink() {
let target = std::fs::read_link(src)?;
let _ = std::fs::remove_file(dst);
std::os::unix::fs::symlink(target, dst)?;
} else if ft.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?;
}
} else if ft.is_file() && std::fs::hard_link(src, dst).is_err() {
std::fs::copy(src, dst)?;
}
Ok(())
}
/// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el
/// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`,
/// instala `hammer` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes.
fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> {
// 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros).
let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs");
if !stage1_dir.is_dir() {
return Err(Error::Other(format!(
"stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero",
spec.stage1_rootfs,
stage1_dir.display()
)));
}
hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static, None)?;
// 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config.
for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] {
std::fs::create_dir_all(staging.join(d))?;
}
// 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs).
if !spec.toolchain_src.is_dir() {
return Err(Error::Other(format!(
"toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)",
spec.toolchain_src.display()
)));
}
link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
// 3b) Swaps de variante (b): piezas construidas por hammer desde fuente pisan su versión Alpine
// en /toolchain. Un `rel_path` puede apuntar a un BINARIO (estático musl ⇒ corre sin el shim
// del loader; p.ej. make, busybox) o a un DIRECTORIO (un árbol de headers; p.ej. los
// `usr/include/{linux,asm,…}` de linux-headers). En el caso directorio se reemplaza el árbol
// entero (remove + copy) para no dejar archivos Alpine huérfanos.
for swap in &spec.swaps {
let art_dir = store.path_of(&swap.artifact, &swap.name);
let src = art_dir.join(&swap.rel_path);
let dst = staging.join("toolchain").join(&swap.rel_path);
if src.is_dir() {
let _ = std::fs::remove_dir_all(&dst);
std::fs::create_dir_all(&dst)?;
link_or_copy_tree(&src, &dst, None)?;
} else if src.is_file() {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&dst);
if std::fs::hard_link(&src, &dst).is_err() {
std::fs::copy(&src, &dst)?;
}
set_executable(&dst)?;
} else {
return Err(Error::Other(format!(
"swap '{}': '{}' no existe (ni archivo ni directorio) en el artefacto sellado {} ({})",
swap.name,
swap.rel_path,
swap.artifact,
src.display(),
)));
}
}
// 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
// adentro resuelva el toolchain por hash sin re-ingerirla.
let seed_name = format!("seed-{}", spec.seed_kind.as_str());
let seed_dir = store.path_of(&spec.seed_hash, &seed_name);
if !seed_dir.is_dir() {
return Err(Error::Other(format!(
"semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
seed_name,
seed_dir.display()
)));
}
link_or_copy_tree(
&seed_dir,
&staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)),
None,
)?;
// 5) Binario hammer → /usr/bin/hammer (+x).
let hammer_dst = staging.join("usr/bin/hammer");
if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() {
std::fs::copy(&spec.hammer_bin, &hammer_dst)?;
}
set_executable(&hammer_dst)?;
// 6) Recetas → /etc/hammer/recipes.
for entry in std::fs::read_dir(&spec.recipes_dir)? {
let path = entry?.path();
if path.extension().and_then(|x| x.to_str()) == Some("toml") {
let name = path.file_name().unwrap();
std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?;
}
}
// 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y
// `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se
// copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El
// `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`.
if let Some(cache) = &spec.work_cache {
let skip = staging.canonicalize().ok();
for sub in ["repos", "tarballs"] {
let src = cache.join(sub);
if src.is_dir() {
link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?;
}
}
}
// 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo).
let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or("");
let env = format!(
"SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n",
spec.seed_hash,
spec.seed_kind.as_str(),
ref_content,
);
std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?;
let driver = staging.join("usr/bin/rebuild-stage1");
std::fs::write(&driver, REBUILD_DRIVER)?;
set_executable(&driver)?;
Ok(())
}
/// Marca un archivo como ejecutable (0o755).
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
Ok(())
}
/// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada
/// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash
/// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida.
pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result<BuilderReport> {
// Hash lógico (insumos), independiente del ensamblado en disco.
let rdigest = recipes_digest(&spec.recipes_dir)?;
let hdigest = sha256_file(&spec.hammer_bin)?;
let sdigest = swaps_digest(&spec.swaps);
let bhash = builder_hash(
&spec.stage1_rootfs,
&spec.seed_hash,
&spec.toolchain_tag,
&rdigest,
&hdigest,
&sdigest,
);
let _ = std::fs::remove_dir_all(out_dir);
std::fs::create_dir_all(out_dir)?;
assemble_builder(spec, store, out_dir)?;
manifest::append_line(
store,
StageEntry {
stage: 2,
recipe_hash: None,
artifact_hash: bhash.clone(),
seed_hash: Some(spec.seed_hash.clone()),
ts: now_unix(),
},
)?;
tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado");
Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() })
}
/// **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"
);
}
/// 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_installs_arje_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"!<arch>\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 harje = seal_component(&store, "arje-zero", "cc33", |w| {
std::fs::create_dir_all(w.join("usr/bin")).unwrap();
std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap();
});
let staging = store.root().join("staging");
std::fs::create_dir_all(&staging).unwrap();
let components = [
("musl".to_string(), hmusl),
("busybox".to_string(), hbb),
("arje-zero".to_string(), harje),
];
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!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado");
// arje-zero es PID 1 vía /sbin/init.
assert_eq!(
std::fs::read_link(staging.join("sbin/init")).unwrap(),
std::path::PathBuf::from("/usr/bin/arje-zero"),
"/sbin/init apunta a arje-zero"
);
// La seed card está en su lugar y es JSON válido con los genesis esperados.
let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
assert_eq!(v["label"], "hammer-stage1");
let labels: Vec<&str> = v["genesis"].as_array().unwrap()
.iter().map(|g| g["label"].as_str().unwrap()).collect();
assert_eq!(labels, vec!["hammerd", "console-getty"]);
// Puntos de montaje que arje monta solos, presentes como dirs vacíos.
assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir());
assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
// Ya no hay init provisional de busybox.
assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado");
}
#[test]
fn stage1_seed_card_is_valid_json_with_native_hammerd() {
// La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart
// (el CRASHED real) — un template roto rompería el boot de arje-zero.
let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap();
assert_eq!(v["payload"], "Virtual", "la semilla es Virtual");
let hammerd = &v["genesis"][0];
assert_eq!(hammerd["label"], "hammerd");
assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd");
assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart");
let getty = &v["genesis"][1];
assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
}
#[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:");
}
}
#[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:?}"),
}
}
// --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM ---
/// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos.
fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) {
let store = Store::open(dir.join("store")).unwrap();
// Stage 1 rootfs sellado (con el nombre que espera el builder).
let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| {
std::fs::create_dir_all(w.join("usr/bin")).unwrap();
std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap();
std::fs::create_dir_all(w.join("sbin")).unwrap();
std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap();
});
// Semilla zig sellada con bin/zig (nombre store seed-zig).
let seed = seal_component(&store, "seed-zig", "5ee0", |w| {
std::fs::create_dir_all(w.join("bin")).unwrap();
std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap();
});
// Recetas, binario hammer y toolchain (Alpine sintético), fuera del store.
let recipes = dir.join("recipes");
std::fs::create_dir_all(&recipes).unwrap();
std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap();
std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap();
let hammer_bin = dir.join("hammer");
std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap();
let toolchain = dir.join("alpine");
std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap();
std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap();
let spec = BuilderSpec {
stage1_rootfs: stage1,
seed_hash: seed,
seed_kind: SeedKind::Zig,
recipes_dir: recipes,
hammer_bin,
toolchain_src: toolchain,
toolchain_tag: "alpine-test".into(),
ref_content: Some(ArtifactHash::from_hex("dead")),
work_cache: None,
swaps: vec![],
};
(store, spec)
}
#[test]
fn builder_rootfs_assembles_full_image() {
let tmp = tempfile::tempdir().unwrap();
let (store, spec) = builder_fixture(tmp.path());
let out = tmp.path().join("builder-out");
let report = builder_rootfs(&spec, &store, &out).unwrap();
assert!(report.builder_hash.as_str().starts_with("b3:"));
assert_eq!(report.out_dir, out);
// Base Stage 1 hidratada (init + symlink).
assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado");
assert_eq!(
std::fs::read_link(out.join("sbin/init")).unwrap(),
PathBuf::from("/usr/bin/arje-zero"),
);
// Toolchain en /toolchain, semilla en /store, hammer + recetas + driver.
assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado");
assert!(
out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(),
"semilla replicada en /store",
);
assert!(out.join("usr/bin/hammer").is_file(), "binario hammer");
assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas");
assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild");
// El driver y hammer son ejecutables.
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap();
assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x");
// rebuild.env lleva la semilla, la clase y la referencia embebidas.
let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}");
assert!(env.contains("SEED_KIND=zig"), "{env}");
assert!(env.contains("REF_CONTENT=b3:dead"), "{env}");
// Línea de manifiesto (stage 2) con el hash lógico del builder.
let man = BootstrapManifest::load(&store).unwrap();
assert!(
man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash),
"el builder debe anotar su línea en el manifiesto",
);
}
#[test]
fn builder_hash_is_deterministic_and_sensitive() {
let s1 = ArtifactHash::from_hex("11");
let seed = ArtifactHash::from_hex("22");
let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "");
assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", ""), "puro");
assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd", ""), "tag del toolchain");
assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd", ""), "recetas");
assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2", ""), "binario hammer");
assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "sd"), "swaps del toolchain");
}
#[test]
fn swaps_digest_empty_is_blank_and_order_insensitive() {
assert_eq!(swaps_digest(&[]), "", "sin swaps ⇒ digest vacío (compat hacia atrás)");
let mk = |n: &str, h: &str| ToolchainSwap {
name: n.into(),
artifact: ArtifactHash::from_hex(h),
rel_path: format!("usr/bin/{n}"),
};
let a = swaps_digest(&[mk("make", "aa"), mk("bwrap", "bb")]);
let b = swaps_digest(&[mk("bwrap", "bb"), mk("make", "aa")]);
assert_eq!(a, b, "el digest se ordena por nombre ⇒ insensible al orden de entrada");
assert!(!a.is_empty());
assert_ne!(a, swaps_digest(&[mk("make", "ac"), mk("bwrap", "bb")]), "cambia con el hash");
}
#[test]
fn builder_rootfs_swaps_toolchain_tool_from_source() {
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
// Artefacto sellado de un `make` "construido por hammer" con un binario distinto al de Alpine.
let make = seal_component(&store, "make", "fbad", |w| {
std::fs::create_dir_all(w.join("usr/bin")).unwrap();
std::fs::write(w.join("usr/bin/make"), b"\x7fELF-hammer-make-static").unwrap();
});
spec.swaps = vec![ToolchainSwap {
name: "make".into(),
artifact: make,
rel_path: "usr/bin/make".into(),
}];
let out = tmp.path().join("builder-out");
let report = builder_rootfs(&spec, &store, &out).unwrap();
// El make de /toolchain es ahora el de hammer, no el de Alpine.
let got = std::fs::read(out.join("toolchain/usr/bin/make")).unwrap();
assert_eq!(got, b"\x7fELF-hammer-make-static", "el swap pisó el make de Alpine");
// El hash lógico cambia respecto a un builder pura-Alpine (la procedencia es auditable).
let mut pure = spec.clone();
pure.swaps = vec![];
let pure_report = builder_rootfs(&pure, &store, &tmp.path().join("builder-pure")).unwrap();
assert_ne!(report.builder_hash, pure_report.builder_hash, "el swap entra al hash lógico");
}
#[test]
fn builder_rootfs_errors_when_swap_artifact_missing_binary() {
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
// Artefacto sellado pero SIN el binario esperado en rel_path.
let make = seal_component(&store, "make", "fbad", |w| {
std::fs::create_dir_all(w.join("usr/share")).unwrap();
std::fs::write(w.join("usr/share/x"), b"no-binary").unwrap();
});
spec.swaps = vec![ToolchainSwap {
name: "make".into(),
artifact: make,
rel_path: "usr/bin/make".into(),
}];
let out = tmp.path().join("builder-out");
let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
assert!(err.contains("no existe") && err.contains("artefacto sellado"), "{err}");
}
#[test]
fn builder_rootfs_swaps_toolchain_header_tree_from_source() {
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
// Artefacto sellado de un `linux-headers` con un árbol `usr/include/linux` propio.
let headers = seal_component(&store, "linux-headers", "abcd", |w| {
std::fs::create_dir_all(w.join("usr/include/linux")).unwrap();
std::fs::write(w.join("usr/include/linux/version.h"), b"#define HAMMER 1\n").unwrap();
std::fs::write(w.join("usr/include/linux/stat.h"), b"struct hammer_stat;\n").unwrap();
});
// El toolchain Alpine tiene un linux/ con un archivo huérfano que el swap debe borrar.
let tc_linux = spec.toolchain_src.join("usr/include/linux");
std::fs::create_dir_all(&tc_linux).unwrap();
std::fs::write(tc_linux.join("version.h"), b"#define ALPINE 1\n").unwrap();
std::fs::write(tc_linux.join("orphan.h"), b"alpine-only\n").unwrap();
spec.swaps = vec![ToolchainSwap {
name: "linux-headers".into(),
artifact: headers,
rel_path: "usr/include/linux".into(),
}];
let out = tmp.path().join("builder-out");
builder_rootfs(&spec, &store, &out).unwrap();
let v = std::fs::read(out.join("toolchain/usr/include/linux/version.h")).unwrap();
assert_eq!(v, b"#define HAMMER 1\n", "el swap pisó el version.h de Alpine");
assert!(out.join("toolchain/usr/include/linux/stat.h").is_file(), "copió el árbol entero");
assert!(
!out.join("toolchain/usr/include/linux/orphan.h").exists(),
"el swap-directorio borró el huérfano de Alpine"
);
}
#[test]
fn builder_rootfs_without_ref_leaves_empty_ref_content() {
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
spec.ref_content = None;
let out = tmp.path().join("builder-out");
builder_rootfs(&spec, &store, &out).unwrap();
let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}");
}
#[test]
fn builder_rootfs_with_work_cache_containing_out_dir_terminates() {
// Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse
// el propio destino (recursión infinita que llena el disco). El skip lo corta.
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
// Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar).
let work = tmp.path().join("work");
std::fs::create_dir_all(work.join("repos/musl.git")).unwrap();
std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap();
std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap();
std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap();
spec.work_cache = Some(work.clone());
// out_dir vive DENTRO de la caché: el caso que disparaba la recursión.
let out = work.join("builder-rootfs");
let report = builder_rootfs(&spec, &store, &out).unwrap();
assert!(report.builder_hash.as_str().starts_with("b3:"));
// Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe.
assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben");
assert!(
!out.join("work/builder-rootfs").exists(),
"el out_dir no debe haberse copiado en su propio /work (recursión)",
);
assert!(
!out.join("work/sources").exists(),
"sources/ es regenerable y pesado: no debe viajar en el builder",
);
}
#[test]
fn builder_rootfs_errors_when_stage1_unsealed() {
let tmp = tempfile::tempdir().unwrap();
let (store, mut spec) = builder_fixture(tmp.path());
spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado
let out = tmp.path().join("builder-out");
let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
assert!(err.contains("no sellado"), "{err}");
}
#[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
// hashear offline. Cubre las que no están en STAGE1_COMPONENTS y futuras.
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();
let mut count = 0;
for entry in std::fs::read_dir(&recipes_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let r = Recipe::load_from_path(&path)
.unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
let h = hammer_build::artifact_hash(&r, &store)
.unwrap_or_else(|e| panic!("hash {}: {e}", path.display()));
assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display());
count += 1;
}
assert!(count >= 5, "esperaba ≥5 recetas (grep/musl/busybox/hammerd/arje-zero), vi {count}");
}
}