gioser

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 1471Shell 174Rust 105Markdown 943556 Definiciones

⎇ ba1fb2d3a8ada748984dceeb0f58e6b3e67772b8 crates/hammer-bootstrap/src/lib.rs Rust · 2706 líneas · 130.4 KB Historia Quién tocó qué crudo

hash: el toolchain del lab entra en hash_inputs — el corpus entero se re-hashea 58d31618b · 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/// Daemons ⇒ llevan card en la seed de producto. Crecer esta lista NO toca `STAGE1_COMPONENTS`.
400const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"];
401
402/// Userland Rust-nativo ADOPTADO (Etapa C): multicall `uutils`/coreutils + `find`/`xargs` + `diff`/`cmp`
403/// + `rg`. Se hidrata sobre la base DESPUÉS de busybox, así que sus symlinks en `/usr/bin` ENSOMBRECEN
404/// los applets de busybox (con `PATH` `/usr/bin:/bin` gana la tool Rust). Es el "adelgazar busybox" del
405/// producto SIN tocar el núcleo: busybox sigue en el 4/4 (self-host) y queda de fallback para lo aún no
406/// reemplazado (`sh`/ash, `tar`, …). NO son daemons ⇒ no llevan card en la seed (sólo ficheros).
407const USERLAND_COMPONENTS: &[&str] =
408    &["uutils", "findutils", "findutils-xargs", "diffutils", "ripgrep"];
409
410/// Card de servicio `sshd` (genesis `Native`/`Restart`): el comando levanta la red (`netup`, DHCP),
411/// genera host keys al primer boot (`ssh-keygen -A`) y `exec`uta `sshd -D`. Todo dentro del card para
412/// no asumir orden de genesis. Mismo esquema validado de [`STAGE1_SEED_CARD`]; ULID propio. Se inyecta
413/// en el `genesis` de la seed de producto. Verificado E2E en QEMU (`scripts/ssh-e2e-test.sh`).
414const SSHD_SERVICE_CARD: &str = r#"{
415    "schema_version": 1,
416    "id": "01HQAR53D4M2NBV8KZTYXFQA12",
417    "lineage": null,
418    "label": "sshd",
419    "provides": [],
420    "requires": [],
421    "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
422    "soma": {
423        "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
424        "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
425        "cgroup": { "path": "arje.slice/sshd", "cpu_weight": null, "io_weight": null },
426        "cpu_affinity": null
427    },
428    "payload": { "Native": {
429        "exec": "/bin/busybox",
430        "argv": ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"],
431        "envp": [["HOME", "/root"]]
432    }},
433    "supervision": { "Restart": { "initial": 500, "max": 20000 } },
434    "lifecycle": "daemon",
435    "priority": "normal",
436    "flow": { "input": [], "output": [] },
437    "genesis": []
438}"#;
439
440/// `/etc/passwd` del producto: `root` (login por llave) + `sshd` (usuario de privsep, shell inerte).
441const PRODUCT_PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n";
442/// `/etc/group` del producto.
443const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n";
444/// `/etc/ssh/sshd_config` del producto: sólo por llave (sin password), pidfile en `/run` (tmpfs que
445/// arje monta), authorized_keys provisionadas en deploy.
446const 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";
447
448// ── Atestación de integridad al arranque (I4 / Etapa D, mitad de HAMMER) ─────────────────────────
449//
450// Por la regla de oro del [plan arje↔hammer] (`PLAN-ATESTACION-Y-HAMMER.md` §B.1): hammer es dueño del
451// **`expected_hash` + firma + TrustStore**; arje es dueño del **gate al boot** (computa `blake3` de cada
452// binario crítico ANTES de incarnar y, si no casa, `halt`). Esta es la mitad de hammer: PRODUCIR el
453// manifiesto de hashes esperados (`/ente/attest.json`) que el gate A2 de arje verifica — "el
454// `expected_hash` de un `.swm` ES el BLAKE3 que arje atesta". El hash es `blake3` CRUDO del fichero
455// (`ArtifactHash::of_file`), el mismo que computa `arje-cas::blake3_of`. La firma con la rootkey del
456// seed (A1, `arje-packager`) es una capa posterior; el manifiesto ya es verificable hoy por `hammer
457// attest` (auto-validación: "reproducir, no confiar"). Es un fichero APARTE de la seed card ⇒ no toca
458// el schema de `card-core` y el producto bootea igual con el arje-zero actual (que aún ignora A2).
459
460/// Binarios críticos cuyo BLAKE3 hammer atesta (la raíz-de-confianza-ejecutable del producto): PID 1,
461/// el diario, el shell/getty, el daemon de red expuesto, el configurador de red y el userland multicall.
462const ATTEST_PATHS: &[&str] = &[
463    "usr/bin/arje-zero", // PID 1 — la raíz
464    "usr/bin/hammerd",   // diario + bus
465    "bin/busybox",       // shell/getty + exec de varios cards
466    "usr/sbin/sshd",     // daemon SSH (superficie de red)
467    "usr/bin/netup",     // configurador de red
468    "usr/bin/coreutils", // userland Rust multicall
469];
470
471/// Una entrada del manifiesto de atestación: ruta (relativa a la raíz) → BLAKE3 esperado del fichero.
472#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
473pub struct AttestEntry {
474    pub path: String,
475    pub b3: String,
476}
477
478/// Manifiesto de atestación que hammer emite en `/ente/attest.json`. `algo` fijo a `blake3` (crudo).
479#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
480pub struct AttestManifest {
481    pub schema_version: u32,
482    pub algo: String,
483    pub entries: Vec<AttestEntry>,
484}
485
486/// Veredicto de [`verify_attestation`]: qué binarios casaron, cuáles divergieron y cuáles faltan.
487#[derive(Debug, Clone, Default)]
488pub struct AttestReport {
489    pub ok: Vec<String>,
490    pub mismatched: Vec<String>,
491    pub missing: Vec<String>,
492}
493
494impl AttestReport {
495    /// Pasa sólo si todo binario listado existe y su BLAKE3 casa.
496    pub fn passed(&self) -> bool {
497        self.mismatched.is_empty() && self.missing.is_empty()
498    }
499}
500
501/// Construye el manifiesto de atestación de un árbol `root`: para cada ruta crítica PRESENTE, su BLAKE3
502/// crudo. Determinista (función pura de los bytes del binario).
503fn build_attestation(root: &Path) -> Result<AttestManifest> {
504    let mut entries = Vec::new();
505    for rel in ATTEST_PATHS {
506        let p = root.join(rel);
507        if p.is_file() {
508            let h = ArtifactHash::of_file(&p).map_err(Error::Io)?;
509            entries.push(AttestEntry { path: format!("/{rel}"), b3: h.as_str().to_string() });
510        }
511    }
512    Ok(AttestManifest { schema_version: 1, algo: "blake3".into(), entries })
513}
514
515/// Verifica un árbol FHS (`rootfs_dir`, p.ej. un product-rootfs hidratado) contra su propio
516/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario listado y lo compara. Es lo que el gate de
517/// arje hará al boot, hecho hoy por hammer (auto-validación de integridad del producto).
518pub fn verify_attestation(rootfs_dir: &Path) -> Result<AttestReport> {
519    let manifest_path = rootfs_dir.join("ente/attest.json");
520    let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
521        Error::Other(format!("no pude leer {}: {e}", manifest_path.display()))
522    })?;
523    let manifest: AttestManifest = serde_json::from_str(&raw)
524        .map_err(|e| Error::Other(format!("attest.json inválido: {e}")))?;
525    let mut report = AttestReport::default();
526    for entry in &manifest.entries {
527        let rel = entry.path.trim_start_matches('/');
528        let p = rootfs_dir.join(rel);
529        if !p.is_file() {
530            report.missing.push(entry.path.clone());
531            continue;
532        }
533        let got = ArtifactHash::of_file(&p).map_err(Error::Io)?;
534        if got.as_str() == entry.b3 {
535            report.ok.push(entry.path.clone());
536        } else {
537            report.mismatched.push(entry.path.clone());
538        }
539    }
540    Ok(report)
541}
542
543/// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía
544/// `serde_json` (hammer sigue autocontenido: no depende de `card-core`). Determinista ⇒ el
545/// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual.
546fn product_seed_card() -> Result<String> {
547    let mut seed: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD)
548        .map_err(|e| Error::Other(format!("seed base inválida: {e}")))?;
549    seed["label"] = serde_json::Value::String("hammer-product".into());
550    let card: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD)
551        .map_err(|e| Error::Other(format!("card sshd inválida: {e}")))?;
552    seed.get_mut("genesis")
553        .and_then(|g| g.as_array_mut())
554        .ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?
555        .push(card);
556    serde_json::to_string_pretty(&seed).map_err(|e| Error::Other(format!("serializar seed: {e}")))
557}
558
559/// Identidad del `product-rootfs`: tag + el `RootfsHash` del 4/4 base + `(nombre,hash)` de cada
560/// componente userland y de servicio (en orden) + la seed de producto + las configs. Cambiar
561/// cualquiera re-hashea (reproducible). El tag `v2` marca el corte respecto al producto sólo-servicios.
562fn product_rootfs_hash(
563    base: &RootfsHash,
564    userland: &[(String, ArtifactHash)],
565    services: &[(String, ArtifactHash)],
566    seed: &str,
567) -> RootfsHash {
568    let mut inputs: Vec<Vec<u8>> = vec![
569        b"hammer-product-rootfs-v3".to_vec(), // v3: + manifiesto de atestación (/ente/attest.json)
570        base.as_str().as_bytes().to_vec(),
571    ];
572    for (name, h) in userland.iter().chain(services.iter()) {
573        inputs.push(name.as_bytes().to_vec());
574        inputs.push(h.as_str().as_bytes().to_vec());
575    }
576    inputs.push(ATTEST_PATHS.join(",").into_bytes()); // el set atestado entra al hash
577    inputs.push(seed.as_bytes().to_vec());
578    inputs.push(PRODUCT_PASSWD.as_bytes().to_vec());
579    inputs.push(PRODUCT_GROUP.as_bytes().to_vec());
580    inputs.push(SSHD_CONFIG.as_bytes().to_vec());
581    let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
582    ArtifactHash::of_inputs(&refs)
583}
584
585/// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4), luego el userland
586/// Rust (ENSOMBRECE los applets de busybox en `/usr/bin`) y los componentes de servicio ENCIMA
587/// (hidratación tardía); sobrescribe la seed con la de producto y escribe las configs de servicio
588/// (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`, `/root/.ssh` 0700). Testeable sin build.
589fn assemble_product_rootfs(
590    store: &Store,
591    base: &RootfsHash,
592    userland: &[(String, ArtifactHash)],
593    services: &[(String, ArtifactHash)],
594    seed: &str,
595    staging: &Path,
596) -> Result<()> {
597    use std::os::unix::fs::PermissionsExt;
598
599    // 1) base verificado (4/4): hidratación por hardlinks del árbol ya sellado (incl. mountpoints
600    //    vacíos, /sbin/init y la seed base, que sobrescribimos en el paso 3).
601    let base_dir = store.path_of(base, "stage1-rootfs");
602    if !base_dir.is_dir() {
603        return Err(Error::Other(format!(
604            "rootfs base no sellado en {}",
605            base_dir.display()
606        )));
607    }
608    hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?;
609
610    // 2) userland Rust + servicios ENCIMA (hardlink). El userland va DESPUÉS de la base: sus symlinks
611    //    en /usr/bin reemplazan a los de busybox (run_hydrate sustituye el symlink existente) ⇒ el
612    //    producto corre las tools Rust. Orden userland→servicios (sin colisiones entre sí).
613    for (name, h) in userland.iter().chain(services.iter()) {
614        let dir = store.path_of(h, name);
615        hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static, None)?;
616    }
617    let _ = std::fs::remove_dir_all(staging.join(".hammer"));
618
619    // ADELGAZAR BUSYBOX (determinista, sin depender del orden de PATH del shell): por cada nombre que
620    // el userland Rust provee en `/usr/bin`, retirar el symlink homónimo de busybox en los OTROS dirs
621    // (`/bin`, `/sbin`, `/usr/sbin`) — busybox suele instalar `ls` en `/bin`, fuera del `/usr/bin` que
622    // la hidratación ya ensombreció. Así la tool Rust es la ÚNICA en PATH. El binario busybox queda
623    // (sh/ash, tar, mount, … lo no reemplazado); sólo se quitan los applets ya cubiertos por Rust.
624    let mut applet_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
625    for (name, h) in userland {
626        let ub = store.path_of(h, name).join("usr/bin");
627        if let Ok(rd) = std::fs::read_dir(&ub) {
628            for e in rd.flatten() {
629                applet_names.insert(e.file_name().to_string_lossy().into_owned());
630            }
631        }
632    }
633    for applet in &applet_names {
634        for d in ["bin", "sbin", "usr/sbin"] {
635            let p = staging.join(d).join(applet);
636            if std::fs::symlink_metadata(&p).is_ok() {
637                let _ = std::fs::remove_file(&p);
638            }
639        }
640    }
641
642    // 3) seed de producto (sobrescribe la base) + configs + dirs de runtime de sshd.
643    // Los ficheros hidratados son HARDLINKS al store sellado (read-only); escribir encima daría
644    // EACCES y `chmod` mutaría el inodo del store. Hay que ROMPER el hardlink: remove + write nuevo.
645    let write_fresh = |rel: &str, data: &str| -> Result<()> {
646        let p = staging.join(rel);
647        if let Some(parent) = p.parent() {
648            std::fs::create_dir_all(parent)?;
649        }
650        let _ = std::fs::remove_file(&p);
651        std::fs::write(&p, data)?;
652        Ok(())
653    };
654    write_fresh("ente/seed.card.json", seed)?;
655    write_fresh("etc/passwd", PRODUCT_PASSWD)?;
656    write_fresh("etc/group", PRODUCT_GROUP)?;
657    write_fresh("etc/ssh/sshd_config", SSHD_CONFIG)?;
658    let empty = staging.join("var/empty"); // jaula de privsep: root, no group/world-writable.
659    std::fs::create_dir_all(&empty)?;
660    std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o711))?;
661    let dotssh = staging.join("root/.ssh"); // authorized_keys provisionadas en deploy.
662    std::fs::create_dir_all(&dotssh)?;
663    std::fs::set_permissions(&dotssh, std::fs::Permissions::from_mode(0o700))?;
664    // Mountpoints de las particiones dedicadas del disco de producto (vacíos; el wrapper /sbin/init de
665    // la imagen monta vda3→/store y vda4→/var/lib/hammer). `/var/lib/hammer` ya viene de la base.
666    std::fs::create_dir_all(staging.join("store"))?;
667    std::fs::create_dir_all(staging.join("var/lib/hammer"))?;
668
669    // Manifiesto de atestación (I4): BLAKE3 esperado de los binarios críticos ya hidratados ⇒ el gate
670    // de arje lo verifica al boot. Se computa al final, sobre el árbol ya ensamblado. Fichero aparte de
671    // la seed card (no toca el schema de card-core).
672    let attest = build_attestation(staging)?;
673    let attest_json = serde_json::to_string_pretty(&attest)
674        .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?;
675    std::fs::write(staging.join("ente/attest.json"), attest_json)?;
676    Ok(())
677}
678
679/// Construye/cachea (idempotente vía el lab) una lista de componentes nombrados, devolviendo sus
680/// `(nombre, hash)` sellados. Cada receta usa su propia config de build (p.ej. `zig_version`).
681fn build_components(
682    names: &[&str],
683    recipes_dir: &Path,
684    cfg: &hammer_build::BuildConfig,
685    store: &Store,
686) -> Result<Vec<(String, ArtifactHash)>> {
687    let mut out = Vec::with_capacity(names.len());
688    for name in names {
689        let recipe_path = recipes_dir.join(format!("{name}.toml"));
690        let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| {
691            Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display()))
692        })?;
693        let h = hammer_build::build(&recipe, cfg, store)?;
694        out.push((name.to_string(), h));
695    }
696    Ok(out)
697}
698
699/// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta por
700/// hidratación tardía el userland Rust (adelgaza busybox) + la capa de servicios, sellando un
701/// `product-rootfs`. El mecanismo base queda intacto: este árbol NO es el que reconstruye el
702/// selfhost-verify (su `of_tree` sigue blindado). Idempotente. `base_cfg` apunta el zig del lab (no la
703/// semilla): cada componente usa su `zig_version` propia, y si ya está sellado sale cacheado.
704pub fn product(
705    base: &RootfsHash,
706    base_cfg: &hammer_build::BuildConfig,
707    recipes_dir: &Path,
708    store: &Store,
709) -> Result<RootfsHash> {
710    // 1) construir/cachear el userland Rust + los servicios DESDE LAS RECETAS LOCALES.
711    let userland = build_components(USERLAND_COMPONENTS, recipes_dir, base_cfg, store)?;
712    let services = build_components(SERVICE_COMPONENTS, recipes_dir, base_cfg, store)?;
713    // 2-3) ensamblar + sellar.
714    seal_product_rootfs(base, &userland, &services, store)
715}
716
717/// **Producto desde un REPO FIRMADO** — cierra el dogfood de [[etapa-f]]: la imagen de producto arma
718/// su userland vía la cadena de suministro de paquetes (no recetas locales hardcodeadas). Verifica la
719/// firma del release, REPRODUCE cada componente desde su `.swm` (`build_source_patch` chequea el
720/// `expected_hash` anclado) y lo hidrata. Como el hash es por-CONTENIDO, los `(nombre,hash)` que
721/// reproduce desde el repo son IDÉNTICOS a los que `build_components` construye de la receta ⇒ el
722/// `product-rootfs` resultante es BIT-IDÉNTICO al de [`product`]: la prueba de que el dogfood es fiel
723/// ("verificar, no confiar" aplicado al propio ensamblado del producto). El núcleo sigue blindado.
724pub fn product_from_repo(
725    base: &RootfsHash,
726    repo_dir: &Path,
727    trust_dir: &Path,
728    base_cfg: &hammer_build::BuildConfig,
729    store: &Store,
730) -> Result<RootfsHash> {
731    // 1) userland + servicios REPRODUCIDOS desde el repo firmado (mismo shape que build_components).
732    let userland = install_components_from_repo(USERLAND_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?;
733    let services = install_components_from_repo(SERVICE_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?;
734    // 2-3) ensamblar + sellar (idéntico a `product`).
735    seal_product_rootfs(base, &userland, &services, store)
736}
737
738/// Pasos 2-3 comunes a [`product`] y [`product_from_repo`]: seed de producto + hash de identidad +
739/// ensamblado + sellado idempotente. Sólo varía la PROCEDENCIA de los `(nombre,hash)` (recetas
740/// locales vs repo firmado); el árbol sellado es función de ellos ⇒ ambas vetas convergen al mismo
741/// `product-rootfs` si los hashes coinciden (lo hacen: el hash es por-contenido).
742fn seal_product_rootfs(
743    base: &RootfsHash,
744    userland: &[(String, ArtifactHash)],
745    services: &[(String, ArtifactHash)],
746    store: &Store,
747) -> Result<RootfsHash> {
748    let seed = product_seed_card()?;
749    let phash = product_rootfs_hash(base, userland, services, &seed);
750    let store_name = "product-rootfs";
751
752    if !store.has(&phash, store_name) {
753        let staging = store
754            .root()
755            .join(".bootstrap-tmp")
756            .join(phash.store_dir_name(store_name));
757        let _ = std::fs::remove_dir_all(&staging);
758        std::fs::create_dir_all(&staging)?;
759        let result = (|| -> Result<()> {
760            assemble_product_rootfs(store, base, userland, services, &seed, &staging)?;
761            store.seal(&staging, &phash, store_name)?;
762            Ok(())
763        })();
764        let _ = std::fs::remove_dir_all(&staging);
765        result?;
766        tracing::info!(hash = %phash, "product: rootfs (userland Rust + servicios) sellado");
767    } else {
768        tracing::info!(hash = %phash, "product: rootfs ya sellado (idempotente)");
769    }
770    Ok(phash)
771}
772
773/// Reproduce un conjunto de componentes DESDE UN REPO FIRMADO, devolviendo sus `(nombre,hash)` sellados
774/// — el MISMO shape que [`build_components`], así [`assemble_product_rootfs`] los hidrata igual. Por cada
775/// componente: resuelve su cierre de deps del índice, puebla el catálogo con los `{dep}.toml` (para que
776/// el lab materialice build-deps al reproducir, igual que `hammer install`), y REPRODUCE su `source_patch`
777/// con `build_source_patch` (que sella y CHEQUEA el `expected_hash` anclado en el índice). Verifica la
778/// firma del release ANTES de tocar nada: el producto NO se ensambla desde un repo no confiado.
779pub fn install_components_from_repo(
780    names: &[&str],
781    repo_dir: &Path,
782    trust_dir: &Path,
783    cfg: &hammer_build::BuildConfig,
784    store: &Store,
785) -> Result<Vec<(String, ArtifactHash)>> {
786    use hammer_core::{RepoIndex, SigStatus, TrustStore};
787
788    let index = RepoIndex::load(repo_dir)?;
789    // Firma del release: exigimos TRUSTED. Un release no firmado / con clave desconocida / con firma
790    // mala NO ensambla un producto (la cadena de suministro debe ser confiable y no manipulada).
791    let ts = TrustStore::load(trust_dir)?;
792    match index.verify_signature(&ts) {
793        SigStatus::Trusted { by } => {
794            tracing::info!(%by, "product: release del repo firmado y TRUSTED");
795        }
796        other => {
797            return Err(Error::Other(format!(
798                "el producto exige un repo firmado por clave confiada en {}; estado: {other:?}",
799                trust_dir.display()
800            )));
801        }
802    }
803
804    let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(cfg, None);
805    std::fs::create_dir_all(&catalog_dir)?;
806
807    let mut out = Vec::with_capacity(names.len());
808    for name in names {
809        let entry = index.find(name).ok_or_else(|| {
810            Error::Other(format!("'{name}' no está en el repo firmado {}", repo_dir.display()))
811        })?;
812        // Poblar el catálogo con las DEPS del cierre (deps antes del target), igual que `install`:
813        // el lab resuelve build-deps por nombre leyendo `{dep}.toml` del catalog_dir.
814        let closure = index.resolve_closure(name)?;
815        for dep in closure.iter().filter(|p| p.name != *name) {
816            let dep_swm = load_swm_file(&RepoIndex::swm_path(repo_dir, dep))?;
817            let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
818                Error::Other(format!("la dep '{}' no es un paquete source_patch", dep.name))
819            })?;
820            let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
821                mutation,
822                Some(&dep.name),
823                &catalog_dir,
824            )?;
825            std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), dep_recipe.to_toml()?)?;
826        }
827        // Reproducir el target: build_source_patch sella + chequea el expected_hash del índice.
828        let swm = load_swm_file(&RepoIndex::swm_path(repo_dir, entry))?;
829        let mutation = source_patch_of(&swm).ok_or_else(|| {
830            Error::Other(format!("'{name}' no es un paquete source_patch"))
831        })?;
832        let hash = hammer_build::swm_bridge::build_source_patch(mutation, cfg, store, None, Some(name))?;
833        tracing::info!(%name, %hash, "product: componente reproducido desde el repo firmado");
834        out.push((name.to_string(), hash));
835    }
836    Ok(out)
837}
838
839/// Lee un `.swm` (YAML) de disco a `Swm`. Equivalente al helper homónimo de la CLI (privado allí).
840fn load_swm_file(path: &Path) -> Result<hammer_core::Swm> {
841    let yaml = std::fs::read_to_string(path)
842        .map_err(|e| Error::Other(format!("no pude leer {}: {e}", path.display())))?;
843    hammer_core::Swm::from_yaml(&yaml)
844        .map_err(|e| Error::Other(format!("no pude parsear {}: {e}", path.display())))
845}
846
847/// Primera mutación `SourcePatch` de un `.swm` (un paquete hammer es un único source_patch).
848fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
849    swm.mutations
850        .iter()
851        .find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
852}
853
854// ── Producto ATESTADO: la capa FIRMADA que el gate de arje verifica al boot (I4 / Etapa D) ───────
855//
856// `product()` sella el `product-rootfs` con el arje-zero del núcleo + un `/ente/attest.json` de
857// AUTO-verificación (esquema paralelo que `hammer attest` valida, pero que el gate de arje NO lee).
858// El producto ATESTADO cierra el lazo canónico: hidrata el arje-zero CON gate (`arje-zero-attest`),
859// FIRMA la seed con `arje-packager` (una concesión Ed25519 por binario crítico, sobre su BLAKE3, bajo
860// una rootkey) y la sella como `product-attested-rootfs`. Booteado, el gate (política `halt`) recomputa
861// el BLAKE3 de cada exec crítico y aborta antes de incarnar si algo no casa la concesión firmada.
862//
863// Esto reemplaza el spike `scripts/attest-boot-test.sh` (que overlayeaba a mano): ahora hammer-bootstrap
864// produce el producto atestado de forma estructurada. Reusa la cripto canónica (arje-attest/agora vía el
865// packager) — cero criptografía nueva en hammer. La rootkey por defecto es determinista ⇒ las firmas
866// Ed25519 (RFC 8032, nonce derivado del mensaje) son reproducibles bit a bit.
867
868/// Rootkey de desarrollo (32 bytes) con la que hammer firma la atestación por defecto. Determinista ⇒
869/// firmas reproducibles ⇒ `product-attested-rootfs` reproducible. El endurecimiento soberano (rootkey
870/// fuera del árbol, `/etc/arje/rootkey.pub` / `ARJE_ATTEST_ROOTKEY_FILE`) es una capa posterior
871/// (roadmap Etapa D, hardening #3); por ahora se inyecta como override de [`AttestConfig::rootkey`].
872pub const DEV_ATTEST_ROOTKEY: &[u8; 32] = b"hammer-attest-dev-rootkey-0001!!";
873
874/// Recetas de la capa de atestación: el init CON gate (variante SÓLO producto, núcleo intacto) y el
875/// firmador (herramienta de build-time, estático musl, corre en el host y NO se embarca).
876const GATED_INIT_COMPONENT: &str = "arje-zero-attest";
877const PACKAGER_COMPONENT: &str = "arje-packager";
878
879/// Configuración de la firma de atestación del producto.
880#[derive(Debug, Clone)]
881pub struct AttestConfig {
882    /// Política que el gate aplica si un binario no atesta: `"halt"` | `"degraded"` | `"warn"`.
883    pub policy: String,
884    /// Rootkey de 32 bytes para firmar las concesiones. Determinista ⇒ producto reproducible.
885    pub rootkey: [u8; 32],
886}
887
888impl Default for AttestConfig {
889    /// Política `halt` (la integridad comprometida NO levanta el entorno) + la rootkey de desarrollo.
890    fn default() -> Self {
891        Self { policy: "halt".into(), rootkey: *DEV_ATTEST_ROOTKEY }
892    }
893}
894
895/// Identidad del `product-attested-rootfs`: el producto base + el init con gate + el firmador + la
896/// política + la rootkey. Cambiar cualquiera re-hashea; como las firmas Ed25519 son deterministas en
897/// estos insumos, el árbol sellado es reproducible.
898fn product_attested_hash(
899    base_product: &RootfsHash,
900    gated_init: &ArtifactHash,
901    packager: &ArtifactHash,
902    cfg: &AttestConfig,
903) -> RootfsHash {
904    ArtifactHash::of_inputs(&[
905        b"hammer-product-attested-v2", // v2: + ancla soberana externa /etc/arje/rootkey.pub (hardening #3)
906        base_product.as_str().as_bytes(),
907        gated_init.as_str().as_bytes(),
908        packager.as_str().as_bytes(),
909        cfg.policy.as_bytes(),
910        &cfg.rootkey,
911    ])
912}
913
914/// Binarios críticos que el gate atesta, derivados de la PROPIA seed (no una lista fija): el ejecutable
915/// de PID 1 (`arje-zero`, el `current_exe` del gate) + el `exec` de cada card `Native` del fractal
916/// `genesis` (recursivo), mapeado a su ruta dentro de `staging`. Así, añadir un servicio a la seed lo
917/// firma automáticamente. Devuelve `(label, ruta_host)` para alimentar `--bin label=path` del packager —
918/// el `label` debe casar el de la card (lo que el gate busca en las concesiones). El orden es estable
919/// (PID 1 primero, luego DFS del genesis) ⇒ los flags `--bin` son deterministas.
920fn critical_bins_from_seed(seed: &serde_json::Value, staging: &Path) -> Vec<(String, PathBuf)> {
921    fn walk(card: &serde_json::Value, staging: &Path, out: &mut Vec<(String, PathBuf)>) {
922        let exec = card
923            .get("payload")
924            .and_then(|p| p.get("Native"))
925            .and_then(|n| n.get("exec"))
926            .and_then(|e| e.as_str());
927        if let (Some(exec), Some(label)) = (exec, card.get("label").and_then(|l| l.as_str())) {
928            out.push((label.to_string(), staging.join(exec.trim_start_matches('/'))));
929        }
930        if let Some(g) = card.get("genesis").and_then(|g| g.as_array()) {
931            for c in g {
932                walk(c, staging, out);
933            }
934        }
935    }
936    let mut out = vec![("arje-zero".to_string(), staging.join("usr/bin/arje-zero"))];
937    if let Some(g) = seed.get("genesis").and_then(|g| g.as_array()) {
938        for c in g {
939            walk(c, staging, &mut out);
940        }
941    }
942    out
943}
944
945/// Ensambla el producto ATESTADO en `staging`: hidrata el `product-rootfs` base, PISA `/usr/bin/arje-zero`
946/// con el init CON gate, fija `attest_policy` en la seed, la FIRMA con el packager (una concesión por
947/// binario crítico sobre su BLAKE3), ancla la pubkey en `/etc/arje/rootkey.pub` (ancla soberana externa,
948/// hardening #3) y regenera `/ente/attest.json` (la auto-verificación de hammer, ahora coherente con el
949/// arje-zero nuevo). El packager corre en el host (estático musl, sin loader, como busybox/bwrap). La
950/// firma se valida E2E en QEMU (`scripts/attest-boot-test.sh`).
951fn assemble_attested_product_rootfs(
952    store: &Store,
953    base_product: &RootfsHash,
954    gated_init_bin: &Path,
955    packager_bin: &Path,
956    cfg: &AttestConfig,
957    staging: &Path,
958) -> Result<()> {
959    use std::os::unix::fs::PermissionsExt;
960
961    // 1) producto base hidratado (hardlinks read-only del árbol sellado).
962    let base_dir = store.path_of(base_product, "product-rootfs");
963    if !base_dir.is_dir() {
964        return Err(Error::Other(format!(
965            "product-rootfs base no sellado en {}",
966            base_dir.display()
967        )));
968    }
969    hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?;
970    let _ = std::fs::remove_dir_all(staging.join(".hammer"));
971
972    // 2) PISAR /usr/bin/arje-zero con el init CON gate. Es un hardlink read-only al store ⇒ romper el
973    //    hardlink (remove + copy), nunca `chmod` (mutaría el inodo del store).
974    let init_dst = staging.join("usr/bin/arje-zero");
975    let _ = std::fs::remove_file(&init_dst);
976    std::fs::copy(gated_init_bin, &init_dst)?;
977    std::fs::set_permissions(&init_dst, std::fs::Permissions::from_mode(0o755))?;
978
979    // 3) seed de producto: fijar `attest_policy` como ENTRADA del firmador. La mutamos y la dejamos en un
980    //    tmp FUERA del árbol a sellar (el seed-out lo escribe el packager sobre /ente/seed.card.json).
981    let seed_path = staging.join("ente/seed.card.json");
982    let raw = std::fs::read_to_string(&seed_path)
983        .map_err(|e| Error::Other(format!("leer seed de producto: {e}")))?;
984    let mut seed: serde_json::Value =
985        serde_json::from_str(&raw).map_err(|e| Error::Other(format!("seed inválida: {e}")))?;
986    seed["attest_policy"] = serde_json::Value::String(cfg.policy.clone());
987
988    let tmp = staging.join(".attest-build"); // se borra antes de sellar (no entra al árbol).
989    std::fs::create_dir_all(&tmp)?;
990    let seed_in = tmp.join("seed-in.json");
991    std::fs::write(
992        &seed_in,
993        serde_json::to_string_pretty(&seed)
994            .map_err(|e| Error::Other(format!("serializar seed-in: {e}")))?,
995    )?;
996    let rootkey_path = tmp.join("rootkey.bin");
997    std::fs::write(&rootkey_path, cfg.rootkey)?;
998    std::fs::set_permissions(&rootkey_path, std::fs::Permissions::from_mode(0o600))?;
999
1000    // 4) bins críticos derivados de la seed → un --bin por exec Native + el PID 1.
1001    let bins = critical_bins_from_seed(&seed, staging);
1002
1003    // 5) FIRMAR: el packager escribe el seed firmado (Card::attest + attest_rootkey + attest_policy)
1004    //    directamente sobre /ente/seed.card.json. El fichero hidratado es un HARDLINK read-only al
1005    //    store ⇒ el packager no puede abrirlo para escribir (EACCES): romper el hardlink primero.
1006    let _ = std::fs::remove_file(&seed_path);
1007    let mut cmd = Command::new(packager_bin);
1008    cmd.arg("--seed")
1009        .arg(&seed_in)
1010        .arg("--rootkey")
1011        .arg(&rootkey_path)
1012        .arg("--seed-out")
1013        .arg(&seed_path);
1014    for (label, path) in &bins {
1015        cmd.arg("--bin").arg(format!("{label}={}", path.display()));
1016    }
1017    let out = cmd
1018        .output()
1019        .map_err(|e| Error::Other(format!("spawn arje-packager: {e}")))?;
1020    if !out.status.success() {
1021        return Err(Error::Other(format!(
1022            "arje-packager falló (exit {:?}): {}",
1023            out.status.code(),
1024            String::from_utf8_lossy(&out.stderr).trim()
1025        )));
1026    }
1027
1028    // 5b) ANCLA SOBERANA EXTERNA (hardening #3): escribir la pubkey de la rootkey en /etc/arje/rootkey.pub
1029    //     (32 bytes raw). El gate (`ancla_externa()` en attest_gate.rs) la prefiere sobre la
1030    //     `attest_rootkey` declarada en el seed ⇒ un seed reescrito con OTRA rootkey cae en AutorNoConfiable
1031    //     (las concesiones no las firmó el ancla). Cierra el hueco que el WARN "sin ancla soberana externa"
1032    //     señala. La pubkey se LEE del seed ya firmado (`attest_rootkey`, lo que el packager computó de
1033    //     nuestra rootkey privada) ⇒ cero criptografía nueva en hammer.
1034    let signed_raw = std::fs::read_to_string(&seed_path)
1035        .map_err(|e| Error::Other(format!("releer seed firmado: {e}")))?;
1036    let signed: serde_json::Value = serde_json::from_str(&signed_raw)
1037        .map_err(|e| Error::Other(format!("seed firmado inválido: {e}")))?;
1038    let pubkey: Vec<u8> = signed
1039        .get("attest_rootkey")
1040        .and_then(|v| v.as_array())
1041        .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect::<Vec<u8>>())
1042        .ok_or_else(|| Error::Other("seed firmado sin attest_rootkey de 32 bytes".into()))?;
1043    if pubkey.len() != 32 {
1044        return Err(Error::Other(format!(
1045            "attest_rootkey del seed firmado no es de 32 bytes (es {})",
1046            pubkey.len()
1047        )));
1048    }
1049    std::fs::create_dir_all(staging.join("etc/arje"))?;
1050    std::fs::write(staging.join("etc/arje/rootkey.pub"), &pubkey)?;
1051
1052    // 6) regenerar /ente/attest.json: el arje-zero cambió ⇒ su BLAKE3 también, así que el manifiesto de
1053    //    auto-verificación de hammer (`hammer attest`) debe casar el árbol nuevo. El producto base lo
1054    //    trae hidratado (hardlink read-only): romperlo antes de reescribir.
1055    let attest = build_attestation(staging)?;
1056    let attest_json = serde_json::to_string_pretty(&attest)
1057        .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?;
1058    let attest_path = staging.join("ente/attest.json");
1059    let _ = std::fs::remove_file(&attest_path);
1060    std::fs::write(&attest_path, attest_json)?;
1061
1062    // 7) limpiar el tmp de build (no debe quedar en el árbol sellado).
1063    let _ = std::fs::remove_dir_all(&tmp);
1064    Ok(())
1065}
1066
1067/// **Producto atestado** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4), sella el
1068/// `product-rootfs` (vía [`product`], idempotente) y produce el `product-attested-rootfs`: init CON gate
1069/// + seed FIRMADA, listo para que el gate de arje lo verifique al boot (A2). Construye/cachea las recetas
1070/// de la capa de atestación (`arje-zero-attest`, `arje-packager`). Idempotente. El núcleo y el producto
1071/// base quedan intactos (Separación Mecanismo/Política): el atestado es un sello APARTE.
1072pub fn product_attested(
1073    base: &RootfsHash,
1074    base_cfg: &hammer_build::BuildConfig,
1075    recipes_dir: &Path,
1076    store: &Store,
1077    cfg: &AttestConfig,
1078) -> Result<RootfsHash> {
1079    // 0) el producto base (idempotente: si ya está sellado, sale cacheado).
1080    let base_product = product(base, base_cfg, recipes_dir, store)?;
1081
1082    // 1) recetas de la capa de atestación (idempotentes/cacheadas por el lab).
1083    let gated = build_components(&[GATED_INIT_COMPONENT], recipes_dir, base_cfg, store)?
1084        .pop()
1085        .map(|(_, h)| h)
1086        .ok_or_else(|| Error::Other("build de arje-zero-attest no devolvió hash".into()))?;
1087    let packager = build_components(&[PACKAGER_COMPONENT], recipes_dir, base_cfg, store)?
1088        .pop()
1089        .map(|(_, h)| h)
1090        .ok_or_else(|| Error::Other("build de arje-packager no devolvió hash".into()))?;
1091
1092    let gated_bin = store.path_of(&gated, GATED_INIT_COMPONENT).join("usr/bin/arje-zero");
1093    let packager_bin = store.path_of(&packager, PACKAGER_COMPONENT).join("usr/bin/arje-packager");
1094    if !gated_bin.is_file() {
1095        return Err(Error::Other(format!(
1096            "arje-zero (con gate) ausente en {}",
1097            gated_bin.display()
1098        )));
1099    }
1100    if !packager_bin.is_file() {
1101        return Err(Error::Other(format!(
1102            "arje-packager ausente en {}",
1103            packager_bin.display()
1104        )));
1105    }
1106
1107    // 2) identidad + ensamblado + sellado (si no estaba ya).
1108    let phash = product_attested_hash(&base_product, &gated, &packager, cfg);
1109    let store_name = "product-attested-rootfs";
1110    if !store.has(&phash, store_name) {
1111        let staging = store
1112            .root()
1113            .join(".bootstrap-tmp")
1114            .join(phash.store_dir_name(store_name));
1115        let _ = std::fs::remove_dir_all(&staging);
1116        std::fs::create_dir_all(&staging)?;
1117        let result = (|| -> Result<()> {
1118            assemble_attested_product_rootfs(
1119                store,
1120                &base_product,
1121                &gated_bin,
1122                &packager_bin,
1123                cfg,
1124                &staging,
1125            )?;
1126            store.seal(&staging, &phash, store_name)?;
1127            Ok(())
1128        })();
1129        let _ = std::fs::remove_dir_all(&staging);
1130        result?;
1131        tracing::info!(hash = %phash, "product: rootfs ATESTADO (init con gate + seed firmada) sellado");
1132    } else {
1133        tracing::info!(hash = %phash, "product: rootfs atestado ya sellado (idempotente)");
1134    }
1135    Ok(phash)
1136}
1137
1138// ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ──────────────────────
1139//
1140// El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando
1141// **sólo** las herramientas de Stage 1, y se compara el **content-hash** (`of_tree`, no el hash
1142// input-addressed del store) de stage1 vs stage1'. Iguales ⇒ el sistema se compila a sí mismo bit
1143// a bit (auto-alojado y reproducible). El rebuild **dentro del rootfs** (bwrap/chroot/VM) corre en
1144// la VM destino; lo que vive en hammer es la **referencia** (content-hash de stage1) y la
1145// comparación. Ver `docs/runbooks/stage1-vm-boot.md`.
1146
1147/// Veredicto de la verificación de auto-alojamiento de Stage 2.
1148#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1149pub enum Reproducibility {
1150    /// stage1 y stage1' tienen el mismo content-hash: el sistema se reconstruye bit-idéntico.
1151    Reproducible,
1152    /// Difieren: hay no-determinismo que cazar ([SDD 09 §2](../../docs/09-trust-model.md)).
1153    Divergent { stage1: ArtifactHash, rebuilt: ArtifactHash },
1154    /// El rebuild nativo dentro del rootfs aún no corrió (se hace en la VM); sólo se ancló el
1155    /// content-hash de stage1 como referencia a reproducir.
1156    RebuildPending,
1157}
1158
1159/// Reporte de Stage 2: la referencia (content-hash de stage1) y el veredicto.
1160#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1161pub struct VerifyReport {
1162    /// Hash input-addressed del rootfs en el store (el que devolvió `stage1`).
1163    pub stage1_rootfs: RootfsHash,
1164    /// Content-hash (`of_tree`) de stage1: los bytes reales que stage1' debe reproducir.
1165    pub stage1_content: ArtifactHash,
1166    pub verdict: Reproducibility,
1167}
1168
1169/// Content-hash (`ArtifactHash::of_tree`) del artefacto sellado `(hash, name)` en el store.
1170pub fn artifact_content_hash(
1171    store: &Store,
1172    hash: &ArtifactHash,
1173    name: &str,
1174) -> Result<ArtifactHash> {
1175    let dir = store.path_of(hash, name);
1176    if !dir.is_dir() {
1177        return Err(Error::Other(format!(
1178            "artefacto '{name}' no sellado en {}",
1179            dir.display()
1180        )));
1181    }
1182    ArtifactHash::of_tree(&dir).map_err(Error::Io)
1183}
1184
1185/// **Stage 2** (referencia + manifiesto). Ancla el **content-hash** del rootfs de Stage 1 como la
1186/// referencia a reproducir y lo anota en el manifiesto (línea stage 2). El rebuild nativo DENTRO
1187/// del rootfs corre en la VM (SDD 11 §3); cuando produzca stage1', se compara con [`verify_against`].
1188pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result<VerifyReport> {
1189    let content = artifact_content_hash(store, stage1, "stage1-rootfs")?;
1190    manifest::append_line(
1191        store,
1192        StageEntry {
1193            stage: 2,
1194            recipe_hash: None,
1195            artifact_hash: content.clone(),
1196            seed_hash: None,
1197            ts: now_unix(),
1198        },
1199    )?;
1200    tracing::info!(content = %content, "stage2: content-hash de stage1 anclado (referencia)");
1201    Ok(VerifyReport {
1202        stage1_rootfs: stage1.clone(),
1203        stage1_content: content,
1204        verdict: Reproducibility::RebuildPending,
1205    })
1206}
1207
1208/// Compara la referencia (`report.stage1_content`) con el content-hash de un rebuild stage1'
1209/// producido en la VM, y emite el veredicto de reproducibilidad.
1210pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> Reproducibility {
1211    if report.stage1_content == rebuilt_content {
1212        Reproducibility::Reproducible
1213    } else {
1214        Reproducibility::Divergent {
1215            stage1: report.stage1_content.clone(),
1216            rebuilt: rebuilt_content,
1217        }
1218    }
1219}
1220
1221/// Reporte de [`all`]: los tres hashes de las etapas + el manifiesto ya poblado. El veredicto de
1222/// auto-alojamiento NO va aquí (es `RebuildPending` en el host): el rebuild nativo que lo decide corre
1223/// **dentro** del rootfs, en la VM destino (`scripts/selfhost-verify.sh`, runbook §8).
1224#[derive(Debug, Clone)]
1225pub struct AllReport {
1226    pub seed_hash: ArtifactHash,
1227    pub stage1_rootfs: RootfsHash,
1228    pub stage2: VerifyReport,
1229    pub manifest: BootstrapManifest,
1230}
1231
1232/// **`bootstrap --all`** ([SDD 11 §5](../../docs/11-bootstrap.md)) — encadena las tres etapas en una
1233/// sola corrida del host: ingiere la semilla (Stage 0), construye y sella el rootfs del userland mínimo
1234/// (Stage 1) y ancla su content-hash como referencia de reproducibilidad (Stage 2). Devuelve los tres
1235/// hashes y el [`BootstrapManifest`] (`bootstrap.json`) resultante: el embrión del log de transparencia
1236/// ([SDD 11 §4](../../docs/11-bootstrap.md)), listo para publicar.
1237///
1238/// El **corte del cordón** (el veredicto `✓ REPRODUCIBLE`) no se emite aquí: el rebuild nativo que lo
1239/// decide corre con las herramientas de **dentro** del rootfs (en la VM), no las del host. `all` deja la
1240/// referencia anclada (`stage2.stage1_content`) lista para que ese rebuild la compare vía `stage2
1241/// --verify` (ver [`verify_against`] y `scripts/selfhost-verify.sh`).
1242///
1243/// Idempotente componiendo idempotencias: cada etapa no rehace lo ya sellado ni duplica su línea de
1244/// manifiesto, así que re-correr `all` sobre un store ya poblado sólo recolecta los hashes.
1245pub fn all(
1246    seed: &SeedSpec,
1247    recipes_dir: &Path,
1248    base_cfg: &hammer_build::BuildConfig,
1249    store: &Store,
1250) -> Result<AllReport> {
1251    let seed_hash = stage0(seed, store)?;
1252    let stage1_spec = Stage1Spec {
1253        seed_hash: seed_hash.clone(),
1254        seed_kind: seed.kind,
1255        recipes_dir: recipes_dir.to_path_buf(),
1256    };
1257    let stage1_rootfs = stage1(&stage1_spec, base_cfg, store)?;
1258    let stage2 = stage2(&stage1_rootfs, store)?;
1259    let manifest = BootstrapManifest::load(store)?;
1260    Ok(AllReport { seed_hash, stage1_rootfs, stage2, manifest })
1261}
1262
1263// ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ────────────────────
1264//
1265// El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no
1266// puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que,
1267// booteada en la VM, corre `hammer bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara
1268// su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit.
1269//
1270// Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por
1271// hammer). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el
1272// auto-alojamiento *puro* (variante b: el toolchain construido por hammer desde fuente, incremental).
1273//
1274// El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se
1275// ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es
1276// reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario +
1277// tag del toolchain + el propio driver), apto para el log de transparencia.
1278
1279/// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee
1280/// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al
1281/// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va
1282/// vacío, sólo imprime el content-hash de `stage1'` para comparación manual.
1283///
1284/// El toolchain es Alpine **dinámico**: sus binarios (bwrap/git/curl/cargo/make) no corren desde el
1285/// userland Stage 1, que es **estático** (no hay loader en `/lib`). El driver instala un *shim* del
1286/// loader musl (`cp` a `/lib`) + `LD_LIBRARY_PATH`/`PATH` al toolchain, y a partir de ahí esas
1287/// herramientas son ejecutables; el `hammer` estático vive en `/usr/bin/hammer`. El sandbox de build
1288/// (bwrap) anida en `/toolchain`. (El shim sólo se usa para *invocar* el toolchain desde fuera del
1289/// sandbox: fetch git/tarball; dentro del sandbox el loader está en su sitio.)
1290const REBUILD_DRIVER: &str = r#"#!/bin/sh
1291# rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7).
1292# Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y
1293# compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera.
1294set -eu
1295. /etc/hammer/rebuild.env            # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío)
1296# Shim del loader musl: deja correr los binarios *dinámicos* del toolchain (bwrap/git/curl/cargo/make)
1297# desde el root Stage 1 estático. Usa el busybox estático (rutas absolutas) ANTES de tocar el PATH.
1298/bin/mkdir -p /lib
1299/bin/cp -f /toolchain/lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
1300export LD_LIBRARY_PATH=/toolchain/lib:/toolchain/usr/lib
1301export PATH=/toolchain/usr/bin:/toolchain/usr/sbin:/toolchain/bin:/toolchain/sbin:/usr/bin:/bin:/sbin
1302# overlay es módulo (CONFIG_OVERLAY_FS=m) y el initramfs arranca sin módulos cargados: el sandbox de
1303# build (bwrap --tmp-overlay) lo exige ⇒ ENODEV sin esto. Lo cargamos del toolchain (insmod=kmod,
1304# vermagic del kernel destino). Idempotente: si ya está, insmod falla y lo ignoramos.
1305insmod /toolchain/lib/overlay.ko 2>/dev/null || true
1306# Red para el vendoring de crates (los componentes Rust hacen `cargo vendor`, que baja de crates.io).
1307# NIC e1000 (qemu user-mode 10.0.2.0/24, módulo del kernel) + config estática + DNS de slirp. La VM
1308# sin NIC ⇒ todo esto es no-op (los componentes C no necesitan red). `ip` sale del toolchain (shim).
1309insmod /toolchain/lib/e1000.ko 2>/dev/null || true
1310if ip link show eth0 >/dev/null 2>&1; then
1311    ip link set eth0 up
1312    ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || true
1313    ip route add default via 10.0.2.2 2>/dev/null || true
1314    printf 'nameserver 10.0.2.3\n' > /etc/resolv.conf
1315    echo ">> red eth0: $(ip -4 -o addr show eth0 2>/dev/null | grep -o 'inet [0-9.]*' || echo down)"
1316    # CA bundle para el TLS de cargo/curl/git (crates.io). El toolchain trae el bundle de Alpine;
1317    # lo exponemos en la ruta estándar y por env (cargo usa libcurl/openssl según el build).
1318    mkdir -p /etc/ssl/certs
1319    cp -f /toolchain/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true
1320    export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
1321    export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
1322    export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt
1323fi
1324# NOTA: el initramfs debe empaquetarse con `cpio --owner=root:root`. Si los ficheros del toolchain
1325# quedan con el uid del que armó el builder (p. ej. 1000), ese uid no está mapeado en el userns de
1326# bwrap (que mapea sólo 0→0) y el copy-up de overlay falla con EACCES al crear /opt/zig. Ver §8c.
1327export HAMMER_ROOTFS=/toolchain      # el sandbox de build: el toolchain (variante a, desde Alpine)
1328export HAMMER_WORK=/work
1329export HAMMER_CACHE=
1330echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)"
1331PRIME=$(/usr/bin/hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes)
1332echo ">> stage1' = $PRIME"
1333if [ -n "${REF_CONTENT:-}" ]; then
1334    /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT"
1335else
1336    /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME"
1337fi
1338"#;
1339
1340/// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7).
1341#[derive(Debug, Clone)]
1342pub struct BuilderSpec {
1343    /// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base
1344    /// runtime sobre la que se hidrata el toolchain.
1345    pub stage1_rootfs: RootfsHash,
1346    /// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `hammer`
1347    /// de adentro resuelva el toolchain por hash, igual que afuera.
1348    pub seed_hash: ArtifactHash,
1349    pub seed_kind: SeedKind,
1350    /// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild).
1351    pub recipes_dir: PathBuf,
1352    /// Binario `hammer` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que hammer
1353    /// construye desde fuente.
1354    pub hammer_bin: PathBuf,
1355    /// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`).
1356    pub toolchain_src: PathBuf,
1357    /// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del
1358    /// builder sin tener que hashear el árbol Alpine entero.
1359    pub toolchain_tag: String,
1360    /// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se
1361    /// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`.
1362    pub ref_content: Option<ArtifactHash>,
1363    /// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y
1364    /// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`).
1365    pub work_cache: Option<PathBuf>,
1366    /// Piezas del toolchain **construidas por hammer desde fuente** (variante b, SDD 11 §7.2b) que se
1367    /// montan sobre el path Alpine en `/toolchain`. Vacío ⇒ variante (a) pura-Alpine. Cada swap entra
1368    /// al hash lógico del builder: la procedencia deja de ser "todo Alpine" y se vuelve auditable.
1369    pub swaps: Vec<ToolchainSwap>,
1370}
1371
1372/// Una herramienta del toolchain reemplazada por su build hammer desde fuente (SDD 11 §7.2b). El
1373/// binario sellado se monta sobre `rel_path` dentro de `/toolchain`, pisando la versión Alpine; su
1374/// hash sellado se ancla en el hash lógico del builder para que el swap sea reproducible y auditable.
1375#[derive(Debug, Clone)]
1376pub struct ToolchainSwap {
1377    /// Nombre del componente/receta (p. ej. `make`); también el nombre store del artefacto sellado.
1378    pub name: String,
1379    /// `of_tree` del build hammer (el artefacto sellado del que sale el binario).
1380    pub artifact: ArtifactHash,
1381    /// Path relativo del binario dentro del artefacto **y** dentro de `/toolchain` (p. ej.
1382    /// `usr/bin/make`). El mismo en origen y destino: el layout del artefacto imita al de Alpine.
1383    pub rel_path: String,
1384}
1385
1386/// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado.
1387#[derive(Debug, Clone)]
1388pub struct BuilderReport {
1389    pub builder_hash: ArtifactHash,
1390    pub out_dir: PathBuf,
1391}
1392
1393/// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain
1394/// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder.
1395fn builder_hash(
1396    stage1: &RootfsHash,
1397    seed: &ArtifactHash,
1398    toolchain_tag: &str,
1399    recipes_digest: &str,
1400    hammer_digest: &str,
1401    swaps_digest: &str,
1402) -> ArtifactHash {
1403    ArtifactHash::of_inputs(&[
1404        b"hammer-builder-rootfs-v1",
1405        stage1.as_str().as_bytes(),
1406        seed.as_str().as_bytes(),
1407        toolchain_tag.as_bytes(),
1408        recipes_digest.as_bytes(),
1409        hammer_digest.as_bytes(),
1410        swaps_digest.as_bytes(),
1411        REBUILD_DRIVER.as_bytes(),
1412    ])
1413}
1414
1415/// Digest de los swaps del toolchain (variante b): sha256 sobre los `(name, artifact, rel_path)` en
1416/// orden por nombre. Sin swaps ⇒ string vacío, así el hash lógico de un builder pura-Alpine no cambia
1417/// respecto a antes de existir el mecanismo (compatibilidad hacia atrás de la línea de manifiesto).
1418fn swaps_digest(swaps: &[ToolchainSwap]) -> String {
1419    if swaps.is_empty() {
1420        return String::new();
1421    }
1422    use sha2::{Digest, Sha256};
1423    let mut sorted: Vec<&ToolchainSwap> = swaps.iter().collect();
1424    sorted.sort_by(|a, b| a.name.cmp(&b.name));
1425    let mut h = Sha256::new();
1426    for s in sorted {
1427        for field in [s.name.as_str(), s.artifact.as_str(), s.rel_path.as_str()] {
1428            h.update(field.as_bytes());
1429            h.update([0u8]);
1430        }
1431    }
1432    hex::encode(h.finalize())
1433}
1434
1435/// sha256 hex del contenido de un archivo.
1436fn sha256_file(path: &Path) -> Result<String> {
1437    use sha2::{Digest, Sha256};
1438    let bytes = std::fs::read(path)?;
1439    let mut h = Sha256::new();
1440    h.update(&bytes);
1441    Ok(hex::encode(h.finalize()))
1442}
1443
1444/// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden.
1445/// Cambiar una receta, añadir o quitar una, re-hashea el builder.
1446fn recipes_digest(dir: &Path) -> Result<String> {
1447    use sha2::{Digest, Sha256};
1448    let mut tomls: Vec<PathBuf> = std::fs::read_dir(dir)?
1449        .filter_map(|e| e.ok().map(|e| e.path()))
1450        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
1451        .collect();
1452    tomls.sort();
1453    let mut h = Sha256::new();
1454    for p in &tomls {
1455        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1456        h.update(name.as_bytes());
1457        h.update([0u8]);
1458        h.update(std::fs::read(p)?);
1459        h.update([0u8]);
1460    }
1461    Ok(hex::encode(h.finalize()))
1462}
1463
1464/// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y
1465/// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder
1466/// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`).
1467///
1468/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej.
1469/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el
1470/// propio destino en una recursión infinita. Se compara por path canónico.
1471fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> {
1472    if let Some(skip) = skip {
1473        if src.canonicalize().ok().as_deref() == Some(skip) {
1474            return Ok(());
1475        }
1476    }
1477    let meta = std::fs::symlink_metadata(src)?;
1478    let ft = meta.file_type();
1479    if ft.is_symlink() {
1480        let target = std::fs::read_link(src)?;
1481        let _ = std::fs::remove_file(dst);
1482        std::os::unix::fs::symlink(target, dst)?;
1483    } else if ft.is_dir() {
1484        std::fs::create_dir_all(dst)?;
1485        for entry in std::fs::read_dir(src)? {
1486            let entry = entry?;
1487            link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?;
1488        }
1489    } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() {
1490        std::fs::copy(src, dst)?;
1491    }
1492    Ok(())
1493}
1494
1495/// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el
1496/// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`,
1497/// instala `hammer` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes.
1498fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> {
1499    // 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros).
1500    let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs");
1501    if !stage1_dir.is_dir() {
1502        return Err(Error::Other(format!(
1503            "stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero",
1504            spec.stage1_rootfs,
1505            stage1_dir.display()
1506        )));
1507    }
1508    hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static, None)?;
1509
1510    // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config.
1511    for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] {
1512        std::fs::create_dir_all(staging.join(d))?;
1513    }
1514
1515    // 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs).
1516    if !spec.toolchain_src.is_dir() {
1517        return Err(Error::Other(format!(
1518            "toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)",
1519            spec.toolchain_src.display()
1520        )));
1521    }
1522    link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
1523
1524    // 3b) Swaps de variante (b): piezas construidas por hammer desde fuente pisan su versión Alpine
1525    //     en /toolchain. Un `rel_path` puede apuntar a un BINARIO (estático musl ⇒ corre sin el shim
1526    //     del loader; p.ej. make, busybox) o a un DIRECTORIO (un árbol de headers; p.ej. los
1527    //     `usr/include/{linux,asm,…}` de linux-headers). En el caso directorio se reemplaza el árbol
1528    //     entero (remove + copy) para no dejar archivos Alpine huérfanos.
1529    for swap in &spec.swaps {
1530        let art_dir = store.path_of(&swap.artifact, &swap.name);
1531        let src = art_dir.join(&swap.rel_path);
1532        let dst = staging.join("toolchain").join(&swap.rel_path);
1533        if src.is_dir() {
1534            let _ = std::fs::remove_dir_all(&dst);
1535            std::fs::create_dir_all(&dst)?;
1536            link_or_copy_tree(&src, &dst, None)?;
1537        } else if src.is_file() {
1538            if let Some(parent) = dst.parent() {
1539                std::fs::create_dir_all(parent)?;
1540            }
1541            let _ = std::fs::remove_file(&dst);
1542            if std::fs::hard_link(&src, &dst).is_err() {
1543                std::fs::copy(&src, &dst)?;
1544            }
1545            set_executable(&dst)?;
1546        } else {
1547            return Err(Error::Other(format!(
1548                "swap '{}': '{}' no existe (ni archivo ni directorio) en el artefacto sellado {} ({})",
1549                swap.name,
1550                swap.rel_path,
1551                swap.artifact,
1552                src.display(),
1553            )));
1554        }
1555    }
1556
1557    // 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
1558    //    adentro resuelva el toolchain por hash sin re-ingerirla.
1559    let seed_name = format!("seed-{}", spec.seed_kind.as_str());
1560    let seed_dir = store.path_of(&spec.seed_hash, &seed_name);
1561    if !seed_dir.is_dir() {
1562        return Err(Error::Other(format!(
1563            "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero",
1564            seed_name,
1565            seed_dir.display()
1566        )));
1567    }
1568    link_or_copy_tree(
1569        &seed_dir,
1570        &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)),
1571        None,
1572    )?;
1573
1574    // 5) Binario hammer → /usr/bin/hammer (+x).
1575    let hammer_dst = staging.join("usr/bin/hammer");
1576    if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() {
1577        std::fs::copy(&spec.hammer_bin, &hammer_dst)?;
1578    }
1579    set_executable(&hammer_dst)?;
1580
1581    // 6) Recetas → /etc/hammer/recipes.
1582    for entry in std::fs::read_dir(&spec.recipes_dir)? {
1583        let path = entry?.path();
1584        if path.extension().and_then(|x| x.to_str()) == Some("toml") {
1585            let name = path.file_name().unwrap();
1586            std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?;
1587        }
1588    }
1589
1590    // 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y
1591    //    `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se
1592    //    copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El
1593    //    `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`.
1594    if let Some(cache) = &spec.work_cache {
1595        let skip = staging.canonicalize().ok();
1596        for sub in ["repos", "tarballs"] {
1597            let src = cache.join(sub);
1598            if src.is_dir() {
1599                link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?;
1600            }
1601        }
1602    }
1603
1604    // 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo).
1605    let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or("");
1606    let env = format!(
1607        "SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n",
1608        spec.seed_hash,
1609        spec.seed_kind.as_str(),
1610        ref_content,
1611    );
1612    std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?;
1613    let driver = staging.join("usr/bin/rebuild-stage1");
1614    std::fs::write(&driver, REBUILD_DRIVER)?;
1615    set_executable(&driver)?;
1616    Ok(())
1617}
1618
1619/// Marca un archivo como ejecutable (0o755).
1620fn set_executable(path: &Path) -> Result<()> {
1621    use std::os::unix::fs::PermissionsExt;
1622    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
1623    Ok(())
1624}
1625
1626/// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada
1627/// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash
1628/// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida.
1629pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result<BuilderReport> {
1630    // Hash lógico (insumos), independiente del ensamblado en disco.
1631    let rdigest = recipes_digest(&spec.recipes_dir)?;
1632    let hdigest = sha256_file(&spec.hammer_bin)?;
1633    let sdigest = swaps_digest(&spec.swaps);
1634    let bhash = builder_hash(
1635        &spec.stage1_rootfs,
1636        &spec.seed_hash,
1637        &spec.toolchain_tag,
1638        &rdigest,
1639        &hdigest,
1640        &sdigest,
1641    );
1642
1643    let _ = std::fs::remove_dir_all(out_dir);
1644    std::fs::create_dir_all(out_dir)?;
1645    assemble_builder(spec, store, out_dir)?;
1646
1647    manifest::append_line(
1648        store,
1649        StageEntry {
1650            stage: 2,
1651            recipe_hash: None,
1652            artifact_hash: bhash.clone(),
1653            seed_hash: Some(spec.seed_hash.clone()),
1654            ts: now_unix(),
1655        },
1656    )?;
1657    tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado");
1658    Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() })
1659}
1660
1661/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
1662///
1663/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
1664/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
1665/// llega al árbol que se sella.
1666pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
1667    let h = seed.seed_hash();
1668    let name = seed.store_name();
1669    if store.has(&h, &name) {
1670        tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
1671    } else {
1672        // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
1673        // hacer el rename atómico.
1674        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
1675        let _ = std::fs::remove_dir_all(&work);
1676        std::fs::create_dir_all(&work)?;
1677
1678        // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo
1679        // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de
1680        // anotar el manifiesto: una semilla que no se selló no genera línea.
1681        let result = stage0_into(seed, store, &h, &name, &work);
1682        let _ = std::fs::remove_dir_all(&work);
1683        result?;
1684    }
1685
1686    // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
1687    // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
1688    manifest::append_line(
1689        store,
1690        StageEntry {
1691            stage: 0,
1692            recipe_hash: None,
1693            artifact_hash: h.clone(),
1694            seed_hash: Some(h.clone()),
1695            ts: now_unix(),
1696        },
1697    )?;
1698    Ok(h)
1699}
1700
1701/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
1702/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
1703/// la reproducibilidad.
1704fn now_unix() -> u64 {
1705    std::time::SystemTime::now()
1706        .duration_since(std::time::UNIX_EPOCH)
1707        .map(|d| d.as_secs())
1708        .unwrap_or(0)
1709}
1710
1711fn stage0_into(
1712    seed: &SeedSpec,
1713    store: &Store,
1714    h: &ArtifactHash,
1715    name: &str,
1716    work: &Path,
1717) -> Result<ArtifactHash> {
1718    let tarball = work.join("seed.tar");
1719    download::fetch_url_to_file(&seed.url, &tarball)?;
1720    // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
1721    download::verify_sha256(&tarball, &seed.sha256)?;
1722
1723    let tree = work.join("tree");
1724    std::fs::create_dir_all(&tree)?;
1725    untar(&tarball, &tree)?;
1726
1727    let sealed = store.seal(&tree, h, name)?;
1728    tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
1729    Ok(h.clone())
1730}
1731
1732/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
1733fn untar(tarball: &Path, into: &Path) -> Result<()> {
1734    let st = Command::new("tar")
1735        .arg("-xf")
1736        .arg(tarball)
1737        .arg("-C")
1738        .arg(into)
1739        .status()
1740        .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
1741    if !st.success() {
1742        return Err(Error::Untar(st.code()));
1743    }
1744    Ok(())
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use super::*;
1750
1751    fn spec(url: &str, sha256: &str) -> SeedSpec {
1752        SeedSpec {
1753            kind: SeedKind::Zig,
1754            version: "0.13.0".into(),
1755            url: url.into(),
1756            sha256: sha256.into(),
1757        }
1758    }
1759
1760    fn sha256_hex(bytes: &[u8]) -> String {
1761        use sha2::{Digest, Sha256};
1762        let mut h = Sha256::new();
1763        h.update(bytes);
1764        hex::encode(h.finalize())
1765    }
1766
1767    /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
1768    fn make_seed_tarball(dir: &Path) -> (String, String) {
1769        let src = dir.join("src");
1770        std::fs::create_dir_all(src.join("bin")).unwrap();
1771        std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
1772        let tarball = dir.join("seed.tar.gz");
1773        let st = Command::new("tar")
1774            .arg("-czf")
1775            .arg(&tarball)
1776            .arg("-C")
1777            .arg(&src)
1778            .arg(".")
1779            .status()
1780            .unwrap();
1781        assert!(st.success(), "tar -c falló");
1782        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
1783        (format!("file://{}", tarball.display()), sha)
1784    }
1785
1786    #[test]
1787    fn seed_hash_is_identity_based() {
1788        let a = spec("file:///mirror-a.tar", "abc123");
1789        let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
1790        assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
1791
1792        let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
1793        assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
1794
1795        let mut d = spec("file:///mirror-a.tar", "abc123");
1796        d.kind = SeedKind::MuslCrossMake;
1797        assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
1798    }
1799
1800    #[test]
1801    fn stage0_ingests_and_seals() {
1802        let tmp = tempfile::tempdir().unwrap();
1803        let (url, sha) = make_seed_tarball(tmp.path());
1804        let store = Store::open(tmp.path().join("store")).unwrap();
1805        let seed = spec(&url, &sha);
1806
1807        let h = stage0(&seed, &store).expect("stage0");
1808        assert_eq!(h, seed.seed_hash());
1809        assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
1810
1811        // El contenido extraído está presente y de sólo-lectura (sellado).
1812        let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
1813        assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
1814        use std::os::unix::fs::PermissionsExt;
1815        let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
1816        assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
1817    }
1818
1819    #[test]
1820    fn stage0_is_idempotent() {
1821        let tmp = tempfile::tempdir().unwrap();
1822        let (url, sha) = make_seed_tarball(tmp.path());
1823        let store = Store::open(tmp.path().join("store")).unwrap();
1824        let seed = spec(&url, &sha);
1825
1826        let h1 = stage0(&seed, &store).unwrap();
1827        let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
1828        assert_eq!(h1, h2);
1829    }
1830
1831    #[test]
1832    fn stage0_records_one_manifest_line() {
1833        let tmp = tempfile::tempdir().unwrap();
1834        let (url, sha) = make_seed_tarball(tmp.path());
1835        let store = Store::open(tmp.path().join("store")).unwrap();
1836        let seed = spec(&url, &sha);
1837
1838        let h = stage0(&seed, &store).unwrap();
1839        stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea
1840
1841        let m = BootstrapManifest::load(&store).unwrap();
1842        assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0");
1843        let e = &m.entries[0];
1844        assert_eq!(e.stage, 0);
1845        assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila");
1846        assert_eq!(e.artifact_hash, h);
1847        assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto");
1848    }
1849
1850    #[test]
1851    fn stage0_bad_sha_records_no_manifest_line() {
1852        let tmp = tempfile::tempdir().unwrap();
1853        let (url, _real_sha) = make_seed_tarball(tmp.path());
1854        let store = Store::open(tmp.path().join("store")).unwrap();
1855        let seed = spec(&url, &"0".repeat(64));
1856
1857        assert!(stage0(&seed, &store).is_err());
1858        // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto.
1859        assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json");
1860    }
1861
1862    #[test]
1863    fn stage0_rejects_bad_sha256_and_seals_nothing() {
1864        let tmp = tempfile::tempdir().unwrap();
1865        let (url, _real_sha) = make_seed_tarball(tmp.path());
1866        let store = Store::open(tmp.path().join("store")).unwrap();
1867        let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
1868
1869        let err = stage0(&seed, &store).unwrap_err();
1870        assert!(
1871            err.to_string().contains("sha256 mismatch"),
1872            "esperaba mismatch de sha256, vino: {err}"
1873        );
1874        assert!(
1875            !store.has(&seed.seed_hash(), &seed.store_name()),
1876            "un sha erróneo no debe sellar nada"
1877        );
1878        // Tampoco debe dejar staging a medio descargar bajo el store.
1879        assert!(
1880            !store.root().join(".bootstrap-tmp").exists()
1881                || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
1882            "el staging debe quedar limpio tras el fallo"
1883        );
1884    }
1885
1886    /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz
1887    /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain.
1888    fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) {
1889        let src = dir.join("src");
1890        let zig = src.join(zig_rel);
1891        std::fs::create_dir_all(zig.parent().unwrap()).unwrap();
1892        std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap();
1893        let tarball = dir.join("seed.tar.gz");
1894        let st = Command::new("tar")
1895            .args(["-czf"])
1896            .arg(&tarball)
1897            .arg("-C")
1898            .arg(&src)
1899            .arg(".")
1900            .status()
1901            .unwrap();
1902        assert!(st.success());
1903        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
1904        let store = Store::open(dir.join("store")).unwrap();
1905        let seed = spec(&format!("file://{}", tarball.display()), &sha);
1906        stage0(&seed, &store).unwrap();
1907        (store, seed)
1908    }
1909
1910    #[test]
1911    fn toolchain_dir_finds_zig_in_versioned_subdir() {
1912        let tmp = tempfile::tempdir().unwrap();
1913        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1914        let dir = seed.toolchain_dir(&store).unwrap();
1915        assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display());
1916        assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display());
1917    }
1918
1919    #[test]
1920    fn toolchain_dir_finds_zig_at_root() {
1921        let tmp = tempfile::tempdir().unwrap();
1922        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig");
1923        let dir = seed.toolchain_dir(&store).unwrap();
1924        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1925        assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla");
1926    }
1927
1928    #[test]
1929    fn toolchain_dir_errors_when_unsealed() {
1930        let tmp = tempfile::tempdir().unwrap();
1931        let store = Store::open(tmp.path().join("store")).unwrap();
1932        let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada
1933        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1934        assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}");
1935    }
1936
1937    #[test]
1938    fn toolchain_dir_errors_when_ambiguous() {
1939        let tmp = tempfile::tempdir().unwrap();
1940        // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas.
1941        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig");
1942        let base = store.path_of(&seed.seed_hash(), &seed.store_name());
1943        // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test.
1944        let b = base.join("b");
1945        let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status();
1946        std::fs::create_dir_all(&b).unwrap();
1947        std::fs::write(b.join("zig"), b"x").unwrap();
1948        let err = seed.toolchain_dir(&store).unwrap_err().to_string();
1949        assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}");
1950    }
1951
1952    #[test]
1953    fn seed_build_config_points_zig_dir_at_seed() {
1954        let tmp = tempfile::tempdir().unwrap();
1955        let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig");
1956        let base = hammer_build::BuildConfig::defaults_for_store(store.root());
1957        let cfg = seed_build_config(&base, &seed, &store).unwrap();
1958        assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap());
1959        // El resto de la config se hereda sin cambios.
1960        assert_eq!(cfg.rootfs, base.rootfs);
1961        assert_eq!(cfg.work_root, base.work_root);
1962    }
1963
1964    // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real ---
1965
1966    /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol
1967    /// antes del `seal` (que lo marca read-only).
1968    fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash {
1969        let h = ArtifactHash::from_hex(hex);
1970        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name));
1971        std::fs::create_dir_all(&work).unwrap();
1972        build(&work);
1973        store.seal(&work, &h, name).unwrap();
1974        h
1975    }
1976
1977    #[test]
1978    fn rootfs_hash_is_deterministic_and_sensitive() {
1979        let base = [
1980            ("musl".to_string(), ArtifactHash::from_hex("11")),
1981            ("busybox".to_string(), ArtifactHash::from_hex("22")),
1982        ];
1983        let a = rootfs_hash(&base);
1984        assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash");
1985
1986        let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))];
1987        assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea");
1988
1989        let reordered = [base[1].clone(), base[0].clone()];
1990        assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa");
1991    }
1992
1993    #[test]
1994    fn assemble_rootfs_hydrates_and_installs_arje_init() {
1995        let tmp = tempfile::tempdir().unwrap();
1996        let store = Store::open(tmp.path().join("store")).unwrap();
1997
1998        let hmusl = seal_component(&store, "musl", "aa11", |w| {
1999            std::fs::create_dir_all(w.join("usr/lib")).unwrap();
2000            std::fs::write(w.join("usr/lib/libc.a"), b"!<arch>\n").unwrap();
2001        });
2002        let hbb = seal_component(&store, "busybox", "bb22", |w| {
2003            std::fs::create_dir_all(w.join("bin")).unwrap();
2004            std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap();
2005            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
2006            // Sidecar de provenance que NO debe terminar en el rootfs.
2007            std::fs::create_dir_all(w.join(".hammer")).unwrap();
2008            std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap();
2009        });
2010        let harje = seal_component(&store, "arje-zero", "cc33", |w| {
2011            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2012            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap();
2013        });
2014
2015        let staging = store.root().join("staging");
2016        std::fs::create_dir_all(&staging).unwrap();
2017        let components = [
2018            ("musl".to_string(), hmusl),
2019            ("busybox".to_string(), hbb),
2020            ("arje-zero".to_string(), harje),
2021        ];
2022        assemble_rootfs(&store, &components, &staging).unwrap();
2023
2024        assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado");
2025        assert!(staging.join("bin/busybox").is_file(), "busybox hidratado");
2026        assert!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado");
2027        // arje-zero es PID 1 vía /sbin/init.
2028        assert_eq!(
2029            std::fs::read_link(staging.join("sbin/init")).unwrap(),
2030            std::path::PathBuf::from("/usr/bin/arje-zero"),
2031            "/sbin/init apunta a arje-zero"
2032        );
2033        // La seed card está en su lugar y es JSON válido con los genesis esperados.
2034        let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
2035        let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
2036        assert_eq!(v["label"], "hammer-stage1");
2037        let labels: Vec<&str> = v["genesis"].as_array().unwrap()
2038            .iter().map(|g| g["label"].as_str().unwrap()).collect();
2039        assert_eq!(labels, vec!["hammerd", "console-getty"]);
2040        // Puntos de montaje que arje monta solos, presentes como dirs vacíos.
2041        assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir());
2042        assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs");
2043        // Ya no hay init provisional de busybox.
2044        assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado");
2045    }
2046
2047    #[test]
2048    fn stage1_seed_card_is_valid_json_with_native_hammerd() {
2049        // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart
2050        // (el CRASHED real) — un template roto rompería el boot de arje-zero.
2051        let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap();
2052        assert_eq!(v["payload"], "Virtual", "la semilla es Virtual");
2053        let hammerd = &v["genesis"][0];
2054        assert_eq!(hammerd["label"], "hammerd");
2055        assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd");
2056        assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart");
2057        let getty = &v["genesis"][1];
2058        assert_eq!(getty["payload"]["Native"]["exec"], "/bin/busybox");
2059    }
2060
2061    // --- Capa de servicios (producto): composición de seed + hidratación tardía ---
2062
2063    #[test]
2064    fn product_seed_appends_sshd_to_base_genesis() {
2065        // La seed de producto = base + sshd; el núcleo (hammerd, getty) se preserva intacto y el
2066        // resultado es JSON válido (lo que arje-zero validará al boot).
2067        let seed = product_seed_card().unwrap();
2068        let v: serde_json::Value = serde_json::from_str(&seed).unwrap();
2069        assert_eq!(v["label"], "hammer-product");
2070        assert_eq!(v["payload"], "Virtual");
2071        let labels: Vec<&str> = v["genesis"].as_array().unwrap()
2072            .iter().map(|g| g["label"].as_str().unwrap()).collect();
2073        assert_eq!(labels, vec!["hammerd", "console-getty", "sshd"], "núcleo intacto + sshd añadido");
2074        let sshd = &v["genesis"][2];
2075        assert_eq!(sshd["payload"]["Native"]["exec"], "/bin/busybox");
2076        assert!(sshd["payload"]["Native"]["argv"][2].as_str().unwrap().contains("/usr/sbin/sshd"));
2077        assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)");
2078    }
2079
2080    #[test]
2081    fn product_rootfs_hash_is_deterministic_and_sensitive() {
2082        let base = ArtifactHash::from_hex("ab");
2083        let userland = [("uutils".to_string(), ArtifactHash::from_hex("11"))];
2084        let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))];
2085        let seed = product_seed_card().unwrap();
2086        let a = product_rootfs_hash(&base, &userland, &svcs, &seed);
2087        assert_eq!(a, product_rootfs_hash(&base, &userland, &svcs, &seed), "determinista");
2088        let other_base = ArtifactHash::from_hex("ef");
2089        assert_ne!(a, product_rootfs_hash(&other_base, &userland, &svcs, &seed), "cambiar la base re-hashea");
2090        let other_userland = [("uutils".to_string(), ArtifactHash::from_hex("22"))];
2091        assert_ne!(a, product_rootfs_hash(&base, &other_userland, &svcs, &seed), "cambiar el userland re-hashea");
2092        let other_svc = [("openssh".to_string(), ArtifactHash::from_hex("99"))];
2093        assert_ne!(a, product_rootfs_hash(&base, &userland, &other_svc, &seed), "cambiar un servicio re-hashea");
2094    }
2095
2096    #[test]
2097    fn assemble_product_injects_services_over_base() {
2098        let tmp = tempfile::tempdir().unwrap();
2099        let store = Store::open(tmp.path().join("store")).unwrap();
2100
2101        // Un rootfs base sintético sellado como `stage1-rootfs` (el 4/4 ya verificado): trae la seed
2102        // base, /sbin/init, un mountpoint vacío, un binario del núcleo y un applet busybox `ls`.
2103        let base = seal_component(&store, "stage1-rootfs", "ba5e", |w| {
2104            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2105            std::fs::create_dir_all(w.join("bin")).unwrap();
2106            std::fs::create_dir_all(w.join("ente")).unwrap();
2107            std::fs::create_dir_all(w.join("proc")).unwrap();
2108            std::fs::create_dir_all(w.join("var")).unwrap();
2109            std::fs::create_dir_all(w.join("root")).unwrap();
2110            std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELFfake").unwrap();
2111            std::fs::write(w.join("bin/busybox"), b"\x7fELFbb").unwrap();
2112            // busybox instala el applet `ls` como symlink en /usr/bin (lo ensombrece la hidratación) y
2113            // `cat` en /bin (lo debe RETIRAR el adelgazamiento). `sh` en /bin NO se reemplaza (queda).
2114            std::os::unix::fs::symlink("../../bin/busybox", w.join("usr/bin/ls")).unwrap();
2115            std::os::unix::fs::symlink("busybox", w.join("bin/cat")).unwrap();
2116            std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap();
2117            std::fs::write(w.join("ente/seed.card.json"), STAGE1_SEED_CARD).unwrap();
2118            std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin_init_tmp")).unwrap();
2119        });
2120        // Userland Rust sintético (uutils): coreutils multicall + symlink `ls`→coreutils.
2121        let huu = seal_component(&store, "uutils", "c0re", |w| {
2122            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2123            std::fs::write(w.join("usr/bin/coreutils"), b"\x7fELFuu").unwrap();
2124            std::os::unix::fs::symlink("coreutils", w.join("usr/bin/ls")).unwrap();
2125            std::os::unix::fs::symlink("coreutils", w.join("usr/bin/cat")).unwrap();
2126        });
2127        // Componentes de servicio sintéticos.
2128        let hossh = seal_component(&store, "openssh", "0551", |w| {
2129            std::fs::create_dir_all(w.join("usr/sbin")).unwrap();
2130            std::fs::write(w.join("usr/sbin/sshd"), b"\x7fELFfake").unwrap();
2131        });
2132        let hnet = seal_component(&store, "netup", "9e72", |w| {
2133            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2134            std::fs::write(w.join("usr/bin/netup"), b"\x7fELFfake").unwrap();
2135        });
2136
2137        let seed = product_seed_card().unwrap();
2138        let userland = [("uutils".to_string(), huu)];
2139        let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)];
2140        let staging = store.root().join("prod-staging");
2141        std::fs::create_dir_all(&staging).unwrap();
2142        assemble_product_rootfs(&store, &base, &userland, &services, &seed, &staging).unwrap();
2143
2144        // base hidratada + userland + servicios inyectados.
2145        assert!(staging.join("usr/bin/hammerd").is_file(), "núcleo presente");
2146        assert!(staging.join("proc").is_dir(), "mountpoint vacío recreado");
2147        assert!(staging.join("usr/bin/coreutils").is_file(), "uutils inyectado");
2148        assert!(staging.join("usr/sbin/sshd").is_file(), "openssh inyectado");
2149        assert!(staging.join("usr/bin/netup").is_file(), "netup inyectado");
2150        // ADELGAZAR BUSYBOX: /usr/bin/ls apunta ahora a coreutils (Rust), no a busybox.
2151        assert_eq!(
2152            std::fs::read_link(staging.join("usr/bin/ls")).unwrap(),
2153            std::path::PathBuf::from("coreutils"),
2154            "el userland Rust ensombrece el applet busybox en /usr/bin"
2155        );
2156        // El applet busybox `cat` en /bin fue RETIRADO (uutils provee cat) ⇒ no compite en PATH.
2157        assert!(!staging.join("bin/cat").exists(), "el applet busybox /bin/cat reemplazado fue retirado");
2158        // Lo NO reemplazado por Rust (sh) y el binario busybox quedan intactos.
2159        assert!(staging.join("bin/sh").exists(), "/bin/sh (ash) interino se preserva");
2160        assert!(staging.join("bin/busybox").is_file(), "el binario busybox queda (fallback)");
2161        // la seed fue sobrescrita con la de producto (3 cards).
2162        let got = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap();
2163        let v: serde_json::Value = serde_json::from_str(&got).unwrap();
2164        assert_eq!(v["label"], "hammer-product");
2165        assert_eq!(v["genesis"].as_array().unwrap().len(), 3);
2166        // configs de servicio.
2167        assert!(std::fs::read_to_string(staging.join("etc/passwd")).unwrap().contains("sshd:"));
2168        assert!(std::fs::read_to_string(staging.join("etc/ssh/sshd_config")).unwrap().contains("Port 22"));
2169        // /var/empty con permisos 0711 (privsep no acepta group/world-writable).
2170        use std::os::unix::fs::PermissionsExt;
2171        let mode = std::fs::metadata(staging.join("var/empty")).unwrap().permissions().mode() & 0o777;
2172        assert_eq!(mode, 0o711, "/var/empty debe ser 0711");
2173        assert!(staging.join("root/.ssh").is_dir(), "/root/.ssh para authorized_keys");
2174        // Mountpoints de disco (el producto es disk-ready): /store + /var/lib/hammer existen vacíos.
2175        assert!(staging.join("store").is_dir() && staging.join("var/lib/hammer").is_dir(),
2176            "/store y /var/lib/hammer como mountpoints del disco de producto");
2177        // Atestación: el producto emite /ente/attest.json y se auto-verifica íntegro.
2178        assert!(staging.join("ente/attest.json").is_file(), "manifiesto de atestación emitido");
2179        let att = verify_attestation(&staging).unwrap();
2180        assert!(att.passed(), "el product-rootfs recién ensamblado pasa su propia atestación");
2181        // cubre los binarios presentes del set crítico (hammerd, busybox, sshd, netup, coreutils).
2182        assert!(att.ok.iter().any(|p| p == "/usr/sbin/sshd") && att.ok.iter().any(|p| p == "/usr/bin/coreutils"));
2183    }
2184
2185    #[test]
2186    fn attestation_manifest_emits_and_verifies_and_catches_tamper() {
2187        let tmp = tempfile::tempdir().unwrap();
2188        let root = tmp.path().join("rootfs");
2189        for d in ["usr/bin", "bin", "usr/sbin", "ente"] {
2190            std::fs::create_dir_all(root.join(d)).unwrap();
2191        }
2192        std::fs::write(root.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap();
2193        std::fs::write(root.join("bin/busybox"), b"\x7fELF-busybox").unwrap();
2194        std::fs::write(root.join("usr/sbin/sshd"), b"\x7fELF-sshd").unwrap();
2195        // arje-zero/netup/coreutils ausentes ⇒ no se listan (sólo binarios presentes).
2196
2197        let manifest = build_attestation(&root).unwrap();
2198        assert_eq!(manifest.algo, "blake3");
2199        let paths: Vec<&str> = manifest.entries.iter().map(|e| e.path.as_str()).collect();
2200        assert!(paths.contains(&"/usr/bin/hammerd") && paths.contains(&"/bin/busybox") && paths.contains(&"/usr/sbin/sshd"));
2201        assert!(!paths.contains(&"/usr/bin/arje-zero"), "binario ausente no se atesta");
2202        std::fs::write(root.join("ente/attest.json"), serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
2203
2204        // verificación íntegra: pasa.
2205        let rep = verify_attestation(&root).unwrap();
2206        assert!(rep.passed() && rep.ok.len() == manifest.entries.len());
2207
2208        // manipular un binario ⇒ DIVERGE (el gate de arje haría halt).
2209        std::fs::write(root.join("bin/busybox"), b"\x7fELF-TAMPERED-longer").unwrap();
2210        let rep2 = verify_attestation(&root).unwrap();
2211        assert!(!rep2.passed());
2212        assert_eq!(rep2.mismatched, vec!["/bin/busybox".to_string()]);
2213
2214        // borrar un binario crítico ⇒ FALTA.
2215        std::fs::remove_file(root.join("usr/sbin/sshd")).unwrap();
2216        let rep3 = verify_attestation(&root).unwrap();
2217        assert!(rep3.missing.contains(&"/usr/sbin/sshd".to_string()) && !rep3.passed());
2218    }
2219
2220    #[test]
2221    fn assemble_attested_swaps_gate_signs_seed_and_regenerates_attest() {
2222        // Hermético: un `arje-packager` SINTÉTICO (script python) simula la firma — añade `attest`/
2223        // `attest_rootkey` al seed-out. Valida el CABLEADO de hammer (pisar el init, romper hardlinks,
2224        // fijar attest_policy, derivar los --bin de la seed, regenerar /ente/attest.json), sin la cripto
2225        // real (eso lo cubre E2E `scripts/attest-boot-test.sh` en QEMU).
2226        use std::os::unix::fs::PermissionsExt;
2227        let tmp = tempfile::tempdir().unwrap();
2228        let store = Store::open(tmp.path().join("store")).unwrap();
2229
2230        // product-rootfs base sellado: seed de producto + /ente/attest.json + los bins críticos.
2231        let base = seal_component(&store, "product-rootfs", "9a07", |w| {
2232            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2233            std::fs::create_dir_all(w.join("bin")).unwrap();
2234            std::fs::create_dir_all(w.join("ente")).unwrap();
2235            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELF-arje-zero-CORE").unwrap();
2236            std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap();
2237            std::fs::write(w.join("bin/busybox"), b"\x7fELF-busybox").unwrap();
2238            std::fs::write(w.join("ente/seed.card.json"), product_seed_card().unwrap()).unwrap();
2239            // attest.json del producto base (con el arje-zero del NÚCLEO) — debe regenerarse.
2240            let a = build_attestation(w).unwrap();
2241            std::fs::write(w.join("ente/attest.json"), serde_json::to_string(&a).unwrap()).unwrap();
2242        });
2243
2244        // init CON gate (bytes distintos al del núcleo) y packager sintético, fuera del store.
2245        let gated_bin = tmp.path().join("arje-zero-gated");
2246        std::fs::write(&gated_bin, b"\x7fELF-arje-zero-GATED-bigger").unwrap();
2247        let pkgr = tmp.path().join("fake-packager");
2248        std::fs::write(&pkgr, FAKE_PACKAGER).unwrap();
2249        std::fs::set_permissions(&pkgr, std::fs::Permissions::from_mode(0o755)).unwrap();
2250
2251        let staging = store.root().join(".bootstrap-tmp/test-attested");
2252        std::fs::create_dir_all(&staging).unwrap();
2253        let cfg = AttestConfig { policy: "halt".into(), rootkey: [3u8; 32] };
2254        assemble_attested_product_rootfs(&store, &base, &gated_bin, &pkgr, &cfg, &staging).unwrap();
2255
2256        // 1) /usr/bin/arje-zero ahora es el del gate (hardlink roto, no mutó el inodo del store).
2257        assert_eq!(
2258            std::fs::read(staging.join("usr/bin/arje-zero")).unwrap(),
2259            b"\x7fELF-arje-zero-GATED-bigger",
2260            "el init con gate pisó al del núcleo"
2261        );
2262        // El sello del store NO se tocó (su arje-zero sigue siendo el del núcleo).
2263        assert_eq!(
2264            std::fs::read(store.path_of(&base, "product-rootfs").join("usr/bin/arje-zero")).unwrap(),
2265            b"\x7fELF-arje-zero-CORE",
2266            "romper el hardlink no mutó el store"
2267        );
2268        // 2) seed firmada: el packager recibió attest_policy=halt y los 4 --bin derivados de la seed.
2269        let seed: serde_json::Value =
2270            serde_json::from_str(&std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap()).unwrap();
2271        assert_eq!(seed["attest_policy"], "halt");
2272        assert!(seed["attest"].is_array(), "el packager firmó (Card::attest)");
2273        let bins_seen = std::fs::read_to_string(staging.join("..").join("bins-seen.txt")).unwrap();
2274        for label in ["arje-zero", "hammerd", "console-getty", "sshd"] {
2275            assert!(bins_seen.contains(&format!("{label}=")), "--bin {label} pasado al packager:\n{bins_seen}");
2276        }
2277        // 3) ANCLA SOBERANA EXTERNA: /etc/arje/rootkey.pub = la pubkey del seed firmado (32 bytes raw).
2278        let anchor = std::fs::read(staging.join("etc/arje/rootkey.pub")).unwrap();
2279        assert_eq!(anchor.len(), 32, "rootkey.pub son 32 bytes raw");
2280        assert_eq!(anchor, vec![7u8; 32], "la pubkey del seed firmado anclada fuera de la Card");
2281        // 4) /ente/attest.json regenerado: pasa con el arje-zero NUEVO (el del núcleo divergiría).
2282        assert!(verify_attestation(&staging).unwrap().passed(), "attest.json regenerado y coherente");
2283        // 5) el tmp de build se limpió.
2284        assert!(!staging.join(".attest-build").exists());
2285    }
2286
2287    /// `arje-packager` sintético para el test hermético: copia `--seed`→`--seed-out` inyectando
2288    /// `attest`/`attest_rootkey` (simula la firma) y vuelca los `--bin` vistos a `../bins-seen.txt`.
2289    const FAKE_PACKAGER: &str = r#"#!/usr/bin/env python3
2290import json, sys
2291seed=out=None; bins=[]
2292a=sys.argv[1:]
2293while a:
2294    f=a.pop(0)
2295    if f=="--seed": seed=a.pop(0)
2296    elif f=="--seed-out": out=a.pop(0)
2297    elif f=="--rootkey": a.pop(0)
2298    elif f=="--bin": bins.append(a.pop(0))
2299    else: pass
2300d=json.load(open(seed))
2301d["attest"]=[{"label":b.split("=",1)[0]} for b in bins]
2302d["attest_rootkey"]=[7]*32  # pubkey de 32 bytes (como la del packager real)
2303json.dump(d, open(out,"w"), indent=2)
2304import os
2305open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n".join(bins))
2306"#;
2307
2308    #[test]
2309    fn product_attested_hash_is_deterministic_and_sensitive() {
2310        let base = ArtifactHash::from_hex("ab");
2311        let gated = ArtifactHash::from_hex("cd");
2312        let pkgr = ArtifactHash::from_hex("ef");
2313        let cfg = AttestConfig::default();
2314        let a = product_attested_hash(&base, &gated, &pkgr, &cfg);
2315        assert_eq!(a, product_attested_hash(&base, &gated, &pkgr, &cfg), "determinista");
2316        // Cada insumo re-hashea.
2317        assert_ne!(a, product_attested_hash(&ArtifactHash::from_hex("11"), &gated, &pkgr, &cfg), "base");
2318        assert_ne!(a, product_attested_hash(&base, &ArtifactHash::from_hex("22"), &pkgr, &cfg), "gate");
2319        assert_ne!(a, product_attested_hash(&base, &gated, &ArtifactHash::from_hex("33"), &cfg), "packager");
2320        let other_policy = AttestConfig { policy: "warn".into(), ..AttestConfig::default() };
2321        assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_policy), "política");
2322        let other_key = AttestConfig { rootkey: [7u8; 32], ..AttestConfig::default() };
2323        assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_key), "rootkey");
2324        // El producto atestado es un sello DISTINTO del producto base (no colisionan).
2325        assert_ne!(a.as_str(), base.as_str(), "el atestado no es el producto base");
2326    }
2327
2328    #[test]
2329    fn critical_bins_are_derived_from_the_seed_native_execs() {
2330        // De la seed de producto real: PID 1 (arje-zero, el current_exe del gate) + un --bin por cada
2331        // exec Native del genesis (hammerd, console-getty, sshd). Los labels deben casar los de las cards
2332        // (lo que el gate busca en las concesiones firmadas) y las rutas resolver dentro del staging.
2333        let staging = Path::new("/tmp/fake-staging");
2334        let seed: serde_json::Value =
2335            serde_json::from_str(&product_seed_card().unwrap()).unwrap();
2336        let bins = critical_bins_from_seed(&seed, staging);
2337        let labels: Vec<&str> = bins.iter().map(|(l, _)| l.as_str()).collect();
2338        assert_eq!(
2339            labels,
2340            vec!["arje-zero", "hammerd", "console-getty", "sshd"],
2341            "PID 1 primero, luego DFS del genesis"
2342        );
2343        // El exec de cada card se mapea a su ruta absoluta dentro del staging.
2344        let by_label = |l: &str| bins.iter().find(|(x, _)| x == l).map(|(_, p)| p.clone()).unwrap();
2345        assert_eq!(by_label("arje-zero"), staging.join("usr/bin/arje-zero"));
2346        assert_eq!(by_label("hammerd"), staging.join("usr/bin/hammerd"));
2347        // console-getty y sshd ejecutan ambos /bin/busybox ⇒ misma ruta, labels distintos.
2348        assert_eq!(by_label("console-getty"), staging.join("bin/busybox"));
2349        assert_eq!(by_label("sshd"), staging.join("bin/busybox"));
2350    }
2351
2352    #[test]
2353    fn shipped_product_recipes_parse_and_hash() {
2354        // Las recetas de producto reales (userland Rust + servicios) cargan y hashean — viven fuera
2355        // del núcleo (no entran a STAGE1_COMPONENTS ni al selfhost-verify).
2356        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
2357        let tmp = tempfile::tempdir().unwrap();
2358        let store = Store::open(tmp.path().join("store")).unwrap();
2359        for name in USERLAND_COMPONENTS.iter().chain(SERVICE_COMPONENTS.iter()) {
2360            let path = recipes_dir.join(format!("{name}.toml"));
2361            let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
2362            assert_eq!(&r.name, name);
2363            let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests()).expect("hash");
2364            assert!(h.as_str().starts_with("b3:"));
2365        }
2366    }
2367
2368    #[test]
2369    fn shipped_stage1_recipes_parse_and_hash() {
2370        // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK).
2371        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
2372        let tmp = tempfile::tempdir().unwrap();
2373        let store = Store::open(tmp.path().join("store")).unwrap();
2374        for name in STAGE1_COMPONENTS {
2375            let path = recipes_dir.join(format!("{name}.toml"));
2376            let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}"));
2377            assert_eq!(&r.name, name);
2378            let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests()).expect("hash");
2379            assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:");
2380        }
2381    }
2382
2383    #[test]
2384    fn stage2_anchors_content_hash_and_verifies() {
2385        let tmp = tempfile::tempdir().unwrap();
2386        let store = Store::open(tmp.path().join("store")).unwrap();
2387        // Sella un "rootfs" sintético con el nombre que espera stage2.
2388        let h = seal_component(&store, "stage1-rootfs", "abcd", |w| {
2389            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2390            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake-init").unwrap();
2391        });
2392
2393        let report = stage2(&h, &store).unwrap();
2394        assert_eq!(report.stage1_rootfs, h);
2395        assert!(report.stage1_content.as_str().starts_with("b3:"));
2396        assert_eq!(report.verdict, Reproducibility::RebuildPending);
2397
2398        // El manifiesto anotó la línea stage 2 con el content-hash como artefacto.
2399        let m = BootstrapManifest::load(&store).unwrap();
2400        let e2 = m.entries.iter().find(|e| e.stage == 2).expect("línea stage 2");
2401        assert_eq!(e2.artifact_hash, report.stage1_content);
2402
2403        // verify_against: un rebuild idéntico ⇒ Reproducible; distinto ⇒ Divergent.
2404        assert_eq!(
2405            verify_against(&report, report.stage1_content.clone()),
2406            Reproducibility::Reproducible
2407        );
2408        match verify_against(&report, ArtifactHash::from_hex("ffff")) {
2409            Reproducibility::Divergent { stage1, rebuilt } => {
2410                assert_eq!(stage1, report.stage1_content);
2411                assert_eq!(rebuilt, ArtifactHash::from_hex("ffff"));
2412            }
2413            v => panic!("esperaba Divergent, vino {v:?}"),
2414        }
2415    }
2416
2417    // --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM ---
2418
2419    /// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos.
2420    fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) {
2421        let store = Store::open(dir.join("store")).unwrap();
2422
2423        // Stage 1 rootfs sellado (con el nombre que espera el builder).
2424        let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| {
2425            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2426            std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap();
2427            std::fs::create_dir_all(w.join("sbin")).unwrap();
2428            std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap();
2429        });
2430
2431        // Semilla zig sellada con bin/zig (nombre store seed-zig).
2432        let seed = seal_component(&store, "seed-zig", "5ee0", |w| {
2433            std::fs::create_dir_all(w.join("bin")).unwrap();
2434            std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap();
2435        });
2436
2437        // Recetas, binario hammer y toolchain (Alpine sintético), fuera del store.
2438        let recipes = dir.join("recipes");
2439        std::fs::create_dir_all(&recipes).unwrap();
2440        std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap();
2441        std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap();
2442
2443        let hammer_bin = dir.join("hammer");
2444        std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap();
2445
2446        let toolchain = dir.join("alpine");
2447        std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap();
2448        std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap();
2449
2450        let spec = BuilderSpec {
2451            stage1_rootfs: stage1,
2452            seed_hash: seed,
2453            seed_kind: SeedKind::Zig,
2454            recipes_dir: recipes,
2455            hammer_bin,
2456            toolchain_src: toolchain,
2457            toolchain_tag: "alpine-test".into(),
2458            ref_content: Some(ArtifactHash::from_hex("dead")),
2459            work_cache: None,
2460            swaps: vec![],
2461        };
2462        (store, spec)
2463    }
2464
2465    #[test]
2466    fn builder_rootfs_assembles_full_image() {
2467        let tmp = tempfile::tempdir().unwrap();
2468        let (store, spec) = builder_fixture(tmp.path());
2469        let out = tmp.path().join("builder-out");
2470
2471        let report = builder_rootfs(&spec, &store, &out).unwrap();
2472        assert!(report.builder_hash.as_str().starts_with("b3:"));
2473        assert_eq!(report.out_dir, out);
2474
2475        // Base Stage 1 hidratada (init + symlink).
2476        assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado");
2477        assert_eq!(
2478            std::fs::read_link(out.join("sbin/init")).unwrap(),
2479            PathBuf::from("/usr/bin/arje-zero"),
2480        );
2481        // Toolchain en /toolchain, semilla en /store, hammer + recetas + driver.
2482        assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado");
2483        assert!(
2484            out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(),
2485            "semilla replicada en /store",
2486        );
2487        assert!(out.join("usr/bin/hammer").is_file(), "binario hammer");
2488        assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas");
2489        assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild");
2490
2491        // El driver y hammer son ejecutables.
2492        use std::os::unix::fs::PermissionsExt;
2493        let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap();
2494        assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x");
2495
2496        // rebuild.env lleva la semilla, la clase y la referencia embebidas.
2497        let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
2498        assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}");
2499        assert!(env.contains("SEED_KIND=zig"), "{env}");
2500        assert!(env.contains("REF_CONTENT=b3:dead"), "{env}");
2501
2502        // Línea de manifiesto (stage 2) con el hash lógico del builder.
2503        let man = BootstrapManifest::load(&store).unwrap();
2504        assert!(
2505            man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash),
2506            "el builder debe anotar su línea en el manifiesto",
2507        );
2508    }
2509
2510    #[test]
2511    fn builder_hash_is_deterministic_and_sensitive() {
2512        let s1 = ArtifactHash::from_hex("11");
2513        let seed = ArtifactHash::from_hex("22");
2514        let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "");
2515        assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", ""), "puro");
2516        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd", ""), "tag del toolchain");
2517        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd", ""), "recetas");
2518        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2", ""), "binario hammer");
2519        assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "sd"), "swaps del toolchain");
2520    }
2521
2522    #[test]
2523    fn swaps_digest_empty_is_blank_and_order_insensitive() {
2524        assert_eq!(swaps_digest(&[]), "", "sin swaps ⇒ digest vacío (compat hacia atrás)");
2525        let mk = |n: &str, h: &str| ToolchainSwap {
2526            name: n.into(),
2527            artifact: ArtifactHash::from_hex(h),
2528            rel_path: format!("usr/bin/{n}"),
2529        };
2530        let a = swaps_digest(&[mk("make", "aa"), mk("bwrap", "bb")]);
2531        let b = swaps_digest(&[mk("bwrap", "bb"), mk("make", "aa")]);
2532        assert_eq!(a, b, "el digest se ordena por nombre ⇒ insensible al orden de entrada");
2533        assert!(!a.is_empty());
2534        assert_ne!(a, swaps_digest(&[mk("make", "ac"), mk("bwrap", "bb")]), "cambia con el hash");
2535    }
2536
2537    #[test]
2538    fn builder_rootfs_swaps_toolchain_tool_from_source() {
2539        let tmp = tempfile::tempdir().unwrap();
2540        let (store, mut spec) = builder_fixture(tmp.path());
2541
2542        // Artefacto sellado de un `make` "construido por hammer" con un binario distinto al de Alpine.
2543        let make = seal_component(&store, "make", "fbad", |w| {
2544            std::fs::create_dir_all(w.join("usr/bin")).unwrap();
2545            std::fs::write(w.join("usr/bin/make"), b"\x7fELF-hammer-make-static").unwrap();
2546        });
2547        spec.swaps = vec![ToolchainSwap {
2548            name: "make".into(),
2549            artifact: make,
2550            rel_path: "usr/bin/make".into(),
2551        }];
2552
2553        let out = tmp.path().join("builder-out");
2554        let report = builder_rootfs(&spec, &store, &out).unwrap();
2555
2556        // El make de /toolchain es ahora el de hammer, no el de Alpine.
2557        let got = std::fs::read(out.join("toolchain/usr/bin/make")).unwrap();
2558        assert_eq!(got, b"\x7fELF-hammer-make-static", "el swap pisó el make de Alpine");
2559
2560        // El hash lógico cambia respecto a un builder pura-Alpine (la procedencia es auditable).
2561        let mut pure = spec.clone();
2562        pure.swaps = vec![];
2563        let pure_report = builder_rootfs(&pure, &store, &tmp.path().join("builder-pure")).unwrap();
2564        assert_ne!(report.builder_hash, pure_report.builder_hash, "el swap entra al hash lógico");
2565    }
2566
2567    #[test]
2568    fn builder_rootfs_errors_when_swap_artifact_missing_binary() {
2569        let tmp = tempfile::tempdir().unwrap();
2570        let (store, mut spec) = builder_fixture(tmp.path());
2571        // Artefacto sellado pero SIN el binario esperado en rel_path.
2572        let make = seal_component(&store, "make", "fbad", |w| {
2573            std::fs::create_dir_all(w.join("usr/share")).unwrap();
2574            std::fs::write(w.join("usr/share/x"), b"no-binary").unwrap();
2575        });
2576        spec.swaps = vec![ToolchainSwap {
2577            name: "make".into(),
2578            artifact: make,
2579            rel_path: "usr/bin/make".into(),
2580        }];
2581        let out = tmp.path().join("builder-out");
2582        let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
2583        assert!(err.contains("no existe") && err.contains("artefacto sellado"), "{err}");
2584    }
2585
2586    #[test]
2587    fn builder_rootfs_swaps_toolchain_header_tree_from_source() {
2588        let tmp = tempfile::tempdir().unwrap();
2589        let (store, mut spec) = builder_fixture(tmp.path());
2590
2591        // Artefacto sellado de un `linux-headers` con un árbol `usr/include/linux` propio.
2592        let headers = seal_component(&store, "linux-headers", "abcd", |w| {
2593            std::fs::create_dir_all(w.join("usr/include/linux")).unwrap();
2594            std::fs::write(w.join("usr/include/linux/version.h"), b"#define HAMMER 1\n").unwrap();
2595            std::fs::write(w.join("usr/include/linux/stat.h"), b"struct hammer_stat;\n").unwrap();
2596        });
2597        // El toolchain Alpine tiene un linux/ con un archivo huérfano que el swap debe borrar.
2598        let tc_linux = spec.toolchain_src.join("usr/include/linux");
2599        std::fs::create_dir_all(&tc_linux).unwrap();
2600        std::fs::write(tc_linux.join("version.h"), b"#define ALPINE 1\n").unwrap();
2601        std::fs::write(tc_linux.join("orphan.h"), b"alpine-only\n").unwrap();
2602
2603        spec.swaps = vec![ToolchainSwap {
2604            name: "linux-headers".into(),
2605            artifact: headers,
2606            rel_path: "usr/include/linux".into(),
2607        }];
2608
2609        let out = tmp.path().join("builder-out");
2610        builder_rootfs(&spec, &store, &out).unwrap();
2611
2612        let v = std::fs::read(out.join("toolchain/usr/include/linux/version.h")).unwrap();
2613        assert_eq!(v, b"#define HAMMER 1\n", "el swap pisó el version.h de Alpine");
2614        assert!(out.join("toolchain/usr/include/linux/stat.h").is_file(), "copió el árbol entero");
2615        assert!(
2616            !out.join("toolchain/usr/include/linux/orphan.h").exists(),
2617            "el swap-directorio borró el huérfano de Alpine"
2618        );
2619    }
2620
2621    #[test]
2622    fn builder_rootfs_without_ref_leaves_empty_ref_content() {
2623        let tmp = tempfile::tempdir().unwrap();
2624        let (store, mut spec) = builder_fixture(tmp.path());
2625        spec.ref_content = None;
2626        let out = tmp.path().join("builder-out");
2627        builder_rootfs(&spec, &store, &out).unwrap();
2628        let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap();
2629        assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}");
2630    }
2631
2632    #[test]
2633    fn builder_rootfs_with_work_cache_containing_out_dir_terminates() {
2634        // Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse
2635        // el propio destino (recursión infinita que llena el disco). El skip lo corta.
2636        let tmp = tempfile::tempdir().unwrap();
2637        let (store, mut spec) = builder_fixture(tmp.path());
2638
2639        // Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar).
2640        let work = tmp.path().join("work");
2641        std::fs::create_dir_all(work.join("repos/musl.git")).unwrap();
2642        std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap();
2643        std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap();
2644        std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap();
2645        spec.work_cache = Some(work.clone());
2646
2647        // out_dir vive DENTRO de la caché: el caso que disparaba la recursión.
2648        let out = work.join("builder-rootfs");
2649        let report = builder_rootfs(&spec, &store, &out).unwrap();
2650        assert!(report.builder_hash.as_str().starts_with("b3:"));
2651
2652        // Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe.
2653        assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben");
2654        assert!(
2655            !out.join("work/builder-rootfs").exists(),
2656            "el out_dir no debe haberse copiado en su propio /work (recursión)",
2657        );
2658        assert!(
2659            !out.join("work/sources").exists(),
2660            "sources/ es regenerable y pesado: no debe viajar en el builder",
2661        );
2662    }
2663
2664    #[test]
2665    fn builder_rootfs_errors_when_stage1_unsealed() {
2666        let tmp = tempfile::tempdir().unwrap();
2667        let (store, mut spec) = builder_fixture(tmp.path());
2668        spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado
2669        let out = tmp.path().join("builder-out");
2670        let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string();
2671        assert!(err.contains("no sellado"), "{err}");
2672    }
2673
2674    #[test]
2675    fn artifact_content_hash_errors_when_unsealed() {
2676        let tmp = tempfile::tempdir().unwrap();
2677        let store = Store::open(tmp.path().join("store")).unwrap();
2678        let err = artifact_content_hash(&store, &ArtifactHash::from_hex("00"), "stage1-rootfs")
2679            .unwrap_err()
2680            .to_string();
2681        assert!(err.contains("no sellado"), "{err}");
2682    }
2683
2684    #[test]
2685    fn all_shipped_recipes_parse_and_hash() {
2686        // Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y
2687        // hashear offline. Cubre las que no están en STAGE1_COMPONENTS y futuras.
2688        let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes");
2689        let tmp = tempfile::tempdir().unwrap();
2690        let store = Store::open(tmp.path().join("store")).unwrap();
2691        let mut count = 0;
2692        for entry in std::fs::read_dir(&recipes_dir).unwrap() {
2693            let path = entry.unwrap().path();
2694            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2695                continue;
2696            }
2697            let r = Recipe::load_from_path(&path)
2698                .unwrap_or_else(|e| panic!("parse {}: {e}", path.display()));
2699            let h = hammer_build::artifact_hash(&r, &store, &hammer_core::lab::LabFingerprint::for_tests())
2700                .unwrap_or_else(|e| panic!("hash {}: {e}", path.display()));
2701            assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display());
2702            count += 1;
2703        }
2704        assert!(count >= 5, "esperaba ≥5 recetas (grep/musl/busybox/hammerd/arje-zero), vi {count}");
2705    }
2706}