gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

Track posterior: Stage 0 del bootstrap (crate hammer-bootstrap) + ADR 0008

Primer eslabón ejecutable del bootstrap from-scratch (SDD 11). Stage 0 ingiere un
toolchain semilla pinned al store sin compilarlo:

- SeedSpec{kind,version,url,sha256}. seed_hash() deriva el ArtifactHash de la
  identidad pinned (clase+versión+sha256), no de rutas del host ni del url → el
  mismo tarball desde otro espejo produce el mismo artefacto.
- stage0(seed, store): descarga (curl, file:// offline-ok), verifica sha256
  ANTES de extraer, untar y seal en el store. Idempotente si ya está sellado;
  un sha erróneo no sella nada.
- Reusa el lab: hammer_build::download (+ nuevo verify_sha256 público), Store::seal.
  Staging bajo el root del store para que el rename de seal sea atómico.

ADR 0008 fija la decisión (3 stages, zig semilla primaria, musl-cross-make
escotilla, ingerir-no-compilar el Stage 0). Renumerado a 0008 porque el 0007 lo
tomó la decisión de adoptar arje como init; SDD 11 y refs alineados con arje
(init supervisado que entrega el CRASHED real, ADR 0007).

4 unit tests (hash por identidad, ingest+seal+readonly, idempotencia, rechazo de
sha) sin red (file://). Workspace 218 verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

3d6d25661cc690e2779a7e873089691493f2c539 · por Sergio · (hace 4 meses) · padre ea10c9f71 · ver el árbol en este commit

Delta semántico · 18

crates/hammer-bootstrap/src/lib.rs

crates/hammer-build/src/download.rs

8 archivos · +348 −6

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<i32>),
+    #[error("bootstrap: {0}")]
+    Other(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// 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-<kind>`.
+    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<ArtifactHash> {
+    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.