selfhost-verify: pieza 4 (bwrap swap) + materialización de build-deps en el lab

Cuarta pieza del toolchain hammer-from-source (variante b, SDD 11 §7.2b): bubblewrap, el
sandbox del propio lab. Binario estático ⇒ swap de archivo sobre /toolchain/usr/bin/bwrap.
Como herramienta del toolchain (no input del 4/4) no necesita casar byte-a-byte con Alpine,
sólo aislar igual.

Tres piezas:
- recipes/libcap.toml (2.78): dep obligatoria de bwrap; el toolchain Alpine no trae el -dev
  (libcap.a / sys/capability.h). Build estático musl con zig cc, sin patches (es tool, no input).
- materialización de build-deps (hammer-build): deps.build ahora se CONSTRUYE recursivamente
  (build() llama build() por cada dep) y cada artefacto sellado se apila como capa --overlay-src
  bajo el rootfs del sandbox, dejando usr/{include,lib,lib/pkgconfig} en /usr. pkgconf y zig cc
  las hallan sin plumbing de flags. Recetas sin deps: sandbox byte-igual (baseline intacto).
  Tests nuevos: no_deps_emits_single_overlay_src, deps_stack_as_overlay_layers_under_rootfs.
- recipes/bwrap.toml (0.11.0): el toolchain no trae meson/ninja/python, así que bypaseamos meson
  compilando los 4 .c de bubblewrap directo con zig cc (+config.h trivial). deps.build=["libcap"].

Validación host fuerte: hammer-bwrap es estático, corre --version y sandboxea, y musl rebuildeó
BYTE-IDÉNTICO usándolo de sandbox (bisección). Expuesto con SWAP_BWRAP=1. Tests verdes.
Pendiente: corrida in-VM acumulando swaps para el sello ✓ REPRODUCIBLE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 20:05:55 -04:00
co-authored by Claude Opus 4.8
parent b05badee23
commit e9f535a1bb
6 changed files with 214 additions and 5 deletions
+43
View File
@@ -82,6 +82,41 @@ fn resolve_build_dep_hashes(
Ok(hashes) Ok(hashes)
} }
/// Construye (si falta) cada `deps.build` y devuelve sus directorios sellados en el store, para
/// apilarlos como capas overlay del sandbox (variante b: bwrap necesita libcap.a + sys/capability.h,
/// ausentes del toolchain Alpine). Recursivo vía `build`: una dep con sus propias deps se materializa
/// transitivamente. Los ciclos ya los corta `artifact_hash` (con su `stack`) antes de llegar acá.
fn materialize_build_deps(
recipe: &Recipe,
cfg: &BuildConfig,
store: &Store,
) -> hammer_core::Result<Vec<PathBuf>> {
if recipe.deps.build.is_empty() {
return Ok(Vec::new());
}
if recipe.base_dir.as_os_str().is_empty() {
return Err(hammer_core::Error::Recipe(format!(
"la receta '{}' declara deps de build {:?} pero no tiene base_dir; \
cárgala con Recipe::load_from_path para resolver el catálogo",
recipe.name, recipe.deps.build
)));
}
let mut dirs = Vec::with_capacity(recipe.deps.build.len());
for dep in &recipe.deps.build {
let dep_path = recipe.base_dir.join(format!("{dep}.toml"));
let dep_recipe = Recipe::load_from_path(&dep_path).map_err(|e| {
hammer_core::Error::Recipe(format!(
"no pude cargar la dep de build '{dep}' de '{}' ({}): {e}",
recipe.name,
dep_path.display()
))
})?;
let dep_hash = build(&dep_recipe, cfg, store)?;
dirs.push(store.path_of(&dep_hash, &dep_recipe.name));
}
Ok(dirs)
}
/// Compila una receta y devuelve el hash del artefacto sellado en el store. /// Compila una receta y devuelve el hash del artefacto sellado en el store.
/// Si el hash ya existe en el store, devuelve sin recompilar (caché). /// Si el hash ya existe en el store, devuelve sin recompilar (caché).
pub fn build( pub fn build(
@@ -96,6 +131,13 @@ pub fn build(
} }
sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?; sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
// Materializa las deps de build (las construye si faltan) ANTES del fetch: sus árboles sellados
// se apilan como capas overlay del sandbox para que headers/libs/pkgconfig estén en /usr.
let dep_dirs = materialize_build_deps(recipe, cfg, store)?;
if !dep_dirs.is_empty() {
tracing::info!(n = dep_dirs.len(), "deps de build materializadas como capas overlay");
}
tracing::info!(hash = %h, name = %recipe.name, "fetch"); tracing::info!(hash = %h, name = %recipe.name, "fetch");
let src_tree = fetch::fetch(recipe, &cfg.work_root)?; let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
@@ -127,6 +169,7 @@ pub fn build(
src_dir: src_tree.clone(), src_dir: src_tree.clone(),
out_dir: out_dir.clone(), out_dir: out_dir.clone(),
cache_dir: cfg.cache_root.clone(), cache_dir: cfg.cache_root.clone(),
deps: dep_dirs,
env, env,
}; };
+56 -1
View File
@@ -66,6 +66,11 @@ pub struct Sandbox {
/// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`. /// Si Some, se bindea RW como `/cache` y se setea `ZIG_GLOBAL_CACHE_DIR=/cache/zig`.
/// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s). /// Persistir esta caché entre builds evita que `zig cc` recompile musl cada vez (~30s).
pub cache_dir: Option<PathBuf>, pub cache_dir: Option<PathBuf>,
/// Directorios sellados de las `deps.build` (artefactos ya construidos en el store). Cada uno se
/// apila como capa overlay read-only BAJO el rootfs Alpine, así sus `usr/{include,lib,lib/pkgconfig}`
/// aparecen en las rutas estándar de `/usr` — el compilador y pkgconf los ven sin plumbing de flags.
/// Vacío ⇒ ninguna capa extra ⇒ el sandbox es idéntico al de una receta sin deps (baseline intacto).
pub deps: Vec<PathBuf>,
/// Vars de entorno extra inyectadas además del set por defecto. /// Vars de entorno extra inyectadas además del set por defecto.
pub env: Vec<(String, String)>, pub env: Vec<(String, String)>,
} }
@@ -125,6 +130,15 @@ impl Sandbox {
let mut args: Vec<String> = vec![ let mut args: Vec<String> = vec![
"--overlay-src".into(), "--overlay-src".into(),
self.rootfs.display().to_string(), self.rootfs.display().to_string(),
];
// Capas overlay de las deps de build, apiladas bajo el rootfs (mismo `--tmp-overlay /`). bwrap
// admite múltiples `--overlay-src` para un overlay; los `usr/{include,lib}` de cada dep se
// funden con los del rootfs en `/usr`. Sólo se emiten si hay deps (compat hacia atrás).
for dep in &self.deps {
args.push("--overlay-src".into());
args.push(dep.display().to_string());
}
args.extend([
"--tmp-overlay".into(), "--tmp-overlay".into(),
"/".into(), "/".into(),
"--proc".into(), "--proc".into(),
@@ -146,7 +160,7 @@ impl Sandbox {
"/src".into(), "/src".into(),
"--unshare-all".into(), "--unshare-all".into(),
"--die-with-parent".into(), "--die-with-parent".into(),
]; ]);
if let Some(cache) = &self.cache_dir { if let Some(cache) = &self.cache_dir {
args.push("--bind".into()); args.push("--bind".into());
@@ -304,4 +318,45 @@ mod tests {
let err = hammer_core::Error::Recipe("nope".into()); let err = hammer_core::Error::Recipe("nope".into());
assert!(BuildFailure::from_error(&err).is_none()); assert!(BuildFailure::from_error(&err).is_none());
} }
fn fixture(deps: Vec<PathBuf>) -> Sandbox {
Sandbox {
rootfs: PathBuf::from("/rootfs"),
zig_dir: PathBuf::from("/zig"),
src_dir: PathBuf::from("/src-host"),
out_dir: PathBuf::from("/out-host"),
cache_dir: None,
deps,
env: vec![],
}
}
#[test]
fn no_deps_emits_single_overlay_src() {
// Compat hacia atrás: sin deps, una sola capa overlay (el rootfs) — sandbox idéntico al previo.
let args = fixture(vec![]).bwrap_args("true");
let n = args.iter().filter(|a| *a == "--overlay-src").count();
assert_eq!(n, 1, "sin deps ⇒ un único --overlay-src");
// El --tmp-overlay viene inmediatamente tras el rootfs.
let i = args.iter().position(|a| a == "--overlay-src").unwrap();
assert_eq!(args[i + 1], "/rootfs");
assert_eq!(args[i + 2], "--tmp-overlay");
}
#[test]
fn deps_stack_as_overlay_layers_under_rootfs() {
let args = fixture(vec![PathBuf::from("/dep/libcap"), PathBuf::from("/dep/zlib")])
.bwrap_args("true");
// rootfs + 2 deps = 3 capas overlay, todas antes del --tmp-overlay.
let srcs: Vec<&String> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "--overlay-src")
.map(|(i, _)| &args[i + 1])
.collect();
assert_eq!(srcs, vec!["/rootfs", "/dep/libcap", "/dep/zlib"]);
let to = args.iter().position(|a| a == "--tmp-overlay").unwrap();
let last_src = args.iter().rposition(|a| a == "--overlay-src").unwrap();
assert!(last_src < to, "todas las capas overlay van antes del --tmp-overlay");
}
} }
+16 -3
View File
@@ -269,9 +269,22 @@ Diseño completo en [SDD 11 — Bootstrap from-scratch](11-bootstrap.md). Resume
se pisan sobre la salida. Criterio de éxito fuerte alcanzado: **`diff -r` del header-tree hammer contra se pisan sobre la salida. Criterio de éxito fuerte alcanzado: **`diff -r` del header-tree hammer contra
el de Alpine = vacío** ⇒ el toolchain swapeado es byte-idéntico al baseline ⇒ todo lo que compile abajo el de Alpine = vacío** ⇒ el toolchain swapeado es byte-idéntico al baseline ⇒ todo lo que compile abajo
reproduce de por sí (garantía por construcción, más fuerte que un rebuild). Expuesto con reproduce de por sí (garantía por construcción, más fuerte que un rebuild). Expuesto con
`SWAP_LINUX_HEADERS=1`; ensamblado end-to-end en host OK. **Pendiente:** la corrida in-VM `SWAP_LINUX_HEADERS=1`; ensamblado end-to-end en host OK. Falta sólo la corrida in-VM para el sello.
(`KVM=1 MEM=24576 SWAP_LINUX_HEADERS=1 ./scripts/selfhost-verify.sh`) para el sello `✓ REPRODUCIBLE`, **Pieza 4 — bwrap (`recipes/bwrap.toml`, bubblewrap 0.11.0, validada en host):** EL sandbox del propio
y seguir con bwrap → rust/llvm (cada una swapeada y re-verificada igual). lab (`hammer-build/src/sandbox.rs`). Binario estático ⇒ swap de archivo sobre `/toolchain/usr/bin/bwrap`.
Como tool del toolchain (no input del 4/4) NO necesita casar byte-a-byte con Alpine, sólo aislar igual.
Tres sub-hallazgos que la hicieron más jugosa que make/busybox: (1) el toolchain Alpine no trae meson/
ninja/python — bypaseamos meson compilando los 4 `.c` de bubblewrap directo con `zig cc` (+`config.h`
trivial generado a mano); (2) depende de **libcap**, cuyo -dev (libcap.a + `sys/capability.h`) tampoco
está en el toolchain ⇒ nueva receta `recipes/libcap.toml`; (3) para que el build de bwrap viera libcap se
agregó **materialización de build-deps** al lab: `deps.build` ahora se construye recursivamente y cada
artefacto sellado se apila como capa `--overlay-src` BAJO el rootfs del sandbox, dejando sus
`usr/{include,lib,lib/pkgconfig}` en `/usr` — pkgconf y zig cc los hallan sin plumbing de flags (recetas
sin deps quedan byte-iguales, baseline intacto). Validación host fuerte: hammer-bwrap es estático, corre
`--version`/sandboxea, y **musl rebuildeó byte-idéntico** usándolo de sandbox (bisección). Expuesto con
`SWAP_BWRAP=1`. **Pendiente:** correr in-VM acumulando los swaps
(`KVM=1 MEM=24576 SWAP_MAKE=1 SWAP_BUSYBOX=1 SWAP_LINUX_HEADERS=1 SWAP_BWRAP=1 ./scripts/selfhost-verify.sh`)
para el `✓ REPRODUCIBLE`, y seguir con la última pieza: rust/llvm (la grande — ya hay infra de deps).
- ⏭️ **También pendiente (Stage 1):** bus único (B.2: exponer el `CRASHED` a la capa de IA) y - ⏭️ **También pendiente (Stage 1):** bus único (B.2: exponer el `CRASHED` a la capa de IA) y
atestación arje (A1/A2). atestación arje (A1/A2).
+47
View File
@@ -0,0 +1,47 @@
# bubblewrap (bwrap) 0.11.0 — pieza 4 del toolchain hammer-from-source (SDD 11 §7.2b).
#
# bwrap es EL sandbox del lab: cada build hermético corre dentro de `bwrap` (ver
# hammer-build/src/sandbox.rs). Reemplazar el bwrap de Alpine por uno construido por hammer cierra
# otra dependencia del toolchain. Como make/busybox/linux-headers, se monta sobre /toolchain con el
# swap del builder (variante b) — aquí un swap de BINARIO (`--swap bwrap=<hash>:usr/bin/bwrap`).
#
# bwrap NO necesita casar byte-a-byte con el de Alpine: es una herramienta del toolchain, no un input
# del 4/4. Cualquier bwrap funcional aísla igual (mismos namespaces/mounts ⇒ mismos outputs), así que
# la reproducibilidad de of_tree(stage1') se mantiene mientras bwrap sandboxee correctamente.
#
# Por qué NO meson: bubblewrap 0.11 usa meson+ninja+python, AUSENTES del toolchain Alpine. Pero el
# proyecto es 4 archivos .c + un `config.h` trivial; los compilamos directo con `zig cc` estático,
# evitando el meson por completo. Es el mismo binario, sólo otro driver de build.
#
# libcap es dependencia obligatoria (bubblewrap.c hace `#include <sys/capability.h>` y llama `cap_*`).
# El toolchain Alpine trae sólo el .so runtime, no el -dev; por eso `deps.build = ["libcap"]`: el lab
# construye libcap y apila su árbol como capa overlay del sandbox, dejando libcap.a + sys/capability.h
# + libcap.pc en /usr — pkgconf y zig cc los ven en las rutas estándar (ver materialize_build_deps).
name = "bwrap"
version = "0.11.0"
[source]
tarball = "https://github.com/containers/bubblewrap/releases/download/v0.11.0/bubblewrap-0.11.0.tar.xz"
sha256 = "988fd6b232dafa04b8b8198723efeaccdb3c6aa9c1c7936219d5791a8b7a8646"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
[deps]
build = ["libcap"]
[build.phases]
# El árbol detecta como BuildSys::Meson ⇒ el default `meson setup` fallaría (no hay meson). Override
# de las 3 fases para compilar a mano con zig cc.
# configure: generar el `config.h` que meson produciría. Sólo PACKAGE_STRING es necesario; HAVE_SELINUX
# y ENABLE_REQUIRE_USERNS son #ifdef opcionales (los dejamos sin definir: sin SELinux, sin require_userns).
configure = "printf '#define PACKAGE_STRING \"bubblewrap 0.11.0\"\\n' > config.h"
# compile: los 4 .c estáticos contra musl + libcap. -D_GNU_SOURCE lo añade meson (bwrap usa clearenv,
# MS_*, etc.). -lcap -lpsx desde /usr/lib (la capa overlay de la dep libcap). -mcpu=baseline por
# reproducibilidad CPU-independiente (igual que el resto del lab).
compile = "zig cc -mcpu=baseline -static -O2 -D_GNU_SOURCE -I. bubblewrap.c bind-mount.c network.c utils.c -L/usr/lib -lcap -lpsx -o bwrap"
# install: el binario a /usr/bin/bwrap, donde lo espera el swap del builder.
install = "mkdir -p /out/usr/bin && cp bwrap /out/usr/bin/bwrap"
+35
View File
@@ -0,0 +1,35 @@
# libcap 2.78 — dependencia de build de bwrap (pieza 4 del toolchain hammer-from-source, SDD 11 §7.2b).
#
# bubblewrap usa libcap para soltar/levantar capabilities al armar el sandbox; su `bubblewrap.c` hace
# `#include <sys/capability.h>` y llama `cap_*`. El toolchain Alpine trae el .so runtime pero NO el
# -dev (ni `sys/capability.h` ni `libcap.a`), así que para compilar bwrap estático necesitamos libcap
# construido por hammer y materializado en el sandbox (ver deps.build de bwrap.toml + sandbox.rs).
#
# A diferencia de linux-headers, libcap NO necesita casar byte-a-byte con la de Alpine: es una pieza
# del TOOLCHAIN (se enlaza dentro de bwrap, una herramienta), no un input del 4/4. Cualquier libcap
# funcional sirve mientras bwrap aísle igual. Por eso sin patches de Alpine: build vainilla y listo.
#
# Build estático musl con zig cc. libcap usa un Makefile plano (no autotools): hay que pasar los
# overrides de toolchain por línea de comando y apagar lo que pide deps ausentes:
# GOLANG=no — sin bindings Go (no hay go en el sandbox).
# PAM_CAP=no — sin el módulo PAM (no hay libpam).
# SHARED=no — sólo estático; no generamos .so (bwrap linkea libcap.a).
# BUILD_CC — el compilador HOST para los toolitos generadores (_makenames/mkconst); zig cc igual.
name = "libcap"
version = "2.78"
[source]
tarball = "https://mirrors.edge.kernel.org/pub/linux/libs/security/linux-privs/libcap2/libcap-2.78.tar.gz"
sha256 = "2a2c705e382c413643a458b837575c0eb0989477ab6fb99c87adbe9a259612ad"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
[build.phases]
# El árbol de libcap detecta como BuildSys::Make ⇒ el compile/install por defecto no pasa los
# overrides de toolchain ni las flags GOLANG/PAM. Override explícito de ambas fases.
compile = "make CC='zig cc -mcpu=baseline' BUILD_CC='zig cc -mcpu=baseline' AR='zig ar' RANLIB='zig ranlib' OBJCOPY='zig objcopy' GOLANG=no PAM_CAP=no SHARED=no lib=lib"
install = "make CC='zig cc -mcpu=baseline' BUILD_CC='zig cc -mcpu=baseline' AR='zig ar' RANLIB='zig ranlib' OBJCOPY='zig objcopy' GOLANG=no PAM_CAP=no SHARED=no lib=lib prefix=/usr DESTDIR=/out install"
+17 -1
View File
@@ -117,9 +117,14 @@ done
# SWAP_BUSYBOX=1 → monta el busybox de hammer sobre /toolchain/bin/busybox (los symlinks de # SWAP_BUSYBOX=1 → monta el busybox de hammer sobre /toolchain/bin/busybox (los symlinks de
# applets de Alpine — sh/sed/grep/awk/tar/find — pasan a usarlo; cp/mkdir/ # applets de Alpine — sh/sed/grep/awk/tar/find — pasan a usarlo; cp/mkdir/
# install siguen siendo GNU coreutils, intactos). # install siguen siendo GNU coreutils, intactos).
# SWAP_LINUX_HEADERS=1 → construye recipes/linux-headers.toml y swap-directorio de los 13 subdirs
# kernel-owned de /toolchain/usr/include (linux/asm/…); musl bits/sys intactos.
# SWAP_BWRAP=1 → construye recipes/bwrap.toml (+libcap, su dep, vía deps.build) y lo monta
# sobre /toolchain/usr/bin/bwrap (el sandbox del propio lab).
# SWAPS="name=hash[:rel] …" → swaps explícitos extra (formato del flag --swap). # SWAPS="name=hash[:rel] …" → swaps explícitos extra (formato del flag --swap).
# Por defecto, off ⇒ corrida pura-Alpine (variante a), idéntica a la baseline conocida-buena. # Por defecto, off ⇒ corrida pura-Alpine (variante a), idéntica a la baseline conocida-buena.
# Ambas piezas validadas en host: con make+busybox de hammer los 4/4 reproducen of_tree=9adefb82. # Piezas validadas en host: make+busybox reproducen of_tree=9adefb82 in-VM; linux-headers da un
# header-tree byte-idéntico a Alpine (diff -r vacío); bwrap rebuildea musl byte-idéntico de sandbox.
SWAP_ARGS=() SWAP_ARGS=()
if [[ "${SWAP_MAKE:-0}" == 1 ]]; then if [[ "${SWAP_MAKE:-0}" == 1 ]]; then
say "variante b — construir make desde fuente (recipes/make.toml) y swapearlo en /toolchain" say "variante b — construir make desde fuente (recipes/make.toml) y swapearlo en /toolchain"
@@ -152,6 +157,17 @@ if [[ "${SWAP_LINUX_HEADERS:-0}" == 1 ]]; then
SWAP_ARGS+=(--swap "linux-headers=${LH_HASH}:usr/include/$sub") SWAP_ARGS+=(--swap "linux-headers=${LH_HASH}:usr/include/$sub")
done done
fi fi
if [[ "${SWAP_BWRAP:-0}" == 1 ]]; then
# Pieza 4: bwrap (bubblewrap), EL sandbox del lab. Binario estático ⇒ swap de archivo sobre
# /toolchain/usr/bin/bwrap. Su dep libcap la construye y materializa el lab solo (deps.build).
# bwrap es herramienta, no input del 4/4: no necesita casar byte-a-byte con Alpine, sólo aislar
# igual (validado en host — musl rebuildeó byte-idéntico bajo hammer-bwrap).
say "variante b — construir bwrap (+libcap) desde fuente y swapearlo en /toolchain/usr/bin/bwrap"
BW_HASH="$("$HAMMER" --store "$STORE" build recipes/bwrap.toml | grep -oE 'b3:[0-9a-f]{64}' | tail -1)"
[[ "$BW_HASH" == b3:* ]] || die "no obtuve el hash sellado de bwrap"
say "bwrap hammer: $BW_HASH"
SWAP_ARGS+=(--swap "bwrap=${BW_HASH}:usr/bin/bwrap")
fi
for s in ${SWAPS:-}; do SWAP_ARGS+=(--swap "$s"); done for s in ${SWAPS:-}; do SWAP_ARGS+=(--swap "$s"); done
# 4) Ensamblar el builder con la referencia embebida. # 4) Ensamblar el builder con la referencia embebida.