tawasuyu

sergio/takana

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

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

builder: --swap del toolchain — montar make hammer sobre Alpine (variante b, pieza 1)

Segundo paso del auto-alojamiento *puro* (SDD 11 §7.2b): tras sellar GNU make
4.4.1 desde fuente (commit 749ea9e), ahora el builder puede *usarlo*. El swap
reemplaza una a una las piezas que el builder toma de Alpine por recetas hammer,
con Stage 2 reverificando que el byte-output no cambia.

- `BuilderSpec.swaps: Vec<ToolchainSwap{name,artifact,rel_path}>`: monta el binario
  sellado sobre el path Alpine en /toolchain (estático musl ⇒ sin shim del loader).
- `swaps_digest` (ordenado por nombre) entra al hash lógico del builder ⇒ la
  procedencia deja de ser "todo Alpine" y se vuelve auditable para el log de
  transparencia. Vacío ⇒ digest "" (compat hacia atrás: builder pura-Alpine
  conserva su hash previo).
- CLI: `hammer bootstrap builder --swap make=<hash>[:rel_path]` (repetible;
  rel_path por defecto usr/bin/<name>).
- selfhost-verify.sh: opt-in `SWAP_MAKE=1` (construye recipes/make.toml y lo
  swapea) + `SWAPS="name=hash …"` para swaps extra. Default off ⇒ corrida
  pura-Alpine idéntica a la baseline conocida-buena.

Validado en el host contra el store real: pura `b3:8a370f5d…` vs swapped
`b3:e7e2282c…`, y /toolchain/usr/bin/make queda hardlinkeado al artefacto
fbad44ac… (ELF estático, no el dinámico de Alpine). 3 tests nuevos
(swap aplica, hash cambia, error si falta el binario). 37 tests verdes.

Pendiente: correr Stage 2 in-VM con el swap y confirmar que of_tree(stage1') == ref
(el make hammer compila los 4/4 idéntico al de Alpine ⇒ toolchain intercambiable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

7bc2ee60194ba9da3ef0fcd1d843d5a7cb2658e9 · por sergio · (hace 4 meses) · padre 749ea9e79 · ver el árbol en este commit

Delta semántico · 13

crates/hammer-bootstrap/src/lib.rs

crates/hammer-cli/src/main.rs

5 archivos · +198 −10

diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs
index 24369501..581cb28c 100644
--- a/crates/hammer-bootstrap/src/lib.rs
+++ b/crates/hammer-bootstrap/src/lib.rs
@@ -572,6 +572,24 @@ pub struct BuilderSpec {
     /// 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<PathBuf>,
+    /// Piezas del toolchain **construidas por hammer 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<ToolchainSwap>,
+}
+
+/// Una herramienta del toolchain reemplazada por su build hammer 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 hammer (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.
@@ -589,6 +607,7 @@ fn builder_hash(
     toolchain_tag: &str,
     recipes_digest: &str,
     hammer_digest: &str,
+    swaps_digest: &str,
 ) -> ArtifactHash {
     ArtifactHash::of_inputs(&[
         b"hammer-builder-rootfs-v1",
@@ -597,10 +616,31 @@ fn builder_hash(
         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<String> {
     use sha2::{Digest, Sha256};
@@ -690,6 +730,31 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result
     }
     link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?;
 
+    // 3b) Swaps de variante (b): herramientas construidas por hammer desde fuente pisan su versión
+    //     Alpine en /toolchain. El binario sellado es estático musl ⇒ corre sin el shim del loader.
+    for swap in &spec.swaps {
+        let art_dir = store.path_of(&swap.artifact, &swap.name);
+        let src = art_dir.join(&swap.rel_path);
+        if !src.is_file() {
+            return Err(Error::Other(format!(
+                "swap '{}': '{}' no existe en el artefacto sellado {} ({})",
+                swap.name,
+                swap.rel_path,
+                swap.artifact,
+                src.display(),
+            )));
+        }
+        let dst = staging.join("toolchain").join(&swap.rel_path);
+        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)?;
+    }
+
     // 4) Semilla → /store/<hash>-seed-<kind>, tal cual está sellada afuera, para que el `hammer` de
     //    adentro resuelva el toolchain por hash sin re-ingerirla.
     let seed_name = format!("seed-{}", spec.seed_kind.as_str());
@@ -766,12 +831,14 @@ pub fn builder_rootfs(spec: &BuilderSpec, store: &Store, out_dir: &Path) -> Resu
     // 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);
@@ -1284,6 +1351,7 @@ mod tests {
             toolchain_tag: "alpine-test".into(),
             ref_content: Some(ArtifactHash::from_hex("dead")),
             work_cache: None,
+            swaps: vec![],
         };
         (store, spec)
     }
@@ -1337,11 +1405,76 @@ mod tests {
     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");
+        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 hammer, 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 en el artefacto sellado"), "{err}");
     }
 
     #[test]
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 3244fd03..3c1ea22c 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -306,6 +306,11 @@ enum BootstrapCmd {
         /// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
         #[arg(long)]
         work_cache: Option<String>,
+        /// Pieza del toolchain construida por hammer desde fuente (variante b, SDD 11 §7.2b) que pisa
+        /// la de Alpine en `/toolchain`. Formato `name=hash[:rel_path]`; `rel_path` por defecto
+        /// `usr/bin/<name>`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico.
+        #[arg(long = "swap", value_name = "name=hash[:rel_path]")]
+        swaps: Vec<String>,
         /// Dónde ensamblar el builder (se reensambla limpio en cada corrida).
         #[arg(long, default_value = "work/builder-rootfs")]
         out: String,
@@ -576,6 +581,7 @@ fn main() -> anyhow::Result<()> {
                 toolchain_tag,
                 ref_content,
                 work_cache,
+                swaps,
                 out,
             } => {
                 let seed_kind = match seed.as_str() {
@@ -584,6 +590,24 @@ fn main() -> anyhow::Result<()> {
                     other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"),
                 };
                 let store = hammer_core::Store::open(&cli.store)?;
+                let swaps = swaps
+                    .iter()
+                    .map(|s| {
+                        // name=hash[:rel_path]; rel_path por defecto usr/bin/<name>.
+                        let (name, rest) = s
+                            .split_once('=')
+                            .ok_or_else(|| anyhow::anyhow!("--swap '{s}': falta '=' (name=hash[:rel_path])"))?;
+                        let (hash, rel_path) = match rest.split_once(':') {
+                            Some((h, p)) => (h, p.to_string()),
+                            None => (rest, format!("usr/bin/{name}")),
+                        };
+                        Ok::<_, anyhow::Error>(hammer_bootstrap::ToolchainSwap {
+                            name: name.to_string(),
+                            artifact: hammer_core::ArtifactHash::from_hex(hash.trim_start_matches("b3:")),
+                            rel_path,
+                        })
+                    })
+                    .collect::<anyhow::Result<Vec<_>>>()?;
                 let spec = hammer_bootstrap::BuilderSpec {
                     stage1_rootfs: hammer_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")),
                     seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
@@ -595,6 +619,7 @@ fn main() -> anyhow::Result<()> {
                     ref_content: ref_content
                         .map(|h| hammer_core::ArtifactHash::from_hex(h.trim_start_matches("b3:").to_string())),
                     work_cache: work_cache.map(std::path::PathBuf::from),
+                    swaps,
                 };
                 let out = std::path::PathBuf::from(out);
                 let report = hammer_bootstrap::builder_rootfs(&spec, &store, &out)?;
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 5eff29a3..ee4f83a7 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -233,8 +233,13 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume
   hammer construya el toolchain del builder desde fuente (no Alpine), reemplazando piezas una a una
   con Stage 2 verificando cada paso. **Pieza 1 hecha:** GNU make `4.4.1` (`recipes/make.toml`) se
   compila desde fuente con el lab — estática musl, sellada `b3:fbad44ac…`, reproducible bit-a-bit.
-  Pendiente: swap al `/toolchain` del builder + Stage 2 reverificando, y luego make/autotools →
-  linux-headers → bwrap → el gran tramo rust/llvm.
+  **Swap implementado:** `BuilderSpec.swaps` + el flag `hammer bootstrap builder --swap make=<hash>`
+  montan el `make` hammer sobre `/toolchain/usr/bin/make` (pisando el de Alpine) y lo anclan en el
+  **hash lógico** del builder ⇒ la procedencia deja de ser "todo Alpine" y se vuelve auditable
+  (pura `b3:8a370f5d…` vs swapped `b3:e7e2282c…` en el host). El verify lo expone con `SWAP_MAKE=1`.
+  Pendiente: correr Stage 2 in-VM con el swap (que `of_tree(stage1')` **no** cambie ⇒ el make hammer
+  compila los 4/4 igual de bit-a-bit), y luego make/autotools → linux-headers → bwrap → el gran tramo
+  rust/llvm.
 - ⏭️ **También pendiente (Stage 1):** bus único (B.2: exponer el `CRASHED` a la capa de IA) y
   atestación arje (A1/A2).
 
diff --git a/docs/11-bootstrap.md b/docs/11-bootstrap.md
index 9ee706e2..a1d11fc3 100644
--- a/docs/11-bootstrap.md
+++ b/docs/11-bootstrap.md
@@ -206,8 +206,16 @@ recetas, la **semilla** (`zig`, ya un artefacto sellado), `make`+autotools, `car
   - **Pieza 1 — GNU make `4.4.1`** (`recipes/make.toml`): arranque de (b). Build estático musl con
     `zig cc` (mismo camino que `grep`: tarball release con `configure` → AutoconfReady), sellado en
     `b3:fbad44ac…` y **reproducible bit-a-bit** (dos builds en stores distintos ⇒ árbol idéntico).
-    Es la herramienta base de toda receta autotools. Pendiente: swap al `/toolchain` del builder en
-    lugar del `make` de Alpine, con Stage 2 reverificando `of_tree(stage1')`.
+    Es la herramienta base de toda receta autotools. **Swap implementado:** `BuilderSpec.swaps`
+    (`ToolchainSwap { name, artifact, rel_path }`) monta el binario sellado sobre el path Alpine en
+    `/toolchain` (estático musl ⇒ sin shim del loader) y lo ancla en el **hash lógico** del builder
+    (`swaps_digest`, ordenado por nombre): la procedencia deja de ser "todo Alpine" y se vuelve
+    auditable para el log de transparencia. CLI: `hammer bootstrap builder --swap make=<hash>[:rel]`
+    (repetible; `rel_path` por defecto `usr/bin/<name>`). En el host: pura `b3:8a370f5d…` vs swapped
+    `b3:e7e2282c…`, y `/toolchain/usr/bin/make` queda hardlinkeado al artefacto `fbad44ac…`. El
+    `selfhost-verify.sh` lo expone opt-in con `SWAP_MAKE=1` (construye make y lo swapea). **Pendiente:**
+    correr Stage 2 in-VM con el swap y confirmar que `of_tree(stage1')` **no** cambia (el make hammer
+    compila los 4/4 idéntico al de Alpine ⇒ el toolchain es intercambiable sin perturbar el byte-output).
 
 ### 7.3 Enganche con lo ya hecho
 
diff --git a/scripts/selfhost-verify.sh b/scripts/selfhost-verify.sh
index 142e3f7c..f1b73d39 100755
--- a/scripts/selfhost-verify.sh
+++ b/scripts/selfhost-verify.sh
@@ -108,13 +108,30 @@ for m in overlay e1000; do
     fi
 done
 
+# 3b) Auto-alojamiento *puro* (variante b, SDD 11 §7.2b): reemplazar piezas del toolchain Alpine por
+#     recetas hammer construidas desde fuente, con Stage 2 reverificando que `of_tree(stage1')` NO
+#     cambia (el make hammer compila los 4/4 igual de bit-a-bit que el de Alpine). Opt-in:
+#       SWAP_MAKE=1  → construye recipes/make.toml y lo monta sobre /toolchain/usr/bin/make.
+#       SWAPS="name=hash …" → swaps explícitos extra (formato del flag --swap).
+#     Por defecto, off ⇒ corrida pura-Alpine (variante a), idéntica a la baseline conocida-buena.
+SWAP_ARGS=()
+if [[ "${SWAP_MAKE:-0}" == 1 ]]; then
+    say "variante b — construir make desde fuente (recipes/make.toml) y swapearlo en /toolchain"
+    MAKE_HASH="$("$HAMMER" --store "$STORE" build recipes/make.toml | grep -oE 'b3:[0-9a-f]{64}' | tail -1)"
+    [[ "$MAKE_HASH" == b3:* ]] || die "no obtuve el hash sellado de make"
+    say "make hammer: $MAKE_HASH"
+    SWAP_ARGS+=(--swap "make=$MAKE_HASH")
+fi
+for s in ${SWAPS:-}; do SWAP_ARGS+=(--swap "$s"); done
+
 # 4) Ensamblar el builder con la referencia embebida.
-say "ensamblar builder (toolchain in-rootfs + hammer baseline + ref)"
+say "ensamblar builder (toolchain in-rootfs + hammer baseline + ref${SWAP_ARGS:+ + swaps})"
 "$HAMMER" --store "$STORE" bootstrap builder \
     --stage1 "$ROOTFS_HASH" --seed-hash "$SEED_HASH" \
     --hammer-bin "$HAMMER_BIN" \
     --toolchain "$TOOLCHAIN" --toolchain-tag "$TOOLCHAIN_TAG" \
     --ref-content "$REF" \
+    "${SWAP_ARGS[@]}" \
     --work-cache work --out work/builder-rootfs
 
 # 4b) Preseed opcional para abaratar el rebuild in-VM (sólo hammerd se reconstruye).