> 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…
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>
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).