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:
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user