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