From 7bc2ee60194ba9da3ef0fcd1d843d5a7cb2658e9 Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 12 Jun 2026 21:38:04 -0400 Subject: [PATCH] =?UTF-8?q?builder:=20--swap=20del=20toolchain=20=E2=80=94?= =?UTF-8?q?=20montar=20make=20hammer=20sobre=20Alpine=20(variante=20b,=20p?= =?UTF-8?q?ieza=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`: 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=[:rel_path]` (repetible; rel_path por defecto usr/bin/). - 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 --- crates/hammer-bootstrap/src/lib.rs | 143 ++++++++++++++++++++++++++++- crates/hammer-cli/src/main.rs | 25 +++++ docs/10-roadmap.md | 9 +- docs/11-bootstrap.md | 12 ++- scripts/selfhost-verify.sh | 19 +++- 5 files changed, 198 insertions(+), 10 deletions(-) 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, + /// 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, +} + +/// 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 { 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/-seed-, 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, + /// 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/`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico. + #[arg(long = "swap", value_name = "name=hash[:rel_path]")] + swaps: Vec, /// 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/. + 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::>>()?; 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=` + 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=[:rel]` + (repetible; `rel_path` por defecto `usr/bin/`). 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).