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…
⎇ cf159956236c3eb4b277a8172ae949c80cca8346
crates/hammer-bootstrap/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
bootstrap: builder carga overlay.ko — el sandbox de build lo exige en la VM
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` (libc), `busybox`
169/// (coreutils + sh), `hammerd` (daemon, receta Cargo) y `arje-zero` (el init PID 1, ADR 0007).
170/// arje-zero es PID 1 vía el symlink `/sbin/init`; supervisa a `hammerd` y a la getty por la
171/// seed card ([SDD 12](../../docs/12-init-real.md)) — su muerte es el `CRASHED` real.
172const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"];
173
174/// La **seed card** que arje-zero lee en `/ente/seed.card.json` al boot ([SDD 12 §4](../../docs/12-init-real.md)):
175/// un `Card` `Virtual` cuyos `genesis` son `hammerd` (servicio `Native` con `Restart` — el
176/// `CRASHED` real) y una `console-getty` supervisada. La forma replica una seed real de arje
177/// (`seeds/arje-qemu.card.json`) para que `Card::validate()` la acepte; los ULID son fijos para que
178/// el `RootfsHash` sea reproducible. Template autocontenido (opción A del SDD): hammer no depende de
179/// `card-core` como librería; el pin a arje-zero + el boot en VM cubren el drift de schema.
180const STAGE1_SEED_CARD: &str = r#"{
181 "schema_version": 1,
182 "id": "01HQAR53D4M2NBV8KZTYXFQA00",
183 "lineage": null,
184 "label": "hammer-stage1",
185 "provides": ["Spawn", "Journal"],
186 "requires": [],
187 "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
188 "soma": {
189 "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
190 "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
191 "cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null },
192 "cpu_affinity": null
193 },
194 "payload": "Virtual",
195 "supervision": "OneShot",
196 "lifecycle": "daemon",
197 "priority": "normal",
198 "flow": { "input": [], "output": [] },
199 "genesis": [
200 {
201 "schema_version": 1,
202 "id": "01HQAR53D4M2NBV8KZTYXFQA01",
203 "lineage": null,
204 "label": "hammerd",
205 "provides": [],
206 "requires": [],
207 "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
208 "soma": {
209 "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
210 "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
211 "cgroup": { "path": "arje.slice/hammerd", "cpu_weight": null, "io_weight": null },
212 "cpu_affinity": null
213 },
214 "payload": { "Native": {
215 "exec": "/usr/bin/hammerd",
216 "argv": ["--store", "/store", "--journal", "/var/lib/hammer/journal"],
217 "envp": [["RUST_LOG", "info"], ["ENTE_BUS_SOCK", "/run/ente-bus.sock"]]
218 }},
219 "supervision": { "Restart": { "initial": 200, "max": 10000 } },
220 "lifecycle": "daemon",
221 "priority": "normal",
222 "flow": { "input": [], "output": [] },
223 "genesis": []
224 },
225 {
226 "schema_version": 1,
227 "id": "01HQAR53D4M2NBV8KZTYXFQA02",
228 "lineage": null,
229 "label": "console-getty",
230 "provides": [],
231 "requires": [],
232 "permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
233 "soma": {
234 "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
235 "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
236 "cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null },
237 "cpu_affinity": null
238 },
239 "payload": { "Native": {
240 "exec": "/bin/busybox",
241 "argv": ["getty", "-n", "-l", "/bin/sh", "115200", "console"],
242 "envp": []
243 }},
244 "supervision": { "Restart": { "initial": 200, "max": 10000 } },
245 "lifecycle": "daemon",
246 "priority": "normal",
247 "flow": { "input": [], "output": [] },
248 "genesis": []
249 }
250 ]
251}
252"#;
253
254/// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio
255/// de recetas donde viven `musl.toml` / `busybox.toml`.
256#[derive(Debug, Clone)]
257pub struct Stage1Spec {
258 pub seed_hash: ArtifactHash,
259 pub seed_kind: SeedKind,
260 pub recipes_dir: PathBuf,
261}
262
263/// **Stage 1** — cross-compila el userland mínimo con la semilla de Stage 0, ensambla un rootfs
264/// FHS y lo sella. Devuelve el [`RootfsHash`] y anota la línea del manifiesto.
265///
266/// Idempotente: si el rootfs (mismo hash de contenido) ya está sellado, no reconstruye. El
267/// cross-compile real requiere el rootfs base + toolchain del lab; en una máquina sin `.dev-fs`
268/// `hammer_build::build` falla limpio antes de ensamblar nada.
269pub fn stage1(
270 spec: &Stage1Spec,
271 base_cfg: &hammer_build::BuildConfig,
272 store: &Store,
273) -> Result<RootfsHash> {
274 // Toolchain = la semilla sellada, no el zig del host.
275 let zig_dir = seed_toolchain_dir(store, &spec.seed_hash, spec.seed_kind)?;
276 let cfg = hammer_build::BuildConfig { zig_dir, ..base_cfg.clone() };
277
278 // 1) Construir cada componente (cacheado por el lab si ya existe).
279 let mut components: Vec<(String, ArtifactHash)> = Vec::with_capacity(STAGE1_COMPONENTS.len());
280 for name in STAGE1_COMPONENTS {
281 let recipe_path = spec.recipes_dir.join(format!("{name}.toml"));
282 let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| {
283 Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display()))
284 })?;
285 let h = hammer_build::build(&recipe, &cfg, store)?;
286 components.push((name.to_string(), h));
287 }
288
289 // 2) Hash del rootfs = función pura de sus componentes (reproducible, sin tocar disco).
290 let rootfs_hash = rootfs_hash(&components);
291 let store_name = "stage1-rootfs";
292
293 // 3) Ensamblar + sellar (si no estaba ya). Staging bajo el store ⇒ rename atómico en seal.
294 if !store.has(&rootfs_hash, store_name) {
295 let staging = store
296 .root()
297 .join(".bootstrap-tmp")
298 .join(rootfs_hash.store_dir_name(store_name));
299 let _ = std::fs::remove_dir_all(&staging);
300 std::fs::create_dir_all(&staging)?;
301 let result = assemble_and_seal(store, &components, &rootfs_hash, store_name, &staging);
302 let _ = std::fs::remove_dir_all(&staging);
303 result?;
304 } else {
305 tracing::info!(hash = %rootfs_hash, "stage1: rootfs ya sellado (idempotente)");
306 }
307
308 // 4) Línea del manifiesto. `recipe_hash = None`: el rootfs es un ensamblado de varias recetas,
309 // no de una sola; el `seed_hash` ancla de qué toolchain salió.
310 manifest::append_line(
311 store,
312 StageEntry {
313 stage: 1,
314 recipe_hash: None,
315 artifact_hash: rootfs_hash.clone(),
316 seed_hash: Some(spec.seed_hash.clone()),
317 ts: now_unix(),
318 },
319 )?;
320 Ok(rootfs_hash)
321}
322
323/// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + la
324/// seed card del init. Cambiar un componente, el orden o la seed re-hashea el rootfs. El tag `v2`
325/// marca el corte respecto al rootfs con init provisional (`v1`).
326fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash {
327 let mut inputs: Vec<Vec<u8>> = vec![b"hammer-stage1-rootfs-v2".to_vec()];
328 for (name, h) in components {
329 inputs.push(name.as_bytes().to_vec());
330 inputs.push(h.as_str().as_bytes().to_vec());
331 }
332 inputs.push(STAGE1_SEED_CARD.as_bytes().to_vec());
333 let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
334 ArtifactHash::of_inputs(&refs)
335}
336
337fn assemble_and_seal(
338 store: &Store,
339 components: &[(String, ArtifactHash)],
340 rootfs_hash: &RootfsHash,
341 store_name: &str,
342 staging: &Path,
343) -> Result<()> {
344 assemble_rootfs(store, components, staging)?;
345 store.seal(staging, rootfs_hash, store_name)?;
346 tracing::info!(hash = %rootfs_hash, "stage1: rootfs sellado");
347 Ok(())
348}
349
350/// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada
351/// componente sellado (hardlinks), la seed card de arje y `/sbin/init`→arje-zero. Es la pieza
352/// testeable sin build real ([SDD 12](../../docs/12-init-real.md)).
353fn assemble_rootfs(
354 store: &Store,
355 components: &[(String, ArtifactHash)],
356 staging: &Path,
357) -> Result<()> {
358 // Esqueleto FHS. arje-zero monta él mismo los pseudo-FS (arje-kernel), así que sólo deben
359 // existir como puntos de montaje vacíos; `/ente` lleva la seed, `/var/lib/hammer` el diario.
360 for d in [
361 "proc", "sys", "sys/fs/cgroup", "dev", "dev/pts", "dev/shm", "run", "tmp", "etc", "root",
362 "var", "var/lib/hammer", "ente", "usr/bin", "bin", "sbin",
363 ] {
364 std::fs::create_dir_all(staging.join(d))?;
365 }
366
367 // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem).
368 for (name, h) in components {
369 let dir = store.path_of(h, name);
370 hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static)?;
371 }
372
373 // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs
374 // ejecutable: lo quitamos para que la imagen quede limpia y su hash no dependa de cuál
375 // componente lo escribió último.
376 let _ = std::fs::remove_dir_all(staging.join(".hammer"));
377
378 // La seed card que arje-zero lee al boot.
379 std::fs::write(staging.join("ente/seed.card.json"), STAGE1_SEED_CARD)?;
380
381 // arje-zero es PID 1 vía `/sbin/init`. Symlink absoluto: el kernel lo sigue sea cual sea el
382 // cmdline, sin exigir `init=`. Idempotente: si ya existe (re-ensamblado), lo reemplazamos.
383 let init_link = staging.join("sbin/init");
384 let _ = std::fs::remove_file(&init_link);
385 std::os::unix::fs::symlink("/usr/bin/arje-zero", &init_link)?;
386 Ok(())
387}
388
389// ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ──────────────────────
390//
391// El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando
392// **sólo** las herramientas de Stage 1, y se compara el **content-hash** (`of_tree`, no el hash
393// input-addressed del store) de stage1 vs stage1'. Iguales ⇒ el sistema se compila a sí mismo bit
394// a bit (auto-alojado y reproducible). El rebuild **dentro del rootfs** (bwrap/chroot/VM) corre en
395// la VM destino; lo que vive en hammer es la **referencia** (content-hash de stage1) y la
396// comparación. Ver `docs/runbooks/stage1-vm-boot.md`.
397
398/// Veredicto de la verificación de auto-alojamiento de Stage 2.
399#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
400pub enum Reproducibility {
401 /// stage1 y stage1' tienen el mismo content-hash: el sistema se reconstruye bit-idéntico.
402 Reproducible,
403 /// Difieren: hay no-determinismo que cazar ([SDD 09 §2](../../docs/09-trust-model.md)).
404 Divergent { stage1: ArtifactHash, rebuilt: ArtifactHash },
405 /// El rebuild nativo dentro del rootfs aún no corrió (se hace en la VM); sólo se ancló el
406 /// content-hash de stage1 como referencia a reproducir.
407 RebuildPending,
408}
409
410/// Reporte de Stage 2: la referencia (content-hash de stage1) y el veredicto.
411#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
412pub struct VerifyReport {
413 /// Hash input-addressed del rootfs en el store (el que devolvió `stage1`).
414 pub stage1_rootfs: RootfsHash,
415 /// Content-hash (`of_tree`) de stage1: los bytes reales que stage1' debe reproducir.
416 pub stage1_content: ArtifactHash,
417 pub verdict: Reproducibility,
418}
419
420/// Content-hash (`ArtifactHash::of_tree`) del artefacto sellado `(hash, name)` en el store.
421pub fn artifact_content_hash(
422 store: &Store,
423 hash: &ArtifactHash,
424 name: &str,
425) -> Result<ArtifactHash> {
426 let dir = store.path_of(hash, name);
427 if !dir.is_dir() {
428 return Err(Error::Other(format!(
429 "artefacto '{name}' no sellado en {}",
430 dir.display()
431 )));
432 }
433 ArtifactHash::of_tree(&dir).map_err(Error::Io)
434}
435
436/// **Stage 2** (referencia + manifiesto). Ancla el **content-hash** del rootfs de Stage 1 como la
437/// referencia a reproducir y lo anota en el manifiesto (línea stage 2). El rebuild nativo DENTRO
438/// del rootfs corre en la VM (SDD 11 §3); cuando produzca stage1', se compara con [`verify_against`].
439pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport> {
440 let content = artifact_content_hash(store, stage1, "stage1-rootfs")?;
441 manifest::append_line(
442 store,
443 StageEntry {
444 stage: 2,
445 recipe_hash: None,
446 artifact_hash: content.clone(),
447 seed_hash: None,
448 ts: now_unix(),
449 },
450 )?;
451 tracing::info!(content = %content, "stage2: content-hash de stage1 anclado (referencia)");
452 Ok(VerifyReport {
453 stage1_rootfs: stage1.clone(),
454 stage1_content: content,
455 verdict: Reproducibility::RebuildPending,
456 })
457}
458
459/// Compara la referencia (`report.stage1_content`) con el content-hash de un rebuild stage1'
460/// producido en la VM, y emite el veredicto de reproducibilidad.
461pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> Reproducibility {
462 if report.stage1_content == rebuilt_content {
463 Reproducibility::Reproducible
464 } else {
465 Reproducibility::Divergent {
466 stage1: report.stage1_content.clone(),
467 rebuilt: rebuilt_content,
468 }
469 }
470}
471
472// ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ────────────────────
473//
474// El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no
475// puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que,
476// booteada en la VM, corre `hammer bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara
477// su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit.
478//
479// Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por
480// hammer). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el
481// auto-alojamiento *puro* (variante b: el toolchain construido por hammer desde fuente, incremental).
482//
483// El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se
484// ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es
485// reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario +
486// tag del toolchain + el propio driver), apto para el log de transparencia.
487
488/// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee
489/// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al
490/// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va
491/// vacío, sólo imprime el content-hash de `stage1'` para comparación manual.
492///
493/// El toolchain es Alpine **dinámico**: sus binarios (bwrap/git/curl/cargo/make) no corren desde el
494/// userland Stage 1, que es **estático** (no hay loader en `/lib`). El driver instala un *shim* del
495/// loader musl (`cp` a `/lib`) + `LD_LIBRARY_PATH`/`PATH` al toolchain, y a partir de ahí esas
496/// herramientas son ejecutables; el `hammer` estático vive en `/usr/bin/hammer`. El sandbox de build
497/// (bwrap) anida en `/toolchain`. (El shim sólo se usa para *invocar* el toolchain desde fuera del
498/// sandbox: fetch git/tarball; dentro del sandbox el loader está en su sitio.)
499const REBUILD_DRIVER: &str = r#"#!/bin/sh
500# rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7).
501# Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y
502# compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera.
503set -eu
504. /etc/hammer/rebuild.env # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío)
505# Shim del loader musl: deja correr los binarios *dinámicos* del toolchain (bwrap/git/curl/cargo/make)
506# desde el root Stage 1 estático. Usa el busybox estático (rutas absolutas) ANTES de tocar el PATH.
507/bin/mkdir -p /lib
508/bin/cp -f /toolchain/lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
509export LD_LIBRARY_PATH=/toolchain/lib:/toolchain/usr/lib
510export PATH=/toolchain/usr/bin:/toolchain/usr/sbin:/toolchain/bin:/toolchain/sbin:/usr/bin:/bin:/sbin
511# overlay es módulo (CONFIG_OVERLAY_FS=m) y el initramfs arranca sin módulos cargados: el sandbox de
512# build (bwrap --tmp-overlay) lo exige ⇒ ENODEV sin esto. Lo cargamos del toolchain (insmod=kmod,
513# vermagic del kernel destino). Idempotente: si ya está, insmod falla y lo ignoramos.
514insmod /toolchain/lib/overlay.ko 2>/dev/null || true
515export HAMMER_ROOTFS=/toolchain # el sandbox de build: el toolchain (variante a, desde Alpine)
516export HAMMER_WORK=/work
517export HAMMER_CACHE=
518echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)"
519PRIME=$(/usr/bin/hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes)
520echo ">> stage1' = $PRIME"
521if [ -n "${REF_CONTENT:-}" ]; then
522 /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT"
523else
524 /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME"
525fi
526"#;
527
528/// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7).
529#[derive(Debug, Clone)]
530pub struct BuilderSpec {
531 /// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base
532 /// runtime sobre la que se hidrata el toolchain.
533 pub stage1_rootfs: RootfsHash,
534 /// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `hammer`
535 /// de adentro resuelva el toolchain por hash, igual que afuera.
536 pub seed_hash: ArtifactHash,
537 pub seed_kind: SeedKind,
538 /// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild).
539 pub recipes_dir: PathBuf,
540 /// Binario `hammer` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que hammer
541 /// construye desde fuente.
542 pub hammer_bin: PathBuf,
543 /// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`).
544 pub toolchain_src: PathBuf,
545 /// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del
546 /// builder sin tener que hashear el árbol Alpine entero.
547 pub toolchain_tag: String,
548 /// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se
549 /// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`.
550 pub ref_content: Option<ArtifactHash>,
551 /// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y
552 /// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`).
553 pub work_cache: Option<PathBuf>,
554}
555
556/// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado.
557#[derive(Debug, Clone)]
558pub struct BuilderReport {
559 pub builder_hash: ArtifactHash,
560 pub out_dir: PathBuf,
561}
562
563/// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain
564/// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder.
565fn builder_hash(
566 stage1: &RootfsHash,
567 seed: &ArtifactHash,
568 toolchain_tag: &str,
569 recipes_digest: &str,
570 hammer_digest: &str,
571) -> ArtifactHash {
572 ArtifactHash::of_inputs(&[
573 b"hammer-builder-rootfs-v1",
574 stage1.as_str().as_bytes(),
575 seed.as_str().as_bytes(),
576 toolchain_tag.as_bytes(),
577 recipes_digest.as_bytes(),
578 hammer_digest.as_bytes(),
579 REBUILD_DRIVER.as_bytes(),
580 ])
581}
582
583/// sha256 hex del contenido de un archivo.
584fn sha256_file(path: &Path) -> Result<String> {
585 use sha2::{Digest, Sha256};
586 let bytes = std::fs::read(path)?;
587 let mut h = Sha256::new();
588 h.update(&bytes);
589 Ok(hex::encode(h.finalize()))
590}
591
592/// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden.
593/// Cambiar una receta, añadir o quitar una, re-hashea el builder.
594fn recipes_digest(dir: &Path) -> Result<String> {
595 use sha2::{Digest, Sha256};
596 let mut tomls: Vec<PathBuf> = std::fs::read_dir(dir)?
597 .filter_map(|e| e.ok().map(|e| e.path()))
598 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
599 .collect();
600 tomls.sort();
601 let mut h = Sha256::new();
602 for p in &tomls {
603 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
604 h.update(name.as_bytes());
605 h.update([0u8]);
606 h.update(std::fs::read(p)?);
607 h.update([0u8]);
608 }
609 Ok(hex::encode(h.finalize()))
610}
611
612/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y
613/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder
614/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`).
615///
616/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej.
617/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el
618/// propio destino en una recursión infinita. Se compara por path canónico.
619fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> {
620 if let Some(skip) = skip {
621 if src.canonicalize().ok().as_deref() == Some(skip) {
622 return Ok(());
623 }
624 }
625 let meta = std::fs::symlink_metadata(src)?;
626 let ft = meta.file_type();
627 if ft.is_symlink() {
628 let target = std::fs::read_link(src)?;
629 let _ = std::fs::remove_file(dst);
630 std::os::unix::fs::symlink(target, dst)?;
631 } else if ft.is_dir() {
632 std::fs::create_dir_all(dst)?;
633 for entry in std::fs::read_dir(src)? {
634 let entry = entry?;
635 link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?;
636 }
637 } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() {
638 std::fs::copy(src, dst)?;
639 }
640 Ok(())
641}
642
643/// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el
644/// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`,
645/// instala `hammer` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes.
646fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> {
647 // 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros).
648 let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs");
649 if !stage1_dir.is_dir() {
650 return Err(Error::Other(format!(
651 "stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero",
652 spec.stage1_rootfs,
653 stage1_dir.display()
654 )));
655 }
656 hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static)?;
657
658 // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config.
659 for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] {
660 std::fs::create_dir_all(staging.join(d))?;
661 }
662
663 // 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs).
664 if !spec.toolchain_src.is_dir() {
665 return Err(Error::Other(format!(
666 "toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)",
667 spec.toolchain_src.display()
668 )));
669 }
670 link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
671
672 // 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
673 // adentro resuelva el toolchain por hash sin re-ingerirla.
674 let seed_name = format!("seed-{}", spec.seed_kind.as_str());
675 let seed_dir = store.path_of(&spec.seed_hash, &seed_name);
676 if !seed_dir.is_dir() {
677 return Err(Error::Other(format!(
678 "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
679 seed_name,
680 seed_dir.display()
681 )));
682 }
683 link_or_copy_tree(
684 &seed_dir,
685 &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)),
686 None,
687 )?;
688
689 // 5) Binario hammer → /usr/bin/hammer (+x).
690 let hammer_dst = staging.join("usr/bin/hammer");
691 if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() {
692 std::fs::copy(&spec.hammer_bin, &hammer_dst)?;
693 }
694 set_executable(&hammer_dst)?;
695
696 // 6) Recetas → /etc/hammer/recipes.
697 for entry in std::fs::read_dir(&spec.recipes_dir)? {
698 let path = entry?.path();
699 if path.extension().and_then(|x| x.to_str()) == Some("toml") {
700 let name = path.file_name().unwrap();
701 std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?;
702 }
703 }
704
705 // 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y
706 // `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se
707 // copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El
708 // `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`.
709 if let Some(cache) = &spec.work_cache {
710 let skip = staging.canonicalize().ok();
711 for sub in ["repos", "tarballs"] {
712 let src = cache.join(sub);
713 if src.is_dir() {
714 link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?;
715 }
716 }
717 }
718
719 // 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo).
720 let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or("");
721 let env = format!(
722 "SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n",
723 spec.seed_hash,
724 spec.seed_kind.as_str(),
725 ref_content,
726 );
727 std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?;
728 let driver = staging.join("usr/bin/rebuild-stage1");
729 std::fs::write(&driver, REBUILD_DRIVER)?;
730 set_executable(&driver)?;
731 Ok(())
732}
733
734/// Marca un archivo como ejecutable (0o755).
735fn set_executable(path: &Path) -> Result<()> {
736 use std::os::unix::fs::PermissionsExt;
737 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
738 Ok(())
739}
740
741/// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada
742/// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash
743/// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida.
744pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result<BuilderReport> {
745 // Hash lógico (insumos), independiente del ensamblado en disco.
746 let rdigest = recipes_digest(&spec.recipes_dir)?;
747 let hdigest = sha256_file(&spec.hammer_bin)?;
748 let bhash = builder_hash(
749 &spec.stage1_rootfs,
750 &spec.seed_hash,
751 &spec.toolchain_tag,
752 &rdigest,
753 &hdigest,
754 );
755
756 let _ = std::fs::remove_dir_all(out_dir);
757 std::fs::create_dir_all(out_dir)?;
758 assemble_builder(spec, store, out_dir)?;
759
760 manifest::append_line(
761 store,
762 StageEntry {
763 stage: 2,
764 recipe_hash: None,
765 artifact_hash: bhash.clone(),
766 seed_hash: Some(spec.seed_hash.clone()),
767 ts: now_unix(),
768 },
769 )?;
770 tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado");
771 Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() })
772}
773
774/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
775///
776/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
777/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
778/// llega al árbol que se sella.
779pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
780 let h = seed.seed_hash();
781 let name = seed.store_name();
782 if store.has(&h, &name) {
783 tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
784 } else {
785 // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
786 // hacer el rename atómico.
787 let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
788 let _ = std::fs::remove_dir_all(&work);
789 std::fs::create_dir_all(&work)?;
790
791 // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo
792 // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de
793 // anotar el manifiesto: una semilla que no se selló no genera línea.
794 let result = stage0_into(seed, store, &h, &name, &work);
795 let _ = std::fs::remove_dir_all(&work);
796 result?;
797 }
798
799 // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
800 // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
801 manifest::append_line(
802 store,
803 StageEntry {
804 stage: 0,
805 recipe_hash: None,
806 artifact_hash: h.clone(),
807 seed_hash: Some(h.clone()),
808 ts: now_unix(),
809 },
810 )?;
811 Ok(h)
812}
813
814/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
815/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
816/// la reproducibilidad.
817fn now_unix() -> u64 {
818 std::time::SystemTime::now()
819 .duration_since(std::time::UNIX_EPOCH)
820 .map(|d| d.as_secs())
821 .unwrap_or(0)
822}
823
824fn stage0_into(
825 seed: &SeedSpec,
826 store: &Store,
827 h: &ArtifactHash,
828 name: &str,
829 work: &Path,
830) -> Result<ArtifactHash> {
831 let tarball = work.join("seed.tar");
832 download::fetch_url_to_file(&seed.url, &tarball)?;
833 // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
834 download::verify_sha256(&tarball, &seed.sha256)?;
835
836 let tree = work.join("tree");
837 std::fs::create_dir_all(&tree)?;
838 untar(&tarball, &tree)?;
839
840 let sealed = store.seal(&tree, h, name)?;
841 tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
842 Ok(h.clone())
843}
844
845/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
846fn untar(tarball: &Path, into: &Path) -> Result<()> {
847 let st = Command::new("tar")
848 .arg("-xf")
849 .arg(tarball)
850 .arg("-C")
851 .arg(into)
852 .status()
853 .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
854 if !st.success() {
855 return Err(Error::Untar(st.code()));
856 }
857 Ok(())
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 fn spec(url: &str, sha256: &str) -> SeedSpec {
865 SeedSpec {
866 kind: SeedKind::Zig,
867 version: "0.13.0".into(),
868 url: url.into(),
869 sha256: sha256.into(),
870 }
871 }
872
873 fn sha256_hex(bytes: &[u8]) -> String {
874 use sha2::{Digest, Sha256};
875 let mut h = Sha256::new();
876 h.update(bytes);
877 hex::encode(h.finalize())
878 }
879
880 /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
881 fn make_seed_tarball(dir: &Path) -> (String, String) {
882 let src = dir.join("src");
883 std::fs::create_dir_all(src.join("bin")).unwrap();
884 std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
885 let tarball = dir.join("seed.tar.gz");
886 let st = Command::new("tar")
887 .arg("-czf")
888 .arg(&tarball)
889 .arg("-C")
890 .arg(&src)
891 .arg(".")
892 .status()
893 .unwrap();
894 assert!(st.success(), "tar -c falló");
895 let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
896 (format!("file://{}", tarball.display()), sha)
897 }
898
899 #[test]
900 fn seed_hash_is_identity_based() {
901 let a = spec("file:///mirror-a.tar", "abc123");
902 let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
903 assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
904
905 let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
906 assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
907
908 let mut d = spec("file:///mirror-a.tar", "abc123");
909 d.kind = SeedKind::MuslCrossMake;
910 assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
911 }
912
913 #[test]
914 fn stage0_ingests_and_seals() {
915 let tmp = tempfile::tempdir().unwrap();
916 let (url, sha) = make_seed_tarball(tmp.path());
917 let store = Store::open(tmp.path().join("store")).unwrap();
918 let seed = spec(&url, &sha);
919
920 let h = stage0(&seed, &store).expect("stage0");
921 assert_eq!(h, seed.seed_hash());
922 assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
923
924 // El contenido extraído está presente y de sólo-lectura (sellado).
925 let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
926 assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
927 use std::os::unix::fs::PermissionsExt;
928 let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
929 assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
930 }
931
932 #[test]
933 fn stage0_is_idempotent() {
934 let tmp = tempfile::tempdir().unwrap();
935 let (url, sha) = make_seed_tarball(tmp.path());
936 let store = Store::open(tmp.path().join("store")).unwrap();
937 let seed = spec(&url, &sha);
938
939 let h1 = stage0(&seed, &store).unwrap();
940 let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
941 assert_eq!(h1, h2);
942 }
943
944 #[test]
945 fn stage0_records_one_manifest_line() {
946 let tmp = tempfile::tempdir().unwrap();
947 let (url, sha) = make_seed_tarball(tmp.path());
948 let store = Store::open(tmp.path().join("store")).unwrap();
949 let seed = spec(&url, &sha);
950
951 let h = stage0(&seed, &store).unwrap();
952 stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea
953
954 let m = BootstrapManifest::load(&store).unwrap();
955 assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0");
956 let e = &m.entries[0];
957 assert_eq!(e.stage, 0);
958 assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila");
959 assert_eq!(e.artifact_hash, h);
960 assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto");
961 }
962
963 #[test]
964 fn stage0_bad_sha_records_no_manifest_line() {
965 let tmp = tempfile::tempdir().unwrap();
966 let (url, _real_sha) = make_seed_tarball(tmp.path());
967 let store = Store::open(tmp.path().join("store")).unwrap();
968 let seed = spec(&url, &"0".repeat(64));
969
970 assert!(stage0(&seed, &store).is_err());
971 // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto.
972 assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json");
973 }
974
975 #[test]
976 fn stage0_rejects_bad_sha256_and_seals_nothing() {
977 let tmp = tempfile::tempdir().unwrap();
978 let (url, _real_sha) = make_seed_tarball(tmp.path());
979 let store = Store::open(tmp.path().join("store")).unwrap();
980 let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
981
982 let err = stage0(&seed, &store).unwrap_err();
983 assert!(
984 err.to_string().contains("sha256 mismatch"),
985 "esperaba mismatch de sha256, vino: {err}"
986 );
987 assert!(
988 !store.has(&seed.seed_hash(), &seed.store_name()),
989 "un sha erróneo no debe sellar nada"
990 );
991 // Tampoco debe dejar staging a medio descargar bajo el store.
992 assert!(
993 !store.root().join(".bootstrap-tmp").exists()
994 || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
995 "el staging debe quedar limpio tras el fallo"
996 );
997 }
998
999 /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
1000 /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
1001 fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
1002 let src = dir.join("src");
1003 let zig = src.join(zig_rel);
1004 std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
1005 std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
1006 let tarball = dir.join("seed.tar.gz");
1007 let st = Command::new("tar")
1008 .args(["-czf"])
1009 .arg(&tarball)
1010 .arg("-C")
1011 .arg(&src)
1012 .arg(".")
1013 .status()
1014 .unwrap();
1015 assert!(st.success());
1016 let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
1017 let store = Store::open(dir.join("store")).unwrap();
1018 let seed = spec(&format!("file://{}", tarball.display()), &sha);
1019 stage0(&seed, &store).unwrap();
1020 (store, seed)
1021 }
1022
1023 #[test]
1024 fn toolchain_dir_finds_zig_in_versioned_subdir() {
1025 let tmp = tempfile::tempdir().unwrap();
1026 let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1027 let dir = seed.toolchain_dir(&store).unwrap();
1028 assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
1029 assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
1030 }
1031
1032 #[test]
1033 fn toolchain_dir_finds_zig_at_root() {
1034 let tmp = tempfile::tempdir().unwrap();
1035 let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
1036 let dir = seed.toolchain_dir(&store).unwrap();
1037 let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1038 assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
1039 }
1040
1041 #[test]
1042 fn toolchain_dir_errors_when_unsealed() {
1043 let tmp = tempfile::tempdir().unwrap();
1044 let store = Store::open(tmp.path().join("store")).unwrap();
1045 let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
1046 let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1047 assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
1048 }
1049
1050 #[test]
1051 fn toolchain_dir_errors_when_ambiguous() {
1052 let tmp = tempfile::tempdir().unwrap();
1053 // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
1054 let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
1055 let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1056 // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
1057 let b = base.join("b");
1058 let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
1059 std::fs::create_dir_all(&b).unwrap();
1060 std::fs::write(b.join("zig"), b"x").unwrap();
1061 let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1062 assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
1063 }
1064
1065 #[test]
1066 fn seed_build_config_points_zig_dir_at_seed() {
1067 let tmp = tempfile::tempdir().unwrap();
1068 let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1069 let base = hammer_build::BuildConfig::defaults_for_store(store.root());
1070 let cfg = seed_build_config(&base, &seed, &store).unwrap();
1071 assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
1072 // El resto de la config se hereda sin cambios.
1073 assert_eq!(cfg.rootfs, base.rootfs);
1074 assert_eq!(cfg.work_root, base.work_root);
1075 }
1076
1077 // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real ---
1078
1079 /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol
1080 /// antes del `seal` (que lo marca read-only).
1081 fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash {
1082 let h = ArtifactHash::from_hex(hex);
1083 let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name));
1084 std::fs::create_dir_all(&work).unwrap();
1085 build(&work);
1086 store.seal(&work, &h, name).unwrap();
1087 h
1088 }
1089
1090 #[test]
1091 fn rootfs_hash_is_deterministic_and_sensitive() {
1092 let base = [
1093 ("musl".to_string(), ArtifactHash::from_hex("11")),
1094 ("busybox".to_string(), ArtifactHash::from_hex("22")),
1095 ];
1096 let a = rootfs_hash(&base);
1097 assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash");
1098
1099 let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))];
1100 assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea");
1101
1102 let reordered = [base[1].clone(), base[0].clone()];
1103 assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa");
1104 }
1105
1106 #[test]
1107 fn assemble_rootfs_hydrates_and_installs_arje_init() {
1108 let tmp = tempfile::tempdir().unwrap();
1109 let store = Store::open(tmp.path().join("store")).unwrap();
1110
1111 let hmusl = seal_component(&store, "musl", "aa11", |w| {
1112 std::fs::create_dir_all(w.join("usr/lib")).unwrap();
1113 std::fs::write(w.join("usr/lib/libc.a"), b"!<arch>\n").unwrap();
1114 });
1115 let hbb = seal_component(&store, "busybox", "bb22", |w| {
1116 std::fs::create_dir_all(w.join("bin")).unwrap();
1117 std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap();
1118 std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
1119 // Sidecar de provenance que NO debe terminar en el rootfs.
1120 std::fs::create_dir_all(w.join(".hammer")).unwrap();
1121 std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap();
1122 });
1123 let harje = seal_component(&store, "arje-zero", "cc33", |w| {
1124 std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1125 std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap();
1126 });
1127
1128 let staging = store.root().join("staging");
1129 std::fs::create_dir_all(&staging).unwrap();
1130 let components = [
1131 ("musl".to_string(), hmusl),
1132 ("busybox".to_string(), hbb),
1133 ("arje-zero".to_string(), harje),
1134 ];
1135 assemble_rootfs(&store, &components, &staging).unwrap();
1136
1137 assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado");
1138 assert!(staging.join("bin/busybox").is_file(), "busybox hidratado");
1139 assert!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado");
1140 // arje-zero es PID 1 vía /sbin/init.
1141 assert_eq!(
1142 std::fs::read_link(staging.join("sbin/init")).unwrap(),
1143 std::path::PathBuf::from("/usr/bin/arje-zero"),
1144 "/sbin/init apunta a arje-zero"
1145 );
1146 // La seed card está en su lugar y es JSON válido con los genesis esperados.
1147 let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
1148 let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
1149 assert_eq!(v["label"], "hammer-stage1");
1150 let labels: Vec<&str> = v["genesis"].as_array().unwrap()
1151 .iter().map(|g| g["label"].as_str().unwrap()).collect();
1152 assert_eq!(labels, vec!["hammerd", "console-getty"]);
1153 // Puntos de montaje que arje monta solos, presentes como dirs vacíos.
1154 assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir());
1155 assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
1156 // Ya no hay init provisional de busybox.
1157 assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado");
1158 }
1159
1160 #[test]
1161 fn stage1_seed_card_is_valid_json_with_native_hammerd() {
1162 // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart
1163 // (el CRASHED real) — un template roto rompería el boot de arje-zero.
1164 let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap();
1165 assert_eq!(v["payload"], "Virtual", "la semilla es Virtual");
1166 let hammerd = &v["genesis"][0];
1167 assert_eq!(hammerd["label"], "hammerd");
1168 assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd");
1169 assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart");
1170 let getty = &v["genesis"][1];
1171 assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
1172 }
1173
1174 #[test]
1175 fn shipped_stage1_recipes_parse_and_hash() {
1176 // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK).
1177 let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
1178 let tmp = tempfile::tempdir().unwrap();
1179 let store = Store::open(tmp.path().join("store")).unwrap();
1180 for name in STAGE1_COMPONENTS {
1181 let path = recipes_dir.join(format!("{name}.toml"));
1182 let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
1183 assert_eq!(&r.name, name);
1184 let h = hammer_build::artifact_hash(&r, &store).expect("hash");
1185 assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:");
1186 }
1187 }
1188
1189 #[test]
1190 fn stage2_anchors_content_hash_and_verifies() {
1191 let tmp = tempfile::tempdir().unwrap();
1192 let store = Store::open(tmp.path().join("store")).unwrap();
1193 // Sella un "rootfs" sintético con el nombre que espera stage2.
1194 let h = seal_component(&store, "stage1-rootfs", "abcd", |w| {
1195 std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1196 std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake-init").unwrap();
1197 });
1198
1199 let report = stage2(&h, &store).unwrap();
1200 assert_eq!(report.stage1_rootfs, h);
1201 assert!(report.stage1_content.as_str().starts_with("b3:"));
1202 assert_eq!(report.verdict, Reproducibility::RebuildPending);
1203
1204 // El manifiesto anotó la línea stage 2 con el content-hash como artefacto.
1205 let m = BootstrapManifest::load(&store).unwrap();
1206 let e2 = m.entries.iter().find(|e| e.stage == 2).expect("línea stage 2");
1207 assert_eq!(e2.artifact_hash, report.stage1_content);
1208
1209 // verify_against: un rebuild idéntico ⇒ Reproducible; distinto ⇒ Divergent.
1210 assert_eq!(
1211 verify_against(&report, report.stage1_content.clone()),
1212 Reproducibility::Reproducible
1213 );
1214 match verify_against(&report, ArtifactHash::from_hex("ffff")) {
1215 Reproducibility::Divergent { stage1, rebuilt } => {
1216 assert_eq!(stage1, report.stage1_content);
1217 assert_eq!(rebuilt, ArtifactHash::from_hex("ffff"));
1218 }
1219 v => panic!("esperaba Divergent, vino {v:?}"),
1220 }
1221 }
1222
1223 // --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM ---
1224
1225 /// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos.
1226 fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) {
1227 let store = Store::open(dir.join("store")).unwrap();
1228
1229 // Stage 1 rootfs sellado (con el nombre que espera el builder).
1230 let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| {
1231 std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1232 std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap();
1233 std::fs::create_dir_all(w.join("sbin")).unwrap();
1234 std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap();
1235 });
1236
1237 // Semilla zig sellada con bin/zig (nombre store seed-zig).
1238 let seed = seal_component(&store, "seed-zig", "5ee0", |w| {
1239 std::fs::create_dir_all(w.join("bin")).unwrap();
1240 std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap();
1241 });
1242
1243 // Recetas, binario hammer y toolchain (Alpine sintético), fuera del store.
1244 let recipes = dir.join("recipes");
1245 std::fs::create_dir_all(&recipes).unwrap();
1246 std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap();
1247 std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap();
1248
1249 let hammer_bin = dir.join("hammer");
1250 std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap();
1251
1252 let toolchain = dir.join("alpine");
1253 std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap();
1254 std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap();
1255
1256 let spec = BuilderSpec {
1257 stage1_rootfs: stage1,
1258 seed_hash: seed,
1259 seed_kind: SeedKind::Zig,
1260 recipes_dir: recipes,
1261 hammer_bin,
1262 toolchain_src: toolchain,
1263 toolchain_tag: "alpine-test".into(),
1264 ref_content: Some(ArtifactHash::from_hex("dead")),
1265 work_cache: None,
1266 };
1267 (store, spec)
1268 }
1269
1270 #[test]
1271 fn builder_rootfs_assembles_full_image() {
1272 let tmp = tempfile::tempdir().unwrap();
1273 let (store, spec) = builder_fixture(tmp.path());
1274 let out = tmp.path().join("builder-out");
1275
1276 let report = builder_rootfs(&spec, &store, &out).unwrap();
1277 assert!(report.builder_hash.as_str().starts_with("b3:"));
1278 assert_eq!(report.out_dir, out);
1279
1280 // Base Stage 1 hidratada (init + symlink).
1281 assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado");
1282 assert_eq!(
1283 std::fs::read_link(out.join("sbin/init")).unwrap(),
1284 PathBuf::from("/usr/bin/arje-zero"),
1285 );
1286 // Toolchain en /toolchain, semilla en /store, hammer + recetas + driver.
1287 assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado");
1288 assert!(
1289 out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(),
1290 "semilla replicada en /store",
1291 );
1292 assert!(out.join("usr/bin/hammer").is_file(), "binario hammer");
1293 assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas");
1294 assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild");
1295
1296 // El driver y hammer son ejecutables.
1297 use std::os::unix::fs::PermissionsExt;
1298 let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap();
1299 assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x");
1300
1301 // rebuild.env lleva la semilla, la clase y la referencia embebidas.
1302 let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
1303 assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}");
1304 assert!(env.contains("SEED_KIND=zig"), "{env}");
1305 assert!(env.contains("REF_CONTENT=b3:dead"), "{env}");
1306
1307 // Línea de manifiesto (stage 2) con el hash lógico del builder.
1308 let man = BootstrapManifest::load(&store).unwrap();
1309 assert!(
1310 man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash),
1311 "el builder debe anotar su línea en el manifiesto",
1312 );
1313 }
1314
1315 #[test]
1316 fn builder_hash_is_deterministic_and_sensitive() {
1317 let s1 = ArtifactHash::from_hex("11");
1318 let seed = ArtifactHash::from_hex("22");
1319 let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd");
1320 assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd"), "puro");
1321 assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd"), "tag del toolchain");
1322 assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd"), "recetas");
1323 assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2"), "binario hammer");
1324 }
1325
1326 #[test]
1327 fn builder_rootfs_without_ref_leaves_empty_ref_content() {
1328 let tmp = tempfile::tempdir().unwrap();
1329 let (store, mut spec) = builder_fixture(tmp.path());
1330 spec.ref_content = None;
1331 let out = tmp.path().join("builder-out");
1332 builder_rootfs(&spec, &store, &out).unwrap();
1333 let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
1334 assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}");
1335 }
1336
1337 #[test]
1338 fn builder_rootfs_with_work_cache_containing_out_dir_terminates() {
1339 // Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse
1340 // el propio destino (recursión infinita que llena el disco). El skip lo corta.
1341 let tmp = tempfile::tempdir().unwrap();
1342 let (store, mut spec) = builder_fixture(tmp.path());
1343
1344 // Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar).
1345 let work = tmp.path().join("work");
1346 std::fs::create_dir_all(work.join("repos/musl.git")).unwrap();
1347 std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap();
1348 std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap();
1349 std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap();
1350 spec.work_cache = Some(work.clone());
1351
1352 // out_dir vive DENTRO de la caché: el caso que disparaba la recursión.
1353 let out = work.join("builder-rootfs");
1354 let report = builder_rootfs(&spec, &store, &out).unwrap();
1355 assert!(report.builder_hash.as_str().starts_with("b3:"));
1356
1357 // Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe.
1358 assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben");
1359 assert!(
1360 !out.join("work/builder-rootfs").exists(),
1361 "el out_dir no debe haberse copiado en su propio /work (recursión)",
1362 );
1363 assert!(
1364 !out.join("work/sources").exists(),
1365 "sources/ es regenerable y pesado: no debe viajar en el builder",
1366 );
1367 }
1368
1369 #[test]
1370 fn builder_rootfs_errors_when_stage1_unsealed() {
1371 let tmp = tempfile::tempdir().unwrap();
1372 let (store, mut spec) = builder_fixture(tmp.path());
1373 spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado
1374 let out = tmp.path().join("builder-out");
1375 let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
1376 assert!(err.contains("no sellado"), "{err}");
1377 }
1378
1379 #[test]
1380 fn artifact_content_hash_errors_when_unsealed() {
1381 let tmp = tempfile::tempdir().unwrap();
1382 let store = Store::open(tmp.path().join("store")).unwrap();
1383 let err = artifact_content_hash(&store, &ArtifactHash::from_hex("00"), "stage1-rootfs")
1384 .unwrap_err()
1385 .to_string();
1386 assert!(err.contains("no sellado"), "{err}");
1387 }
1388
1389 #[test]
1390 fn all_shipped_recipes_parse_and_hash() {
1391 // Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y
1392 // hashear offline. Cubre las que no están en STAGE1_COMPONENTS y futuras.
1393 let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
1394 let tmp = tempfile::tempdir().unwrap();
1395 let store = Store::open(tmp.path().join("store")).unwrap();
1396 let mut count = 0;
1397 for entry in std::fs::read_dir(&recipes_dir).unwrap() {
1398 let path = entry.unwrap().path();
1399 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1400 continue;
1401 }
1402 let r = Recipe::load_from_path(&path)
1403 .unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
1404 let h = hammer_build::artifact_hash(&r, &store)
1405 .unwrap_or_else(|e| panic!("hash {}: {e}", path.display()));
1406 assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display());
1407 count += 1;
1408 }
1409 assert!(count >= 5, "esperaba ≥5 recetas (grep/musl/busybox/hammerd/arje-zero), vi {count}");
1410 }
1411}