//! Bootstrap from-scratch de takana — el track posterior del [SDD 11](../../docs/11-bootstrap.md). //! //! Esta primera entrega cubre el **Stage 0**: ingerir un toolchain semilla ya construido //! (no se compila; ver [ADR 0008](../../docs/adr/0008-bootstrap-stages.md)) como una *fuente //! fijada por sha256* y sellarlo en el `Store`. El `ArtifactHash` resultante se deriva de la //! **identidad pinned** de la semilla (clase + versión + sha256), no de rutas del host, así //! que el resto del bootstrap depende de un artefacto reproducible y auditable. //! //! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes) //! llegan en entregas siguientes y reusan este mismo store. //! //! Cada etapa anota su línea en el [`manifest`] de bootstrap (`bootstrap.json`), el embrión del //! log de transparencia ([SDD 11 §4](../../docs/11-bootstrap.md)). pub mod manifest; use std::path::{Path, PathBuf}; use std::process::Command; use takana_build::download; use takana_core::{ArtifactHash, Recipe, Store}; pub use manifest::{BootstrapManifest, StageEntry}; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("core: {0}")] Core(#[from] takana_core::Error), #[error("tar -x de la semilla falló (exit {0:?})")] Untar(Option), #[error("bootstrap: {0}")] Other(String), } pub type Result = std::result::Result; /// Qué toolchain semilla usamos. `zig` es la semilla primaria (ADR 0003/0007); `musl-cross-make` /// (gcc + musl) es la escotilla para paquetes con gcc-ismos. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SeedKind { Zig, MuslCrossMake, } impl SeedKind { pub fn as_str(self) -> &'static str { match self { SeedKind::Zig => "zig", SeedKind::MuslCrossMake => "musl-cross-make", } } } /// Especificación de una semilla: su identidad pinned. La integridad la garantiza `sha256` /// (fijado, [ADR 0006](../../docs/adr/0006-pinned-commits.md)); `url` puede ser cualquier /// esquema que entienda curl, incluido `file://` para ingestión offline. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SeedSpec { pub kind: SeedKind, pub version: String, pub url: String, /// sha256 hex del tarball, fijado. Se verifica **antes** de extraer y sellar. pub sha256: String, } impl SeedSpec { /// Hash del artefacto del store para esta semilla. Función pura de la identidad pinned: /// misma `(kind, version, sha256)` ⇒ mismo hash, en cualquier máquina. El `url` queda /// fuera a propósito (un espejo distinto del mismo tarball produce el mismo artefacto). pub fn seed_hash(&self) -> ArtifactHash { ArtifactHash::of_inputs(&[ b"hammer-seed-v1", self.kind.as_str().as_bytes(), self.version.as_bytes(), self.sha256.as_bytes(), ]) } /// Nombre del artefacto en el store: `seed-`. pub fn store_name(&self) -> String { format!("seed-{}", self.kind.as_str()) } /// Directorio del toolchain **dentro de la semilla sellada**, listo para que el sandbox lo /// bindee como `/opt/zig`. Stage 0 sella el árbol del tarball tal cual (ingesta pura, sin /// aplanar): este método lo resuelve al usarlo, localizando el dir que contiene el ejecutable /// `zig` —en la raíz si el tarball lo trae plano, o en el único hijo si trae el típico /// top-level versionado `zig-linux-x86_64-/`—. Falla si la semilla no está sellada o si /// el ejecutable no aparece (o es ambiguo). pub fn toolchain_dir(&self, store: &Store) -> Result { seed_toolchain_dir(store, &self.seed_hash(), self.kind) } } /// Igual que [`SeedSpec::toolchain_dir`] pero sólo desde la identidad mínima `(seed_hash, kind)`: /// lo que basta para localizar la semilla ya sellada sin re-pasar `url`/`sha256`. Lo usa Stage 1, /// que conoce el `seed_hash` (de Stage 0 o del manifiesto) pero no necesariamente el `SeedSpec`. pub fn seed_toolchain_dir( store: &Store, seed_hash: &ArtifactHash, kind: SeedKind, ) -> Result { let base = store.path_of(seed_hash, &format!("seed-{}", kind.as_str())); if !base.is_dir() { return Err(Error::Other(format!( "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", kind.as_str(), base.display() ))); } match kind { SeedKind::Zig => locate_tool_dir(&base, "zig"), SeedKind::MuslCrossMake => Err(Error::Other( "toolchain_dir para musl-cross-make aún no implementado (escotilla diferida)".into(), )), } } /// Localiza, dentro de `base`, el directorio que contiene **directamente** el ejecutable `tool`. /// Mira la raíz primero y luego los hijos inmediatos; exige exactamente una coincidencia para no /// elegir a ciegas entre layouts ambiguos. fn locate_tool_dir(base: &Path, tool: &str) -> Result { if base.join(tool).is_file() { return Ok(base.to_path_buf()); } let mut hits = Vec::new(); for entry in std::fs::read_dir(base)? { let path = entry?.path(); if path.is_dir() && path.join(tool).is_file() { hits.push(path); } } hits.sort(); match hits.len() { 1 => Ok(hits.pop().unwrap()), 0 => Err(Error::Other(format!( "no encontré el ejecutable '{tool}' en la raíz ni en los hijos inmediatos de {}", base.display() ))), _ => Err(Error::Other(format!( "ambiguo: '{tool}' aparece en varios directorios de {}: {hits:?}", base.display() ))), } } /// Deriva una [`takana_build::BuildConfig`] desde `base` cuyo `zig_dir` apunta a la **semilla /// sellada** en vez del toolchain del host: así las recetas del Stage 1 cross-compilan con el /// toolchain fijado por hash (`seed_hash`), no con el `.dev-fs/tools/zig` de desarrollo. El resto /// de la config (rootfs, work, cache) se hereda intacto. pub fn seed_build_config( base: &takana_build::BuildConfig, seed: &SeedSpec, store: &Store, ) -> Result { let zig_dir = seed.toolchain_dir(store)?; Ok(takana_build::BuildConfig { zig_dir, ..base.clone() }) } /// Hash de un rootfs sellado por Stage 1. Es un `ArtifactHash` derivado del **contenido lógico** /// (los hashes de sus componentes + el init), no del árbol en disco: así dos ensamblados de los /// mismos artefactos producen el mismo hash aunque difieran en inodes o timestamps. pub type RootfsHash = ArtifactHash; /// Los componentes del userland mínimo, en orden de ensamblado: `musl` (libc), `busybox` /// (coreutils + sh), `util-linux` (la consola), `hammerd` (daemon, receta Cargo) y `arje-zero` /// (el init PID 1, ADR 0007). arje-zero es PID 1 vía el symlink `/sbin/init`; supervisa a `hammerd` /// y a la getty por la seed card ([SDD 12](../../docs/12-init-real.md)) — su muerte es el `CRASHED` /// real. /// /// ⚠ **`util-linux` entró el 2026-09-22 y el núcleo pasó de 4 a 5** (decisión del usuario, frente /// de botar busybox). Entra por UNA razón concreta y conviene que se lea antes de añadir nada más /// aquí: **la card `console-getty` de abajo vive en esta seed, o sea en el núcleo, y su `exec` tiene /// que existir en ESTE rootfs.** Mientras el núcleo tuvo cuatro componentes, el único getty posible /// era el applet de busybox: `util-linux` está en los 7 perfiles de `targets.toml` y eso **no lo /// pone en el rootfs** — el producto se ensambla de estas listas, no del catálogo /// ([[catalogo-no-es-imagen]]). Apuntar la card a `/sbin/agetty` sin esto deja el arranque MUDO, /// con `supervision: Restart` reintentando un binario inexistente para siempre. /// /// ⚠ **Lo que esto cuesta, dicho entero:** el `of_tree` del stage1 cambia, así que el baseline del /// selfhost-verify (`9adefb82/7fa6cb4e`) deja de valer y hay que re-verificar; el núcleo que se /// reconstruye bit a bit dentro de la VM pasa a incluir un paquete C grande; y la raíz de confianza /// crece en ~119 binarios, de los cuales el producto usa uno. Ese último punto es el que hay que /// revisar si algún día molesta: lo que hace falta es `agetty`, no util-linux entero. const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "util-linux", "hammerd", "arje-zero"]; /// La **seed card** que arje-zero lee en `/ente/seed.card.json` al boot ([SDD 12 §4](../../docs/12-init-real.md)): /// un `Card` `Virtual` cuyos `genesis` son `hammerd` (servicio `Native` con `Restart` — el /// `CRASHED` real) y una `console-getty` supervisada. La forma replica una seed real de arje /// (`seeds/arje-qemu.card.json`) para que `Card::validate()` la acepte; los ULID son fijos para que /// el `RootfsHash` sea reproducible. Template autocontenido (opción A del SDD): takana no depende de /// `card-core` como librería; el pin a arje-zero + el boot en VM cubren el drift de schema. /// ⚠ **La consola la abre `agetty` de util-linux, no el applet `getty`** (2026-09-22). El argv es /// EL MISMO y eso no es casualidad: agetty documenta las dos formas —`agetty [opts] /// []` y `agetty [opts] ,... `— y acepta `-n` (--skip-login) y `-l` /// (--login-program) con la misma semántica que el applet. Verificado ejecutando el binario /// sellado, no leyendo su manual. /// /// ⚠ Este literal es **JSON**: los comentarios van ACÁ, no dentro. Meter `//` en la cadena la deja /// inválida y caen siete pruebas de golpe, ninguna con un mensaje que hable de comentarios. const STAGE1_SEED_CARD: &str = r#"{ "schema_version": 1, "id": "01HQAR53D4M2NBV8KZTYXFQA00", "lineage": null, "label": "hammer-stage1", "provides": ["Spawn", "Journal"], "requires": [], "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, "soma": { "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, "cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null }, "cpu_affinity": null }, "payload": "Virtual", "supervision": "OneShot", "lifecycle": "daemon", "priority": "normal", "flow": { "input": [], "output": [] }, "genesis": [ { "schema_version": 1, "id": "01HQAR53D4M2NBV8KZTYXFQA01", "lineage": null, "label": "hammerd", "provides": [], "requires": [], "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, "soma": { "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, "cgroup": { "path": "arje.slice/hammerd", "cpu_weight": null, "io_weight": null }, "cpu_affinity": null }, "payload": { "Native": { "exec": "/usr/bin/hammerd", "argv": ["--store", "/store", "--journal", "/var/lib/hammer/journal"], "envp": [["RUST_LOG", "info"], ["ENTE_BUS_SOCK", "/run/ente-bus.sock"]] }}, "supervision": { "Restart": { "initial": 200, "max": 10000 } }, "lifecycle": "daemon", "priority": "normal", "flow": { "input": [], "output": [] }, "genesis": [] }, { "schema_version": 1, "id": "01HQAR53D4M2NBV8KZTYXFQA02", "lineage": null, "label": "console-getty", "provides": [], "requires": [], "permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, "soma": { "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, "cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null }, "cpu_affinity": null }, "payload": { "Native": { "exec": "/sbin/agetty", "argv": ["agetty", "-n", "-l", "/bin/sh", "115200", "console"], "envp": [] }}, "supervision": { "Restart": { "initial": 200, "max": 10000 } }, "lifecycle": "daemon", "priority": "normal", "flow": { "input": [], "output": [] }, "genesis": [] } ] } "#; /// Qué construir para Stage 1: la semilla ya sellada (por `(seed_hash, seed_kind)`) y el directorio /// de recetas donde viven `musl.toml` / `busybox.toml`. #[derive(Debug, Clone)] pub struct Stage1Spec { pub seed_hash: ArtifactHash, pub seed_kind: SeedKind, pub recipes_dir: PathBuf, } /// **Stage 1** — cross-compila el userland mínimo con la semilla de Stage 0, ensambla un rootfs /// FHS y lo sella. Devuelve el [`RootfsHash`] y anota la línea del manifiesto. /// /// Idempotente: si el rootfs (mismo hash de contenido) ya está sellado, no reconstruye. El /// cross-compile real requiere el rootfs base + toolchain del lab; en una máquina sin `.dev-fs` /// `takana_build::build` falla limpio antes de ensamblar nada. pub fn stage1( spec: &Stage1Spec, base_cfg: &takana_build::BuildConfig, store: &Store, ) -> Result { // Toolchain = la semilla sellada, no el zig del host. let zig_dir = seed_toolchain_dir(store, &spec.seed_hash, spec.seed_kind)?; let cfg = takana_build::BuildConfig { zig_dir, ..base_cfg.clone() }; // 1) Construir cada componente (cacheado por el lab si ya existe). let mut components: Vec<(String, ArtifactHash)> = Vec::with_capacity(STAGE1_COMPONENTS.len()); for name in STAGE1_COMPONENTS { let recipe_path = spec.recipes_dir.join(format!("{name}.toml")); let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) })?; let h = takana_build::build(&recipe, &cfg, store)?; components.push((name.to_string(), h)); } // 2) Hash del rootfs = función pura de sus componentes (reproducible, sin tocar disco). let rootfs_hash = rootfs_hash(&components); let store_name = "stage1-rootfs"; // 3) Ensamblar + sellar (si no estaba ya). Staging bajo el store ⇒ rename atómico en seal. if !store.has(&rootfs_hash, store_name) { let staging = store .root() .join(".bootstrap-tmp") .join(rootfs_hash.store_dir_name(store_name)); let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)?; let result = assemble_and_seal(store, &components, &rootfs_hash, store_name, &staging); let _ = std::fs::remove_dir_all(&staging); result?; } else { tracing::info!(hash = %rootfs_hash, "stage1: rootfs ya sellado (idempotente)"); } // 4) Línea del manifiesto. `recipe_hash = None`: el rootfs es un ensamblado de varias recetas, // no de una sola; el `seed_hash` ancla de qué toolchain salió. manifest::append_line( store, StageEntry { stage: 1, recipe_hash: None, artifact_hash: rootfs_hash.clone(), seed_hash: Some(spec.seed_hash.clone()), ts: now_unix(), }, )?; Ok(rootfs_hash) } /// Hash de contenido del rootfs: tag de versión + `(name, hash)` de cada componente en orden + la /// seed card del init. Cambiar un componente, el orden o la seed re-hashea el rootfs. El tag `v2` /// marca el corte respecto al rootfs con init provisional (`v1`). fn rootfs_hash(components: &[(String, ArtifactHash)]) -> RootfsHash { let mut inputs: Vec> = vec![b"hammer-stage1-rootfs-v2".to_vec()]; for (name, h) in components { inputs.push(name.as_bytes().to_vec()); inputs.push(h.as_str().as_bytes().to_vec()); } inputs.push(STAGE1_SEED_CARD.as_bytes().to_vec()); let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); ArtifactHash::of_inputs(&refs) } fn assemble_and_seal( store: &Store, components: &[(String, ArtifactHash)], rootfs_hash: &RootfsHash, store_name: &str, staging: &Path, ) -> Result<()> { assemble_rootfs(store, components, staging)?; store.seal(staging, rootfs_hash, store_name)?; tracing::info!(hash = %rootfs_hash, "stage1: rootfs sellado"); Ok(()) } /// Ensambla el árbol FHS del rootfs en `staging`: esqueleto de directorios, hidratación de cada /// componente sellado (hardlinks), la seed card de arje y `/sbin/init`→arje-zero. Es la pieza /// testeable sin build real ([SDD 12](../../docs/12-init-real.md)). fn assemble_rootfs( store: &Store, components: &[(String, ArtifactHash)], staging: &Path, ) -> Result<()> { // Esqueleto FHS. arje-zero monta él mismo los pseudo-FS (arje-kernel), así que sólo deben // existir como puntos de montaje vacíos; `/ente` lleva la seed, `/var/lib/hammer` el diario. for d in [ "proc", "sys", "sys/fs/cgroup", "dev", "dev/pts", "dev/shm", "run", "tmp", "etc", "root", "var", "var/lib/hammer", "ente", "usr/bin", "bin", "sbin", ] { std::fs::create_dir_all(staging.join(d))?; } // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem). for (name, h) in components { let dir = store.path_of(h, name); takana_build::run_hydrate(&dir, staging, takana_core::LinkMode::Static, None)?; } // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs // ejecutable: lo quitamos para que la imagen quede limpia y su hash no dependa de cuál // componente lo escribió último. let _ = std::fs::remove_dir_all(staging.join(".hammer")); // La seed card que arje-zero lee al boot. std::fs::write(staging.join("ente/seed.card.json"), STAGE1_SEED_CARD)?; // arje-zero es PID 1 vía `/sbin/init`. Symlink absoluto: el kernel lo sigue sea cual sea el // cmdline, sin exigir `init=`. Idempotente: si ya existe (re-ensamblado), lo reemplazamos. let init_link = staging.join("sbin/init"); let _ = std::fs::remove_file(&init_link); std::os::unix::fs::symlink("/usr/bin/arje-zero", &init_link)?; Ok(()) } // ── Capa de servicios: el PRODUCTO (Separación Mecanismo/Política) ────────────────────────────── // // `STAGE1_COMPONENTS` + `STAGE1_SEED_CARD` son el **mecanismo base**: el núcleo atómico que el // selfhost-verify reconstruye bit a bit. ⚠ El baseline `of_tree` 9adefb82/7fa6cb4e es de cuando // el núcleo eran CUATRO componentes: `util-linux` entró el 2026-09-22 y hay que re-verificar. Los // **servicios** (sshd, …) son **política de producto**: NO entran al núcleo (no contaminan el // `of_tree` ni obligan a un rebuild pesado de openssl/zlib dentro de la VM). Se inyectan por // **hidratación tardía** sobre el rootfs base ya sellado, produciendo un `product-rootfs` APARTE. // Así, meter más servicios mañana no toca jamás el núcleo verificado. /// Componentes de servicio inyectados sobre el 5/5 verificado (era 4/4 hasta que /// `util-linux` entró al núcleo el 2026-09-22): `netup` (red) + `openssh` (sshd/ssh). /// Daemons ⇒ llevan card en la seed de producto. Crecer esta lista NO toca `STAGE1_COMPONENTS`. const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"]; /// Userland Rust-nativo ADOPTADO (Etapa C): multicall `uutils`/coreutils + `find`/`xargs` + `diff`/`cmp` /// + `rg`. Se hidrata sobre la base DESPUÉS de busybox, así que sus symlinks en `/usr/bin` ENSOMBRECEN /// los applets de busybox (con `PATH` `/usr/bin:/bin` gana la tool Rust). Es el "adelgazar busybox" del /// producto SIN tocar el núcleo: busybox sigue en el núcleo (self-host) y queda de fallback para lo aún no /// reemplazado (`sh`/ash, `tar`, …). NO son daemons ⇒ no llevan card en la seed (sólo ficheros). const USERLAND_COMPONENTS: &[&str] = &["uutils", "findutils", "findutils-xargs", "diffutils", "ripgrep"]; /// Card de servicio `sshd` (genesis `Native`/`Restart`): el comando levanta la red (`netup`, DHCP), /// genera host keys al primer boot (`ssh-keygen -A`) y `exec`uta `sshd -D`. Todo dentro del card para /// no asumir orden de genesis. Mismo esquema validado de [`STAGE1_SEED_CARD`]; ULID propio. Se inyecta /// en el `genesis` de la seed de producto. Verificado E2E en QEMU (`scripts/ssh-e2e-test.sh`). const SSHD_SERVICE_CARD: &str = r#"{ "schema_version": 1, "id": "01HQAR53D4M2NBV8KZTYXFQA12", "lineage": null, "label": "sshd", "provides": [], "requires": [], "permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true }, "soma": { "namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false }, "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null }, "cgroup": { "path": "arje.slice/sshd", "cpu_weight": null, "io_weight": null }, "cpu_affinity": null }, "payload": { "Native": { "exec": "/bin/busybox", "argv": ["sh", "-c", "/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e"], "envp": [["HOME", "/root"]] }}, "supervision": { "Restart": { "initial": 500, "max": 20000 } }, "lifecycle": "daemon", "priority": "normal", "flow": { "input": [], "output": [] }, "genesis": [] }"#; /// `/etc/passwd` del producto: `root` (login por llave) + `sshd` (usuario de privsep, shell inerte). const PRODUCT_PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n"; /// `/etc/group` del producto. const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n"; /// `/etc/ssh/sshd_config` del producto: sólo por llave (sin password), pidfile en `/run` (tmpfs que /// arje monta), authorized_keys provisionadas en deploy. const 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"; // ── Atestación de integridad al arranque (I4 / Etapa D, mitad de HAMMER) ───────────────────────── // // Por la regla de oro del [plan arje↔takana] (`PLAN-ATESTACION-Y-HAMMER.md` §B.1): takana es dueño del // **`expected_hash` + firma + TrustStore**; arje es dueño del **gate al boot** (computa `blake3` de cada // binario crítico ANTES de incarnar y, si no casa, `halt`). Esta es la mitad de takana: PRODUCIR el // manifiesto de hashes esperados (`/ente/attest.json`) que el gate A2 de arje verifica — "el // `expected_hash` de un `.swm` ES el BLAKE3 que arje atesta". El hash es `blake3` CRUDO del fichero // (`ArtifactHash::of_file`), el mismo que computa `arje-cas::blake3_of`. La firma con la rootkey del // seed (A1, `arje-packager`) es una capa posterior; el manifiesto ya es verificable hoy por `takana // attest` (auto-validación: "reproducir, no confiar"). Es un fichero APARTE de la seed card ⇒ no toca // el schema de `card-core` y el producto bootea igual con el arje-zero actual (que aún ignora A2). /// Binarios críticos cuyo BLAKE3 hammer atesta (la raíz-de-confianza-ejecutable del producto): PID 1, /// el diario, el shell/getty, el daemon de red expuesto, el configurador de red y el userland multicall. const ATTEST_PATHS: &[&str] = &[ "usr/bin/arje-zero", // PID 1 — la raíz "usr/bin/hammerd", // diario + bus "bin/busybox", // shell (`/bin/sh`) + exec del card de sshd "sbin/agetty", // la consola: lo ejecuta el card `console-getty` "usr/sbin/sshd", // daemon SSH (superficie de red) "usr/bin/netup", // configurador de red "usr/bin/coreutils", // userland Rust multicall ]; /// Una entrada del manifiesto de atestación: ruta (relativa a la raíz) → BLAKE3 esperado del fichero. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AttestEntry { pub path: String, pub b3: String, } /// Manifiesto de atestación que takana emite en `/ente/attest.json`. `algo` fijo a `blake3` (crudo). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AttestManifest { pub schema_version: u32, pub algo: String, pub entries: Vec, } /// Veredicto de [`verify_attestation`]: qué binarios casaron, cuáles divergieron y cuáles faltan. #[derive(Debug, Clone, Default)] pub struct AttestReport { pub ok: Vec, pub mismatched: Vec, pub missing: Vec, } impl AttestReport { /// Pasa sólo si todo binario listado existe y su BLAKE3 casa. pub fn passed(&self) -> bool { self.mismatched.is_empty() && self.missing.is_empty() } } /// Construye el manifiesto de atestación de un árbol `root`: para cada ruta crítica PRESENTE, su BLAKE3 /// crudo. Determinista (función pura de los bytes del binario). fn build_attestation(root: &Path) -> Result { let mut entries = Vec::new(); for rel in ATTEST_PATHS { let p = root.join(rel); if p.is_file() { let h = ArtifactHash::of_file(&p).map_err(Error::Io)?; entries.push(AttestEntry { path: format!("/{rel}"), b3: h.as_str().to_string() }); } } Ok(AttestManifest { schema_version: 1, algo: "blake3".into(), entries }) } /// Verifica un árbol FHS (`rootfs_dir`, p.ej. un product-rootfs hidratado) contra su propio /// `/ente/attest.json`: recomputa el BLAKE3 de cada binario listado y lo compara. Es lo que el gate de /// arje hará al boot, hecho hoy por takana (auto-validación de integridad del producto). pub fn verify_attestation(rootfs_dir: &Path) -> Result { let manifest_path = rootfs_dir.join("ente/attest.json"); let raw = std::fs::read_to_string(&manifest_path).map_err(|e| { Error::Other(format!("no pude leer {}: {e}", manifest_path.display())) })?; let manifest: AttestManifest = serde_json::from_str(&raw) .map_err(|e| Error::Other(format!("attest.json inválido: {e}")))?; let mut report = AttestReport::default(); for entry in &manifest.entries { let rel = entry.path.trim_start_matches('/'); let p = rootfs_dir.join(rel); if !p.is_file() { report.missing.push(entry.path.clone()); continue; } let got = ArtifactHash::of_file(&p).map_err(Error::Io)?; if got.as_str() == entry.b3 { report.ok.push(entry.path.clone()); } else { report.mismatched.push(entry.path.clone()); } } Ok(report) } /// Las Cards de servicio que aportan los componentes de servicio, **leídas de la receta que viaja /// DENTRO de cada artefacto sellado** (el sidecar de provenance `.hammer/recipe.toml`). /// /// Por qué del sidecar y no del directorio de recetas: [`seal_product_rootfs`] sirve a las DOS /// vetas —`product` desde recetas locales y `product_from_repo` desde el repo firmado— y sólo una /// tiene `recipes/` a mano. El sidecar es la única fuente que ambas comparten, y así las dos /// convergen al mismo `product-rootfs`, que es la propiedad que esa función existe para sostener. /// /// Sólo entran las de `scope = "system"`: una Card de sesión en el `genesis` arrancaría antes de que /// exista `XDG_RUNTIME_DIR` o un usuario logueado (ver `takana_core::service::Scope`). /// /// ⚠ **Falla RUIDOSAMENTE si no sale ninguna**, y la razón es una trampa concreta: el sidecar **no /// entra en el `ArtifactHash`**, así que un artefacto sellado antes de que la receta declarara sus /// servicios sigue siendo un cache-hit válido y **no se reconstruye solo**. Si esto devolviera una /// lista vacía sin protestar, el producto saldría sin sshd —booteando, verde y sin acceso— que es /// exactamente la clase de fallo que este proyecto ya pagó con los artefactos vacíos. fn service_cards( store: &Store, services: &[(String, ArtifactHash)], ) -> Result> { let mut cards = Vec::new(); let mut sin_sidecar = Vec::new(); for (nombre, hash) in services { let recipe = match store.recipe_for_hash(hash.as_str()) { Ok(Some(r)) => r, Ok(None) => { sin_sidecar.push(nombre.clone()); continue; } Err(e) => return Err(Error::Other(format!("sidecar de `{nombre}` ilegible: {e}"))), }; for s in &recipe.services { if s.scope == takana_core::service::Scope::System { cards.push(s.card()); } } } if cards.is_empty() { return Err(Error::Other(format!( "ningún componente de servicio ({}) declara un servicio de sistema.\n\ Casi seguro los artefactos son ANTERIORES al bloque `[[service]]`: el sidecar no entra \ en el ArtifactHash, así que siguen siendo cache-hit válidos y no se reconstruyen solos.\n\ Arreglo: borrar el artefacto del store y reconstruirlo (`flock -o work/.farm-build.lock \ takana build recipes/.toml`), comparando el árbol nuevo con el viejo.{}", services.iter().map(|(n, _)| n.as_str()).collect::>().join(", "), if sin_sidecar.is_empty() { String::new() } else { format!("\nSin sidecar siquiera: {}", sin_sidecar.join(", ")) } ))); } Ok(cards) } /// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía /// `serde_json` (takana sigue autocontenido: no depende de `card-core`). Determinista ⇒ el /// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual. fn product_seed_card(cards: &[serde_json::Value]) -> Result { let mut seed: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD) .map_err(|e| Error::Other(format!("seed base inválida: {e}")))?; seed["label"] = serde_json::Value::String("hammer-product".into()); let genesis = seed .get_mut("genesis") .and_then(|g| g.as_array_mut()) .ok_or_else(|| Error::Other("seed base sin array `genesis`".into()))?; for c in cards { genesis.push(c.clone()); } serde_json::to_string_pretty(&seed).map_err(|e| Error::Other(format!("serializar seed: {e}"))) } /// Identidad del `product-rootfs`: tag + el `RootfsHash` del 4/4 base + `(nombre,hash)` de cada /// componente userland y de servicio (en orden) + la seed de producto + las configs. Cambiar /// cualquiera re-hashea (reproducible). El tag `v2` marca el corte respecto al producto sólo-servicios. fn product_rootfs_hash( base: &RootfsHash, userland: &[(String, ArtifactHash)], services: &[(String, ArtifactHash)], seed: &str, ) -> RootfsHash { let mut inputs: Vec> = vec![ b"hammer-product-rootfs-v3".to_vec(), // v3: + manifiesto de atestación (/ente/attest.json) base.as_str().as_bytes().to_vec(), ]; for (name, h) in userland.iter().chain(services.iter()) { inputs.push(name.as_bytes().to_vec()); inputs.push(h.as_str().as_bytes().to_vec()); } inputs.push(ATTEST_PATHS.join(",").into_bytes()); // el set atestado entra al hash inputs.push(seed.as_bytes().to_vec()); inputs.push(PRODUCT_PASSWD.as_bytes().to_vec()); inputs.push(PRODUCT_GROUP.as_bytes().to_vec()); inputs.push(SSHD_CONFIG.as_bytes().to_vec()); let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); ArtifactHash::of_inputs(&refs) } /// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4), luego el userland /// Rust (ENSOMBRECE los applets de busybox en `/usr/bin`) y los componentes de servicio ENCIMA /// (hidratación tardía); sobrescribe la seed con la de producto y escribe las configs de servicio /// (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`, `/root/.ssh` 0700). Testeable sin build. fn assemble_product_rootfs( store: &Store, base: &RootfsHash, userland: &[(String, ArtifactHash)], services: &[(String, ArtifactHash)], seed: &str, staging: &Path, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; // 1) base verificado (4/4): hidratación por hardlinks del árbol ya sellado (incl. mountpoints // vacíos, /sbin/init y la seed base, que sobrescribimos en el paso 3). let base_dir = store.path_of(base, "stage1-rootfs"); if !base_dir.is_dir() { return Err(Error::Other(format!( "rootfs base no sellado en {}", base_dir.display() ))); } takana_build::run_hydrate(&base_dir, staging, takana_core::LinkMode::Static, None)?; // 2) userland Rust + servicios ENCIMA (hardlink). El userland va DESPUÉS de la base: sus symlinks // en /usr/bin reemplazan a los de busybox (run_hydrate sustituye el symlink existente) ⇒ el // producto corre las tools Rust. Orden userland→servicios (sin colisiones entre sí). for (name, h) in userland.iter().chain(services.iter()) { let dir = store.path_of(h, name); takana_build::run_hydrate(&dir, staging, takana_core::LinkMode::Static, None)?; } let _ = std::fs::remove_dir_all(staging.join(".hammer")); // ADELGAZAR BUSYBOX (determinista, sin depender del orden de PATH del shell): por cada nombre que // el userland Rust provee en `/usr/bin`, retirar el symlink homónimo de busybox en los OTROS dirs // (`/bin`, `/sbin`, `/usr/sbin`) — busybox suele instalar `ls` en `/bin`, fuera del `/usr/bin` que // la hidratación ya ensombreció. Así la tool Rust es la ÚNICA en PATH. El binario busybox queda // (sh/ash, tar, mount, … lo no reemplazado); sólo se quitan los applets ya cubiertos por Rust. let mut applet_names: std::collections::BTreeSet = std::collections::BTreeSet::new(); for (name, h) in userland { let ub = store.path_of(h, name).join("usr/bin"); if let Ok(rd) = std::fs::read_dir(&ub) { for e in rd.flatten() { applet_names.insert(e.file_name().to_string_lossy().into_owned()); } } } for applet in &applet_names { for d in ["bin", "sbin", "usr/sbin"] { let p = staging.join(d).join(applet); if std::fs::symlink_metadata(&p).is_ok() { let _ = std::fs::remove_file(&p); } } } // 3) seed de producto (sobrescribe la base) + configs + dirs de runtime de sshd. // Los ficheros hidratados son HARDLINKS al store sellado (read-only); escribir encima daría // EACCES y `chmod` mutaría el inodo del store. Hay que ROMPER el hardlink: remove + write nuevo. let write_fresh = |rel: &str, data: &str| -> Result<()> { let p = staging.join(rel); if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; } let _ = std::fs::remove_file(&p); std::fs::write(&p, data)?; Ok(()) }; write_fresh("ente/seed.card.json", seed)?; write_fresh("etc/passwd", PRODUCT_PASSWD)?; write_fresh("etc/group", PRODUCT_GROUP)?; write_fresh("etc/ssh/sshd_config", SSHD_CONFIG)?; let empty = staging.join("var/empty"); // jaula de privsep: root, no group/world-writable. std::fs::create_dir_all(&empty)?; std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o711))?; let dotssh = staging.join("root/.ssh"); // authorized_keys provisionadas en deploy. std::fs::create_dir_all(&dotssh)?; std::fs::set_permissions(&dotssh, std::fs::Permissions::from_mode(0o700))?; // Mountpoints de las particiones dedicadas del disco de producto (vacíos; el wrapper /sbin/init de // la imagen monta vda3→/store y vda4→/var/lib/hammer). `/var/lib/hammer` ya viene de la base. std::fs::create_dir_all(staging.join("store"))?; std::fs::create_dir_all(staging.join("var/lib/hammer"))?; // Manifiesto de atestación (I4): BLAKE3 esperado de los binarios críticos ya hidratados ⇒ el gate // de arje lo verifica al boot. Se computa al final, sobre el árbol ya ensamblado. Fichero aparte de // la seed card (no toca el schema de card-core). let attest = build_attestation(staging)?; let attest_json = serde_json::to_string_pretty(&attest) .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?; std::fs::write(staging.join("ente/attest.json"), attest_json)?; Ok(()) } /// Construye/cachea (idempotente vía el lab) una lista de componentes nombrados, devolviendo sus /// `(nombre, hash)` sellados. Cada receta usa su propia config de build (p.ej. `zig_version`). fn build_components( names: &[&str], recipes_dir: &Path, cfg: &takana_build::BuildConfig, store: &Store, ) -> Result> { let mut out = Vec::with_capacity(names.len()); for name in names { let recipe_path = recipes_dir.join(format!("{name}.toml")); let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) })?; let h = takana_build::build(&recipe, cfg, store)?; out.push((name.to_string(), h)); } Ok(out) } /// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta por /// hidratación tardía el userland Rust (adelgaza busybox) + la capa de servicios, sellando un /// `product-rootfs`. El mecanismo base queda intacto: este árbol NO es el que reconstruye el /// selfhost-verify (su `of_tree` sigue blindado). Idempotente. `base_cfg` apunta el zig del lab (no la /// semilla): cada componente usa su `zig_version` propia, y si ya está sellado sale cacheado. pub fn product( base: &RootfsHash, base_cfg: &takana_build::BuildConfig, recipes_dir: &Path, store: &Store, ) -> Result { // 1) construir/cachear el userland Rust + los servicios DESDE LAS RECETAS LOCALES. let userland = build_components(USERLAND_COMPONENTS, recipes_dir, base_cfg, store)?; let services = build_components(SERVICE_COMPONENTS, recipes_dir, base_cfg, store)?; // 2-3) ensamblar + sellar. seal_product_rootfs(base, &userland, &services, store) } /// **Producto desde un REPO FIRMADO** — cierra el dogfood de [[etapa-f]]: la imagen de producto arma /// su userland vía la cadena de suministro de paquetes (no recetas locales hardcodeadas). Verifica la /// firma del release, REPRODUCE cada componente desde su `.swm` (`build_source_patch` chequea el /// `expected_hash` anclado) y lo hidrata. Como el hash es por-CONTENIDO, los `(nombre,hash)` que /// reproduce desde el repo son IDÉNTICOS a los que `build_components` construye de la receta ⇒ el /// `product-rootfs` resultante es BIT-IDÉNTICO al de [`product`]: la prueba de que el dogfood es fiel /// ("verificar, no confiar" aplicado al propio ensamblado del producto). El núcleo sigue blindado. pub fn product_from_repo( base: &RootfsHash, repo_dir: &Path, trust_dir: &Path, base_cfg: &takana_build::BuildConfig, store: &Store, ) -> Result { // 1) userland + servicios REPRODUCIDOS desde el repo firmado (mismo shape que build_components). let userland = install_components_from_repo(USERLAND_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?; let services = install_components_from_repo(SERVICE_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?; // 2-3) ensamblar + sellar (idéntico a `product`). seal_product_rootfs(base, &userland, &services, store) } /// Pasos 2-3 comunes a [`product`] y [`product_from_repo`]: seed de producto + hash de identidad + /// ensamblado + sellado idempotente. Sólo varía la PROCEDENCIA de los `(nombre,hash)` (recetas /// locales vs repo firmado); el árbol sellado es función de ellos ⇒ ambas vetas convergen al mismo /// `product-rootfs` si los hashes coinciden (lo hacen: el hash es por-contenido). fn seal_product_rootfs( base: &RootfsHash, userland: &[(String, ArtifactHash)], services: &[(String, ArtifactHash)], store: &Store, ) -> Result { let seed = product_seed_card(&service_cards(store, services)?)?; let phash = product_rootfs_hash(base, userland, services, &seed); let store_name = "product-rootfs"; if !store.has(&phash, store_name) { let staging = store .root() .join(".bootstrap-tmp") .join(phash.store_dir_name(store_name)); let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)?; let result = (|| -> Result<()> { assemble_product_rootfs(store, base, userland, services, &seed, &staging)?; store.seal(&staging, &phash, store_name)?; Ok(()) })(); let _ = std::fs::remove_dir_all(&staging); result?; tracing::info!(hash = %phash, "product: rootfs (userland Rust + servicios) sellado"); } else { tracing::info!(hash = %phash, "product: rootfs ya sellado (idempotente)"); } Ok(phash) } /// Reproduce un conjunto de componentes DESDE UN REPO FIRMADO, devolviendo sus `(nombre,hash)` sellados /// — el MISMO shape que [`build_components`], así [`assemble_product_rootfs`] los hidrata igual. Por cada /// componente: resuelve su cierre de deps del índice, puebla el catálogo con los `{dep}.toml` (para que /// el lab materialice build-deps al reproducir, igual que `takana install`), y REPRODUCE su `source_patch` /// con `build_source_patch` (que sella y CHEQUEA el `expected_hash` anclado en el índice). Verifica la /// firma del release ANTES de tocar nada: el producto NO se ensambla desde un repo no confiado. pub fn install_components_from_repo( names: &[&str], repo_dir: &Path, trust_dir: &Path, cfg: &takana_build::BuildConfig, store: &Store, ) -> Result> { use takana_core::{RepoIndex, SigStatus, TrustStore}; let index = RepoIndex::load(repo_dir)?; // Firma del release: exigimos TRUSTED. Un release no firmado / con clave desconocida / con firma // mala NO ensambla un producto (la cadena de suministro debe ser confiable y no manipulada). let ts = TrustStore::load(trust_dir)?; match index.verify_signature(&ts) { SigStatus::Trusted { by } => { tracing::info!(%by, "product: release del repo firmado y TRUSTED"); } other => { return Err(Error::Other(format!( "el producto exige un repo firmado por clave confiada en {}; estado: {other:?}", trust_dir.display() ))); } } let catalog_dir = takana_build::swm_bridge::catalog_dir_for(cfg, None); std::fs::create_dir_all(&catalog_dir)?; let mut out = Vec::with_capacity(names.len()); for name in names { let entry = index.find(name).ok_or_else(|| { Error::Other(format!("'{name}' no está en el repo firmado {}", repo_dir.display())) })?; // Poblar el catálogo con las DEPS del cierre (deps antes del target), igual que `install`: // el lab resuelve build-deps por nombre leyendo `{dep}.toml` del catalog_dir. let closure = index.resolve_closure(name)?; for dep in closure.iter().filter(|p| p.name != *name) { let dep_swm = load_swm_file(&RepoIndex::swm_path(repo_dir, dep))?; let mutation = source_patch_of(&dep_swm).ok_or_else(|| { Error::Other(format!("la dep '{}' no es un paquete source_patch", dep.name)) })?; let dep_recipe = takana_build::swm_bridge::recipe_from_source_patch( mutation, Some(&dep.name), &catalog_dir, )?; std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), dep_recipe.to_toml()?)?; } // Reproducir el target: build_source_patch sella + chequea el expected_hash del índice. let swm = load_swm_file(&RepoIndex::swm_path(repo_dir, entry))?; let mutation = source_patch_of(&swm).ok_or_else(|| { Error::Other(format!("'{name}' no es un paquete source_patch")) })?; let hash = takana_build::swm_bridge::build_source_patch(mutation, cfg, store, None, Some(name))?; tracing::info!(%name, %hash, "product: componente reproducido desde el repo firmado"); out.push((name.to_string(), hash)); } Ok(out) } /// Lee un `.swm` (YAML) de disco a `Swm`. Equivalente al helper homónimo de la CLI (privado allí). fn load_swm_file(path: &Path) -> Result { let yaml = std::fs::read_to_string(path) .map_err(|e| Error::Other(format!("no pude leer {}: {e}", path.display())))?; takana_core::Swm::from_yaml(&yaml) .map_err(|e| Error::Other(format!("no pude parsear {}: {e}", path.display()))) } /// Primera mutación `SourcePatch` de un `.swm` (un paquete takana es un único source_patch). fn source_patch_of(swm: &takana_core::Swm) -> Option<&takana_core::Mutation> { swm.mutations .iter() .find(|m| matches!(m, takana_core::Mutation::SourcePatch { .. })) } // ── Producto ATESTADO: la capa FIRMADA que el gate de arje verifica al boot (I4 / Etapa D) ─────── // // `product()` sella el `product-rootfs` con el arje-zero del núcleo + un `/ente/attest.json` de // AUTO-verificación (esquema paralelo que `takana attest` valida, pero que el gate de arje NO lee). // El producto ATESTADO cierra el lazo canónico: hidrata el arje-zero CON gate (`arje-zero-attest`), // FIRMA la seed con `arje-packager` (una concesión Ed25519 por binario crítico, sobre su BLAKE3, bajo // una rootkey) y la sella como `product-attested-rootfs`. Booteado, el gate (política `halt`) recomputa // el BLAKE3 de cada exec crítico y aborta antes de incarnar si algo no casa la concesión firmada. // // Esto reemplaza el spike `scripts/attest-boot-test.sh` (que overlayeaba a mano): ahora takana-bootstrap // produce el producto atestado de forma estructurada. Reusa la cripto canónica (arje-attest/agora vía el // packager) — cero criptografía nueva en takana. La rootkey por defecto es determinista ⇒ las firmas // Ed25519 (RFC 8032, nonce derivado del mensaje) son reproducibles bit a bit. /// Rootkey de desarrollo (32 bytes) con la que takana firma la atestación por defecto. Determinista ⇒ /// firmas reproducibles ⇒ `product-attested-rootfs` reproducible. El endurecimiento soberano (rootkey /// fuera del árbol, `/etc/arje/rootkey.pub` / `ARJE_ATTEST_ROOTKEY_FILE`) es una capa posterior /// (roadmap Etapa D, hardening #3); por ahora se inyecta como override de [`AttestConfig::rootkey`]. pub const DEV_ATTEST_ROOTKEY: &[u8; 32] = b"hammer-attest-dev-rootkey-0001!!"; /// Recetas de la capa de atestación: el init CON gate (variante SÓLO producto, núcleo intacto) y el /// firmador (herramienta de build-time, estático musl, corre en el host y NO se embarca). const GATED_INIT_COMPONENT: &str = "arje-zero-attest"; const PACKAGER_COMPONENT: &str = "arje-packager"; /// Configuración de la firma de atestación del producto. #[derive(Debug, Clone)] pub struct AttestConfig { /// Política que el gate aplica si un binario no atesta: `"halt"` | `"degraded"` | `"warn"`. pub policy: String, /// Rootkey de 32 bytes para firmar las concesiones. Determinista ⇒ producto reproducible. pub rootkey: [u8; 32], } impl Default for AttestConfig { /// Política `halt` (la integridad comprometida NO levanta el entorno) + la rootkey de desarrollo. fn default() -> Self { Self { policy: "halt".into(), rootkey: *DEV_ATTEST_ROOTKEY } } } /// Identidad del `product-attested-rootfs`: el producto base + el init con gate + el firmador + la /// política + la rootkey. Cambiar cualquiera re-hashea; como las firmas Ed25519 son deterministas en /// estos insumos, el árbol sellado es reproducible. fn product_attested_hash( base_product: &RootfsHash, gated_init: &ArtifactHash, packager: &ArtifactHash, cfg: &AttestConfig, ) -> RootfsHash { ArtifactHash::of_inputs(&[ b"hammer-product-attested-v2", // v2: + ancla soberana externa /etc/arje/rootkey.pub (hardening #3) base_product.as_str().as_bytes(), gated_init.as_str().as_bytes(), packager.as_str().as_bytes(), cfg.policy.as_bytes(), &cfg.rootkey, ]) } /// Binarios críticos que el gate atesta, derivados de la PROPIA seed (no una lista fija): el ejecutable /// de PID 1 (`arje-zero`, el `current_exe` del gate) + el `exec` de cada card `Native` del fractal /// `genesis` (recursivo), mapeado a su ruta dentro de `staging`. Así, añadir un servicio a la seed lo /// firma automáticamente. Devuelve `(label, ruta_host)` para alimentar `--bin label=path` del packager — /// el `label` debe casar el de la card (lo que el gate busca en las concesiones). El orden es estable /// (PID 1 primero, luego DFS del genesis) ⇒ los flags `--bin` son deterministas. fn critical_bins_from_seed(seed: &serde_json::Value, staging: &Path) -> Vec<(String, PathBuf)> { fn walk(card: &serde_json::Value, staging: &Path, out: &mut Vec<(String, PathBuf)>) { let exec = card .get("payload") .and_then(|p| p.get("Native")) .and_then(|n| n.get("exec")) .and_then(|e| e.as_str()); if let (Some(exec), Some(label)) = (exec, card.get("label").and_then(|l| l.as_str())) { out.push((label.to_string(), staging.join(exec.trim_start_matches('/')))); } if let Some(g) = card.get("genesis").and_then(|g| g.as_array()) { for c in g { walk(c, staging, out); } } } let mut out = vec![("arje-zero".to_string(), staging.join("usr/bin/arje-zero"))]; if let Some(g) = seed.get("genesis").and_then(|g| g.as_array()) { for c in g { walk(c, staging, &mut out); } } out } /// Ensambla el producto ATESTADO en `staging`: hidrata el `product-rootfs` base, PISA `/usr/bin/arje-zero` /// con el init CON gate, fija `attest_policy` en la seed, la FIRMA con el packager (una concesión por /// binario crítico sobre su BLAKE3), ancla la pubkey en `/etc/arje/rootkey.pub` (ancla soberana externa, /// hardening #3) y regenera `/ente/attest.json` (la auto-verificación de takana, ahora coherente con el /// arje-zero nuevo). El packager corre en el host (estático musl, sin loader, como busybox/bwrap). La /// firma se valida E2E en QEMU (`scripts/attest-boot-test.sh`). fn assemble_attested_product_rootfs( store: &Store, base_product: &RootfsHash, gated_init_bin: &Path, packager_bin: &Path, cfg: &AttestConfig, staging: &Path, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; // 1) producto base hidratado (hardlinks read-only del árbol sellado). let base_dir = store.path_of(base_product, "product-rootfs"); if !base_dir.is_dir() { return Err(Error::Other(format!( "product-rootfs base no sellado en {}", base_dir.display() ))); } takana_build::run_hydrate(&base_dir, staging, takana_core::LinkMode::Static, None)?; let _ = std::fs::remove_dir_all(staging.join(".hammer")); // 2) PISAR /usr/bin/arje-zero con el init CON gate. Es un hardlink read-only al store ⇒ romper el // hardlink (remove + copy), nunca `chmod` (mutaría el inodo del store). let init_dst = staging.join("usr/bin/arje-zero"); let _ = std::fs::remove_file(&init_dst); std::fs::copy(gated_init_bin, &init_dst)?; std::fs::set_permissions(&init_dst, std::fs::Permissions::from_mode(0o755))?; // 3) seed de producto: fijar `attest_policy` como ENTRADA del firmador. La mutamos y la dejamos en un // tmp FUERA del árbol a sellar (el seed-out lo escribe el packager sobre /ente/seed.card.json). let seed_path = staging.join("ente/seed.card.json"); let raw = std::fs::read_to_string(&seed_path) .map_err(|e| Error::Other(format!("leer seed de producto: {e}")))?; let mut seed: serde_json::Value = serde_json::from_str(&raw).map_err(|e| Error::Other(format!("seed inválida: {e}")))?; seed["attest_policy"] = serde_json::Value::String(cfg.policy.clone()); let tmp = staging.join(".attest-build"); // se borra antes de sellar (no entra al árbol). std::fs::create_dir_all(&tmp)?; let seed_in = tmp.join("seed-in.json"); std::fs::write( &seed_in, serde_json::to_string_pretty(&seed) .map_err(|e| Error::Other(format!("serializar seed-in: {e}")))?, )?; let rootkey_path = tmp.join("rootkey.bin"); std::fs::write(&rootkey_path, cfg.rootkey)?; std::fs::set_permissions(&rootkey_path, std::fs::Permissions::from_mode(0o600))?; // 4) bins críticos derivados de la seed → un --bin por exec Native + el PID 1. let bins = critical_bins_from_seed(&seed, staging); // 5) FIRMAR: el packager escribe el seed firmado (Card::attest + attest_rootkey + attest_policy) // directamente sobre /ente/seed.card.json. El fichero hidratado es un HARDLINK read-only al // store ⇒ el packager no puede abrirlo para escribir (EACCES): romper el hardlink primero. let _ = std::fs::remove_file(&seed_path); let mut cmd = Command::new(packager_bin); cmd.arg("--seed") .arg(&seed_in) .arg("--rootkey") .arg(&rootkey_path) .arg("--seed-out") .arg(&seed_path); for (label, path) in &bins { cmd.arg("--bin").arg(format!("{label}={}", path.display())); } let out = cmd .output() .map_err(|e| Error::Other(format!("spawn arje-packager: {e}")))?; if !out.status.success() { return Err(Error::Other(format!( "arje-packager falló (exit {:?}): {}", out.status.code(), String::from_utf8_lossy(&out.stderr).trim() ))); } // 5b) ANCLA SOBERANA EXTERNA (hardening #3): escribir la pubkey de la rootkey en /etc/arje/rootkey.pub // (32 bytes raw). El gate (`ancla_externa()` en attest_gate.rs) la prefiere sobre la // `attest_rootkey` declarada en el seed ⇒ un seed reescrito con OTRA rootkey cae en AutorNoConfiable // (las concesiones no las firmó el ancla). Cierra el hueco que el WARN "sin ancla soberana externa" // señala. La pubkey se LEE del seed ya firmado (`attest_rootkey`, lo que el packager computó de // nuestra rootkey privada) ⇒ cero criptografía nueva en takana. let signed_raw = std::fs::read_to_string(&seed_path) .map_err(|e| Error::Other(format!("releer seed firmado: {e}")))?; let signed: serde_json::Value = serde_json::from_str(&signed_raw) .map_err(|e| Error::Other(format!("seed firmado inválido: {e}")))?; let pubkey: Vec = signed .get("attest_rootkey") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect::>()) .ok_or_else(|| Error::Other("seed firmado sin attest_rootkey de 32 bytes".into()))?; if pubkey.len() != 32 { return Err(Error::Other(format!( "attest_rootkey del seed firmado no es de 32 bytes (es {})", pubkey.len() ))); } std::fs::create_dir_all(staging.join("etc/arje"))?; std::fs::write(staging.join("etc/arje/rootkey.pub"), &pubkey)?; // 6) regenerar /ente/attest.json: el arje-zero cambió ⇒ su BLAKE3 también, así que el manifiesto de // auto-verificación de takana (`takana attest`) debe casar el árbol nuevo. El producto base lo // trae hidratado (hardlink read-only): romperlo antes de reescribir. let attest = build_attestation(staging)?; let attest_json = serde_json::to_string_pretty(&attest) .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?; let attest_path = staging.join("ente/attest.json"); let _ = std::fs::remove_file(&attest_path); std::fs::write(&attest_path, attest_json)?; // 7) limpiar el tmp de build (no debe quedar en el árbol sellado). let _ = std::fs::remove_dir_all(&tmp); Ok(()) } /// **Producto atestado** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4), sella el /// `product-rootfs` (vía [`product`], idempotente) y produce el `product-attested-rootfs`: init CON gate /// + seed FIRMADA, listo para que el gate de arje lo verifique al boot (A2). Construye/cachea las recetas /// de la capa de atestación (`arje-zero-attest`, `arje-packager`). Idempotente. El núcleo y el producto /// base quedan intactos (Separación Mecanismo/Política): el atestado es un sello APARTE. pub fn product_attested( base: &RootfsHash, base_cfg: &takana_build::BuildConfig, recipes_dir: &Path, store: &Store, cfg: &AttestConfig, ) -> Result { // 0) el producto base (idempotente: si ya está sellado, sale cacheado). let base_product = product(base, base_cfg, recipes_dir, store)?; // 1) recetas de la capa de atestación (idempotentes/cacheadas por el lab). let gated = build_components(&[GATED_INIT_COMPONENT], recipes_dir, base_cfg, store)? .pop() .map(|(_, h)| h) .ok_or_else(|| Error::Other("build de arje-zero-attest no devolvió hash".into()))?; let packager = build_components(&[PACKAGER_COMPONENT], recipes_dir, base_cfg, store)? .pop() .map(|(_, h)| h) .ok_or_else(|| Error::Other("build de arje-packager no devolvió hash".into()))?; let gated_bin = store.path_of(&gated, GATED_INIT_COMPONENT).join("usr/bin/arje-zero"); let packager_bin = store.path_of(&packager, PACKAGER_COMPONENT).join("usr/bin/arje-packager"); if !gated_bin.is_file() { return Err(Error::Other(format!( "arje-zero (con gate) ausente en {}", gated_bin.display() ))); } if !packager_bin.is_file() { return Err(Error::Other(format!( "arje-packager ausente en {}", packager_bin.display() ))); } // 2) identidad + ensamblado + sellado (si no estaba ya). let phash = product_attested_hash(&base_product, &gated, &packager, cfg); let store_name = "product-attested-rootfs"; if !store.has(&phash, store_name) { let staging = store .root() .join(".bootstrap-tmp") .join(phash.store_dir_name(store_name)); let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)?; let result = (|| -> Result<()> { assemble_attested_product_rootfs( store, &base_product, &gated_bin, &packager_bin, cfg, &staging, )?; store.seal(&staging, &phash, store_name)?; Ok(()) })(); let _ = std::fs::remove_dir_all(&staging); result?; tracing::info!(hash = %phash, "product: rootfs ATESTADO (init con gate + seed firmada) sellado"); } else { tracing::info!(hash = %phash, "product: rootfs atestado ya sellado (idempotente)"); } Ok(phash) } // ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ────────────────────── // // El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando // **sólo** las herramientas de Stage 1, y se compara el **content-hash** (`of_tree`, no el hash // input-addressed del store) de stage1 vs stage1'. Iguales ⇒ el sistema se compila a sí mismo bit // a bit (auto-alojado y reproducible). El rebuild **dentro del rootfs** (bwrap/chroot/VM) corre en // la VM destino; lo que vive en takana es la **referencia** (content-hash de stage1) y la // comparación. Ver `docs/runbooks/stage1-vm-boot.md`. /// Veredicto de la verificación de auto-alojamiento de Stage 2. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Reproducibility { /// stage1 y stage1' tienen el mismo content-hash: el sistema se reconstruye bit-idéntico. Reproducible, /// Difieren: hay no-determinismo que cazar ([SDD 09 §2](../../docs/09-trust-model.md)). Divergent { stage1: ArtifactHash, rebuilt: ArtifactHash }, /// El rebuild nativo dentro del rootfs aún no corrió (se hace en la VM); sólo se ancló el /// content-hash de stage1 como referencia a reproducir. RebuildPending, } /// Reporte de Stage 2: la referencia (content-hash de stage1) y el veredicto. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct VerifyReport { /// Hash input-addressed del rootfs en el store (el que devolvió `stage1`). pub stage1_rootfs: RootfsHash, /// Content-hash (`of_tree`) de stage1: los bytes reales que stage1' debe reproducir. pub stage1_content: ArtifactHash, pub verdict: Reproducibility, } /// Content-hash (`ArtifactHash::of_tree`) del artefacto sellado `(hash, name)` en el store. pub fn artifact_content_hash( store: &Store, hash: &ArtifactHash, name: &str, ) -> Result { let dir = store.path_of(hash, name); if !dir.is_dir() { return Err(Error::Other(format!( "artefacto '{name}' no sellado en {}", dir.display() ))); } ArtifactHash::of_tree(&dir).map_err(Error::Io) } /// **Stage 2** (referencia + manifiesto). Ancla el **content-hash** del rootfs de Stage 1 como la /// referencia a reproducir y lo anota en el manifiesto (línea stage 2). El rebuild nativo DENTRO /// del rootfs corre en la VM (SDD 11 §3); cuando produzca stage1', se compara con [`verify_against`]. pub fn stage2(stage1: &RootfsHash, store: &Store) -> Result { let content = artifact_content_hash(store, stage1, "stage1-rootfs")?; manifest::append_line( store, StageEntry { stage: 2, recipe_hash: None, artifact_hash: content.clone(), seed_hash: None, ts: now_unix(), }, )?; tracing::info!(content = %content, "stage2: content-hash de stage1 anclado (referencia)"); Ok(VerifyReport { stage1_rootfs: stage1.clone(), stage1_content: content, verdict: Reproducibility::RebuildPending, }) } /// Compara la referencia (`report.stage1_content`) con el content-hash de un rebuild stage1' /// producido en la VM, y emite el veredicto de reproducibilidad. pub fn verify_against(report: &VerifyReport, rebuilt_content: ArtifactHash) -> Reproducibility { if report.stage1_content == rebuilt_content { Reproducibility::Reproducible } else { Reproducibility::Divergent { stage1: report.stage1_content.clone(), rebuilt: rebuilt_content, } } } /// Reporte de [`all`]: los tres hashes de las etapas + el manifiesto ya poblado. El veredicto de /// auto-alojamiento NO va aquí (es `RebuildPending` en el host): el rebuild nativo que lo decide corre /// **dentro** del rootfs, en la VM destino (`scripts/selfhost-verify.sh`, runbook §8). #[derive(Debug, Clone)] pub struct AllReport { pub seed_hash: ArtifactHash, pub stage1_rootfs: RootfsHash, pub stage2: VerifyReport, pub manifest: BootstrapManifest, } /// **`bootstrap --all`** ([SDD 11 §5](../../docs/11-bootstrap.md)) — encadena las tres etapas en una /// sola corrida del host: ingiere la semilla (Stage 0), construye y sella el rootfs del userland mínimo /// (Stage 1) y ancla su content-hash como referencia de reproducibilidad (Stage 2). Devuelve los tres /// hashes y el [`BootstrapManifest`] (`bootstrap.json`) resultante: el embrión del log de transparencia /// ([SDD 11 §4](../../docs/11-bootstrap.md)), listo para publicar. /// /// El **corte del cordón** (el veredicto `✓ REPRODUCIBLE`) no se emite aquí: el rebuild nativo que lo /// decide corre con las herramientas de **dentro** del rootfs (en la VM), no las del host. `all` deja la /// referencia anclada (`stage2.stage1_content`) lista para que ese rebuild la compare vía `stage2 /// --verify` (ver [`verify_against`] y `scripts/selfhost-verify.sh`). /// /// Idempotente componiendo idempotencias: cada etapa no rehace lo ya sellado ni duplica su línea de /// manifiesto, así que re-correr `all` sobre un store ya poblado sólo recolecta los hashes. pub fn all( seed: &SeedSpec, recipes_dir: &Path, base_cfg: &takana_build::BuildConfig, store: &Store, ) -> Result { let seed_hash = stage0(seed, store)?; let stage1_spec = Stage1Spec { seed_hash: seed_hash.clone(), seed_kind: seed.kind, recipes_dir: recipes_dir.to_path_buf(), }; let stage1_rootfs = stage1(&stage1_spec, base_cfg, store)?; let stage2 = stage2(&stage1_rootfs, store)?; let manifest = BootstrapManifest::load(store)?; Ok(AllReport { seed_hash, stage1_rootfs, stage2, manifest }) } // ── Builder rootfs: el camino al rebuild in-rootfs (Stage 2 pleno, SDD 11 §7) ──────────────────── // // El Stage 1 que booteamos es un *runtime* (musl+busybox+hammerd+arje-zero): no trae compilador, no // puede reconstruirse. El **builder rootfs** es Stage 1 **+ el toolchain adentro** — la imagen que, // booteada en la VM, corre `takana bootstrap stage1` dentro de sí misma y produce `stage1'`. Compara // su content-hash con la referencia anclada por `stage2`: iguales ⇒ auto-alojamiento bit a bit. // // Variante (a) **pragmática** (SDD 11 §7.2): el toolchain entra desde Alpine (no construido por // takana). Demuestra el *mecanismo* y cierra la reproducibilidad end-to-end; no es aún el // auto-alojamiento *puro* (variante b: el toolchain construido por takana desde fuente, incremental). // // El builder NO se sella en el store (su /toolchain Alpine no es content-addressed y abultaría): se // ensambla en un `out_dir` que se empaqueta como initramfs (ver runbook §8c). Su identidad sí es // reproducible: un `ArtifactHash` *lógico* de sus insumos (stage1 + semilla + recetas + binario + // tag del toolchain + el propio driver), apto para el log de transparencia. /// Driver embebido en `/usr/bin/rebuild-stage1`: corre el rebuild dentro del propio rootfs. Lee /// `/etc/hammer/rebuild.env` (`SEED_HASH`/`SEED_KIND`/`REF_CONTENT`), apunta el sandbox de build al /// toolchain de adentro (`/toolchain`) y compara `stage1'` contra la referencia. Si `REF_CONTENT` va /// vacío, sólo imprime el content-hash de `stage1'` para comparación manual. /// /// El toolchain es Alpine **dinámico**: sus binarios (bwrap/git/curl/cargo/make) no corren desde el /// userland Stage 1, que es **estático** (no hay loader en `/lib`). El driver instala un *shim* del /// loader musl (`cp` a `/lib`) + `LD_LIBRARY_PATH`/`PATH` al toolchain, y a partir de ahí esas /// herramientas son ejecutables; el `takana` estático vive en `/usr/bin/hammer`. El sandbox de build /// (bwrap) anida en `/toolchain`. (El shim sólo se usa para *invocar* el toolchain desde fuera del /// sandbox: fetch git/tarball; dentro del sandbox el loader está en su sitio.) const REBUILD_DRIVER: &str = r#"#!/bin/sh # rebuild-stage1 — rebuild de Stage 1 con el toolchain del propio rootfs (auto-alojamiento, SDD 11 §7). # Booteado en la VM (builder rootfs), reconstruye stage1' usando SÓLO las herramientas de adentro y # compara su content-hash con la referencia que `hammer bootstrap stage2` ancló afuera. set -eu . /etc/hammer/rebuild.env # SEED_HASH, SEED_KIND, REF_CONTENT (REF_CONTENT puede ir vacío) # Shim del loader musl: deja correr los binarios *dinámicos* del toolchain (bwrap/git/curl/cargo/make) # desde el root Stage 1 estático. Usa el busybox estático (rutas absolutas) ANTES de tocar el PATH. /bin/mkdir -p /lib /bin/cp -f /toolchain/lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1 export LD_LIBRARY_PATH=/toolchain/lib:/toolchain/usr/lib export PATH=/toolchain/usr/bin:/toolchain/usr/sbin:/toolchain/bin:/toolchain/sbin:/usr/bin:/bin:/sbin # overlay es módulo (CONFIG_OVERLAY_FS=m) y el initramfs arranca sin módulos cargados: el sandbox de # build (bwrap --tmp-overlay) lo exige ⇒ ENODEV sin esto. Lo cargamos del toolchain (insmod=kmod, # vermagic del kernel destino). Idempotente: si ya está, insmod falla y lo ignoramos. insmod /toolchain/lib/overlay.ko 2>/dev/null || true # Red para el vendoring de crates (los componentes Rust hacen `cargo vendor`, que baja de crates.io). # NIC e1000 (qemu user-mode 10.0.2.0/24, módulo del kernel) + config estática + DNS de slirp. La VM # sin NIC ⇒ todo esto es no-op (los componentes C no necesitan red). `ip` sale del toolchain (shim). insmod /toolchain/lib/e1000.ko 2>/dev/null || true if ip link show eth0 >/dev/null 2>&1; then ip link set eth0 up ip addr add 10.0.2.15/24 dev eth0 2>/dev/null || true ip route add default via 10.0.2.2 2>/dev/null || true printf 'nameserver 10.0.2.3\n' > /etc/resolv.conf echo ">> red eth0: $(ip -4 -o addr show eth0 2>/dev/null | grep -o 'inet [0-9.]*' || echo down)" # CA bundle para el TLS de cargo/curl/git (crates.io). El toolchain trae el bundle de Alpine; # lo exponemos en la ruta estándar y por env (cargo usa libcurl/openssl según el build). mkdir -p /etc/ssl/certs cp -f /toolchain/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt fi # NOTA: el initramfs debe empaquetarse con `cpio --owner=root:root`. Si los ficheros del toolchain # quedan con el uid del que armó el builder (p. ej. 1000), ese uid no está mapeado en el userns de # bwrap (que mapea sólo 0→0) y el copy-up de overlay falla con EACCES al crear /opt/zig. Ver §8c. # Se exportan LAS DOS formas a propósito (ADR 0016): el binario que corre acá adentro es # `/usr/bin/hammer`, que puede ser el pinado del baseline y sólo conoce los nombres viejos. # Un lector nuevo prefiere TAKANA_*; uno viejo usa HAMMER_*. Las dos se retiran en la etapa 6. export TAKANA_ROOTFS=/toolchain # el sandbox de build: el toolchain (variante a, desde Alpine) export TAKANA_WORK=/work export TAKANA_CACHE= export HAMMER_ROOTFS=/toolchain export HAMMER_WORK=/work export HAMMER_CACHE= echo ">> rebuild stage1 (toolchain in-rootfs, semilla $SEED_HASH)" PRIME=$(/usr/bin/hammer --store /store bootstrap stage1 --seed-hash "$SEED_HASH" --seed "$SEED_KIND" --recipes /etc/hammer/recipes) echo ">> stage1' = $PRIME" if [ -n "${REF_CONTENT:-}" ]; then /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" --verify "$REF_CONTENT" else /usr/bin/hammer --store /store bootstrap stage2 --rootfs "$PRIME" fi "#; /// Qué ensamblar en el builder rootfs (variante a, SDD 11 §7). #[derive(Debug, Clone)] pub struct BuilderSpec { /// Stage 1 rootfs ya sellado (nombre store `stage1-rootfs`) que el builder extiende — la base /// runtime sobre la que se hidrata el toolchain. pub stage1_rootfs: RootfsHash, /// Semilla zig sellada (Stage 0). Se replica en el `/store` del builder para que el `takana` /// de adentro resuelva el toolchain por hash, igual que afuera. pub seed_hash: ArtifactHash, pub seed_kind: SeedKind, /// Recetas a embeber en `/etc/hammer/recipes` (las que `stage1` rehidrata el rebuild). pub recipes_dir: PathBuf, /// Binario `takana` estático → `/usr/bin/hammer`. Variante (a): el del host; (b): el que takana /// construye desde fuente. pub hammer_bin: PathBuf, /// Toolchain del sandbox de build → `/toolchain` (variante a: el rootfs Alpine de `.dev-fs`). pub toolchain_src: PathBuf, /// Etiqueta de identidad del toolchain (p. ej. `alpine-3.23.4`) — entra al hash lógico del /// builder sin tener que hashear el árbol Alpine entero. pub toolchain_tag: String, /// Referencia `of_tree(stage1)` anclada por `stage2`, embebida para que el rebuild se /// auto-verifique. `None` ⇒ el driver sólo imprime el content-hash de `stage1'`. pub ref_content: Option, /// Caché de fuentes (`work/repos`, `work/tarballs`) → `/work`, para un rebuild **offline** y /// determinista en la VM. `None` ⇒ el rebuild fetchea por red (la seed card trae `networking: full`). pub work_cache: Option, /// Piezas del toolchain **construidas por takana desde fuente** (variante b, SDD 11 §7.2b) que se /// montan sobre el path Alpine en `/toolchain`. Vacío ⇒ variante (a) pura-Alpine. Cada swap entra /// al hash lógico del builder: la procedencia deja de ser "todo Alpine" y se vuelve auditable. pub swaps: Vec, } /// Una herramienta del toolchain reemplazada por su build takana desde fuente (SDD 11 §7.2b). El /// binario sellado se monta sobre `rel_path` dentro de `/toolchain`, pisando la versión Alpine; su /// hash sellado se ancla en el hash lógico del builder para que el swap sea reproducible y auditable. #[derive(Debug, Clone)] pub struct ToolchainSwap { /// Nombre del componente/receta (p. ej. `make`); también el nombre store del artefacto sellado. pub name: String, /// `of_tree` del build takana (el artefacto sellado del que sale el binario). pub artifact: ArtifactHash, /// Path relativo del binario dentro del artefacto **y** dentro de `/toolchain` (p. ej. /// `usr/bin/make`). El mismo en origen y destino: el layout del artefacto imita al de Alpine. pub rel_path: String, } /// Resultado de [`builder_rootfs`]: la identidad lógica del builder y dónde quedó ensamblado. #[derive(Debug, Clone)] pub struct BuilderReport { pub builder_hash: ArtifactHash, pub out_dir: PathBuf, } /// Hash **lógico** del builder: función pura de sus insumos. No es `of_tree` del árbol (el toolchain /// Alpine no es content-addressed); identifica de forma reproducible *de qué* se ensambló el builder. fn builder_hash( stage1: &RootfsHash, seed: &ArtifactHash, toolchain_tag: &str, recipes_digest: &str, hammer_digest: &str, swaps_digest: &str, ) -> ArtifactHash { ArtifactHash::of_inputs(&[ b"hammer-builder-rootfs-v1", stage1.as_str().as_bytes(), seed.as_str().as_bytes(), toolchain_tag.as_bytes(), recipes_digest.as_bytes(), hammer_digest.as_bytes(), swaps_digest.as_bytes(), REBUILD_DRIVER.as_bytes(), ]) } /// Digest de los swaps del toolchain (variante b): sha256 sobre los `(name, artifact, rel_path)` en /// orden por nombre. Sin swaps ⇒ string vacío, así el hash lógico de un builder pura-Alpine no cambia /// respecto a antes de existir el mecanismo (compatibilidad hacia atrás de la línea de manifiesto). fn swaps_digest(swaps: &[ToolchainSwap]) -> String { if swaps.is_empty() { return String::new(); } use sha2::{Digest, Sha256}; let mut sorted: Vec<&ToolchainSwap> = swaps.iter().collect(); sorted.sort_by(|a, b| a.name.cmp(&b.name)); let mut h = Sha256::new(); for s in sorted { for field in [s.name.as_str(), s.artifact.as_str(), s.rel_path.as_str()] { h.update(field.as_bytes()); h.update([0u8]); } } hex::encode(h.finalize()) } /// sha256 hex del contenido de un archivo. fn sha256_file(path: &Path) -> Result { use sha2::{Digest, Sha256}; let bytes = std::fs::read(path)?; let mut h = Sha256::new(); h.update(&bytes); Ok(hex::encode(h.finalize())) } /// Digest de un directorio de recetas: sha256 sobre los `(nombre, bytes)` de cada `*.toml` en orden. /// Cambiar una receta, añadir o quitar una, re-hashea el builder. fn recipes_digest(dir: &Path) -> Result { use sha2::{Digest, Sha256}; let mut tomls: Vec = std::fs::read_dir(dir)? .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml")) .collect(); tomls.sort(); let mut h = Sha256::new(); for p in &tomls { let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); h.update(name.as_bytes()); h.update([0u8]); h.update(std::fs::read(p)?); h.update([0u8]); } Ok(hex::encode(h.finalize())) } /// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y /// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder /// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`). /// /// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej. /// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el /// propio destino en una recursión infinita. Se compara por path canónico. fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> { if let Some(skip) = skip { if src.canonicalize().ok().as_deref() == Some(skip) { return Ok(()); } } let meta = std::fs::symlink_metadata(src)?; let ft = meta.file_type(); if ft.is_symlink() { let target = std::fs::read_link(src)?; let _ = std::fs::remove_file(dst); std::os::unix::fs::symlink(target, dst)?; } else if ft.is_dir() { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?; } } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() { std::fs::copy(src, dst)?; } Ok(()) } /// Ensambla el árbol del builder en `staging` (la pieza testeable, sin VM ni Alpine real): hidrata el /// Stage 1 rootfs como base, monta el toolchain en `/toolchain`, replica la semilla en `/store`, /// instala `takana` + recetas + el driver `rebuild-stage1` y, opcionalmente, la caché de fuentes. fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result<()> { // 1) Base: el Stage 1 rootfs sellado (read-only en el store ⇒ hardlinks seguros). let stage1_dir = store.path_of(&spec.stage1_rootfs, "stage1-rootfs"); if !stage1_dir.is_dir() { return Err(Error::Other(format!( "stage1-rootfs '{}' no sellado en {}; corre `hammer bootstrap stage1` primero", spec.stage1_rootfs, stage1_dir.display() ))); } takana_build::run_hydrate(&stage1_dir, staging, takana_core::LinkMode::Static, None)?; // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config. for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] { std::fs::create_dir_all(staging.join(d))?; } // 3) Toolchain → /toolchain (variante a: el rootfs Alpine de .dev-fs). if !spec.toolchain_src.is_dir() { return Err(Error::Other(format!( "toolchain '{}' no es un directorio (¿corriste bootstrap-devfs.sh?)", spec.toolchain_src.display() ))); } link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?; // 3b) Swaps de variante (b): piezas construidas por takana desde fuente pisan su versión Alpine // en /toolchain. Un `rel_path` puede apuntar a un BINARIO (estático musl ⇒ corre sin el shim // del loader; p.ej. make, busybox) o a un DIRECTORIO (un árbol de headers; p.ej. los // `usr/include/{linux,asm,…}` de linux-headers). En el caso directorio se reemplaza el árbol // entero (remove + copy) para no dejar archivos Alpine huérfanos. for swap in &spec.swaps { let art_dir = store.path_of(&swap.artifact, &swap.name); let src = art_dir.join(&swap.rel_path); let dst = staging.join("toolchain").join(&swap.rel_path); if src.is_dir() { let _ = std::fs::remove_dir_all(&dst); std::fs::create_dir_all(&dst)?; link_or_copy_tree(&src, &dst, None)?; } else if src.is_file() { if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } let _ = std::fs::remove_file(&dst); if std::fs::hard_link(&src, &dst).is_err() { std::fs::copy(&src, &dst)?; } set_executable(&dst)?; } else { return Err(Error::Other(format!( "swap '{}': '{}' no existe (ni archivo ni directorio) en el artefacto sellado {} ({})", swap.name, swap.rel_path, swap.artifact, src.display(), ))); } } // 4) Semilla → /store/-seed-, tal cual está sellada afuera, para que el `takana` de // adentro resuelva el toolchain por hash sin re-ingerirla. let seed_name = format!("seed-{}", spec.seed_kind.as_str()); let seed_dir = store.path_of(&spec.seed_hash, &seed_name); if !seed_dir.is_dir() { return Err(Error::Other(format!( "semilla '{}' no sellada en {}; corre `hammer bootstrap stage0` primero", seed_name, seed_dir.display() ))); } link_or_copy_tree( &seed_dir, &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)), None, )?; // 5) Binario takana → /usr/bin/hammer (+x). let hammer_dst = staging.join("usr/bin/hammer"); if std::fs::hard_link(&spec.hammer_bin, &hammer_dst).is_err() { std::fs::copy(&spec.hammer_bin, &hammer_dst)?; } set_executable(&hammer_dst)?; // 6) Recetas → /etc/hammer/recipes. for entry in std::fs::read_dir(&spec.recipes_dir)? { let path = entry?.path(); if path.extension().and_then(|x| x.to_str()) == Some("toml") { let name = path.file_name().unwrap(); std::fs::copy(&path, staging.join("etc/hammer/recipes").join(name))?; } } // 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y // `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se // copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El // `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`. if let Some(cache) = &spec.work_cache { let skip = staging.canonicalize().ok(); for sub in ["repos", "tarballs"] { let src = cache.join(sub); if src.is_dir() { link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?; } } } // 8) Config del rebuild + driver. REF_CONTENT vacío si no hay referencia (rebuild informativo). let ref_content = spec.ref_content.as_ref().map(|h| h.as_str()).unwrap_or(""); let env = format!( "SEED_HASH={}\nSEED_KIND={}\nREF_CONTENT={}\n", spec.seed_hash, spec.seed_kind.as_str(), ref_content, ); std::fs::write(staging.join("etc/hammer/rebuild.env"), env)?; let driver = staging.join("usr/bin/rebuild-stage1"); std::fs::write(&driver, REBUILD_DRIVER)?; set_executable(&driver)?; Ok(()) } /// Marca un archivo como ejecutable (0o755). fn set_executable(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; Ok(()) } /// **Builder rootfs** (SDD 11 §7) — ensambla en `out_dir` la imagen Stage 1 + toolchain que, booteada /// en la VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento). Devuelve el hash /// lógico del builder y anota la línea del manifiesto. `out_dir` se reensambla limpio en cada corrida. pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Result { // Hash lógico (insumos), independiente del ensamblado en disco. let rdigest = recipes_digest(&spec.recipes_dir)?; let hdigest = sha256_file(&spec.hammer_bin)?; let sdigest = swaps_digest(&spec.swaps); let bhash = builder_hash( &spec.stage1_rootfs, &spec.seed_hash, &spec.toolchain_tag, &rdigest, &hdigest, &sdigest, ); let _ = std::fs::remove_dir_all(out_dir); std::fs::create_dir_all(out_dir)?; assemble_builder(spec, store, out_dir)?; manifest::append_line( store, StageEntry { stage: 2, recipe_hash: None, artifact_hash: bhash.clone(), seed_hash: Some(spec.seed_hash.clone()), ts: now_unix(), }, )?; tracing::info!(hash = %bhash, out = %out_dir.display(), "builder: rootfs ensamblado"); Ok(BuilderReport { builder_hash: bhash, out_dir: out_dir.to_path_buf() }) } /// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`. /// /// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a /// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca /// llega al árbol que se sella. pub fn stage0(seed: &SeedSpec, store: &Store) -> Result { let h = seed.seed_hash(); let name = seed.store_name(); if store.has(&h, &name) { tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)"); } else { // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda // hacer el rename atómico. let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name)); let _ = std::fs::remove_dir_all(&work); std::fs::create_dir_all(&work)?; // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de // anotar el manifiesto: una semilla que no se selló no genera línea. let result = stage0_into(seed, store, &h, &name, &work); let _ = std::fs::remove_dir_all(&work); result?; } // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea. manifest::append_line( store, StageEntry { stage: 0, recipe_hash: None, artifact_hash: h.clone(), seed_hash: Some(h.clone()), ts: now_unix(), }, )?; Ok(h) } /// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época /// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a /// la reproducibilidad. fn now_unix() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn stage0_into( seed: &SeedSpec, store: &Store, h: &ArtifactHash, name: &str, work: &Path, ) -> Result { let tarball = work.join("seed.tar"); download::fetch_url_to_file(&seed.url, &tarball)?; // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella. download::verify_sha256(&tarball, &seed.sha256)?; let tree = work.join("tree"); std::fs::create_dir_all(&tree)?; untar(&tarball, &tree)?; let sealed = store.seal(&tree, h, name)?; tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada"); Ok(h.clone()) } /// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado. fn untar(tarball: &Path, into: &Path) -> Result<()> { let st = Command::new("tar") .arg("-xf") .arg(tarball) .arg("-C") .arg(into) .status() .map_err(|e| Error::Other(format!("spawn tar: {e}")))?; if !st.success() { return Err(Error::Untar(st.code())); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn spec(url: &str, sha256: &str) -> SeedSpec { SeedSpec { kind: SeedKind::Zig, version: "0.13.0".into(), url: url.into(), sha256: sha256.into(), } } fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(bytes); hex::encode(h.finalize()) } /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`. fn make_seed_tarball(dir: &Path) -> (String, String) { let src = dir.join("src"); std::fs::create_dir_all(src.join("bin")).unwrap(); std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap(); let tarball = dir.join("seed.tar.gz"); let st = Command::new("tar") .arg("-czf") .arg(&tarball) .arg("-C") .arg(&src) .arg(".") .status() .unwrap(); assert!(st.success(), "tar -c falló"); let sha = sha256_hex(&std::fs::read(&tarball).unwrap()); (format!("file://{}", tarball.display()), sha) } #[test] fn seed_hash_is_identity_based() { let a = spec("file:///mirror-a.tar", "abc123"); let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash"); let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash"); let mut d = spec("file:///mirror-a.tar", "abc123"); d.kind = SeedKind::MuslCrossMake; assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash"); } #[test] fn stage0_ingests_and_seals() { let tmp = tempfile::tempdir().unwrap(); let (url, sha) = make_seed_tarball(tmp.path()); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec(&url, &sha); let h = stage0(&seed, &store).expect("stage0"); assert_eq!(h, seed.seed_hash()); assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada"); // El contenido extraído está presente y de sólo-lectura (sellado). let zig = store.path_of(&h, &seed.store_name()).join("bin/zig"); assert!(zig.is_file(), "bin/zig debe existir en el artefacto"); use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(&zig).unwrap().permissions().mode(); assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible"); } #[test] fn stage0_is_idempotent() { let tmp = tempfile::tempdir().unwrap(); let (url, sha) = make_seed_tarball(tmp.path()); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec(&url, &sha); let h1 = stage0(&seed, &store).unwrap(); let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit assert_eq!(h1, h2); } #[test] fn stage0_records_one_manifest_line() { let tmp = tempfile::tempdir().unwrap(); let (url, sha) = make_seed_tarball(tmp.path()); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec(&url, &sha); let h = stage0(&seed, &store).unwrap(); stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea let m = BootstrapManifest::load(&store).unwrap(); assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0"); let e = &m.entries[0]; assert_eq!(e.stage, 0); assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila"); assert_eq!(e.artifact_hash, h); assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto"); } #[test] fn stage0_bad_sha_records_no_manifest_line() { let tmp = tempfile::tempdir().unwrap(); let (url, _real_sha) = make_seed_tarball(tmp.path()); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec(&url, &"0".repeat(64)); assert!(stage0(&seed, &store).is_err()); // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto. assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json"); } #[test] fn stage0_rejects_bad_sha256_and_seals_nothing() { let tmp = tempfile::tempdir().unwrap(); let (url, _real_sha) = make_seed_tarball(tmp.path()); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado let err = stage0(&seed, &store).unwrap_err(); assert!( err.to_string().contains("sha256 mismatch"), "esperaba mismatch de sha256, vino: {err}" ); assert!( !store.has(&seed.seed_hash(), &seed.store_name()), "un sha erróneo no debe sellar nada" ); // Tampoco debe dejar staging a medio descargar bajo el store. assert!( !store.root().join(".bootstrap-tmp").exists() || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(), "el staging debe quedar limpio tras el fallo" ); } /// Sella una semilla cuyo tarball coloca un `zig` ejecutable en `zig_rel` (relativo a la raíz /// del árbol) y devuelve `(store, seed)` listos para resolver el toolchain. fn seal_seed_with_zig_at(dir: &Path, zig_rel: &str) -> (Store, SeedSpec) { let src = dir.join("src"); let zig = src.join(zig_rel); std::fs::create_dir_all(zig.parent().unwrap()).unwrap(); std::fs::write(&zig, b"#!/bin/sh\necho fake zig\n").unwrap(); let tarball = dir.join("seed.tar.gz"); let st = Command::new("tar") .args(["-czf"]) .arg(&tarball) .arg("-C") .arg(&src) .arg(".") .status() .unwrap(); assert!(st.success()); let sha = sha256_hex(&std::fs::read(&tarball).unwrap()); let store = Store::open(dir.join("store")).unwrap(); let seed = spec(&format!("file://{}", tarball.display()), &sha); stage0(&seed, &store).unwrap(); (store, seed) } #[test] fn toolchain_dir_finds_zig_in_versioned_subdir() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); let dir = seed.toolchain_dir(&store).unwrap(); assert!(dir.join("zig").is_file(), "el dir resuelto debe contener zig: {}", dir.display()); assert!(dir.ends_with("zig-linux-x86_64-0.13.0"), "debe elegir el hijo versionado: {}", dir.display()); } #[test] fn toolchain_dir_finds_zig_at_root() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig"); let dir = seed.toolchain_dir(&store).unwrap(); let base = store.path_of(&seed.seed_hash(), &seed.store_name()); assert_eq!(dir, base, "zig en la raíz ⇒ el dir es la raíz de la semilla"); } #[test] fn toolchain_dir_errors_when_unsealed() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let seed = spec("file:///nope.tar", &"0".repeat(64)); // nunca sellada let err = seed.toolchain_dir(&store).unwrap_err().to_string(); assert!(err.contains("no sellada"), "esperaba 'no sellada': {err}"); } #[test] fn toolchain_dir_errors_when_ambiguous() { let tmp = tempfile::tempdir().unwrap(); // Dos hijos, cada uno con su `zig`: el resolutor no debe elegir a ciegas. let (store, seed) = seal_seed_with_zig_at(tmp.path(), "a/zig"); let base = store.path_of(&seed.seed_hash(), &seed.store_name()); // El store sella read-only; relajamos permisos para inyectar el segundo `zig` del test. let b = base.join("b"); let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&base).status(); std::fs::create_dir_all(&b).unwrap(); std::fs::write(b.join("zig"), b"x").unwrap(); let err = seed.toolchain_dir(&store).unwrap_err().to_string(); assert!(err.contains("ambiguo"), "esperaba 'ambiguo': {err}"); } #[test] fn seed_build_config_points_zig_dir_at_seed() { let tmp = tempfile::tempdir().unwrap(); let (store, seed) = seal_seed_with_zig_at(tmp.path(), "zig-linux-x86_64-0.13.0/zig"); let base = takana_build::BuildConfig::defaults_for_store(store.root()); let cfg = seed_build_config(&base, &seed, &store).unwrap(); assert_eq!(cfg.zig_dir, seed.toolchain_dir(&store).unwrap()); // El resto de la config se hereda sin cambios. assert_eq!(cfg.rootfs, base.rootfs); assert_eq!(cfg.work_root, base.work_root); } // --- Stage 1 (Lote 5): hash del rootfs + ensamblado, testeables sin build real --- /// Sella un componente sintético en el store y devuelve su hash. `build` puebla el árbol /// antes del `seal` (que lo marca read-only). fn seal_component(store: &Store, name: &str, hex: &str, build: impl Fn(&Path)) -> ArtifactHash { let h = ArtifactHash::from_hex(hex); let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(name)); std::fs::create_dir_all(&work).unwrap(); build(&work); store.seal(&work, &h, name).unwrap(); h } #[test] fn rootfs_hash_is_deterministic_and_sensitive() { let base = [ ("musl".to_string(), ArtifactHash::from_hex("11")), ("busybox".to_string(), ArtifactHash::from_hex("22")), ]; let a = rootfs_hash(&base); assert_eq!(a, rootfs_hash(&base), "mismos componentes ⇒ mismo hash"); let changed = [base[0].clone(), ("busybox".to_string(), ArtifactHash::from_hex("33"))]; assert_ne!(a, rootfs_hash(&changed), "cambiar un componente re-hashea"); let reordered = [base[1].clone(), base[0].clone()]; assert_ne!(a, rootfs_hash(&reordered), "el orden de ensamblado importa"); } #[test] fn assemble_rootfs_hydrates_and_installs_arje_init() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let hmusl = seal_component(&store, "musl", "aa11", |w| { std::fs::create_dir_all(w.join("usr/lib")).unwrap(); std::fs::write(w.join("usr/lib/libc.a"), b"!\n").unwrap(); }); let hbb = seal_component(&store, "busybox", "bb22", |w| { std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::write(w.join("bin/busybox"), b"\x7fELFfake").unwrap(); std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); // Sidecar de provenance que NO debe terminar en el rootfs. std::fs::create_dir_all(w.join(".hammer")).unwrap(); std::fs::write(w.join(".hammer/recipe.toml"), b"name = 'busybox'\n").unwrap(); }); let harje = seal_component(&store, "arje-zero", "cc33", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake").unwrap(); }); let staging = store.root().join("staging"); std::fs::create_dir_all(&staging).unwrap(); let components = [ ("musl".to_string(), hmusl), ("busybox".to_string(), hbb), ("arje-zero".to_string(), harje), ]; assemble_rootfs(&store, &components, &staging).unwrap(); assert!(staging.join("usr/lib/libc.a").is_file(), "musl hidratado"); assert!(staging.join("bin/busybox").is_file(), "busybox hidratado"); assert!(staging.join("usr/bin/arje-zero").is_file(), "arje-zero hidratado"); // arje-zero es PID 1 vía /sbin/init. assert_eq!( std::fs::read_link(staging.join("sbin/init")).unwrap(), std::path::PathBuf::from("/usr/bin/arje-zero"), "/sbin/init apunta a arje-zero" ); // La seed card está en su lugar y es JSON válido con los genesis esperados. let seed = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap(); let v: serde_json::Value = serde_json::from_str(&seed).unwrap(); assert_eq!(v["label"], "hammer-stage1"); let labels: Vec<&str> = v["genesis"].as_array().unwrap() .iter().map(|g| g["label"].as_str().unwrap()).collect(); assert_eq!(labels, vec!["hammerd", "console-getty"]); // Puntos de montaje que arje monta solos, presentes como dirs vacíos. assert!(staging.join("sys/fs/cgroup").is_dir() && staging.join("dev/pts").is_dir()); assert!(!staging.join(".hammer").exists(), "el sidecar no debe quedar en el rootfs"); // Ya no hay init provisional de busybox. assert!(!staging.join("etc/inittab").exists(), "el inittab provisional fue retirado"); } #[test] fn stage1_seed_card_is_valid_json_with_native_hammerd() { // La seed embebida debe ser JSON válido y declarar hammerd como servicio Native Restart // (el CRASHED real) — un template roto rompería el boot de arje-zero. let v: serde_json::Value = serde_json::from_str(STAGE1_SEED_CARD).unwrap(); assert_eq!(v["payload"], "Virtual", "la semilla es Virtual"); let hammerd = &v["genesis"][0]; assert_eq!(hammerd["label"], "hammerd"); assert_eq!(hammerd["payload"]["Native"]["exec"], "/usr/bin/hammerd"); assert!(hammerd["supervision"]["Restart"]["initial"].is_number(), "hammerd es Restart"); let getty = &v["genesis"][1]; assert_eq!(getty["payload"]["Native"]["exec"], "/sbin/agetty"); // El argv es el mismo que tenía con el applet: agetty acepta ` ` y las dos // opciones. Si alguien lo cambia, que sea a sabiendas. assert_eq!(getty["payload"]["Native"]["argv"][0], "agetty"); assert_eq!(getty["payload"]["Native"]["argv"][3], "/bin/sh"); } // --- Capa de servicios (producto): composición de seed + hidratación tardía --- #[test] fn product_seed_appends_sshd_to_base_genesis() { // La seed de producto = base + sshd; el núcleo (hammerd, getty) se preserva intacto y el // resultado es JSON válido (lo que arje-zero validará al boot). let seed = product_seed_card(&[card_sshd_esperada()]).unwrap(); let v: serde_json::Value = serde_json::from_str(&seed).unwrap(); assert_eq!(v["label"], "hammer-product"); assert_eq!(v["payload"], "Virtual"); let labels: Vec<&str> = v["genesis"].as_array().unwrap() .iter().map(|g| g["label"].as_str().unwrap()).collect(); assert_eq!(labels, vec!["hammerd", "console-getty", "sshd"], "núcleo intacto + sshd añadido"); let sshd = &v["genesis"][2]; assert_eq!(sshd["payload"]["Native"]["exec"], "/bin/busybox"); assert!(sshd["payload"]["Native"]["argv"][2].as_str().unwrap().contains("/usr/sbin/sshd")); assert!(sshd["supervision"]["Restart"]["initial"].is_number(), "sshd es Restart (lifecycle)"); } /// **El sidecar rancio NO puede pasar en silencio.** /// /// Es la trampa que este mecanismo tiene incorporada y que hay que dejar armada: el sidecar de /// provenance **no entra en el `ArtifactHash`**, así que un artefacto sellado antes de que su /// receta declarara servicios sigue siendo un cache-hit perfectamente válido y no se reconstruye /// nunca solo. Si `service_cards` devolviera una lista vacía sin protestar, el producto saldría /// **booteando, verde y sin sshd**. Un ausente falla ruidosamente; un vacío llega hasta el final /// diciendo que todo fue bien — regla 3 del CLAUDE.md, aplicada acá antes de pagarla. #[test] fn service_cards_falla_ruidosamente_con_sidecar_rancio() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); // Un store sin ese artefacto ⇒ sin sidecar ⇒ sin servicios: exactamente lo que se ve cuando // el artefacto es anterior al bloque `[[service]]`. let servicios = [("openssh".to_string(), ArtifactHash::from_hex("ab"))]; let e = service_cards(&store, &servicios).unwrap_err().to_string(); assert!(e.contains("ningún componente de servicio"), "{e}"); assert!(e.contains("ArtifactHash"), "el error tiene que EXPLICAR la trampa: {e}"); assert!(e.contains("borrar el artefacto del store"), "y decir el arreglo: {e}"); } /// La Card de `sshd` de referencia: la constante que el producto **ya bootea en QEMU**. Los /// tests componen la seed con ella (no con la derivada del sidecar) a propósito: así el /// esqueleto de la seed se prueba sin necesitar un store, y la equivalencia /// «receta ⇒ esta misma card» la prueba `la_receta_de_openssh_reproduce_la_card_hardcodeada`. fn card_sshd_esperada() -> serde_json::Value { serde_json::from_str(SSHD_SERVICE_CARD).unwrap() } /// **La receta produce la MISMA Card que la constante hardcodeada.** /// /// Esta es la prueba que autoriza a mover el servicio de un literal de Rust a `[[service]]` en /// `recipes/openssh.toml`: no compara «una card parecida», compara el `serde_json::Value` /// entero contra [`SSHD_SERVICE_CARD`], que es **la card que el producto ya bootea en QEMU** /// (verificada e2e por `scripts/ssh-e2e-test.sh`). Si empatan, declarar el servicio en la /// receta no cambia ni un byte de la seed — y por lo tanto tampoco el `product_rootfs_hash`. /// /// Lee la receta REAL del repo a propósito: un fixture inline probaría el emisor contra sí /// mismo y no detectaría que alguien editó la receta y la sacó de sincronía con el producto. #[test] fn la_receta_de_openssh_reproduce_la_card_hardcodeada() { let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let r = takana_core::Recipe::load_from_path(repo.join("recipes/openssh.toml")).unwrap(); assert_eq!(r.services.len(), 1, "openssh declara exactamente un servicio"); let generada = r.services[0].card(); let hardcodeada: serde_json::Value = serde_json::from_str(SSHD_SERVICE_CARD).unwrap(); assert_eq!( generada, hardcodeada, "la card generada desde recipes/openssh.toml difiere de SSHD_SERVICE_CARD: \ mover el servicio a la receta CAMBIARÍA la seed de producto" ); } #[test] fn product_rootfs_hash_is_deterministic_and_sensitive() { let base = ArtifactHash::from_hex("ab"); let userland = [("uutils".to_string(), ArtifactHash::from_hex("11"))]; let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))]; let seed = product_seed_card(&[card_sshd_esperada()]).unwrap(); let a = product_rootfs_hash(&base, &userland, &svcs, &seed); assert_eq!(a, product_rootfs_hash(&base, &userland, &svcs, &seed), "determinista"); let other_base = ArtifactHash::from_hex("ef"); assert_ne!(a, product_rootfs_hash(&other_base, &userland, &svcs, &seed), "cambiar la base re-hashea"); let other_userland = [("uutils".to_string(), ArtifactHash::from_hex("22"))]; assert_ne!(a, product_rootfs_hash(&base, &other_userland, &svcs, &seed), "cambiar el userland re-hashea"); let other_svc = [("openssh".to_string(), ArtifactHash::from_hex("99"))]; assert_ne!(a, product_rootfs_hash(&base, &userland, &other_svc, &seed), "cambiar un servicio re-hashea"); } #[test] fn assemble_product_injects_services_over_base() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); // Un rootfs base sintético sellado como `stage1-rootfs` (el 4/4 ya verificado): trae la seed // base, /sbin/init, un mountpoint vacío, un binario del núcleo y un applet busybox `ls`. let base = seal_component(&store, "stage1-rootfs", "ba5e", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::create_dir_all(w.join("ente")).unwrap(); std::fs::create_dir_all(w.join("proc")).unwrap(); std::fs::create_dir_all(w.join("var")).unwrap(); std::fs::create_dir_all(w.join("root")).unwrap(); std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELFfake").unwrap(); std::fs::write(w.join("bin/busybox"), b"\x7fELFbb").unwrap(); // busybox instala el applet `ls` como symlink en /usr/bin (lo ensombrece la hidratación) y // `cat` en /bin (lo debe RETIRAR el adelgazamiento). `sh` en /bin NO se reemplaza (queda). std::os::unix::fs::symlink("../../bin/busybox", w.join("usr/bin/ls")).unwrap(); std::os::unix::fs::symlink("busybox", w.join("bin/cat")).unwrap(); std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); std::fs::write(w.join("ente/seed.card.json"), STAGE1_SEED_CARD).unwrap(); std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin_init_tmp")).unwrap(); }); // Userland Rust sintético (uutils): coreutils multicall + symlink `ls`→coreutils. let huu = seal_component(&store, "uutils", "c0re", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/coreutils"), b"\x7fELFuu").unwrap(); std::os::unix::fs::symlink("coreutils", w.join("usr/bin/ls")).unwrap(); std::os::unix::fs::symlink("coreutils", w.join("usr/bin/cat")).unwrap(); }); // Componentes de servicio sintéticos. let hossh = seal_component(&store, "openssh", "0551", |w| { std::fs::create_dir_all(w.join("usr/sbin")).unwrap(); std::fs::write(w.join("usr/sbin/sshd"), b"\x7fELFfake").unwrap(); }); let hnet = seal_component(&store, "netup", "9e72", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/netup"), b"\x7fELFfake").unwrap(); }); let seed = product_seed_card(&[card_sshd_esperada()]).unwrap(); let userland = [("uutils".to_string(), huu)]; let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)]; let staging = store.root().join("prod-staging"); std::fs::create_dir_all(&staging).unwrap(); assemble_product_rootfs(&store, &base, &userland, &services, &seed, &staging).unwrap(); // base hidratada + userland + servicios inyectados. assert!(staging.join("usr/bin/hammerd").is_file(), "núcleo presente"); assert!(staging.join("proc").is_dir(), "mountpoint vacío recreado"); assert!(staging.join("usr/bin/coreutils").is_file(), "uutils inyectado"); assert!(staging.join("usr/sbin/sshd").is_file(), "openssh inyectado"); assert!(staging.join("usr/bin/netup").is_file(), "netup inyectado"); // ADELGAZAR BUSYBOX: /usr/bin/ls apunta ahora a coreutils (Rust), no a busybox. assert_eq!( std::fs::read_link(staging.join("usr/bin/ls")).unwrap(), std::path::PathBuf::from("coreutils"), "el userland Rust ensombrece el applet busybox en /usr/bin" ); // El applet busybox `cat` en /bin fue RETIRADO (uutils provee cat) ⇒ no compite en PATH. assert!(!staging.join("bin/cat").exists(), "el applet busybox /bin/cat reemplazado fue retirado"); // Lo NO reemplazado por Rust (sh) y el binario busybox quedan intactos. assert!(staging.join("bin/sh").exists(), "/bin/sh (ash) interino se preserva"); assert!(staging.join("bin/busybox").is_file(), "el binario busybox queda (fallback)"); // la seed fue sobrescrita con la de producto (3 cards). let got = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap(); let v: serde_json::Value = serde_json::from_str(&got).unwrap(); assert_eq!(v["label"], "hammer-product"); assert_eq!(v["genesis"].as_array().unwrap().len(), 3); // configs de servicio. assert!(std::fs::read_to_string(staging.join("etc/passwd")).unwrap().contains("sshd:")); assert!(std::fs::read_to_string(staging.join("etc/ssh/sshd_config")).unwrap().contains("Port 22")); // /var/empty con permisos 0711 (privsep no acepta group/world-writable). use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(staging.join("var/empty")).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o711, "/var/empty debe ser 0711"); assert!(staging.join("root/.ssh").is_dir(), "/root/.ssh para authorized_keys"); // Mountpoints de disco (el producto es disk-ready): /store + /var/lib/hammer existen vacíos. assert!(staging.join("store").is_dir() && staging.join("var/lib/hammer").is_dir(), "/store y /var/lib/hammer como mountpoints del disco de producto"); // Atestación: el producto emite /ente/attest.json y se auto-verifica íntegro. assert!(staging.join("ente/attest.json").is_file(), "manifiesto de atestación emitido"); let att = verify_attestation(&staging).unwrap(); assert!(att.passed(), "el product-rootfs recién ensamblado pasa su propia atestación"); // cubre los binarios presentes del set crítico (hammerd, busybox, sshd, netup, coreutils). assert!(att.ok.iter().any(|p| p == "/usr/sbin/sshd") && att.ok.iter().any(|p| p == "/usr/bin/coreutils")); } #[test] fn attestation_manifest_emits_and_verifies_and_catches_tamper() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("rootfs"); for d in ["usr/bin", "bin", "usr/sbin", "ente"] { std::fs::create_dir_all(root.join(d)).unwrap(); } std::fs::write(root.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap(); std::fs::write(root.join("bin/busybox"), b"\x7fELF-busybox").unwrap(); std::fs::write(root.join("usr/sbin/sshd"), b"\x7fELF-sshd").unwrap(); // arje-zero/netup/coreutils ausentes ⇒ no se listan (sólo binarios presentes). let manifest = build_attestation(&root).unwrap(); assert_eq!(manifest.algo, "blake3"); let paths: Vec<&str> = manifest.entries.iter().map(|e| e.path.as_str()).collect(); assert!(paths.contains(&"/usr/bin/hammerd") && paths.contains(&"/bin/busybox") && paths.contains(&"/usr/sbin/sshd")); assert!(!paths.contains(&"/usr/bin/arje-zero"), "binario ausente no se atesta"); std::fs::write(root.join("ente/attest.json"), serde_json::to_string_pretty(&manifest).unwrap()).unwrap(); // verificación íntegra: pasa. let rep = verify_attestation(&root).unwrap(); assert!(rep.passed() && rep.ok.len() == manifest.entries.len()); // manipular un binario ⇒ DIVERGE (el gate de arje haría halt). std::fs::write(root.join("bin/busybox"), b"\x7fELF-TAMPERED-longer").unwrap(); let rep2 = verify_attestation(&root).unwrap(); assert!(!rep2.passed()); assert_eq!(rep2.mismatched, vec!["/bin/busybox".to_string()]); // borrar un binario crítico ⇒ FALTA. std::fs::remove_file(root.join("usr/sbin/sshd")).unwrap(); let rep3 = verify_attestation(&root).unwrap(); assert!(rep3.missing.contains(&"/usr/sbin/sshd".to_string()) && !rep3.passed()); } #[test] fn assemble_attested_swaps_gate_signs_seed_and_regenerates_attest() { // Hermético: un `arje-packager` SINTÉTICO (script python) simula la firma — añade `attest`/ // `attest_rootkey` al seed-out. Valida el CABLEADO de takana (pisar el init, romper hardlinks, // fijar attest_policy, derivar los --bin de la seed, regenerar /ente/attest.json), sin la cripto // real (eso lo cubre E2E `scripts/attest-boot-test.sh` en QEMU). use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); // product-rootfs base sellado: seed de producto + /ente/attest.json + los bins críticos. let base = seal_component(&store, "product-rootfs", "9a07", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::create_dir_all(w.join("ente")).unwrap(); std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELF-arje-zero-CORE").unwrap(); std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap(); std::fs::write(w.join("bin/busybox"), b"\x7fELF-busybox").unwrap(); std::fs::write(w.join("ente/seed.card.json"), product_seed_card(&[card_sshd_esperada()]).unwrap()).unwrap(); // attest.json del producto base (con el arje-zero del NÚCLEO) — debe regenerarse. let a = build_attestation(w).unwrap(); std::fs::write(w.join("ente/attest.json"), serde_json::to_string(&a).unwrap()).unwrap(); }); // init CON gate (bytes distintos al del núcleo) y packager sintético, fuera del store. let gated_bin = tmp.path().join("arje-zero-gated"); std::fs::write(&gated_bin, b"\x7fELF-arje-zero-GATED-bigger").unwrap(); let pkgr = tmp.path().join("fake-packager"); std::fs::write(&pkgr, FAKE_PACKAGER).unwrap(); std::fs::set_permissions(&pkgr, std::fs::Permissions::from_mode(0o755)).unwrap(); let staging = store.root().join(".bootstrap-tmp/test-attested"); std::fs::create_dir_all(&staging).unwrap(); let cfg = AttestConfig { policy: "halt".into(), rootkey: [3u8; 32] }; assemble_attested_product_rootfs(&store, &base, &gated_bin, &pkgr, &cfg, &staging).unwrap(); // 1) /usr/bin/arje-zero ahora es el del gate (hardlink roto, no mutó el inodo del store). assert_eq!( std::fs::read(staging.join("usr/bin/arje-zero")).unwrap(), b"\x7fELF-arje-zero-GATED-bigger", "el init con gate pisó al del núcleo" ); // El sello del store NO se tocó (su arje-zero sigue siendo el del núcleo). assert_eq!( std::fs::read(store.path_of(&base, "product-rootfs").join("usr/bin/arje-zero")).unwrap(), b"\x7fELF-arje-zero-CORE", "romper el hardlink no mutó el store" ); // 2) seed firmada: el packager recibió attest_policy=halt y los 4 --bin derivados de la seed. let seed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap()).unwrap(); assert_eq!(seed["attest_policy"], "halt"); assert!(seed["attest"].is_array(), "el packager firmó (Card::attest)"); let bins_seen = std::fs::read_to_string(staging.join("..").join("bins-seen.txt")).unwrap(); for label in ["arje-zero", "hammerd", "console-getty", "sshd"] { assert!(bins_seen.contains(&format!("{label}=")), "--bin {label} pasado al packager:\n{bins_seen}"); } // 3) ANCLA SOBERANA EXTERNA: /etc/arje/rootkey.pub = la pubkey del seed firmado (32 bytes raw). let anchor = std::fs::read(staging.join("etc/arje/rootkey.pub")).unwrap(); assert_eq!(anchor.len(), 32, "rootkey.pub son 32 bytes raw"); assert_eq!(anchor, vec![7u8; 32], "la pubkey del seed firmado anclada fuera de la Card"); // 4) /ente/attest.json regenerado: pasa con el arje-zero NUEVO (el del núcleo divergiría). assert!(verify_attestation(&staging).unwrap().passed(), "attest.json regenerado y coherente"); // 5) el tmp de build se limpió. assert!(!staging.join(".attest-build").exists()); } /// `arje-packager` sintético para el test hermético: copia `--seed`→`--seed-out` inyectando /// `attest`/`attest_rootkey` (simula la firma) y vuelca los `--bin` vistos a `../bins-seen.txt`. const FAKE_PACKAGER: &str = r#"#!/usr/bin/env python3 import json, sys seed=out=None; bins=[] a=sys.argv[1:] while a: f=a.pop(0) if f=="--seed": seed=a.pop(0) elif f=="--seed-out": out=a.pop(0) elif f=="--rootkey": a.pop(0) elif f=="--bin": bins.append(a.pop(0)) else: pass d=json.load(open(seed)) d["attest"]=[{"label":b.split("=",1)[0]} for b in bins] d["attest_rootkey"]=[7]*32 # pubkey de 32 bytes (como la del packager real) json.dump(d, open(out,"w"), indent=2) import os open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n".join(bins)) "#; #[test] fn product_attested_hash_is_deterministic_and_sensitive() { let base = ArtifactHash::from_hex("ab"); let gated = ArtifactHash::from_hex("cd"); let pkgr = ArtifactHash::from_hex("ef"); let cfg = AttestConfig::default(); let a = product_attested_hash(&base, &gated, &pkgr, &cfg); assert_eq!(a, product_attested_hash(&base, &gated, &pkgr, &cfg), "determinista"); // Cada insumo re-hashea. assert_ne!(a, product_attested_hash(&ArtifactHash::from_hex("11"), &gated, &pkgr, &cfg), "base"); assert_ne!(a, product_attested_hash(&base, &ArtifactHash::from_hex("22"), &pkgr, &cfg), "gate"); assert_ne!(a, product_attested_hash(&base, &gated, &ArtifactHash::from_hex("33"), &cfg), "packager"); let other_policy = AttestConfig { policy: "warn".into(), ..AttestConfig::default() }; assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_policy), "política"); let other_key = AttestConfig { rootkey: [7u8; 32], ..AttestConfig::default() }; assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_key), "rootkey"); // El producto atestado es un sello DISTINTO del producto base (no colisionan). assert_ne!(a.as_str(), base.as_str(), "el atestado no es el producto base"); } #[test] fn critical_bins_are_derived_from_the_seed_native_execs() { // De la seed de producto real: PID 1 (arje-zero, el current_exe del gate) + un --bin por cada // exec Native del genesis (hammerd, console-getty, sshd). Los labels deben casar los de las cards // (lo que el gate busca en las concesiones firmadas) y las rutas resolver dentro del staging. let staging = Path::new("/tmp/fake-staging"); let seed: serde_json::Value = serde_json::from_str(&product_seed_card(&[card_sshd_esperada()]).unwrap()).unwrap(); let bins = critical_bins_from_seed(&seed, staging); let labels: Vec<&str> = bins.iter().map(|(l, _)| l.as_str()).collect(); assert_eq!( labels, vec!["arje-zero", "hammerd", "console-getty", "sshd"], "PID 1 primero, luego DFS del genesis" ); // El exec de cada card se mapea a su ruta absoluta dentro del staging. let by_label = |l: &str| bins.iter().find(|(x, _)| x == l).map(|(_, p)| p.clone()).unwrap(); assert_eq!(by_label("arje-zero"), staging.join("usr/bin/arje-zero")); assert_eq!(by_label("hammerd"), staging.join("usr/bin/hammerd")); // ⚠ Desde 2026-09-22 la consola la abre `agetty` y sshd sigue en busybox: dos rutas // distintas donde antes había una. Este test existe para eso — que el derivado siga al // `exec` de cada card y no a una lista escrita a mano. assert_eq!(by_label("console-getty"), staging.join("sbin/agetty")); assert_eq!(by_label("sshd"), staging.join("bin/busybox")); } #[test] fn shipped_product_recipes_parse_and_hash() { // Las recetas de producto reales (userland Rust + servicios) cargan y hashean — viven fuera // del núcleo (no entran a STAGE1_COMPONENTS ni al selfhost-verify). let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); for name in USERLAND_COMPONENTS.iter().chain(SERVICE_COMPONENTS.iter()) { let path = recipes_dir.join(format!("{name}.toml")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); assert_eq!(&r.name, name); let h = takana_build::artifact_hash(&r, &store, &takana_core::lab::LabFingerprint::for_tests()).expect("hash"); assert!(h.as_str().starts_with("b3:")); } } #[test] fn shipped_stage1_recipes_parse_and_hash() { // Las recetas reales del repo (Lote 4) deben cargar y hashear (sha256 pinned, parse OK). let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); for name in STAGE1_COMPONENTS { let path = recipes_dir.join(format!("{name}.toml")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); assert_eq!(&r.name, name); let h = takana_build::artifact_hash(&r, &store, &takana_core::lab::LabFingerprint::for_tests()).expect("hash"); assert!(h.as_str().starts_with("b3:"), "hash con prefijo b3:"); } } #[test] fn stage2_anchors_content_hash_and_verifies() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); // Sella un "rootfs" sintético con el nombre que espera stage2. let h = seal_component(&store, "stage1-rootfs", "abcd", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFfake-init").unwrap(); }); let report = stage2(&h, &store).unwrap(); assert_eq!(report.stage1_rootfs, h); assert!(report.stage1_content.as_str().starts_with("b3:")); assert_eq!(report.verdict, Reproducibility::RebuildPending); // El manifiesto anotó la línea stage 2 con el content-hash como artefacto. let m = BootstrapManifest::load(&store).unwrap(); let e2 = m.entries.iter().find(|e| e.stage == 2).expect("línea stage 2"); assert_eq!(e2.artifact_hash, report.stage1_content); // verify_against: un rebuild idéntico ⇒ Reproducible; distinto ⇒ Divergent. assert_eq!( verify_against(&report, report.stage1_content.clone()), Reproducibility::Reproducible ); match verify_against(&report, ArtifactHash::from_hex("ffff")) { Reproducibility::Divergent { stage1, rebuilt } => { assert_eq!(stage1, report.stage1_content); assert_eq!(rebuilt, ArtifactHash::from_hex("ffff")); } v => panic!("esperaba Divergent, vino {v:?}"), } } // --- Builder rootfs (SDD 11 §7): ensamblado testeable sin Alpine ni VM --- /// Monta los insumos mínimos de un builder en `dir` y devuelve `(store, spec)` listos. fn builder_fixture(dir: &Path) -> (Store, BuilderSpec) { let store = Store::open(dir.join("store")).unwrap(); // Stage 1 rootfs sellado (con el nombre que espera el builder). let stage1 = seal_component(&store, "stage1-rootfs", "5111", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELFinit").unwrap(); std::fs::create_dir_all(w.join("sbin")).unwrap(); std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin/init")).unwrap(); }); // Semilla zig sellada con bin/zig (nombre store seed-zig). let seed = seal_component(&store, "seed-zig", "5ee0", |w| { std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::write(w.join("bin/zig"), b"#!/bin/sh\necho zig\n").unwrap(); }); // Recetas, binario takana y toolchain (Alpine sintético), fuera del store. let recipes = dir.join("recipes"); std::fs::create_dir_all(&recipes).unwrap(); std::fs::write(recipes.join("musl.toml"), b"name = 'musl'\n").unwrap(); std::fs::write(recipes.join("busybox.toml"), b"name = 'busybox'\n").unwrap(); let hammer_bin = dir.join("hammer"); std::fs::write(&hammer_bin, b"\x7fELF-hammer-static").unwrap(); let toolchain = dir.join("alpine"); std::fs::create_dir_all(toolchain.join("usr/bin")).unwrap(); std::fs::write(toolchain.join("usr/bin/make"), b"\x7fELFmake").unwrap(); let spec = BuilderSpec { stage1_rootfs: stage1, seed_hash: seed, seed_kind: SeedKind::Zig, recipes_dir: recipes, hammer_bin, toolchain_src: toolchain, toolchain_tag: "alpine-test".into(), ref_content: Some(ArtifactHash::from_hex("dead")), work_cache: None, swaps: vec![], }; (store, spec) } #[test] fn builder_rootfs_assembles_full_image() { let tmp = tempfile::tempdir().unwrap(); let (store, spec) = builder_fixture(tmp.path()); let out = tmp.path().join("builder-out"); let report = builder_rootfs(&spec, &store, &out).unwrap(); assert!(report.builder_hash.as_str().starts_with("b3:")); assert_eq!(report.out_dir, out); // Base Stage 1 hidratada (init + symlink). assert!(out.join("usr/bin/arje-zero").is_file(), "stage1 hidratado"); assert_eq!( std::fs::read_link(out.join("sbin/init")).unwrap(), PathBuf::from("/usr/bin/arje-zero"), ); // Toolchain en /toolchain, semilla en /store, takana + recetas + driver. assert!(out.join("toolchain/usr/bin/make").is_file(), "toolchain montado"); assert!( out.join("store").join(spec.seed_hash.store_dir_name("seed-zig")).join("bin/zig").is_file(), "semilla replicada en /store", ); assert!(out.join("usr/bin/hammer").is_file(), "binario hammer"); assert!(out.join("etc/hammer/recipes/musl.toml").is_file(), "recetas embebidas"); assert!(out.join("usr/bin/rebuild-stage1").is_file(), "driver de rebuild"); // El driver y takana son ejecutables. use std::os::unix::fs::PermissionsExt; let m = std::fs::metadata(out.join("usr/bin/rebuild-stage1")).unwrap(); assert_eq!(m.permissions().mode() & 0o111, 0o111, "driver +x"); // rebuild.env lleva la semilla, la clase y la referencia embebidas. let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap(); assert!(env.contains(&format!("SEED_HASH={}", spec.seed_hash)), "{env}"); assert!(env.contains("SEED_KIND=zig"), "{env}"); assert!(env.contains("REF_CONTENT=b3:dead"), "{env}"); // Línea de manifiesto (stage 2) con el hash lógico del builder. let man = BootstrapManifest::load(&store).unwrap(); assert!( man.entries.iter().any(|e| e.stage == 2 && e.artifact_hash == report.builder_hash), "el builder debe anotar su línea en el manifiesto", ); } #[test] fn builder_hash_is_deterministic_and_sensitive() { let s1 = ArtifactHash::from_hex("11"); let seed = ArtifactHash::from_hex("22"); let a = builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", ""); assert_eq!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", ""), "puro"); assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.24.0", "rd", "hd", ""), "tag del toolchain"); assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd2", "hd", ""), "recetas"); assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd2", ""), "binario hammer"); assert_ne!(a, builder_hash(&s1, &seed, "alpine-3.23.4", "rd", "hd", "sd"), "swaps del toolchain"); } #[test] fn swaps_digest_empty_is_blank_and_order_insensitive() { assert_eq!(swaps_digest(&[]), "", "sin swaps ⇒ digest vacío (compat hacia atrás)"); let mk = |n: &str, h: &str| ToolchainSwap { name: n.into(), artifact: ArtifactHash::from_hex(h), rel_path: format!("usr/bin/{n}"), }; let a = swaps_digest(&[mk("make", "aa"), mk("bwrap", "bb")]); let b = swaps_digest(&[mk("bwrap", "bb"), mk("make", "aa")]); assert_eq!(a, b, "el digest se ordena por nombre ⇒ insensible al orden de entrada"); assert!(!a.is_empty()); assert_ne!(a, swaps_digest(&[mk("make", "ac"), mk("bwrap", "bb")]), "cambia con el hash"); } #[test] fn builder_rootfs_swaps_toolchain_tool_from_source() { let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); // Artefacto sellado de un `make` "construido por hammer" con un binario distinto al de Alpine. let make = seal_component(&store, "make", "fbad", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); std::fs::write(w.join("usr/bin/make"), b"\x7fELF-hammer-make-static").unwrap(); }); spec.swaps = vec![ToolchainSwap { name: "make".into(), artifact: make, rel_path: "usr/bin/make".into(), }]; let out = tmp.path().join("builder-out"); let report = builder_rootfs(&spec, &store, &out).unwrap(); // El make de /toolchain es ahora el de takana, no el de Alpine. let got = std::fs::read(out.join("toolchain/usr/bin/make")).unwrap(); assert_eq!(got, b"\x7fELF-hammer-make-static", "el swap pisó el make de Alpine"); // El hash lógico cambia respecto a un builder pura-Alpine (la procedencia es auditable). let mut pure = spec.clone(); pure.swaps = vec![]; let pure_report = builder_rootfs(&pure, &store, &tmp.path().join("builder-pure")).unwrap(); assert_ne!(report.builder_hash, pure_report.builder_hash, "el swap entra al hash lógico"); } #[test] fn builder_rootfs_errors_when_swap_artifact_missing_binary() { let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); // Artefacto sellado pero SIN el binario esperado en rel_path. let make = seal_component(&store, "make", "fbad", |w| { std::fs::create_dir_all(w.join("usr/share")).unwrap(); std::fs::write(w.join("usr/share/x"), b"no-binary").unwrap(); }); spec.swaps = vec![ToolchainSwap { name: "make".into(), artifact: make, rel_path: "usr/bin/make".into(), }]; let out = tmp.path().join("builder-out"); let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string(); assert!(err.contains("no existe") && err.contains("artefacto sellado"), "{err}"); } #[test] fn builder_rootfs_swaps_toolchain_header_tree_from_source() { let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); // Artefacto sellado de un `linux-headers` con un árbol `usr/include/linux` propio. let headers = seal_component(&store, "linux-headers", "abcd", |w| { std::fs::create_dir_all(w.join("usr/include/linux")).unwrap(); std::fs::write(w.join("usr/include/linux/version.h"), b"#define HAMMER 1\n").unwrap(); std::fs::write(w.join("usr/include/linux/stat.h"), b"struct hammer_stat;\n").unwrap(); }); // El toolchain Alpine tiene un linux/ con un archivo huérfano que el swap debe borrar. let tc_linux = spec.toolchain_src.join("usr/include/linux"); std::fs::create_dir_all(&tc_linux).unwrap(); std::fs::write(tc_linux.join("version.h"), b"#define ALPINE 1\n").unwrap(); std::fs::write(tc_linux.join("orphan.h"), b"alpine-only\n").unwrap(); spec.swaps = vec![ToolchainSwap { name: "linux-headers".into(), artifact: headers, rel_path: "usr/include/linux".into(), }]; let out = tmp.path().join("builder-out"); builder_rootfs(&spec, &store, &out).unwrap(); let v = std::fs::read(out.join("toolchain/usr/include/linux/version.h")).unwrap(); assert_eq!(v, b"#define HAMMER 1\n", "el swap pisó el version.h de Alpine"); assert!(out.join("toolchain/usr/include/linux/stat.h").is_file(), "copió el árbol entero"); assert!( !out.join("toolchain/usr/include/linux/orphan.h").exists(), "el swap-directorio borró el huérfano de Alpine" ); } #[test] fn builder_rootfs_without_ref_leaves_empty_ref_content() { let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); spec.ref_content = None; let out = tmp.path().join("builder-out"); builder_rootfs(&spec, &store, &out).unwrap(); let env = std::fs::read_to_string(out.join("etc/hammer/rebuild.env")).unwrap(); assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}"); } #[test] fn builder_rootfs_with_work_cache_containing_out_dir_terminates() { // Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse // el propio destino (recursión infinita que llena el disco). El skip lo corta. let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); // Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar). let work = tmp.path().join("work"); std::fs::create_dir_all(work.join("repos/musl.git")).unwrap(); std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap(); std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap(); std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap(); spec.work_cache = Some(work.clone()); // out_dir vive DENTRO de la caché: el caso que disparaba la recursión. let out = work.join("builder-rootfs"); let report = builder_rootfs(&spec, &store, &out).unwrap(); assert!(report.builder_hash.as_str().starts_with("b3:")); // Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe. assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben"); assert!( !out.join("work/builder-rootfs").exists(), "el out_dir no debe haberse copiado en su propio /work (recursión)", ); assert!( !out.join("work/sources").exists(), "sources/ es regenerable y pesado: no debe viajar en el builder", ); } #[test] fn builder_rootfs_errors_when_stage1_unsealed() { let tmp = tempfile::tempdir().unwrap(); let (store, mut spec) = builder_fixture(tmp.path()); spec.stage1_rootfs = ArtifactHash::from_hex("0000"); // nunca sellado let out = tmp.path().join("builder-out"); let err = builder_rootfs(&spec, &store, &out).unwrap_err().to_string(); assert!(err.contains("no sellado"), "{err}"); } #[test] fn artifact_content_hash_errors_when_unsealed() { let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let err = artifact_content_hash(&store, &ArtifactHash::from_hex("00"), "stage1-rootfs") .unwrap_err() .to_string(); assert!(err.contains("no sellado"), "{err}"); } #[test] fn all_shipped_recipes_parse_and_hash() { // Toda receta en recipes/ (incluida arje-zero, el puente Cargo a tawasuyu) debe parsear y // hashear offline. Cubre las que no están en STAGE1_COMPONENTS y futuras. let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); let mut count = 0; for entry in std::fs::read_dir(&recipes_dir).unwrap() { let path = entry.unwrap().path(); if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; } let r = Recipe::load_from_path(&path) .unwrap_or_else(|e| panic!("parse {}: {e}", path.display())); let h = takana_build::artifact_hash(&r, &store, &takana_core::lab::LabFingerprint::for_tests()) .unwrap_or_else(|e| panic!("hash {}: {e}", path.display())); assert!(h.as_str().starts_with("b3:"), "{}: hash con prefijo b3:", path.display()); count += 1; } assert!(count >= 5, "esperaba ≥5 recetas (grep/musl/busybox/hammerd/arje-zero), vi {count}"); } }