diff --git a/Cargo.lock b/Cargo.lock index e853c86c..8e66e389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,21 @@ dependencies = [ "ureq", ] +[[package]] +name = "hammer-bootstrap" +version = "0.0.1" +dependencies = [ + "anyhow", + "hammer-build", + "hammer-core", + "hex", + "serde", + "sha2", + "tempfile", + "thiserror", + "tracing", +] + [[package]] name = "hammer-build" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index a5adeb37..41572682 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/hammer-core", "crates/hammer-build", + "crates/hammer-bootstrap", "crates/hammer-overlay", "crates/hammer-journal", "crates/hammer-agent", @@ -20,6 +21,7 @@ repository = "https://gitea.gioser.net/sergio/hammer" [workspace.dependencies] hammer-core = { path = "crates/hammer-core" } hammer-build = { path = "crates/hammer-build" } +hammer-bootstrap = { path = "crates/hammer-bootstrap" } hammer-overlay = { path = "crates/hammer-overlay" } hammer-journal = { path = "crates/hammer-journal" } hammer-agent = { path = "crates/hammer-agent" } diff --git a/crates/hammer-bootstrap/Cargo.toml b/crates/hammer-bootstrap/Cargo.toml new file mode 100644 index 00000000..fdf7d9e9 --- /dev/null +++ b/crates/hammer-bootstrap/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hammer-bootstrap" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Bootstrap from-scratch de hammer: Stage 0/1/2 hacia el auto-alojamiento (SDD 11)." + +[dependencies] +hammer-core.workspace = true +hammer-build.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs new file mode 100644 index 00000000..fc410f64 --- /dev/null +++ b/crates/hammer-bootstrap/src/lib.rs @@ -0,0 +1,233 @@ +//! Bootstrap from-scratch de hammer — 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. + +use std::path::Path; +use std::process::Command; + +use hammer_build::download; +use hammer_core::{ArtifactHash, Store}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("core: {0}")] + Core(#[from] hammer_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()) + } +} + +/// **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)"); + return Ok(h); + } + + // 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)?; + + 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"); + + // Limpieza del staging (el árbol ya se movió por rename; queda el tarball + el dir padre). + let _ = std::fs::remove_dir_all(&work); + Ok(h) +} + +/// 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_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" + ); + } +} diff --git a/crates/hammer-build/src/download.rs b/crates/hammer-build/src/download.rs index ef6fbf7d..58e7f8c8 100644 --- a/crates/hammer-build/src/download.rs +++ b/crates/hammer-build/src/download.rs @@ -46,6 +46,24 @@ pub fn fetch_url_to_file(url: &str, dst: &Path) -> hammer_core::Result<()> { Ok(()) } +/// Verifica que el sha256 de `path` coincide con `expected` (hex, case-insensitive). Lo usan +/// los consumidores que ingieren una fuente fijada (p. ej. el toolchain semilla del bootstrap, +/// [SDD 11](../../docs/11-bootstrap.md)) y necesitan comprobar integridad **antes** de sellar. +pub fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> { + use sha2::{Digest, Sha256}; + let mut f = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut f, &mut hasher)?; + let got = hex::encode(hasher.finalize()); + if !got.eq_ignore_ascii_case(expected) { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "sha256 mismatch en {}: esperado {expected}, obtenido {got}", + path.display() + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md index ad7c2a99..e5bdf5f1 100644 --- a/docs/11-bootstrap.md +++ b/docs/11-bootstrap.md @@ -40,7 +40,7 @@ confianza de Alpine y la deriva de su propio [log de transparencia](09-trust-mod └─────────────┬─────────────┘ │ artifact_hash(seed) ← dependencia de build de todo lo demás ┌─────────────▼─────────────┐ - │ Stage 1 — userland mínimo │ musl, busybox/toybox, init propio, hammerd, + │ Stage 1 — userland mínimo │ musl, busybox/toybox, init arje, hammerd, │ cross-compilado │ cross-compilados CON Stage 0. Sale un rootfs sellado. └─────────────┬─────────────┘ │ rootfs_hash(stage1) @@ -70,13 +70,14 @@ y se administra solo: |---|---|---| | `musl` | libc del target | zig cc (estático) | | `busybox` o `toybox` | coreutils + shell | zig cc (estático) | -| `init` propio | PID 1 + bus por pipes nativo ([SDD 07](07-agent-bus.md)) | zig cc | -| `hammerd` | diario + bus de agente + watcher | el toolchain Rust del lab | +| `arje` | init: PID 1 + supervisión ([ADR 0007](adr/0007-arje-como-init-propio.md)) | toolchain Rust | +| `hammerd` | diario + bus de agente + watcher, **servicio supervisado por arje** | toolchain Rust | Todo se enlaza estático o con rpath controlado (sin depender del loader del host). El resultado se ensambla en un **rootfs** (árbol FHS) que a su vez se sella como un artefacto del store -(`stage1_hash`). El init propio es la pieza que habilita `CRASHED` real (el único ítem diferido -de la Fase 5): PID 1 supervisa servicios y publica `Crashed` al bus. +(`stage1_hash`). El init **no se escribe de cero**: se adopta `arje` +([ADR 0007](adr/0007-arje-como-init-propio.md)), cuyo PID 1 con supervisión real entrega el +`CRASHED` real que la Fase 5 dejó diferido — `hammerd` corre como servicio bajo arje. > El **kernel** no es el foco de este hito: se importa pinned (como la semilla) para poder > correr el rootfs. Construirlo desde fuente es un sub-ítem posterior, ortogonal al @@ -126,7 +127,7 @@ hammer bootstrap stage2 --verify hammer bootstrap --all # las tres + reporte de reproducibilidad ``` -## 6. Decisiones abiertas (a fijar en ADR 0007) +## 6. Decisiones abiertas (fijadas en [ADR 0008](adr/0008-bootstrap-stages.md)) - **Semilla primaria:** `zig` vs `musl-cross-make`. Inclinación: `zig` por hermeticidad (ADR 0003), con `musl-cross-make` como escotilla por receta. diff --git a/docs/README.md b/docs/README.md index 2acf4af8..3e08d19d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,3 +32,5 @@ Decisiones tomadas, con su contexto y consecuencias. Ver [`adr/`](adr/). | 0004 | [No escribir nuestro propio Nix](adr/0004-no-custom-nix.md) | | 0005 | [Hidratación por hardlinks](adr/0005-hardlink-hydration.md) | | 0006 | [Commits fijados, no HEAD vivo](adr/0006-pinned-commits.md) | +| 0007 | [`arje` como init propio del track posterior](adr/0007-arje-como-init-propio.md) | +| 0008 | [Bootstrap en 3 stages, `zig` como semilla](adr/0008-bootstrap-stages.md) | diff --git a/docs/adr/0008-bootstrap-stages.md b/docs/adr/0008-bootstrap-stages.md new file mode 100644 index 00000000..a3a4e15e --- /dev/null +++ b/docs/adr/0008-bootstrap-stages.md @@ -0,0 +1,50 @@ +# ADR 0008 — Bootstrap from-scratch en 3 stages, con `zig` como semilla + +- **Estado:** aceptada +- **Fecha:** 2026-06-10 + +## Contexto + +El track posterior ([SDD 11](../11-bootstrap.md)) baja de "hammer sobre Alpine" a "hammer sobre +sí mismo": compilar el sistema completo sin heredar el toolchain ni el userland de Alpine. Hay +que decidir **cómo se rompe el cordón umbilical** sin reintroducir la fragilidad del Stage 0 +clásico (cross-toolchain a mano) que [ADR 0003](0003-zig-cc-builder.md) ya esquivó. + +## Decisión + +Bootstrap en **tres etapas**, todas modeladas como recetas selladas en el `Store` (sin +mecanismo de build nuevo): + +0. **Stage 0 — toolchain semilla:** se **ingiere** un toolchain ya construido (no se compila) + como una *fuente fijada por sha256*, y se sella en el store. El `ArtifactHash` se deriva de + la **identidad pinned** (clase + versión + sha256), no de rutas del host. **`zig` es la + semilla primaria**; `musl-cross-make` (gcc+musl) es escotilla detrás de la misma interfaz. +1. **Stage 1 — userland mínimo:** cross-compilado con Stage 0 (musl, busybox/toybox, el init + `arje` [ADR 0007](0007-arje-como-init-propio.md), `hammerd`), ensamblado en un rootfs sellado. +2. **Stage 2 — rebuild nativo:** dentro del rootfs Stage 1, recompilar con las herramientas de + Stage 1 y comparar hashes (`stage1` vs `stage1'`). Iguales ⇒ auto-alojado y reproducible. + +El único insumo del host es la semilla pinned (más un kernel para correr procesos). + +## Razones + +- **Reusa el lab entero:** las etapas son subgrafos del grafo de dependencias de + [SDD 02](../02-build-lab.md); cero retrabajo. +- **`zig` semilla** por las mismas razones que ADR 0003: un binario hermético = compilador + + musl + headers, cross al target, determinista. Evita compilar gcc desde cero sólo para + arrancar. +- **Ingerir, no compilar, el Stage 0** mantiene el bootstrap finito y reproducible: el sha256 + fijado ([ADR 0006](0006-pinned-commits.md)) ancla la confianza; quien audita reproduce desde + el mismo tarball. +- **Stage 2 como verificación** convierte la reproducibilidad en una propiedad *comprobable*, + no una promesa — alimenta el log de transparencia ([SDD 09](../09-trust-model.md) §4). + +## Consecuencias / riesgos + +- La semilla **no** se compila desde fuente: confiamos en el tarball pinned de `zig`. Es un + punto de confianza explícito y auditable (su sha256 está en el manifiesto), no oculto. Reducir + esa confianza (diverse double-compilation, semilla desde fuente) es trabajo futuro. +- El **kernel** se importa pinned en el primer hito; construirlo desde fuente es ortogonal al + auto-alojamiento del userland y queda después. +- Stage 2 expondrá no-determinismos reales (timestamps, paths embebidos, orden de enlace): es + el costo esperado de exigir bit-reproducibilidad, no una sorpresa.