From 20936e1f96e9cfdb935cf33703dfcae75038a424 Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 17:47:20 -0400 Subject: [PATCH] hammer-bootstrap: userland Rust en el producto + adelgazar busybox (Etapa C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extiende la capa de producto con el userland Rust-nativo ADOPTADO, fuera del núcleo blindado (sigue sin tocar STAGE1_COMPONENTS ni el of_tree). - USERLAND_COMPONENTS = [uutils, findutils, findutils-xargs, diffutils, ripgrep]: se hidratan sobre el 4/4 DESPUÉS de busybox ⇒ sus symlinks en /usr/bin ensombrecen los applets busybox. - ADELGAZAR BUSYBOX (determinista, sin depender del PATH del shell): por cada nombre que el userland Rust provee en /usr/bin, se RETIRA el symlink homónimo de busybox en /bin, /sbin, /usr/sbin (busybox suele dejar `ls` en /bin, fuera del /usr/bin ya ensombrecido). La tool Rust queda ÚNICA en PATH. El binario busybox y lo no reemplazado (sh/ash, tar, mount) se preservan. - product_rootfs_hash v2 (incluye userland); build_components() helper; assemble_product_rootfs hidrata base→userland→servicios. - 36 tests verde (nuevo: ensombrecido + retiro de applet + sh/busybox quedan). Validado in-VM (product-boot-test.sh sobre el product-rootfs de la ruta real): ls = "uutils coreutils 0.9.0" (/usr/bin/ls), find = "find (Rust) 0.9.1", rg = "ripgrep 14.1.1"; /bin/ls y /bin/cat retirados, /bin/sh + busybox preservados. SSH sigue verde. "Adelgazar busybox" cerrado en el producto. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-bootstrap/src/lib.rs | 156 ++++++++++++++++++++++------- scripts/product-boot-test.sh | 10 +- 2 files changed, 126 insertions(+), 40 deletions(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 65d7a326..a411bbc9 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -396,9 +396,17 @@ fn assemble_rootfs( // Así, meter más servicios mañana no toca jamás el núcleo verificado. /// Componentes de servicio inyectados sobre el 4/4 verificado: `netup` (red) + `openssh` (sshd/ssh). -/// Crecer esta lista NO toca `STAGE1_COMPONENTS` ni el selfhost-verify. +/// Daemons ⇒ llevan card en la seed de producto. Crecer esta lista NO toca `STAGE1_COMPONENTS`. const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"]; +/// Userland Rust-nativo ADOPTADO (Etapa C): multicall `uutils`/coreutils + `find`/`xargs` + `diff`/`cmp` +/// + `rg`. Se hidrata sobre la base DESPUÉS de busybox, así que sus symlinks en `/usr/bin` ENSOMBRECEN +/// los applets de busybox (con `PATH` `/usr/bin:/bin` gana la tool Rust). Es el "adelgazar busybox" del +/// producto SIN tocar el núcleo: busybox sigue en el 4/4 (self-host) y queda de fallback para lo aún no +/// reemplazado (`sh`/ash, `tar`, …). NO son daemons ⇒ no llevan card en la seed (sólo ficheros). +const USERLAND_COMPONENTS: &[&str] = + &["uutils", "findutils", "findutils-xargs", "diffutils", "ripgrep"]; + /// Card de servicio `sshd` (genesis `Native`/`Restart`): el comando levanta la red (`netup`, DHCP), /// genera host keys al primer boot (`ssh-keygen -A`) y `exec`uta `sshd -D`. Todo dentro del card para /// no asumir orden de genesis. Mismo esquema validado de [`STAGE1_SEED_CARD`]; ULID propio. Se inyecta @@ -454,17 +462,19 @@ fn product_seed_card() -> Result { } /// Identidad del `product-rootfs`: tag + el `RootfsHash` del 4/4 base + `(nombre,hash)` de cada -/// servicio + la seed de producto + las configs. Cambiar cualquiera re-hashea (reproducible). +/// componente userland y de servicio (en orden) + la seed de producto + las configs. Cambiar +/// cualquiera re-hashea (reproducible). El tag `v2` marca el corte respecto al producto sólo-servicios. fn product_rootfs_hash( base: &RootfsHash, + userland: &[(String, ArtifactHash)], services: &[(String, ArtifactHash)], seed: &str, ) -> RootfsHash { let mut inputs: Vec> = vec![ - b"hammer-product-rootfs-v1".to_vec(), + b"hammer-product-rootfs-v2".to_vec(), base.as_str().as_bytes().to_vec(), ]; - for (name, h) in services { + for (name, h) in userland.iter().chain(services.iter()) { inputs.push(name.as_bytes().to_vec()); inputs.push(h.as_str().as_bytes().to_vec()); } @@ -476,13 +486,14 @@ fn product_rootfs_hash( ArtifactHash::of_inputs(&refs) } -/// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4) y luego los -/// componentes de servicio ENCIMA (hidratación tardía); sobrescribe la seed con la de producto y -/// escribe las configs de servicio (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`, -/// `/root/.ssh` 0700). Pieza testeable sin build real. +/// Ensambla el árbol de producto en `staging`: hidrata el rootfs base sellado (4/4), luego el userland +/// Rust (ENSOMBRECE los applets de busybox en `/usr/bin`) y los componentes de servicio ENCIMA +/// (hidratación tardía); sobrescribe la seed con la de producto y escribe las configs de servicio +/// (passwd/group/sshd_config + `/var/empty` 0711, `/etc/ssh`, `/root/.ssh` 0700). Testeable sin build. fn assemble_product_rootfs( store: &Store, base: &RootfsHash, + userland: &[(String, ArtifactHash)], services: &[(String, ArtifactHash)], seed: &str, staging: &Path, @@ -500,13 +511,38 @@ fn assemble_product_rootfs( } hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?; - // 2) servicios encima (openssh, netup): mismo proyectado por hardlink. - for (name, h) in services { + // 2) userland Rust + servicios ENCIMA (hardlink). El userland va DESPUÉS de la base: sus symlinks + // en /usr/bin reemplazan a los de busybox (run_hydrate sustituye el symlink existente) ⇒ el + // producto corre las tools Rust. Orden userland→servicios (sin colisiones entre sí). + for (name, h) in userland.iter().chain(services.iter()) { let dir = store.path_of(h, name); hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static, None)?; } let _ = std::fs::remove_dir_all(staging.join(".hammer")); + // ADELGAZAR BUSYBOX (determinista, sin depender del orden de PATH del shell): por cada nombre que + // el userland Rust provee en `/usr/bin`, retirar el symlink homónimo de busybox en los OTROS dirs + // (`/bin`, `/sbin`, `/usr/sbin`) — busybox suele instalar `ls` en `/bin`, fuera del `/usr/bin` que + // la hidratación ya ensombreció. Así la tool Rust es la ÚNICA en PATH. El binario busybox queda + // (sh/ash, tar, mount, … lo no reemplazado); sólo se quitan los applets ya cubiertos por Rust. + let mut applet_names: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for (name, h) in userland { + let ub = store.path_of(h, name).join("usr/bin"); + if let Ok(rd) = std::fs::read_dir(&ub) { + for e in rd.flatten() { + applet_names.insert(e.file_name().to_string_lossy().into_owned()); + } + } + } + for applet in &applet_names { + for d in ["bin", "sbin", "usr/sbin"] { + let p = staging.join(d).join(applet); + if std::fs::symlink_metadata(&p).is_ok() { + let _ = std::fs::remove_file(&p); + } + } + } + // 3) seed de producto (sobrescribe la base) + configs + dirs de runtime de sshd. // Los ficheros hidratados son HARDLINKS al store sellado (read-only); escribir encima daría // EACCES y `chmod` mutaría el inodo del store. Hay que ROMPER el hardlink: remove + write nuevo. @@ -532,31 +568,44 @@ fn assemble_product_rootfs( Ok(()) } -/// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta la -/// capa de servicios por hidratación tardía, sellando un `product-rootfs`. El mecanismo base queda -/// intacto: este árbol NO es el que reconstruye el selfhost-verify (su `of_tree` sigue blindado). -/// Idempotente. `base_cfg` apunta el zig del lab (no la semilla): los servicios usan su `zig_version` -/// propia (openssh ⇒ 0.13.0), y si ya están sellados, `build` los devuelve cacheados sin recompilar. +/// Construye/cachea (idempotente vía el lab) una lista de componentes nombrados, devolviendo sus +/// `(nombre, hash)` sellados. Cada receta usa su propia config de build (p.ej. `zig_version`). +fn build_components( + names: &[&str], + recipes_dir: &Path, + cfg: &hammer_build::BuildConfig, + store: &Store, +) -> Result> { + let mut out = Vec::with_capacity(names.len()); + for name in names { + let recipe_path = recipes_dir.join(format!("{name}.toml")); + let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { + Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) + })?; + let h = hammer_build::build(&recipe, cfg, store)?; + out.push((name.to_string(), h)); + } + Ok(out) +} + +/// **Producto** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4) y le inyecta por +/// hidratación tardía el userland Rust (adelgaza busybox) + la capa de servicios, sellando un +/// `product-rootfs`. El mecanismo base queda intacto: este árbol NO es el que reconstruye el +/// selfhost-verify (su `of_tree` sigue blindado). Idempotente. `base_cfg` apunta el zig del lab (no la +/// semilla): cada componente usa su `zig_version` propia, y si ya está sellado sale cacheado. pub fn product( base: &RootfsHash, base_cfg: &hammer_build::BuildConfig, recipes_dir: &Path, store: &Store, ) -> Result { - // 1) construir/cachear los componentes de servicio (idempotente vía el lab). - let mut services: Vec<(String, ArtifactHash)> = Vec::with_capacity(SERVICE_COMPONENTS.len()); - for name in SERVICE_COMPONENTS { - let recipe_path = recipes_dir.join(format!("{name}.toml")); - let recipe = Recipe::load_from_path(&recipe_path).map_err(|e| { - Error::Other(format!("receta '{name}' ({}): {e}", recipe_path.display())) - })?; - let h = hammer_build::build(&recipe, base_cfg, store)?; - services.push((name.to_string(), h)); - } + // 1) construir/cachear el userland Rust + los servicios. + let userland = build_components(USERLAND_COMPONENTS, recipes_dir, base_cfg, store)?; + let services = build_components(SERVICE_COMPONENTS, recipes_dir, base_cfg, store)?; // 2) seed de producto + hash de identidad. let seed = product_seed_card()?; - let phash = product_rootfs_hash(base, &services, &seed); + let phash = product_rootfs_hash(base, &userland, &services, &seed); let store_name = "product-rootfs"; // 3) ensamblar + sellar (si no estaba ya). @@ -568,13 +617,13 @@ pub fn product( let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)?; let result = (|| -> Result<()> { - assemble_product_rootfs(store, base, &services, &seed, &staging)?; + assemble_product_rootfs(store, base, &userland, &services, &seed, &staging)?; store.seal(&staging, &phash, store_name)?; Ok(()) })(); let _ = std::fs::remove_dir_all(&staging); result?; - tracing::info!(hash = %phash, "product: rootfs de servicios sellado"); + tracing::info!(hash = %phash, "product: rootfs (userland Rust + servicios) sellado"); } else { tracing::info!(hash = %phash, "product: rootfs ya sellado (idempotente)"); } @@ -1526,14 +1575,17 @@ mod tests { #[test] fn product_rootfs_hash_is_deterministic_and_sensitive() { let base = ArtifactHash::from_hex("ab"); + let userland = [("uutils".to_string(), ArtifactHash::from_hex("11"))]; let svcs = [("openssh".to_string(), ArtifactHash::from_hex("cd"))]; let seed = product_seed_card().unwrap(); - let a = product_rootfs_hash(&base, &svcs, &seed); - assert_eq!(a, product_rootfs_hash(&base, &svcs, &seed), "determinista"); + let a = product_rootfs_hash(&base, &userland, &svcs, &seed); + assert_eq!(a, product_rootfs_hash(&base, &userland, &svcs, &seed), "determinista"); let other_base = ArtifactHash::from_hex("ef"); - assert_ne!(a, product_rootfs_hash(&other_base, &svcs, &seed), "cambiar la base re-hashea"); + assert_ne!(a, product_rootfs_hash(&other_base, &userland, &svcs, &seed), "cambiar la base re-hashea"); + let other_userland = [("uutils".to_string(), ArtifactHash::from_hex("22"))]; + assert_ne!(a, product_rootfs_hash(&base, &other_userland, &svcs, &seed), "cambiar el userland re-hashea"); let other_svc = [("openssh".to_string(), ArtifactHash::from_hex("99"))]; - assert_ne!(a, product_rootfs_hash(&base, &other_svc, &seed), "cambiar un servicio re-hashea"); + assert_ne!(a, product_rootfs_hash(&base, &userland, &other_svc, &seed), "cambiar un servicio re-hashea"); } #[test] @@ -1542,17 +1594,31 @@ mod tests { let store = Store::open(tmp.path().join("store")).unwrap(); // Un rootfs base sintético sellado como `stage1-rootfs` (el 4/4 ya verificado): trae la seed - // base, /sbin/init, un mountpoint vacío y un binario del núcleo. + // base, /sbin/init, un mountpoint vacío, un binario del núcleo y un applet busybox `ls`. let base = seal_component(&store, "stage1-rootfs", "ba5e", |w| { std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::create_dir_all(w.join("bin")).unwrap(); std::fs::create_dir_all(w.join("ente")).unwrap(); std::fs::create_dir_all(w.join("proc")).unwrap(); std::fs::create_dir_all(w.join("var")).unwrap(); std::fs::create_dir_all(w.join("root")).unwrap(); std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELFfake").unwrap(); + std::fs::write(w.join("bin/busybox"), b"\x7fELFbb").unwrap(); + // busybox instala el applet `ls` como symlink en /usr/bin (lo ensombrece la hidratación) y + // `cat` en /bin (lo debe RETIRAR el adelgazamiento). `sh` en /bin NO se reemplaza (queda). + std::os::unix::fs::symlink("../../bin/busybox", w.join("usr/bin/ls")).unwrap(); + std::os::unix::fs::symlink("busybox", w.join("bin/cat")).unwrap(); + std::os::unix::fs::symlink("busybox", w.join("bin/sh")).unwrap(); std::fs::write(w.join("ente/seed.card.json"), STAGE1_SEED_CARD).unwrap(); std::os::unix::fs::symlink("/usr/bin/arje-zero", w.join("sbin_init_tmp")).unwrap(); }); + // Userland Rust sintético (uutils): coreutils multicall + symlink `ls`→coreutils. + let huu = seal_component(&store, "uutils", "c0re", |w| { + std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::write(w.join("usr/bin/coreutils"), b"\x7fELFuu").unwrap(); + std::os::unix::fs::symlink("coreutils", w.join("usr/bin/ls")).unwrap(); + std::os::unix::fs::symlink("coreutils", w.join("usr/bin/cat")).unwrap(); + }); // Componentes de servicio sintéticos. let hossh = seal_component(&store, "openssh", "0551", |w| { std::fs::create_dir_all(w.join("usr/sbin")).unwrap(); @@ -1564,16 +1630,29 @@ mod tests { }); let seed = product_seed_card().unwrap(); + let userland = [("uutils".to_string(), huu)]; let services = [("openssh".to_string(), hossh), ("netup".to_string(), hnet)]; let staging = store.root().join("prod-staging"); std::fs::create_dir_all(&staging).unwrap(); - assemble_product_rootfs(&store, &base, &services, &seed, &staging).unwrap(); + assemble_product_rootfs(&store, &base, &userland, &services, &seed, &staging).unwrap(); - // base hidratada + servicios inyectados. + // base hidratada + userland + servicios inyectados. assert!(staging.join("usr/bin/hammerd").is_file(), "núcleo presente"); assert!(staging.join("proc").is_dir(), "mountpoint vacío recreado"); + assert!(staging.join("usr/bin/coreutils").is_file(), "uutils inyectado"); assert!(staging.join("usr/sbin/sshd").is_file(), "openssh inyectado"); assert!(staging.join("usr/bin/netup").is_file(), "netup inyectado"); + // ADELGAZAR BUSYBOX: /usr/bin/ls apunta ahora a coreutils (Rust), no a busybox. + assert_eq!( + std::fs::read_link(staging.join("usr/bin/ls")).unwrap(), + std::path::PathBuf::from("coreutils"), + "el userland Rust ensombrece el applet busybox en /usr/bin" + ); + // El applet busybox `cat` en /bin fue RETIRADO (uutils provee cat) ⇒ no compite en PATH. + assert!(!staging.join("bin/cat").exists(), "el applet busybox /bin/cat reemplazado fue retirado"); + // Lo NO reemplazado por Rust (sh) y el binario busybox quedan intactos. + assert!(staging.join("bin/sh").exists(), "/bin/sh (ash) interino se preserva"); + assert!(staging.join("bin/busybox").is_file(), "el binario busybox queda (fallback)"); // la seed fue sobrescrita con la de producto (3 cards). let got = std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap(); let v: serde_json::Value = serde_json::from_str(&got).unwrap(); @@ -1590,12 +1669,13 @@ mod tests { } #[test] - fn shipped_service_recipes_parse_and_hash() { - // Las recetas de servicio reales (netup, openssh) cargan y hashean — no contaminan el núcleo. + fn shipped_product_recipes_parse_and_hash() { + // Las recetas de producto reales (userland Rust + servicios) cargan y hashean — viven fuera + // del núcleo (no entran a STAGE1_COMPONENTS ni al selfhost-verify). let recipes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../recipes"); let tmp = tempfile::tempdir().unwrap(); let store = Store::open(tmp.path().join("store")).unwrap(); - for name in SERVICE_COMPONENTS { + for name in USERLAND_COMPONENTS.iter().chain(SERVICE_COMPONENTS.iter()) { let path = recipes_dir.join(format!("{name}.toml")); let r = Recipe::load_from_path(&path).unwrap_or_else(|e| panic!("parse {name}: {e}")); assert_eq!(&r.name, name); diff --git a/scripts/product-boot-test.sh b/scripts/product-boot-test.sh index e91d75c4..46f96308 100755 --- a/scripts/product-boot-test.sh +++ b/scripts/product-boot-test.sh @@ -42,10 +42,13 @@ chmod 0600 "$RFS/root/.ssh/authorized_keys" # sanity: el árbol de producto trae lo que esperamos (sin ensamblarlo acá) echo "==> chequeo de contenido del artefacto:" -for f in usr/sbin/sshd usr/bin/netup etc/ssh/sshd_config etc/passwd ente/seed.card.json; do +for f in usr/sbin/sshd usr/bin/netup usr/bin/coreutils usr/bin/find usr/bin/rg etc/ssh/sshd_config etc/passwd ente/seed.card.json; do [ -e "$RFS/$f" ] && echo " ok $f" || { echo " FALTA $f"; exit 1; } done grep -q '"label": "sshd"' "$RFS/ente/seed.card.json" && echo " ok card sshd en la seed" || { echo " FALTA card sshd"; exit 1; } +# adelgazar busybox: /usr/bin/ls debe apuntar a coreutils (Rust), no a busybox +lstgt=$(readlink "$RFS/usr/bin/ls" 2>/dev/null || true) +[ "$lstgt" = coreutils ] && echo " ok /usr/bin/ls → coreutils (ensombrece busybox)" || echo " !! /usr/bin/ls → ${lstgt:-?}" # empaquetar (root-owned) y bootear ( cd "$RFS" && find . -print0 | cpio --null -o -H newc -R 0:0 2>/dev/null | gzip -1 ) > "$WORK/initramfs.cpio.gz" @@ -63,7 +66,10 @@ start=$(date +%s); ok=0 while [ $(( $(date +%s) - start )) -lt "$DEADLINE" ]; do kill -0 $QPID 2>/dev/null || { echo "!! QEMU murió"; break; } if out=$(ssh "${SSHOPTS[@]}" root@localhost \ - 'echo PRODUCT_SSH_OK uid=$(id -u); cat /ente/seed.card.json | grep -o "\"label\": \"[a-z-]*\"" | tr "\n" " "; echo; uname -sr' 2>/dev/null); then + 'echo PRODUCT_SSH_OK uid=$(id -u); uname -sr; + printf "ls: "; command -v ls; ls --version 2>&1 | head -1; + printf "find: "; find --version 2>&1 | head -1; + printf "rg: "; rg --version 2>&1 | head -1' 2>/dev/null); then echo "============== RESPUESTA DEL GUEST =============="; echo "$out"; echo "================================================" echo "$out" | grep -q PRODUCT_SSH_OK && ok=1; break fi