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>
This commit is contained in:
2026-06-12 21:38:04 -04:00
co-authored by Claude Opus 4.8
parent 749ea9e797
commit 7bc2ee6019
5 changed files with 198 additions and 10 deletions
+138 -5
View File
@@ -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]
+25
View File
@@ -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)?;
+7 -2
View File
@@ -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).
+10 -2
View File
@@ -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
+18 -1
View File
@@ -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).