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 1457Shell 147Markdown 90Rust 853012 Definiciones

⎇ 7baaf4afc5d19de48e07fc92103337dd4177cd9f crates/hammer-bootstrap/src/lib.rs Rust · 1945 líneas · 89.7 KB Historia Quién tocó qué crudo

hammer-bootstrap: capa de servicios (sshd como producto) — Etapa C Paso 2 7baaf4afc · 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` (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, None)?;
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// ── Capa de servicios: el PRODUCTO (Separación Mecanismo/Política) ──────────────────────────────
390//
391// `STAGE1_COMPONENTS` + `STAGE1_SEED_CARD` son el **mecanismo base**: el núcleo atómico que el
392// selfhost-verify reconstruye bit a bit (`of_tree` 9adefb82/7fa6cb4e). Inmutable y blindado. Los
393// **servicios** (sshd, …) son **política de producto**: NO entran al núcleo (no contaminan el
394// `of_tree` ni obligan a un rebuild pesado de openssl/zlib dentro de la VM). Se inyectan por
395// **hidratación tardía** sobre el rootfs base ya sellado, produciendo un `product-rootfs` APARTE.
396// Así, meter más servicios mañana no toca jamás el núcleo verificado.
397
398/// Componentes de servicio inyectados sobre el 4/4 verificado: `netup` (red) + `openssh` (sshd/ssh).
399/// Crecer esta lista NO toca `STAGE1_COMPONENTS` ni el selfhost-verify.
400const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"];
401
402/// Card de servicio `sshd` (genesis `Native`/`Restart`): el comando levanta la red (`netup`, DHCP),
403/// genera host keys al primer boot (`ssh-keygen -A`) y `exec`uta `sshd -D`. Todo dentro del card para
404/// no asumir orden de genesis. Mismo esquema validado de [`STAGE1_SEED_CARD`]; ULID propio. Se inyecta
405/// en el `genesis` de la seed de producto. Verificado E2E en QEMU (`scripts/ssh-e2e-test.sh`).
406const SSHD_SERVICE_CARD: &str = r#"{
407    "schema_version": 1,
408    "id": "01HQAR53D4M2NBV8KZTYXFQA12",
409    "lineage": null,
410    "label": "sshd",
411    "provides": [],
412    "requires": [],
413    "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
414    "soma": {
415        "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
416        "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
417        "cgroup": { "path": "arje.slice/sshd", "cpu_weight": null, "io_weight": null },
418        "cpu_affinity": null
419    },
420    "payload": { "Native": {
421        "exec": "/bin/busybox",
422        "argv": ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"],
423        "envp": [["HOME", "/root"]]
424    }},
425    "supervision": { "Restart": { "initial": 500, "max": 20000 } },
426    "lifecycle": "daemon",
427    "priority": "normal",
428    "flow": { "input": [], "output": [] },
429    "genesis": []
430}"#;
431
432/// `/etc/passwd` del producto: `root` (login por llave) + `sshd` (usuario de privsep, shell inerte).
433const PRODUCT_PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
434/// `/etc/group` del producto.
435const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n";
436/// `/etc/ssh/sshd_config` del producto: sólo por llave (sin password), pidfile en `/run` (tmpfs que
437/// arje monta), authorized_keys provisionadas en deploy.
438const SSHD_CONFIG: &str = "Port 22\nListenAddress 0.0.0.0\nPermitRootLogin prohibit-password\nPubkeyAuthentication yes\nPasswordAuthentication no\nAuthorizedKeysFile /root/.ssh/authorized_keys\nPidFile /run/sshd.pid\nSubsystem sftp /usr/libexec/sftp-server\n";
439
440/// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía
441/// `serde_json` (hammer sigue autocontenido: no depende de `card-core`). Determinista ⇒ el
442/// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual.
443fn product_seed_card() -> Result<String> {
444    let mut seed: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD)
445        .map_err(|e| Error::Other(format!("seed base inválida: {e}")))?;
446    seed["label"] = serde_json::Value::String("hammer-product".into());
447    let card: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD)
448        .map_err(|e| Error::Other(format!("card sshd inválida: {e}")))?;
449    seed.get_mut("genesis")
450        .and_then(|g| g.as_array_mut())
451        .ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?
452        .push(card);
453    serde_json::to_string_pretty(&seed).map_err(|e| Error::Other(format!("serializar seed: {e}")))
454}
455
456/// Identidad del `product-rootfs`: tag + el `RootfsHash` del 4/4 base + `(nombre,hash)` de cada
457/// servicio + la seed de producto + las configs. Cambiar cualquiera re-hashea (reproducible).
458fn product_rootfs_hash(
459    base: &RootfsHash,
460    services: &[(String, ArtifactHash)],
461    seed: &str,
462) -> RootfsHash {
463    let mut inputs: Vec<Vec<u8>> = vec![
464        b"hammer-product-rootfs-v1".to_vec(),
465        base.as_str().as_bytes().to_vec(),
466    ];
467    for (name, h) in services {
468        inputs.push(name.as_bytes().to_vec());
469        inputs.push(h.as_str().as_bytes().to_vec());
470    }
471    inputs.push(seed.as_bytes().to_vec());
472    inputs.push(PRODUCT_PASSWD.as_bytes().to_vec());
473    inputs.push(PRODUCT_GROUP.as_bytes().to_vec());
474    inputs.push(SSHD_CONFIG.as_bytes().to_vec());
475    let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
476    ArtifactHash::of_inputs(&refs)
477}
478
479/// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4) y luego los
480/// componentes de servicio ENCIMA (hidratación tardía); sobrescribe la seed con la de producto y
481/// escribe las configs de servicio (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`,
482/// `/root/.ssh` 0700). Pieza testeable sin build real.
483fn assemble_product_rootfs(
484    store: &Store,
485    base: &RootfsHash,
486    services: &[(String, ArtifactHash)],
487    seed: &str,
488    staging: &Path,
489) -> Result<()> {
490    use std::os::unix::fs::PermissionsExt;
491
492    // 1) base verificado (4/4): hidratación por hardlinks del árbol ya sellado (incl. mountpoints
493    //    vacíos, /sbin/init y la seed base, que sobrescribimos en el paso 3).
494    let base_dir = store.path_of(base, "stage1-rootfs");
495    if !base_dir.is_dir() {
496        return Err(Error::Other(format!(
497            "rootfs base no sellado en {}",
498            base_dir.display()
499        )));
500    }
501    hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?;
502
503    // 2) servicios encima (openssh, netup): mismo proyectado por hardlink.
504    for (name, h) in services {
505        let dir = store.path_of(h, name);
506        hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static, None)?;
507    }
508    let _ = std::fs::remove_dir_all(staging.join(".hammer"));
509
510    // 3) seed de producto (sobrescribe la base) + configs + dirs de runtime de sshd.
511    // Los ficheros hidratados son HARDLINKS al store sellado (read-only); escribir encima daría
512    // EACCES y `chmod` mutaría el inodo del store. Hay que ROMPER el hardlink: remove + write nuevo.
513    let write_fresh = |rel: &str, data: &str| -> Result<()> {
514        let p = staging.join(rel);
515        if let Some(parent) = p.parent() {
516            std::fs::create_dir_all(parent)?;
517        }
518        let _ = std::fs::remove_file(&p);
519        std::fs::write(&p, data)?;
520        Ok(())
521    };
522    write_fresh("ente/seed.card.json", seed)?;
523    write_fresh("etc/passwd", PRODUCT_PASSWD)?;
524    write_fresh("etc/group", PRODUCT_GROUP)?;
525    write_fresh("etc/ssh/sshd_config", SSHD_CONFIG)?;
526    let empty = staging.join("var/empty"); // jaula de privsep: root, no group/world-writable.
527    std::fs::create_dir_all(&empty)?;
528    std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o711))?;
529    let dotssh = staging.join("root/.ssh"); // authorized_keys provisionadas en deploy.
530    std::fs::create_dir_all(&dotssh)?;
531    std::fs::set_permissions(&dotssh, std::fs::Permissions::from_mode(0o700))?;
532    Ok(())
533}
534
535/// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta la
536/// capa de servicios por hidratación tardía, sellando un `product-rootfs`. El mecanismo base queda
537/// intacto: este árbol NO es el que reconstruye el selfhost-verify (su `of_tree` sigue blindado).
538/// Idempotente. `base_cfg` apunta el zig del lab (no la semilla): los servicios usan su `zig_version`
539/// propia (openssh ⇒ 0.13.0), y si ya están sellados, `build` los devuelve cacheados sin recompilar.
540pub fn product(
541    base: &RootfsHash,
542    base_cfg: &hammer_build::BuildConfig,
543    recipes_dir: &Path,
544    store: &Store,
545) -> Result<RootfsHash> {
546    // 1) construir/cachear los componentes de servicio (idempotente vía el lab).
547    let mut services: Vec<(String, ArtifactHash)> = Vec::with_capacity(SERVICE_COMPONENTS.len());
548    for name in SERVICE_COMPONENTS {
549        let recipe_path = recipes_dir.join(format!("{name}.toml"));
550        let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| {
551            Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display()))
552        })?;
553        let h = hammer_build::build(&recipe, base_cfg, store)?;
554        services.push((name.to_string(), h));
555    }
556
557    // 2) seed de producto + hash de identidad.
558    let seed = product_seed_card()?;
559    let phash = product_rootfs_hash(base, &services, &seed);
560    let store_name = "product-rootfs";
561
562    // 3) ensamblar + sellar (si no estaba ya).
563    if !store.has(&phash, store_name) {
564        let staging = store
565            .root()
566            .join(".bootstrap-tmp")
567            .join(phash.store_dir_name(store_name));
568        let _ = std::fs::remove_dir_all(&staging);
569        std::fs::create_dir_all(&staging)?;
570        let result = (|| -> Result<()> {
571            assemble_product_rootfs(store, base, &services, &seed, &staging)?;
572            store.seal(&staging, &phash, store_name)?;
573            Ok(())
574        })();
575        let _ = std::fs::remove_dir_all(&staging);
576        result?;
577        tracing::info!(hash = %phash, "product: rootfs de servicios sellado");
578    } else {
579        tracing::info!(hash = %phash, "product: rootfs ya sellado (idempotente)");
580    }
581    Ok(phash)
582}
583
584// ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ──────────────────────
585//
586// El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando
587// **sólo** las herramientas de Stage 1, y se compara el **content-hash** (`of_tree`, no el hash
588// input-addressed del store) de stage1 vs stage1'. Iguales ⇒ el sistema se compila a sí mismo bit
589// a bit (auto-alojado y reproducible). El rebuild **dentro del rootfs** (bwrap/chroot/VM) corre en
590// la VM destino; lo que vive en hammer es la **referencia** (content-hash de stage1) y la
591// comparación. Ver `docs/runbooks/stage1-vm-boot.md`.
592
593/// Veredicto de la verificación de auto-alojamiento de Stage 2.
594#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
595pub enum Reproducibility {
596    /// stage1 y stage1' tienen el mismo content-hash: el sistema se reconstruye bit-idéntico.
597    Reproducible,
598    /// Difieren: hay no-determinismo que cazar ([SDD 09 §2](../../docs/09-trust-model.md)).
599    Divergent { stage1: ArtifactHash, rebuilt: ArtifactHash },
600    /// El rebuild nativo dentro del rootfs aún no corrió (se hace en la VM); sólo se ancló el
601    /// content-hash de stage1 como referencia a reproducir.
602    RebuildPending,
603}
604
605/// Reporte de Stage 2: la referencia (content-hash de stage1) y el veredicto.
606#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
607pub struct VerifyReport {
608    /// Hash input-addressed del rootfs en el store (el que devolvió `stage1`).
609    pub stage1_rootfs: RootfsHash,
610    /// Content-hash (`of_tree`) de stage1: los bytes reales que stage1' debe reproducir.
611    pub stage1_content: ArtifactHash,
612    pub verdict: Reproducibility,
613}
614
615/// Content-hash (`ArtifactHash::of_tree`) del artefacto sellado `(hash, name)` en el store.
616pub fn artifact_content_hash(
617    store: &Store,
618    hash: &ArtifactHash,
619    name: &str,
620) -> Result<ArtifactHash> {
621    let dir = store.path_of(hash, name);
622    if !dir.is_dir() {
623        return Err(Error::Other(format!(
624            "artefacto '{name}' no sellado en {}",
625            dir.display()
626        )));
627    }
628    ArtifactHash::of_tree(&dir).map_err(Error::Io)
629}
630
631/// **Stage 2** (referencia + manifiesto). Ancla el **content-hash** del rootfs de Stage 1 como la
632/// referencia a reproducir y lo anota en el manifiesto (línea stage 2). El rebuild nativo DENTRO
633/// del rootfs corre en la VM (SDD 11 §3); cuando produzca stage1', se compara con [`verify_against`].
634pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport> {
635    let content = artifact_content_hash(store, stage1, "stage1-rootfs")?;
636    manifest::append_line(
637        store,
638        StageEntry {
639            stage: 2,
640            recipe_hash: None,
641            artifact_hash: content.clone(),
642            seed_hash: None,
643            ts: now_unix(),
644        },
645    )?;
646    tracing::info!(content = %content, "stage2: content-hash de stage1 anclado (referencia)");
647    Ok(VerifyReport {
648        stage1_rootfs: stage1.clone(),
649        stage1_content: content,
650        verdict: Reproducibility::RebuildPending,
651    })
652}
653
654/// Compara la referencia (`report.stage1_content`) con el content-hash de un rebuild stage1'
655/// producido en la VM, y emite el veredicto de reproducibilidad.
656pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> Reproducibility {
657    if report.stage1_content == rebuilt_content {
658        Reproducibility::Reproducible
659    } else {
660        Reproducibility::Divergent {
661            stage1: report.stage1_content.clone(),
662            rebuilt: rebuilt_content,
663        }
664    }
665}
666
667/// Reporte de [`all`]: los tres hashes de las etapas + el manifiesto ya poblado. El veredicto de
668/// auto-alojamiento NO va aquí (es `RebuildPending` en el host): el rebuild nativo que lo decide corre
669/// **dentro** del rootfs, en la VM destino (`scripts/selfhost-verify.sh`, runbook §8).
670#[derive(Debug, Clone)]
671pub struct AllReport {
672    pub seed_hash: ArtifactHash,
673    pub stage1_rootfs: RootfsHash,
674    pub stage2: VerifyReport,
675    pub manifest: BootstrapManifest,
676}
677
678/// **`bootstrap --all`** ([SDD 11 §5](../../docs/11-bootstrap.md)) — encadena las tres etapas en una
679/// sola corrida del host: ingiere la semilla (Stage 0), construye y sella el rootfs del userland mínimo
680/// (Stage 1) y ancla su content-hash como referencia de reproducibilidad (Stage 2). Devuelve los tres
681/// hashes y el [`BootstrapManifest`] (`bootstrap.json`) resultante: el embrión del log de transparencia
682/// ([SDD 11 §4](../../docs/11-bootstrap.md)), listo para publicar.
683///
684/// El **corte del cordón** (el veredicto `✓ REPRODUCIBLE`) no se emite aquí: el rebuild nativo que lo
685/// decide corre con las herramientas de **dentro** del rootfs (en la VM), no las del host. `all` deja la
686/// referencia anclada (`stage2.stage1_content`) lista para que ese rebuild la compare vía `stage2
687/// --verify` (ver [`verify_against`] y `scripts/selfhost-verify.sh`).
688///
689/// Idempotente componiendo idempotencias: cada etapa no rehace lo ya sellado ni duplica su línea de
690/// manifiesto, así que re-correr `all` sobre un store ya poblado sólo recolecta los hashes.
691pub fn all(
692    seed: &SeedSpec,
693    recipes_dir: &Path,
694    base_cfg: &hammer_build::BuildConfig,
695    store: &Store,
696) -> Result<AllReport> {
697    let seed_hash = stage0(seed, store)?;
698    let stage1_spec = Stage1Spec {
699        seed_hash: seed_hash.clone(),
700        seed_kind: seed.kind,
701        recipes_dir: recipes_dir.to_path_buf(),
702    };
703    let stage1_rootfs = stage1(&stage1_spec, base_cfg, store)?;
704    let stage2 = stage2(&stage1_rootfs, store)?;
705    let manifest = BootstrapManifest::load(store)?;
706    Ok(AllReport { seed_hash, stage1_rootfs, stage2, manifest })
707}
708
709// ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ────────────────────
710//
711// El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no
712// puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que,
713// booteada en la VM, corre `hammer bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara
714// su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit.
715//
716// Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por
717// hammer). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el
718// auto-alojamiento *puro* (variante b: el toolchain construido por hammer desde fuente, incremental).
719//
720// El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se
721// ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es
722// reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario +
723// tag del toolchain + el propio driver), apto para el log de transparencia.
724
725/// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee
726/// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al
727/// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va
728/// vacío, sólo imprime el content-hash de `stage1'` para comparación manual.
729///
730/// El toolchain es Alpine **dinámico**: sus binarios (bwrap/git/curl/cargo/make) no corren desde el
731/// userland Stage 1, que es **estático** (no hay loader en `/lib`). El driver instala un *shim* del
732/// loader musl (`cp` a `/lib`) + `LD_LIBRARY_PATH`/`PATH` al toolchain, y a partir de ahí esas
733/// herramientas son ejecutables; el `hammer` estático vive en `/usr/bin/hammer`. El sandbox de build
734/// (bwrap) anida en `/toolchain`. (El shim sólo se usa para *invocar* el toolchain desde fuera del
735/// sandbox: fetch git/tarball; dentro del sandbox el loader está en su sitio.)
736const REBUILD_DRIVER: &str = r#"#!/bin/sh
737# rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7).
738# Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y
739# compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera.
740set -eu
741. /etc/hammer/rebuild.env            # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío)
742# Shim del loader musl: deja correr los binarios *dinámicos* del toolchain (bwrap/git/curl/cargo/make)
743# desde el root Stage 1 estático. Usa el busybox estático (rutas absolutas) ANTES de tocar el PATH.
744/bin/mkdir -p /lib
745/bin/cp -f /toolchain/lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
746export LD_LIBRARY_PATH=/toolchain/lib:/toolchain/usr/lib
747export PATH=/toolchain/usr/bin:/toolchain/usr/sbin:/toolchain/bin:/toolchain/sbin:/usr/bin:/bin:/sbin
748# overlay es módulo (CONFIG_OVERLAY_FS=m) y el initramfs arranca sin módulos cargados: el sandbox de
749# build (bwrap --tmp-overlay) lo exige ⇒ ENODEV sin esto. Lo cargamos del toolchain (insmod=kmod,
750# vermagic del kernel destino). Idempotente: si ya está, insmod falla y lo ignoramos.
751insmod /toolchain/lib/overlay.ko 2>/dev/null || true
752# Red para el vendoring de crates (los componentes Rust hacen `cargo vendor`, que baja de crates.io).
753# NIC e1000 (qemu user-mode 10.0.2.0/24, módulo del kernel) + config estática + DNS de slirp. La VM
754# sin NIC ⇒ todo esto es no-op (los componentes C no necesitan red). `ip` sale del toolchain (shim).
755insmod /toolchain/lib/e1000.ko 2>/dev/null || true
756if ip link show eth0 >/dev/null 2>&1; then
757    ip link set eth0 up
758    ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || true
759    ip route add default via 10.0.2.2 2>/dev/null || true
760    printf 'nameserver 10.0.2.3\n' > /etc/resolv.conf
761    echo ">> red eth0: $(ip -4 -o addr show eth0 2>/dev/null | grep -o 'inet [0-9.]*' || echo down)"
762    # CA bundle para el TLS de cargo/curl/git (crates.io). El toolchain trae el bundle de Alpine;
763    # lo exponemos en la ruta estándar y por env (cargo usa libcurl/openssl según el build).
764    mkdir -p /etc/ssl/certs
765    cp -f /toolchain/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true
766    export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
767    export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
768    export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt
769fi
770# NOTA: el initramfs debe empaquetarse con `cpio --owner=root:root`. Si los ficheros del toolchain
771# quedan con el uid del que armó el builder (p. ej. 1000), ese uid no está mapeado en el userns de
772# bwrap (que mapea sólo 0→0) y el copy-up de overlay falla con EACCES al crear /opt/zig. Ver §8c.
773export HAMMER_ROOTFS=/toolchain      # el sandbox de build: el toolchain (variante a, desde Alpine)
774export HAMMER_WORK=/work
775export HAMMER_CACHE=
776echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)"
777PRIME=$(/usr/bin/hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes)
778echo ">> stage1' = $PRIME"
779if [ -n "${REF_CONTENT:-}" ]; then
780    /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT"
781else
782    /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME"
783fi
784"#;
785
786/// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7).
787#[derive(Debug, Clone)]
788pub struct BuilderSpec {
789    /// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base
790    /// runtime sobre la que se hidrata el toolchain.
791    pub stage1_rootfs: RootfsHash,
792    /// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `hammer`
793    /// de adentro resuelva el toolchain por hash, igual que afuera.
794    pub seed_hash: ArtifactHash,
795    pub seed_kind: SeedKind,
796    /// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild).
797    pub recipes_dir: PathBuf,
798    /// Binario `hammer` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que hammer
799    /// construye desde fuente.
800    pub hammer_bin: PathBuf,
801    /// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`).
802    pub toolchain_src: PathBuf,
803    /// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del
804    /// builder sin tener que hashear el árbol Alpine entero.
805    pub toolchain_tag: String,
806    /// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se
807    /// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`.
808    pub ref_content: Option<ArtifactHash>,
809    /// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y
810    /// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`).
811    pub work_cache: Option<PathBuf>,
812    /// Piezas del toolchain **construidas por hammer desde fuente** (variante b, SDD 11 §7.2b) que se
813    /// montan sobre el path Alpine en `/toolchain`. Vacío ⇒ variante (a) pura-Alpine. Cada swap entra
814    /// al hash lógico del builder: la procedencia deja de ser "todo Alpine" y se vuelve auditable.
815    pub swaps: Vec<ToolchainSwap>,
816}
817
818/// Una herramienta del toolchain reemplazada por su build hammer desde fuente (SDD 11 §7.2b). El
819/// binario sellado se monta sobre `rel_path` dentro de `/toolchain`, pisando la versión Alpine; su
820/// hash sellado se ancla en el hash lógico del builder para que el swap sea reproducible y auditable.
821#[derive(Debug, Clone)]
822pub struct ToolchainSwap {
823    /// Nombre del componente/receta (p. ej. `make`); también el nombre store del artefacto sellado.
824    pub name: String,
825    /// `of_tree` del build hammer (el artefacto sellado del que sale el binario).
826    pub artifact: ArtifactHash,
827    /// Path relativo del binario dentro del artefacto **y** dentro de `/toolchain` (p. ej.
828    /// `usr/bin/make`). El mismo en origen y destino: el layout del artefacto imita al de Alpine.
829    pub rel_path: String,
830}
831
832/// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado.
833#[derive(Debug, Clone)]
834pub struct BuilderReport {
835    pub builder_hash: ArtifactHash,
836    pub out_dir: PathBuf,
837}
838
839/// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain
840/// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder.
841fn builder_hash(
842    stage1: &RootfsHash,
843    seed: &ArtifactHash,
844    toolchain_tag: &str,
845    recipes_digest: &str,
846    hammer_digest: &str,
847    swaps_digest: &str,
848) -> ArtifactHash {
849    ArtifactHash::of_inputs(&[
850        b"hammer-builder-rootfs-v1",
851        stage1.as_str().as_bytes(),
852        seed.as_str().as_bytes(),
853        toolchain_tag.as_bytes(),
854        recipes_digest.as_bytes(),
855        hammer_digest.as_bytes(),
856        swaps_digest.as_bytes(),
857        REBUILD_DRIVER.as_bytes(),
858    ])
859}
860
861/// Digest de los swaps del toolchain (variante b): sha256 sobre los `(name, artifact, rel_path)` en
862/// orden por nombre. Sin swaps ⇒ string vacío, así el hash lógico de un builder pura-Alpine no cambia
863/// respecto a antes de existir el mecanismo (compatibilidad hacia atrás de la línea de manifiesto).
864fn swaps_digest(swaps: &[ToolchainSwap]) -> String {
865    if swaps.is_empty() {
866        return String::new();
867    }
868    use sha2::{Digest, Sha256};
869    let mut sorted: Vec<&ToolchainSwap> = swaps.iter().collect();
870    sorted.sort_by(|a, b| a.name.cmp(&b.name));
871    let mut h = Sha256::new();
872    for s in sorted {
873        for field in [s.name.as_str(), s.artifact.as_str(), s.rel_path.as_str()] {
874            h.update(field.as_bytes());
875            h.update([0u8]);
876        }
877    }
878    hex::encode(h.finalize())
879}
880
881/// sha256 hex del contenido de un archivo.
882fn sha256_file(path: &Path) -> Result<String> {
883    use sha2::{Digest, Sha256};
884    let bytes = std::fs::read(path)?;
885    let mut h = Sha256::new();
886    h.update(&bytes);
887    Ok(hex::encode(h.finalize()))
888}
889
890/// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden.
891/// Cambiar una receta, añadir o quitar una, re-hashea el builder.
892fn recipes_digest(dir: &Path) -> Result<String> {
893    use sha2::{Digest, Sha256};
894    let mut tomls: Vec<PathBuf> = std::fs::read_dir(dir)?
895        .filter_map(|e| e.ok().map(|e| e.path()))
896        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
897        .collect();
898    tomls.sort();
899    let mut h = Sha256::new();
900    for p in &tomls {
901        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
902        h.update(name.as_bytes());
903        h.update([0u8]);
904        h.update(std::fs::read(p)?);
905        h.update([0u8]);
906    }
907    Ok(hex::encode(h.finalize()))
908}
909
910/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y
911/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder
912/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`).
913///
914/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej.
915/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el
916/// propio destino en una recursión infinita. Se compara por path canónico.
917fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> {
918    if let Some(skip) = skip {
919        if src.canonicalize().ok().as_deref() == Some(skip) {
920            return Ok(());
921        }
922    }
923    let meta = std::fs::symlink_metadata(src)?;
924    let ft = meta.file_type();
925    if ft.is_symlink() {
926        let target = std::fs::read_link(src)?;
927        let _ = std::fs::remove_file(dst);
928        std::os::unix::fs::symlink(target, dst)?;
929    } else if ft.is_dir() {
930        std::fs::create_dir_all(dst)?;
931        for entry in std::fs::read_dir(src)? {
932            let entry = entry?;
933            link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?;
934        }
935    } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() {
936        std::fs::copy(src, dst)?;
937    }
938    Ok(())
939}
940
941/// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el
942/// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`,
943/// instala `hammer` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes.
944fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> {
945    // 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros).
946    let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs");
947    if !stage1_dir.is_dir() {
948        return Err(Error::Other(format!(
949            "stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero",
950            spec.stage1_rootfs,
951            stage1_dir.display()
952        )));
953    }
954    hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static, None)?;
955
956    // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config.
957    for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] {
958        std::fs::create_dir_all(staging.join(d))?;
959    }
960
961    // 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs).
962    if !spec.toolchain_src.is_dir() {
963        return Err(Error::Other(format!(
964            "toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)",
965            spec.toolchain_src.display()
966        )));
967    }
968    link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
969
970    // 3b) Swaps de variante (b): piezas construidas por hammer desde fuente pisan su versión Alpine
971    //     en /toolchain. Un `rel_path` puede apuntar a un BINARIO (estático musl ⇒ corre sin el shim
972    //     del loader; p.ej. make, busybox) o a un DIRECTORIO (un árbol de headers; p.ej. los
973    //     `usr/include/{linux,asm,…}` de linux-headers). En el caso directorio se reemplaza el árbol
974    //     entero (remove + copy) para no dejar archivos Alpine huérfanos.
975    for swap in &spec.swaps {
976        let art_dir = store.path_of(&swap.artifact, &swap.name);
977        let src = art_dir.join(&swap.rel_path);
978        let dst = staging.join("toolchain").join(&swap.rel_path);
979        if src.is_dir() {
980            let _ = std::fs::remove_dir_all(&dst);
981            std::fs::create_dir_all(&dst)?;
982            link_or_copy_tree(&src, &dst, None)?;
983        } else if src.is_file() {
984            if let Some(parent) = dst.parent() {
985                std::fs::create_dir_all(parent)?;
986            }
987            let _ = std::fs::remove_file(&dst);
988            if std::fs::hard_link(&src, &dst).is_err() {
989                std::fs::copy(&src, &dst)?;
990            }
991            set_executable(&dst)?;
992        } else {
993            return Err(Error::Other(format!(
994                "swap '{}': '{}' no existe (ni archivo ni directorio) en el artefacto sellado {} ({})",
995                swap.name,
996                swap.rel_path,
997                swap.artifact,
998                src.display(),
999            )));
1000        }
1001    }
1002
1003    // 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
1004    //    adentro resuelva el toolchain por hash sin re-ingerirla.
1005    let seed_name = format!("seed-{}", spec.seed_kind.as_str());
1006    let seed_dir = store.path_of(&spec.seed_hash, &seed_name);
1007    if !seed_dir.is_dir() {
1008        return Err(Error::Other(format!(
1009            "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
1010            seed_name,
1011            seed_dir.display()
1012        )));
1013    }
1014    link_or_copy_tree(
1015        &seed_dir,
1016        &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)),
1017        None,
1018    )?;
1019
1020    // 5) Binario hammer → /usr/bin/hammer (+x).
1021    let hammer_dst = staging.join("usr/bin/hammer");
1022    if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() {
1023        std::fs::copy(&spec.hammer_bin, &hammer_dst)?;
1024    }
1025    set_executable(&hammer_dst)?;
1026
1027    // 6) Recetas → /etc/hammer/recipes.
1028    for entry in std::fs::read_dir(&spec.recipes_dir)? {
1029        let path = entry?.path();
1030        if path.extension().and_then(|x| x.to_str()) == Some("toml") {
1031            let name = path.file_name().unwrap();
1032            std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?;
1033        }
1034    }
1035
1036    // 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y
1037    //    `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se
1038    //    copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El
1039    //    `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`.
1040    if let Some(cache) = &spec.work_cache {
1041        let skip = staging.canonicalize().ok();
1042        for sub in ["repos", "tarballs"] {
1043            let src = cache.join(sub);
1044            if src.is_dir() {
1045                link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?;
1046            }
1047        }
1048    }
1049
1050    // 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo).
1051    let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or("");
1052    let env = format!(
1053        "SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n",
1054        spec.seed_hash,
1055        spec.seed_kind.as_str(),
1056        ref_content,
1057    );
1058    std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?;
1059    let driver = staging.join("usr/bin/rebuild-stage1");
1060    std::fs::write(&driver, REBUILD_DRIVER)?;
1061    set_executable(&driver)?;
1062    Ok(())
1063}
1064
1065/// Marca un archivo como ejecutable (0o755).
1066fn set_executable(path: &Path) -> Result<()> {
1067    use std::os::unix::fs::PermissionsExt;
1068    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
1069    Ok(())
1070}
1071
1072/// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada
1073/// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash
1074/// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida.
1075pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result<BuilderReport> {
1076    // Hash lógico (insumos), independiente del ensamblado en disco.
1077    let rdigest = recipes_digest(&spec.recipes_dir)?;
1078    let hdigest = sha256_file(&spec.hammer_bin)?;
1079    let sdigest = swaps_digest(&spec.swaps);
1080    let bhash = builder_hash(
1081        &spec.stage1_rootfs,
1082        &spec.seed_hash,
1083        &spec.toolchain_tag,
1084        &rdigest,
1085        &hdigest,
1086        &sdigest,
1087    );
1088
1089    let _ = std::fs::remove_dir_all(out_dir);
1090    std::fs::create_dir_all(out_dir)?;
1091    assemble_builder(spec, store, out_dir)?;
1092
1093    manifest::append_line(
1094        store,
1095        StageEntry {
1096            stage: 2,
1097            recipe_hash: None,
1098            artifact_hash: bhash.clone(),
1099            seed_hash: Some(spec.seed_hash.clone()),
1100            ts: now_unix(),
1101        },
1102    )?;
1103    tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado");
1104    Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() })
1105}
1106
1107/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
1108///
1109/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
1110/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
1111/// llega al árbol que se sella.
1112pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
1113    let h = seed.seed_hash();
1114    let name = seed.store_name();
1115    if store.has(&h, &name) {
1116        tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
1117    } else {
1118        // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
1119        // hacer el rename atómico.
1120        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
1121        let _ = std::fs::remove_dir_all(&work);
1122        std::fs::create_dir_all(&work)?;
1123
1124        // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo
1125        // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de
1126        // anotar el manifiesto: una semilla que no se selló no genera línea.
1127        let result = stage0_into(seed, store, &h, &name, &work);
1128        let _ = std::fs::remove_dir_all(&work);
1129        result?;
1130    }
1131
1132    // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
1133    // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
1134    manifest::append_line(
1135        store,
1136        StageEntry {
1137            stage: 0,
1138            recipe_hash: None,
1139            artifact_hash: h.clone(),
1140            seed_hash: Some(h.clone()),
1141            ts: now_unix(),
1142        },
1143    )?;
1144    Ok(h)
1145}
1146
1147/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
1148/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
1149/// la reproducibilidad.
1150fn now_unix() -> u64 {
1151    std::time::SystemTime::now()
1152        .duration_since(std::time::UNIX_EPOCH)
1153        .map(|d| d.as_secs())
1154        .unwrap_or(0)
1155}
1156
1157fn stage0_into(
1158    seed: &SeedSpec,
1159    store: &Store,
1160    h: &ArtifactHash,
1161    name: &str,
1162    work: &Path,
1163) -> Result<ArtifactHash> {
1164    let tarball = work.join("seed.tar");
1165    download::fetch_url_to_file(&seed.url, &tarball)?;
1166    // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
1167    download::verify_sha256(&tarball, &seed.sha256)?;
1168
1169    let tree = work.join("tree");
1170    std::fs::create_dir_all(&tree)?;
1171    untar(&tarball, &tree)?;
1172
1173    let sealed = store.seal(&tree, h, name)?;
1174    tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
1175    Ok(h.clone())
1176}
1177
1178/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
1179fn untar(tarball: &Path, into: &Path) -> Result<()> {
1180    let st = Command::new("tar")
1181        .arg("-xf")
1182        .arg(tarball)
1183        .arg("-C")
1184        .arg(into)
1185        .status()
1186        .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
1187    if !st.success() {
1188        return Err(Error::Untar(st.code()));
1189    }
1190    Ok(())
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    fn spec(url: &str, sha256: &str) -> SeedSpec {
1198        SeedSpec {
1199            kind: SeedKind::Zig,
1200            version: "0.13.0".into(),
1201            url: url.into(),
1202            sha256: sha256.into(),
1203        }
1204    }
1205
1206    fn sha256_hex(bytes: &[u8]) -> String {
1207        use sha2::{Digest, Sha256};
1208        let mut h = Sha256::new();
1209        h.update(bytes);
1210        hex::encode(h.finalize())
1211    }
1212
1213    /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
1214    fn make_seed_tarball(dir: &Path) -> (String, String) {
1215        let src = dir.join("src");
1216        std::fs::create_dir_all(src.join("bin")).unwrap();
1217        std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
1218        let tarball = dir.join("seed.tar.gz");
1219        let st = Command::new("tar")
1220            .arg("-czf")
1221            .arg(&tarball)
1222            .arg("-C")
1223            .arg(&src)
1224            .arg(".")
1225            .status()
1226            .unwrap();
1227        assert!(st.success(), "tar -c falló");
1228        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
1229        (format!("file://{}", tarball.display()), sha)
1230    }
1231
1232    #[test]
1233    fn seed_hash_is_identity_based() {
1234        let a = spec("file:///mirror-a.tar", "abc123");
1235        let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
1236        assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
1237
1238        let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
1239        assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
1240
1241        let mut d = spec("file:///mirror-a.tar", "abc123");
1242        d.kind = SeedKind::MuslCrossMake;
1243        assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
1244    }
1245
1246    #[test]
1247    fn stage0_ingests_and_seals() {
1248        let tmp = tempfile::tempdir().unwrap();
1249        let (url, sha) = make_seed_tarball(tmp.path());
1250        let store = Store::open(tmp.path().join("store")).unwrap();
1251        let seed = spec(&url, &sha);
1252
1253        let h = stage0(&seed, &store).expect("stage0");
1254        assert_eq!(h, seed.seed_hash());
1255        assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
1256
1257        // El contenido extraído está presente y de sólo-lectura (sellado).
1258        let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
1259        assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
1260        use std::os::unix::fs::PermissionsExt;
1261        let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
1262        assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
1263    }
1264
1265    #[test]
1266    fn stage0_is_idempotent() {
1267        let tmp = tempfile::tempdir().unwrap();
1268        let (url, sha) = make_seed_tarball(tmp.path());
1269        let store = Store::open(tmp.path().join("store")).unwrap();
1270        let seed = spec(&url, &sha);
1271
1272        let h1 = stage0(&seed, &store).unwrap();
1273        let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
1274        assert_eq!(h1, h2);
1275    }
1276
1277    #[test]
1278    fn stage0_records_one_manifest_line() {
1279        let tmp = tempfile::tempdir().unwrap();
1280        let (url, sha) = make_seed_tarball(tmp.path());
1281        let store = Store::open(tmp.path().join("store")).unwrap();
1282        let seed = spec(&url, &sha);
1283
1284        let h = stage0(&seed, &store).unwrap();
1285        stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea
1286
1287        let m = BootstrapManifest::load(&store).unwrap();
1288        assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0");
1289        let e = &m.entries[0];
1290        assert_eq!(e.stage, 0);
1291        assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila");
1292        assert_eq!(e.artifact_hash, h);
1293        assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto");
1294    }
1295
1296    #[test]
1297    fn stage0_bad_sha_records_no_manifest_line() {
1298        let tmp = tempfile::tempdir().unwrap();
1299        let (url, _real_sha) = make_seed_tarball(tmp.path());
1300        let store = Store::open(tmp.path().join("store")).unwrap();
1301        let seed = spec(&url, &"0".repeat(64));
1302
1303        assert!(stage0(&seed, &store).is_err());
1304        // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto.
1305        assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json");
1306    }
1307
1308    #[test]
1309    fn stage0_rejects_bad_sha256_and_seals_nothing() {
1310        let tmp = tempfile::tempdir().unwrap();
1311        let (url, _real_sha) = make_seed_tarball(tmp.path());
1312        let store = Store::open(tmp.path().join("store")).unwrap();
1313        let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
1314
1315        let err = stage0(&seed, &store).unwrap_err();
1316        assert!(
1317            err.to_string().contains("sha256 mismatch"),
1318            "esperaba mismatch de sha256, vino: {err}"
1319        );
1320        assert!(
1321            !store.has(&seed.seed_hash(), &seed.store_name()),
1322            "un sha erróneo no debe sellar nada"
1323        );
1324        // Tampoco debe dejar staging a medio descargar bajo el store.
1325        assert!(
1326            !store.root().join(".bootstrap-tmp").exists()
1327                || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
1328            "el staging debe quedar limpio tras el fallo"
1329        );
1330    }
1331
1332    /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
1333    /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
1334    fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
1335        let src = dir.join("src");
1336        let zig = src.join(zig_rel);
1337        std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
1338        std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
1339        let tarball = dir.join("seed.tar.gz");
1340        let st = Command::new("tar")
1341            .args(["-czf"])
1342            .arg(&tarball)
1343            .arg("-C")
1344            .arg(&src)
1345            .arg(".")
1346            .status()
1347            .unwrap();
1348        assert!(st.success());
1349        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
1350        let store = Store::open(dir.join("store")).unwrap();
1351        let seed = spec(&format!("file://{}", tarball.display()), &sha);
1352        stage0(&seed, &store).unwrap();
1353        (store, seed)
1354    }
1355
1356    #[test]
1357    fn toolchain_dir_finds_zig_in_versioned_subdir() {
1358        let tmp = tempfile::tempdir().unwrap();
1359        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1360        let dir = seed.toolchain_dir(&store).unwrap();
1361        assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
1362        assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
1363    }
1364
1365    #[test]
1366    fn toolchain_dir_finds_zig_at_root() {
1367        let tmp = tempfile::tempdir().unwrap();
1368        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
1369        let dir = seed.toolchain_dir(&store).unwrap();
1370        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1371        assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
1372    }
1373
1374    #[test]
1375    fn toolchain_dir_errors_when_unsealed() {
1376        let tmp = tempfile::tempdir().unwrap();
1377        let store = Store::open(tmp.path().join("store")).unwrap();
1378        let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
1379        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1380        assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
1381    }
1382
1383    #[test]
1384    fn toolchain_dir_errors_when_ambiguous() {
1385        let tmp = tempfile::tempdir().unwrap();
1386        // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
1387        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
1388        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1389        // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
1390        let b = base.join("b");
1391        let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
1392        std::fs::create_dir_all(&b).unwrap();
1393        std::fs::write(b.join("zig"), b"x").unwrap();
1394        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1395        assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
1396    }
1397
1398    #[test]
1399    fn seed_build_config_points_zig_dir_at_seed() {
1400        let tmp = tempfile::tempdir().unwrap();
1401        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1402        let base = hammer_build::BuildConfig::defaults_for_store(store.root());
1403        let cfg = seed_build_config(&base, &seed, &store).unwrap();
1404        assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
1405        // El resto de la config se hereda sin cambios.
1406        assert_eq!(cfg.rootfs, base.rootfs);
1407        assert_eq!(cfg.work_root, base.work_root);
1408    }
1409
1410    // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real ---
1411
1412    /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol
1413    /// antes del `seal` (que lo marca read-only).
1414    fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash {
1415        let h = ArtifactHash::from_hex(hex);
1416        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name));
1417        std::fs::create_dir_all(&work).unwrap();
1418        build(&work);
1419        store.seal(&work, &h, name).unwrap();
1420        h
1421    }
1422
1423    #[test]
1424    fn rootfs_hash_is_deterministic_and_sensitive() {
1425        let base = [
1426            ("musl".to_string(), ArtifactHash::from_hex("11")),
1427            ("busybox".to_string(), ArtifactHash::from_hex("22")),
1428        ];
1429        let a = rootfs_hash(&base);
1430        assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash");
1431
1432        let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))];
1433        assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea");
1434
1435        let reordered = [base[1].clone(), base[0].clone()];
1436        assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa");
1437    }
1438
1439    #[test]
1440    fn assemble_rootfs_hydrates_and_installs_arje_init() {
1441        let tmp = tempfile::tempdir().unwrap();
1442        let store = Store::open(tmp.path().join("store")).unwrap();
1443
1444        let hmusl = seal_component(&store, "musl", "aa11", |w| {
1445            std::fs::create_dir_all(w.join("usr/lib")).unwrap();
1446            std::fs::write(w.join("usr/lib/libc.a"), b"!<arch>\n").unwrap();
1447        });
1448        let hbb = seal_component(&store, "busybox", "bb22", |w| {
1449            std::fs::create_dir_all(w.join("bin")).unwrap();
1450            std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap();
1451            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
1452            // Sidecar de provenance que NO debe terminar en el rootfs.
1453            std::fs::create_dir_all(w.join(".hammer")).unwrap();
1454            std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap();
1455        });
1456        let harje = seal_component(&store, "arje-zero", "cc33", |w| {
1457            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1458            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap();
1459        });
1460
1461        let staging = store.root().join("staging");
1462        std::fs::create_dir_all(&staging).unwrap();
1463        let components = [
1464            ("musl".to_string(), hmusl),
1465            ("busybox".to_string(), hbb),
1466            ("arje-zero".to_string(), harje),
1467        ];
1468        assemble_rootfs(&store, &components, &staging).unwrap();
1469
1470        assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado");
1471        assert!(staging.join("bin/busybox").is_file(), "busybox hidratado");
1472        assert!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado");
1473        // arje-zero es PID 1 vía /sbin/init.
1474        assert_eq!(
1475            std::fs::read_link(staging.join("sbin/init")).unwrap(),
1476            std::path::PathBuf::from("/usr/bin/arje-zero"),
1477            "/sbin/init apunta a arje-zero"
1478        );
1479        // La seed card está en su lugar y es JSON válido con los genesis esperados.
1480        let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
1481        let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
1482        assert_eq!(v["label"], "hammer-stage1");
1483        let labels: Vec<&str> = v["genesis"].as_array().unwrap()
1484            .iter().map(|g| g["label"].as_str().unwrap()).collect();
1485        assert_eq!(labels, vec!["hammerd", "console-getty"]);
1486        // Puntos de montaje que arje monta solos, presentes como dirs vacíos.
1487        assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir());
1488        assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
1489        // Ya no hay init provisional de busybox.
1490        assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado");
1491    }
1492
1493    #[test]
1494    fn stage1_seed_card_is_valid_json_with_native_hammerd() {
1495        // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart
1496        // (el CRASHED real) — un template roto rompería el boot de arje-zero.
1497        let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap();
1498        assert_eq!(v["payload"], "Virtual", "la semilla es Virtual");
1499        let hammerd = &v["genesis"][0];
1500        assert_eq!(hammerd["label"], "hammerd");
1501        assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd");
1502        assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart");
1503        let getty = &v["genesis"][1];
1504        assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
1505    }
1506
1507    // --- Capa de servicios (producto): composición de seed + hidratación tardía ---
1508
1509    #[test]
1510    fn product_seed_appends_sshd_to_base_genesis() {
1511        // La seed de producto = base + sshd; el núcleo (hammerd, getty) se preserva intacto y el
1512        // resultado es JSON válido (lo que arje-zero validará al boot).
1513        let seed = product_seed_card().unwrap();
1514        let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
1515        assert_eq!(v["label"], "hammer-product");
1516        assert_eq!(v["payload"], "Virtual");
1517        let labels: Vec<&str> = v["genesis"].as_array().unwrap()
1518            .iter().map(|g| g["label"].as_str().unwrap()).collect();
1519        assert_eq!(labels, vec!["hammerd", "console-getty", "sshd"], "núcleo intacto + sshd añadido");
1520        let sshd = &v["genesis"][2];
1521        assert_eq!(sshd["payload"]["Native"]["exec"], "/bin/busybox");
1522        assert!(sshd["payload"]["Native"]["argv"][2].as_str().unwrap().contains("/usr/sbin/sshd"));
1523        assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)");
1524    }
1525
1526    #[test]
1527    fn product_rootfs_hash_is_deterministic_and_sensitive() {
1528        let base = ArtifactHash::from_hex("ab");
1529        let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))];
1530        let seed = product_seed_card().unwrap();
1531        let a = product_rootfs_hash(&base, &svcs, &seed);
1532        assert_eq!(a, product_rootfs_hash(&base, &svcs, &seed), "determinista");
1533        let other_base = ArtifactHash::from_hex("ef");
1534        assert_ne!(a, product_rootfs_hash(&other_base, &svcs, &seed), "cambiar la base re-hashea");
1535        let other_svc = [("openssh".to_string(), ArtifactHash::from_hex("99"))];
1536        assert_ne!(a, product_rootfs_hash(&base, &other_svc, &seed), "cambiar un servicio re-hashea");
1537    }
1538
1539    #[test]
1540    fn assemble_product_injects_services_over_base() {
1541        let tmp = tempfile::tempdir().unwrap();
1542        let store = Store::open(tmp.path().join("store")).unwrap();
1543
1544        // Un rootfs base sintético sellado como `stage1-rootfs` (el 4/4 ya verificado): trae la seed
1545        // base, /sbin/init, un mountpoint vacío y un binario del núcleo.
1546        let base = seal_component(&store, "stage1-rootfs", "ba5e", |w| {
1547            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1548            std::fs::create_dir_all(w.join("ente")).unwrap();
1549            std::fs::create_dir_all(w.join("proc")).unwrap();
1550            std::fs::create_dir_all(w.join("var")).unwrap();
1551            std::fs::create_dir_all(w.join("root")).unwrap();
1552            std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELFfake").unwrap();
1553            std::fs::write(w.join("ente/seed.card.json"), STAGE1_SEED_CARD).unwrap();
1554            std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin_init_tmp")).unwrap();
1555        });
1556        // Componentes de servicio sintéticos.
1557        let hossh = seal_component(&store, "openssh", "0551", |w| {
1558            std::fs::create_dir_all(w.join("usr/sbin")).unwrap();
1559            std::fs::write(w.join("usr/sbin/sshd"), b"\x7fELFfake").unwrap();
1560        });
1561        let hnet = seal_component(&store, "netup", "9e72", |w| {
1562            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1563            std::fs::write(w.join("usr/bin/netup"), b"\x7fELFfake").unwrap();
1564        });
1565
1566        let seed = product_seed_card().unwrap();
1567        let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)];
1568        let staging = store.root().join("prod-staging");
1569        std::fs::create_dir_all(&staging).unwrap();
1570        assemble_product_rootfs(&store, &base, &services, &seed, &staging).unwrap();
1571
1572        // base hidratada + servicios inyectados.
1573        assert!(staging.join("usr/bin/hammerd").is_file(), "núcleo presente");
1574        assert!(staging.join("proc").is_dir(), "mountpoint vacío recreado");
1575        assert!(staging.join("usr/sbin/sshd").is_file(), "openssh inyectado");
1576        assert!(staging.join("usr/bin/netup").is_file(), "netup inyectado");
1577        // la seed fue sobrescrita con la de producto (3 cards).
1578        let got = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
1579        let v: serde_json::Value = serde_json::from_str(&got).unwrap();
1580        assert_eq!(v["label"], "hammer-product");
1581        assert_eq!(v["genesis"].as_array().unwrap().len(), 3);
1582        // configs de servicio.
1583        assert!(std::fs::read_to_string(staging.join("etc/passwd")).unwrap().contains("sshd:"));
1584        assert!(std::fs::read_to_string(staging.join("etc/ssh/sshd_config")).unwrap().contains("Port 22"));
1585        // /var/empty con permisos 0711 (privsep no acepta group/world-writable).
1586        use std::os::unix::fs::PermissionsExt;
1587        let mode = std::fs::metadata(staging.join("var/empty")).unwrap().permissions().mode() & 0o777;
1588        assert_eq!(mode, 0o711, "/var/empty debe ser 0711");
1589        assert!(staging.join("root/.ssh").is_dir(), "/root/.ssh para authorized_keys");
1590    }
1591
1592    #[test]
1593    fn shipped_service_recipes_parse_and_hash() {
1594        // Las recetas de servicio reales (netup, openssh) cargan y hashean — no contaminan el núcleo.
1595        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
1596        let tmp = tempfile::tempdir().unwrap();
1597        let store = Store::open(tmp.path().join("store")).unwrap();
1598        for name in SERVICE_COMPONENTS {
1599            let path = recipes_dir.join(format!("{name}.toml"));
1600            let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
1601            assert_eq!(&r.name, name);
1602            let h = hammer_build::artifact_hash(&r, &store).expect("hash");
1603            assert!(h.as_str().starts_with("b3:"));
1604        }
1605    }
1606
1607    #[test]
1608    fn shipped_stage1_recipes_parse_and_hash() {
1609        // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK).
1610        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
1611        let tmp = tempfile::tempdir().unwrap();
1612        let store = Store::open(tmp.path().join("store")).unwrap();
1613        for name in STAGE1_COMPONENTS {
1614            let path = recipes_dir.join(format!("{name}.toml"));
1615            let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
1616            assert_eq!(&r.name, name);
1617            let h = hammer_build::artifact_hash(&r, &store).expect("hash");
1618            assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:");
1619        }
1620    }
1621
1622    #[test]
1623    fn stage2_anchors_content_hash_and_verifies() {
1624        let tmp = tempfile::tempdir().unwrap();
1625        let store = Store::open(tmp.path().join("store")).unwrap();
1626        // Sella un "rootfs" sintético con el nombre que espera stage2.
1627        let h = seal_component(&store, "stage1-rootfs", "abcd", |w| {
1628            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1629            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake-init").unwrap();
1630        });
1631
1632        let report = stage2(&h, &store).unwrap();
1633        assert_eq!(report.stage1_rootfs, h);
1634        assert!(report.stage1_content.as_str().starts_with("b3:"));
1635        assert_eq!(report.verdict, Reproducibility::RebuildPending);
1636
1637        // El manifiesto anotó la línea stage 2 con el content-hash como artefacto.
1638        let m = BootstrapManifest::load(&store).unwrap();
1639        let e2 = m.entries.iter().find(|e| e.stage == 2).expect("línea stage 2");
1640        assert_eq!(e2.artifact_hash, report.stage1_content);
1641
1642        // verify_against: un rebuild idéntico ⇒ Reproducible; distinto ⇒ Divergent.
1643        assert_eq!(
1644            verify_against(&report, report.stage1_content.clone()),
1645            Reproducibility::Reproducible
1646        );
1647        match verify_against(&report, ArtifactHash::from_hex("ffff")) {
1648            Reproducibility::Divergent { stage1, rebuilt } => {
1649                assert_eq!(stage1, report.stage1_content);
1650                assert_eq!(rebuilt, ArtifactHash::from_hex("ffff"));
1651            }
1652            v => panic!("esperaba Divergent, vino {v:?}"),
1653        }
1654    }
1655
1656    // --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM ---
1657
1658    /// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos.
1659    fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) {
1660        let store = Store::open(dir.join("store")).unwrap();
1661
1662        // Stage 1 rootfs sellado (con el nombre que espera el builder).
1663        let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| {
1664            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1665            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap();
1666            std::fs::create_dir_all(w.join("sbin")).unwrap();
1667            std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap();
1668        });
1669
1670        // Semilla zig sellada con bin/zig (nombre store seed-zig).
1671        let seed = seal_component(&store, "seed-zig", "5ee0", |w| {
1672            std::fs::create_dir_all(w.join("bin")).unwrap();
1673            std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap();
1674        });
1675
1676        // Recetas, binario hammer y toolchain (Alpine sintético), fuera del store.
1677        let recipes = dir.join("recipes");
1678        std::fs::create_dir_all(&recipes).unwrap();
1679        std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap();
1680        std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap();
1681
1682        let hammer_bin = dir.join("hammer");
1683        std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap();
1684
1685        let toolchain = dir.join("alpine");
1686        std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap();
1687        std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap();
1688
1689        let spec = BuilderSpec {
1690            stage1_rootfs: stage1,
1691            seed_hash: seed,
1692            seed_kind: SeedKind::Zig,
1693            recipes_dir: recipes,
1694            hammer_bin,
1695            toolchain_src: toolchain,
1696            toolchain_tag: "alpine-test".into(),
1697            ref_content: Some(ArtifactHash::from_hex("dead")),
1698            work_cache: None,
1699            swaps: vec![],
1700        };
1701        (store, spec)
1702    }
1703
1704    #[test]
1705    fn builder_rootfs_assembles_full_image() {
1706        let tmp = tempfile::tempdir().unwrap();
1707        let (store, spec) = builder_fixture(tmp.path());
1708        let out = tmp.path().join("builder-out");
1709
1710        let report = builder_rootfs(&spec, &store, &out).unwrap();
1711        assert!(report.builder_hash.as_str().starts_with("b3:"));
1712        assert_eq!(report.out_dir, out);
1713
1714        // Base Stage 1 hidratada (init + symlink).
1715        assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado");
1716        assert_eq!(
1717            std::fs::read_link(out.join("sbin/init")).unwrap(),
1718            PathBuf::from("/usr/bin/arje-zero"),
1719        );
1720        // Toolchain en /toolchain, semilla en /store, hammer + recetas + driver.
1721        assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado");
1722        assert!(
1723            out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(),
1724            "semilla replicada en /store",
1725        );
1726        assert!(out.join("usr/bin/hammer").is_file(), "binario hammer");
1727        assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas");
1728        assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild");
1729
1730        // El driver y hammer son ejecutables.
1731        use std::os::unix::fs::PermissionsExt;
1732        let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap();
1733        assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x");
1734
1735        // rebuild.env lleva la semilla, la clase y la referencia embebidas.
1736        let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
1737        assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}");
1738        assert!(env.contains("SEED_KIND=zig"), "{env}");
1739        assert!(env.contains("REF_CONTENT=b3:dead"), "{env}");
1740
1741        // Línea de manifiesto (stage 2) con el hash lógico del builder.
1742        let man = BootstrapManifest::load(&store).unwrap();
1743        assert!(
1744            man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash),
1745            "el builder debe anotar su línea en el manifiesto",
1746        );
1747    }
1748
1749    #[test]
1750    fn builder_hash_is_deterministic_and_sensitive() {
1751        let s1 = ArtifactHash::from_hex("11");
1752        let seed = ArtifactHash::from_hex("22");
1753        let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "");
1754        assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", ""), "puro");
1755        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd", ""), "tag del toolchain");
1756        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd", ""), "recetas");
1757        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2", ""), "binario hammer");
1758        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "sd"), "swaps del toolchain");
1759    }
1760
1761    #[test]
1762    fn swaps_digest_empty_is_blank_and_order_insensitive() {
1763        assert_eq!(swaps_digest(&[]), "", "sin swaps ⇒ digest vacío (compat hacia atrás)");
1764        let mk = |n: &str, h: &str| ToolchainSwap {
1765            name: n.into(),
1766            artifact: ArtifactHash::from_hex(h),
1767            rel_path: format!("usr/bin/{n}"),
1768        };
1769        let a = swaps_digest(&[mk("make", "aa"), mk("bwrap", "bb")]);
1770        let b = swaps_digest(&[mk("bwrap", "bb"), mk("make", "aa")]);
1771        assert_eq!(a, b, "el digest se ordena por nombre ⇒ insensible al orden de entrada");
1772        assert!(!a.is_empty());
1773        assert_ne!(a, swaps_digest(&[mk("make", "ac"), mk("bwrap", "bb")]), "cambia con el hash");
1774    }
1775
1776    #[test]
1777    fn builder_rootfs_swaps_toolchain_tool_from_source() {
1778        let tmp = tempfile::tempdir().unwrap();
1779        let (store, mut spec) = builder_fixture(tmp.path());
1780
1781        // Artefacto sellado de un `make` "construido por hammer" con un binario distinto al de Alpine.
1782        let make = seal_component(&store, "make", "fbad", |w| {
1783            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
1784            std::fs::write(w.join("usr/bin/make"), b"\x7fELF-hammer-make-static").unwrap();
1785        });
1786        spec.swaps = vec![ToolchainSwap {
1787            name: "make".into(),
1788            artifact: make,
1789            rel_path: "usr/bin/make".into(),
1790        }];
1791
1792        let out = tmp.path().join("builder-out");
1793        let report = builder_rootfs(&spec, &store, &out).unwrap();
1794
1795        // El make de /toolchain es ahora el de hammer, no el de Alpine.
1796        let got = std::fs::read(out.join("toolchain/usr/bin/make")).unwrap();
1797        assert_eq!(got, b"\x7fELF-hammer-make-static", "el swap pisó el make de Alpine");
1798
1799        // El hash lógico cambia respecto a un builder pura-Alpine (la procedencia es auditable).
1800        let mut pure = spec.clone();
1801        pure.swaps = vec![];
1802        let pure_report = builder_rootfs(&pure, &store, &tmp.path().join("builder-pure")).unwrap();
1803        assert_ne!(report.builder_hash, pure_report.builder_hash, "el swap entra al hash lógico");
1804    }
1805
1806    #[test]
1807    fn builder_rootfs_errors_when_swap_artifact_missing_binary() {
1808        let tmp = tempfile::tempdir().unwrap();
1809        let (store, mut spec) = builder_fixture(tmp.path());
1810        // Artefacto sellado pero SIN el binario esperado en rel_path.
1811        let make = seal_component(&store, "make", "fbad", |w| {
1812            std::fs::create_dir_all(w.join("usr/share")).unwrap();
1813            std::fs::write(w.join("usr/share/x"), b"no-binary").unwrap();
1814        });
1815        spec.swaps = vec![ToolchainSwap {
1816            name: "make".into(),
1817            artifact: make,
1818            rel_path: "usr/bin/make".into(),
1819        }];
1820        let out = tmp.path().join("builder-out");
1821        let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
1822        assert!(err.contains("no existe") && err.contains("artefacto sellado"), "{err}");
1823    }
1824
1825    #[test]
1826    fn builder_rootfs_swaps_toolchain_header_tree_from_source() {
1827        let tmp = tempfile::tempdir().unwrap();
1828        let (store, mut spec) = builder_fixture(tmp.path());
1829
1830        // Artefacto sellado de un `linux-headers` con un árbol `usr/include/linux` propio.
1831        let headers = seal_component(&store, "linux-headers", "abcd", |w| {
1832            std::fs::create_dir_all(w.join("usr/include/linux")).unwrap();
1833            std::fs::write(w.join("usr/include/linux/version.h"), b"#define HAMMER 1\n").unwrap();
1834            std::fs::write(w.join("usr/include/linux/stat.h"), b"struct hammer_stat;\n").unwrap();
1835        });
1836        // El toolchain Alpine tiene un linux/ con un archivo huérfano que el swap debe borrar.
1837        let tc_linux = spec.toolchain_src.join("usr/include/linux");
1838        std::fs::create_dir_all(&tc_linux).unwrap();
1839        std::fs::write(tc_linux.join("version.h"), b"#define ALPINE 1\n").unwrap();
1840        std::fs::write(tc_linux.join("orphan.h"), b"alpine-only\n").unwrap();
1841
1842        spec.swaps = vec![ToolchainSwap {
1843            name: "linux-headers".into(),
1844            artifact: headers,
1845            rel_path: "usr/include/linux".into(),
1846        }];
1847
1848        let out = tmp.path().join("builder-out");
1849        builder_rootfs(&spec, &store, &out).unwrap();
1850
1851        let v = std::fs::read(out.join("toolchain/usr/include/linux/version.h")).unwrap();
1852        assert_eq!(v, b"#define HAMMER 1\n", "el swap pisó el version.h de Alpine");
1853        assert!(out.join("toolchain/usr/include/linux/stat.h").is_file(), "copió el árbol entero");
1854        assert!(
1855            !out.join("toolchain/usr/include/linux/orphan.h").exists(),
1856            "el swap-directorio borró el huérfano de Alpine"
1857        );
1858    }
1859
1860    #[test]
1861    fn builder_rootfs_without_ref_leaves_empty_ref_content() {
1862        let tmp = tempfile::tempdir().unwrap();
1863        let (store, mut spec) = builder_fixture(tmp.path());
1864        spec.ref_content = None;
1865        let out = tmp.path().join("builder-out");
1866        builder_rootfs(&spec, &store, &out).unwrap();
1867        let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
1868        assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}");
1869    }
1870
1871    #[test]
1872    fn builder_rootfs_with_work_cache_containing_out_dir_terminates() {
1873        // Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse
1874        // el propio destino (recursión infinita que llena el disco). El skip lo corta.
1875        let tmp = tempfile::tempdir().unwrap();
1876        let (store, mut spec) = builder_fixture(tmp.path());
1877
1878        // Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar).
1879        let work = tmp.path().join("work");
1880        std::fs::create_dir_all(work.join("repos/musl.git")).unwrap();
1881        std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap();
1882        std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap();
1883        std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap();
1884        spec.work_cache = Some(work.clone());
1885
1886        // out_dir vive DENTRO de la caché: el caso que disparaba la recursión.
1887        let out = work.join("builder-rootfs");
1888        let report = builder_rootfs(&spec, &store, &out).unwrap();
1889        assert!(report.builder_hash.as_str().starts_with("b3:"));
1890
1891        // Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe.
1892        assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben");
1893        assert!(
1894            !out.join("work/builder-rootfs").exists(),
1895            "el out_dir no debe haberse copiado en su propio /work (recursión)",
1896        );
1897        assert!(
1898            !out.join("work/sources").exists(),
1899            "sources/ es regenerable y pesado: no debe viajar en el builder",
1900        );
1901    }
1902
1903    #[test]
1904    fn builder_rootfs_errors_when_stage1_unsealed() {
1905        let tmp = tempfile::tempdir().unwrap();
1906        let (store, mut spec) = builder_fixture(tmp.path());
1907        spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado
1908        let out = tmp.path().join("builder-out");
1909        let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
1910        assert!(err.contains("no sellado"), "{err}");
1911    }
1912
1913    #[test]
1914    fn artifact_content_hash_errors_when_unsealed() {
1915        let tmp = tempfile::tempdir().unwrap();
1916        let store = Store::open(tmp.path().join("store")).unwrap();
1917        let err = artifact_content_hash(&store, &ArtifactHash::from_hex("00"), "stage1-rootfs")
1918            .unwrap_err()
1919            .to_string();
1920        assert!(err.contains("no sellado"), "{err}");
1921    }
1922
1923    #[test]
1924    fn all_shipped_recipes_parse_and_hash() {
1925        // Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y
1926        // hashear offline. Cubre las que no están en STAGE1_COMPONENTS y futuras.
1927        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
1928        let tmp = tempfile::tempdir().unwrap();
1929        let store = Store::open(tmp.path().join("store")).unwrap();
1930        let mut count = 0;
1931        for entry in std::fs::read_dir(&recipes_dir).unwrap() {
1932            let path = entry.unwrap().path();
1933            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1934                continue;
1935            }
1936            let r = Recipe::load_from_path(&path)
1937                .unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
1938            let h = hammer_build::artifact_hash(&r, &store)
1939                .unwrap_or_else(|e| panic!("hash {}: {e}", path.display()));
1940            assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display());
1941            count += 1;
1942        }
1943        assert!(count >= 5, "esperaba ≥5 recetas (grep/musl/busybox/hammerd/arje-zero), vi {count}");
1944    }
1945}