tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 60a9451a03faa7f1186ab784c33279eeca03bc2e crates/hammer-bootstrap/src/lib.rs Rust · 693 líneas · 28.7 KB Historia Quién tocó qué crudo

bootstrap: stage1() ensambla el userland mínimo (Lote 5) 60a9451a0 · Sergio ·

1//! Bootstrap from-scratch de hammer — el track posterior del [SDD 11](../../docs/11-bootstrap.md).
2//!
3//! Esta primera entrega cubre el **Stage 0**: ingerir un toolchain semilla ya construido
4//! (no se compila; ver [ADR 0008](../../docs/adr/0008-bootstrap-stages.md)) como una *fuente
5//! fijada por sha256* y sellarlo en el `Store`. El `ArtifactHash` resultante se deriva de la
6//! **identidad pinned** de la semilla (clase + versión + sha256), no de rutas del host, así
7//! que el resto del bootstrap depende de un artefacto reproducible y auditable.
8//!
9//! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes)
10//! llegan en entregas siguientes y reusan este mismo store.
11//!
12//! Cada etapa anota su línea en el [`manifest`] de bootstrap (`bootstrap.json`), el embrión del
13//! log de transparencia ([SDD 11 §4](../../docs/11-bootstrap.md)).
14
15pub mod manifest;
16
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use hammer_build::download;
21use hammer_core::{ArtifactHash, Recipe, Store};
22
23pub use manifest::{BootstrapManifest, StageEntry};
24
25#[derive(Debug, thiserror::Error)]
26pub enum Error {
27    #[error("io: {0}")]
28    Io(#[from] std::io::Error),
29    #[error("core: {0}")]
30    Core(#[from] hammer_core::Error),
31    #[error("tar -x de la semilla falló (exit {0:?})")]
32    Untar(Option<i32>),
33    #[error("bootstrap: {0}")]
34    Other(String),
35}
36
37pub type Result<T> = std::result::Result<T, Error>;
38
39/// Qué toolchain semilla usamos. `zig` es la semilla primaria (ADR 0003/0007); `musl-cross-make`
40/// (gcc + musl) es la escotilla para paquetes con gcc-ismos.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum SeedKind {
44    Zig,
45    MuslCrossMake,
46}
47
48impl SeedKind {
49    pub fn as_str(self) -> &'static str {
50        match self {
51            SeedKind::Zig => "zig",
52            SeedKind::MuslCrossMake => "musl-cross-make",
53        }
54    }
55}
56
57/// Especificación de una semilla: su identidad pinned. La integridad la garantiza `sha256`
58/// (fijado, [ADR 0006](../../docs/adr/0006-pinned-commits.md)); `url` puede ser cualquier
59/// esquema que entienda curl, incluido `file://` para ingestión offline.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct SeedSpec {
62    pub kind: SeedKind,
63    pub version: String,
64    pub url: String,
65    /// sha256 hex del tarball, fijado. Se verifica **antes** de extraer y sellar.
66    pub sha256: String,
67}
68
69impl SeedSpec {
70    /// Hash del artefacto del store para esta semilla. Función pura de la identidad pinned:
71    /// misma `(kind, version, sha256)` ⇒ mismo hash, en cualquier máquina. El `url` queda
72    /// fuera a propósito (un espejo distinto del mismo tarball produce el mismo artefacto).
73    pub fn seed_hash(&self) -> ArtifactHash {
74        ArtifactHash::of_inputs(&[
75            b"hammer-seed-v1",
76            self.kind.as_str().as_bytes(),
77            self.version.as_bytes(),
78            self.sha256.as_bytes(),
79        ])
80    }
81
82    /// Nombre del artefacto en el store: `seed-<kind>`.
83    pub fn store_name(&self) -> String {
84        format!("seed-{}", self.kind.as_str())
85    }
86
87    /// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo
88    /// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin
89    /// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable
90    /// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico
91    /// top-level versionado `zig-linux-x86_64-<ver>/`—. Falla si la semilla no está sellada o si
92    /// el ejecutable no aparece (o es ambiguo).
93    pub fn toolchain_dir(&self, store: &Store) -> Result<PathBuf> {
94        seed_toolchain_dir(store, &self.seed_hash(), self.kind)
95    }
96}
97
98/// Igual que [`SeedSpec::toolchain_dir`] pero sólo desde la identidad mínima `(seed_hash, kind)`:
99/// lo que basta para localizar la semilla ya sellada sin re-pasar `url`/`sha256`. Lo usa Stage 1,
100/// que conoce el `seed_hash` (de Stage 0 o del manifiesto) pero no necesariamente el `SeedSpec`.
101pub fn seed_toolchain_dir(
102    store: &Store,
103    seed_hash: &ArtifactHash,
104    kind: SeedKind,
105) -> Result<PathBuf> {
106    let base = store.path_of(seed_hash, &format!("seed-{}", kind.as_str()));
107    if !base.is_dir() {
108        return Err(Error::Other(format!(
109            "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
110            kind.as_str(),
111            base.display()
112        )));
113    }
114    match kind {
115        SeedKind::Zig => locate_tool_dir(&base, "zig"),
116        SeedKind::MuslCrossMake => Err(Error::Other(
117            "toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(),
118        )),
119    }
120}
121
122/// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`.
123/// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no
124/// elegir a ciegas entre layouts ambiguos.
125fn locate_tool_dir(base: &Path, tool: &str) -> Result<PathBuf> {
126    if base.join(tool).is_file() {
127        return Ok(base.to_path_buf());
128    }
129    let mut hits = Vec::new();
130    for entry in std::fs::read_dir(base)? {
131        let path = entry?.path();
132        if path.is_dir() && path.join(tool).is_file() {
133            hits.push(path);
134        }
135    }
136    hits.sort();
137    match hits.len() {
138        1 => Ok(hits.pop().unwrap()),
139        0 => Err(Error::Other(format!(
140            "no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}",
141            base.display()
142        ))),
143        _ => Err(Error::Other(format!(
144            "ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}",
145            base.display()
146        ))),
147    }
148}
149
150/// Deriva una [`hammer_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla
151/// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el
152/// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto
153/// de la config (rootfs, work, cache) se hereda intacto.
154pub fn seed_build_config(
155    base: &hammer_build::BuildConfig,
156    seed: &SeedSpec,
157    store: &Store,
158) -> Result<hammer_build::BuildConfig> {
159    let zig_dir = seed.toolchain_dir(store)?;
160    Ok(hammer_build::BuildConfig { zig_dir, ..base.clone() })
161}
162
163/// Hash de un rootfs sellado por Stage 1. Es un `ArtifactHash` derivado del **contenido lógico**
164/// (los hashes de sus componentes + el init), no del árbol en disco: así dos ensamblados de los
165/// mismos artefactos producen el mismo hash aunque difieran en inodes o timestamps.
166pub type RootfsHash = ArtifactHash;
167
168/// Los componentes del userland mínimo, en orden de ensamblado. `musl` primero (libc), luego
169/// `busybox` (coreutils + sh + init). `hammerd` (Rust, vía receta Cargo) y el init `arje` entran
170/// en un lote posterior (ADR 0008): hasta entonces el PID 1 provisional es el `init` de busybox.
171const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox"];
172
173/// `inittab` del init provisional (busybox). Monta los pseudo-FS y abre una shell. Cuando
174/// `hammerd` entre como servicio supervisado se añadirá `::respawn:/sbin/hammerd`; cuando entre
175/// `arje` como PID 1 (ADR 0007) este archivo se reemplaza por su supervisión real.
176const PROVISIONAL_INITTAB: &str = "\
177# Stage 1 — init provisional (busybox). Reemplazado por arje (ADR 0007) más adelante.
178::sysinit:/bin/mount -t proc proc /proc
179::sysinit:/bin/mount -t sysfs sysfs /sys
180::sysinit:/bin/mount -t devtmpfs dev /dev
181::sysinit:/bin/mount -o remount,rw /
182::respawn:/bin/sh
183::ctrlaltdel:/bin/umount -a -r
184::shutdown:/bin/umount -a -r
185";
186
187/// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio
188/// de recetas donde viven `musl.toml` / `busybox.toml`.
189#[derive(Debug, Clone)]
190pub struct Stage1Spec {
191    pub seed_hash: ArtifactHash,
192    pub seed_kind: SeedKind,
193    pub recipes_dir: PathBuf,
194}
195
196/// **Stage 1** — cross-compila el userland mínimo con la semilla de Stage 0, ensambla un rootfs
197/// FHS y lo sella. Devuelve el [`RootfsHash`] y anota la línea del manifiesto.
198///
199/// Idempotente: si el rootfs (mismo hash de contenido) ya está sellado, no reconstruye. El
200/// cross-compile real requiere el rootfs base + toolchain del lab; en una máquina sin `.dev-fs`
201/// `hammer_build::build` falla limpio antes de ensamblar nada.
202pub fn stage1(
203    spec: &Stage1Spec,
204    base_cfg: &hammer_build::BuildConfig,
205    store: &Store,
206) -> Result<RootfsHash> {
207    // Toolchain = la semilla sellada, no el zig del host.
208    let zig_dir = seed_toolchain_dir(store, &spec.seed_hash, spec.seed_kind)?;
209    let cfg = hammer_build::BuildConfig { zig_dir, ..base_cfg.clone() };
210
211    // 1) Construir cada componente (cacheado por el lab si ya existe).
212    let mut components: Vec<(String, ArtifactHash)> = Vec::with_capacity(STAGE1_COMPONENTS.len());
213    for name in STAGE1_COMPONENTS {
214        let recipe_path = spec.recipes_dir.join(format!("{name}.toml"));
215        let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| {
216            Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display()))
217        })?;
218        let h = hammer_build::build(&recipe, &cfg, store)?;
219        components.push((name.to_string(), h));
220    }
221
222    // 2) Hash del rootfs = función pura de sus componentes (reproducible, sin tocar disco).
223    let rootfs_hash = rootfs_hash(&components);
224    let store_name = "stage1-rootfs";
225
226    // 3) Ensamblar + sellar (si no estaba ya). Staging bajo el store ⇒ rename atómico en seal.
227    if !store.has(&rootfs_hash, store_name) {
228        let staging = store
229            .root()
230            .join(".bootstrap-tmp")
231            .join(rootfs_hash.store_dir_name(store_name));
232        let _ = std::fs::remove_dir_all(&staging);
233        std::fs::create_dir_all(&staging)?;
234        let result = assemble_and_seal(store, &components, &rootfs_hash, store_name, &staging);
235        let _ = std::fs::remove_dir_all(&staging);
236        result?;
237    } else {
238        tracing::info!(hash = %rootfs_hash, "stage1: rootfs ya sellado (idempotente)");
239    }
240
241    // 4) Línea del manifiesto. `recipe_hash = None`: el rootfs es un ensamblado de varias recetas,
242    // no de una sola; el `seed_hash` ancla de qué toolchain salió.
243    manifest::append_line(
244        store,
245        StageEntry {
246            stage: 1,
247            recipe_hash: None,
248            artifact_hash: rootfs_hash.clone(),
249            seed_hash: Some(spec.seed_hash.clone()),
250            ts: now_unix(),
251        },
252    )?;
253    Ok(rootfs_hash)
254}
255
256/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + el
257/// init provisional. Cambiar un componente, el orden o el inittab re-hashea el rootfs.
258fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash {
259    let mut inputs: Vec<Vec<u8>> = vec![b"hammer-stage1-rootfs-v1".to_vec()];
260    for (name, h) in components {
261        inputs.push(name.as_bytes().to_vec());
262        inputs.push(h.as_str().as_bytes().to_vec());
263    }
264    inputs.push(PROVISIONAL_INITTAB.as_bytes().to_vec());
265    let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
266    ArtifactHash::of_inputs(&refs)
267}
268
269fn assemble_and_seal(
270    store: &Store,
271    components: &[(String, ArtifactHash)],
272    rootfs_hash: &RootfsHash,
273    store_name: &str,
274    staging: &Path,
275) -> Result<()> {
276    assemble_rootfs(store, components, staging)?;
277    store.seal(staging, rootfs_hash, store_name)?;
278    tracing::info!(hash = %rootfs_hash, "stage1: rootfs sellado");
279    Ok(())
280}
281
282/// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada
283/// componente sellado (hardlinks) y el init provisional. Es la pieza testeable sin build real.
284fn assemble_rootfs(
285    store: &Store,
286    components: &[(String, ArtifactHash)],
287    staging: &Path,
288) -> Result<()> {
289    // Esqueleto FHS mínimo. Los pseudo-FS quedan como puntos de montaje vacíos.
290    for d in ["proc", "sys", "dev", "tmp", "run", "etc", "root", "var", "usr/bin", "bin", "sbin"] {
291        std::fs::create_dir_all(staging.join(d))?;
292    }
293
294    // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem).
295    for (name, h) in components {
296        let dir = store.path_of(h, name);
297        hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static)?;
298    }
299
300    // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs
301    // ejecutable: lo quitamos para que la imagen quede limpia y su hash no dependa de cuál
302    // componente lo escribió último.
303    let _ = std::fs::remove_dir_all(staging.join(".hammer"));
304
305    // Init provisional (busybox lo lee de /etc/inittab).
306    std::fs::write(staging.join("etc/inittab"), PROVISIONAL_INITTAB)?;
307    Ok(())
308}
309
310/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
311///
312/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
313/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
314/// llega al árbol que se sella.
315pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
316    let h = seed.seed_hash();
317    let name = seed.store_name();
318    if store.has(&h, &name) {
319        tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
320    } else {
321        // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
322        // hacer el rename atómico.
323        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
324        let _ = std::fs::remove_dir_all(&work);
325        std::fs::create_dir_all(&work)?;
326
327        // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo
328        // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de
329        // anotar el manifiesto: una semilla que no se selló no genera línea.
330        let result = stage0_into(seed, store, &h, &name, &work);
331        let _ = std::fs::remove_dir_all(&work);
332        result?;
333    }
334
335    // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
336    // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
337    manifest::append_line(
338        store,
339        StageEntry {
340            stage: 0,
341            recipe_hash: None,
342            artifact_hash: h.clone(),
343            seed_hash: Some(h.clone()),
344            ts: now_unix(),
345        },
346    )?;
347    Ok(h)
348}
349
350/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
351/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
352/// la reproducibilidad.
353fn now_unix() -> u64 {
354    std::time::SystemTime::now()
355        .duration_since(std::time::UNIX_EPOCH)
356        .map(|d| d.as_secs())
357        .unwrap_or(0)
358}
359
360fn stage0_into(
361    seed: &SeedSpec,
362    store: &Store,
363    h: &ArtifactHash,
364    name: &str,
365    work: &Path,
366) -> Result<ArtifactHash> {
367    let tarball = work.join("seed.tar");
368    download::fetch_url_to_file(&seed.url, &tarball)?;
369    // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
370    download::verify_sha256(&tarball, &seed.sha256)?;
371
372    let tree = work.join("tree");
373    std::fs::create_dir_all(&tree)?;
374    untar(&tarball, &tree)?;
375
376    let sealed = store.seal(&tree, h, name)?;
377    tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
378    Ok(h.clone())
379}
380
381/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
382fn untar(tarball: &Path, into: &Path) -> Result<()> {
383    let st = Command::new("tar")
384        .arg("-xf")
385        .arg(tarball)
386        .arg("-C")
387        .arg(into)
388        .status()
389        .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
390    if !st.success() {
391        return Err(Error::Untar(st.code()));
392    }
393    Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn spec(url: &str, sha256: &str) -> SeedSpec {
401        SeedSpec {
402            kind: SeedKind::Zig,
403            version: "0.13.0".into(),
404            url: url.into(),
405            sha256: sha256.into(),
406        }
407    }
408
409    fn sha256_hex(bytes: &[u8]) -> String {
410        use sha2::{Digest, Sha256};
411        let mut h = Sha256::new();
412        h.update(bytes);
413        hex::encode(h.finalize())
414    }
415
416    /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
417    fn make_seed_tarball(dir: &Path) -> (String, String) {
418        let src = dir.join("src");
419        std::fs::create_dir_all(src.join("bin")).unwrap();
420        std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
421        let tarball = dir.join("seed.tar.gz");
422        let st = Command::new("tar")
423            .arg("-czf")
424            .arg(&tarball)
425            .arg("-C")
426            .arg(&src)
427            .arg(".")
428            .status()
429            .unwrap();
430        assert!(st.success(), "tar -c falló");
431        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
432        (format!("file://{}", tarball.display()), sha)
433    }
434
435    #[test]
436    fn seed_hash_is_identity_based() {
437        let a = spec("file:///mirror-a.tar", "abc123");
438        let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
439        assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
440
441        let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
442        assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
443
444        let mut d = spec("file:///mirror-a.tar", "abc123");
445        d.kind = SeedKind::MuslCrossMake;
446        assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
447    }
448
449    #[test]
450    fn stage0_ingests_and_seals() {
451        let tmp = tempfile::tempdir().unwrap();
452        let (url, sha) = make_seed_tarball(tmp.path());
453        let store = Store::open(tmp.path().join("store")).unwrap();
454        let seed = spec(&url, &sha);
455
456        let h = stage0(&seed, &store).expect("stage0");
457        assert_eq!(h, seed.seed_hash());
458        assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
459
460        // El contenido extraído está presente y de sólo-lectura (sellado).
461        let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
462        assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
463        use std::os::unix::fs::PermissionsExt;
464        let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
465        assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
466    }
467
468    #[test]
469    fn stage0_is_idempotent() {
470        let tmp = tempfile::tempdir().unwrap();
471        let (url, sha) = make_seed_tarball(tmp.path());
472        let store = Store::open(tmp.path().join("store")).unwrap();
473        let seed = spec(&url, &sha);
474
475        let h1 = stage0(&seed, &store).unwrap();
476        let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
477        assert_eq!(h1, h2);
478    }
479
480    #[test]
481    fn stage0_records_one_manifest_line() {
482        let tmp = tempfile::tempdir().unwrap();
483        let (url, sha) = make_seed_tarball(tmp.path());
484        let store = Store::open(tmp.path().join("store")).unwrap();
485        let seed = spec(&url, &sha);
486
487        let h = stage0(&seed, &store).unwrap();
488        stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea
489
490        let m = BootstrapManifest::load(&store).unwrap();
491        assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0");
492        let e = &m.entries[0];
493        assert_eq!(e.stage, 0);
494        assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila");
495        assert_eq!(e.artifact_hash, h);
496        assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto");
497    }
498
499    #[test]
500    fn stage0_bad_sha_records_no_manifest_line() {
501        let tmp = tempfile::tempdir().unwrap();
502        let (url, _real_sha) = make_seed_tarball(tmp.path());
503        let store = Store::open(tmp.path().join("store")).unwrap();
504        let seed = spec(&url, &"0".repeat(64));
505
506        assert!(stage0(&seed, &store).is_err());
507        // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto.
508        assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json");
509    }
510
511    #[test]
512    fn stage0_rejects_bad_sha256_and_seals_nothing() {
513        let tmp = tempfile::tempdir().unwrap();
514        let (url, _real_sha) = make_seed_tarball(tmp.path());
515        let store = Store::open(tmp.path().join("store")).unwrap();
516        let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
517
518        let err = stage0(&seed, &store).unwrap_err();
519        assert!(
520            err.to_string().contains("sha256 mismatch"),
521            "esperaba mismatch de sha256, vino: {err}"
522        );
523        assert!(
524            !store.has(&seed.seed_hash(), &seed.store_name()),
525            "un sha erróneo no debe sellar nada"
526        );
527        // Tampoco debe dejar staging a medio descargar bajo el store.
528        assert!(
529            !store.root().join(".bootstrap-tmp").exists()
530                || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
531            "el staging debe quedar limpio tras el fallo"
532        );
533    }
534
535    /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
536    /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
537    fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
538        let src = dir.join("src");
539        let zig = src.join(zig_rel);
540        std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
541        std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
542        let tarball = dir.join("seed.tar.gz");
543        let st = Command::new("tar")
544            .args(["-czf"])
545            .arg(&tarball)
546            .arg("-C")
547            .arg(&src)
548            .arg(".")
549            .status()
550            .unwrap();
551        assert!(st.success());
552        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
553        let store = Store::open(dir.join("store")).unwrap();
554        let seed = spec(&format!("file://{}", tarball.display()), &sha);
555        stage0(&seed, &store).unwrap();
556        (store, seed)
557    }
558
559    #[test]
560    fn toolchain_dir_finds_zig_in_versioned_subdir() {
561        let tmp = tempfile::tempdir().unwrap();
562        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
563        let dir = seed.toolchain_dir(&store).unwrap();
564        assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
565        assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
566    }
567
568    #[test]
569    fn toolchain_dir_finds_zig_at_root() {
570        let tmp = tempfile::tempdir().unwrap();
571        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
572        let dir = seed.toolchain_dir(&store).unwrap();
573        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
574        assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
575    }
576
577    #[test]
578    fn toolchain_dir_errors_when_unsealed() {
579        let tmp = tempfile::tempdir().unwrap();
580        let store = Store::open(tmp.path().join("store")).unwrap();
581        let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
582        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
583        assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
584    }
585
586    #[test]
587    fn toolchain_dir_errors_when_ambiguous() {
588        let tmp = tempfile::tempdir().unwrap();
589        // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
590        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
591        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
592        // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
593        let b = base.join("b");
594        let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
595        std::fs::create_dir_all(&b).unwrap();
596        std::fs::write(b.join("zig"), b"x").unwrap();
597        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
598        assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
599    }
600
601    #[test]
602    fn seed_build_config_points_zig_dir_at_seed() {
603        let tmp = tempfile::tempdir().unwrap();
604        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
605        let base = hammer_build::BuildConfig::defaults_for_store(store.root());
606        let cfg = seed_build_config(&base, &seed, &store).unwrap();
607        assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
608        // El resto de la config se hereda sin cambios.
609        assert_eq!(cfg.rootfs, base.rootfs);
610        assert_eq!(cfg.work_root, base.work_root);
611    }
612
613    // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real ---
614
615    /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol
616    /// antes del `seal` (que lo marca read-only).
617    fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash {
618        let h = ArtifactHash::from_hex(hex);
619        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name));
620        std::fs::create_dir_all(&work).unwrap();
621        build(&work);
622        store.seal(&work, &h, name).unwrap();
623        h
624    }
625
626    #[test]
627    fn rootfs_hash_is_deterministic_and_sensitive() {
628        let base = [
629            ("musl".to_string(), ArtifactHash::from_hex("11")),
630            ("busybox".to_string(), ArtifactHash::from_hex("22")),
631        ];
632        let a = rootfs_hash(&base);
633        assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash");
634
635        let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))];
636        assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea");
637
638        let reordered = [base[1].clone(), base[0].clone()];
639        assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa");
640    }
641
642    #[test]
643    fn assemble_rootfs_hydrates_and_writes_provisional_init() {
644        let tmp = tempfile::tempdir().unwrap();
645        let store = Store::open(tmp.path().join("store")).unwrap();
646
647        let hmusl = seal_component(&store, "musl", "aa11", |w| {
648            std::fs::create_dir_all(w.join("usr/lib")).unwrap();
649            std::fs::write(w.join("usr/lib/libc.a"), b"!<arch>\n").unwrap();
650        });
651        let hbb = seal_component(&store, "busybox", "bb22", |w| {
652            std::fs::create_dir_all(w.join("bin")).unwrap();
653            std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap();
654            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
655            // Sidecar de provenance que NO debe terminar en el rootfs.
656            std::fs::create_dir_all(w.join(".hammer")).unwrap();
657            std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap();
658        });
659
660        let staging = store.root().join("staging");
661        std::fs::create_dir_all(&staging).unwrap();
662        let components = [("musl".to_string(), hmusl), ("busybox".to_string(), hbb)];
663        assemble_rootfs(&store, &components, &staging).unwrap();
664
665        assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado");
666        assert!(staging.join("bin/busybox").is_file(), "busybox hidratado");
667        assert_eq!(
668            std::fs::read_link(staging.join("bin/sh")).unwrap(),
669            std::path::PathBuf::from("busybox"),
670            "el symlink de applet se replica literal"
671        );
672        assert!(staging.join("proc").is_dir() && staging.join("sbin").is_dir(), "esqueleto FHS");
673        let inittab = std::fs::read_to_string(staging.join("etc/inittab")).unwrap();
674        assert!(inittab.contains("/bin/sh"), "init provisional abre una shell");
675        assert!(inittab.contains("mount -t proc"), "monta los pseudo-FS");
676        assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
677    }
678
679    #[test]
680    fn shipped_stage1_recipes_parse_and_hash() {
681        // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK).
682        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
683        let tmp = tempfile::tempdir().unwrap();
684        let store = Store::open(tmp.path().join("store")).unwrap();
685        for name in STAGE1_COMPONENTS {
686            let path = recipes_dir.join(format!("{name}.toml"));
687            let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
688            assert_eq!(&r.name, name);
689            let h = hammer_build::artifact_hash(&r, &store).expect("hash");
690            assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:");
691        }
692    }
693}