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…
⎇ 60a9451a03faa7f1186ab784c33279eeca03bc2e
crates/hammer-bootstrap/src/lib.rs
Historia
Quién tocó qué
crudo
bootstrap: stage1() ensambla el userland mínimo (Lote 5)
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}