tawasuyu

sergio/takana

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

TOML 604Rust 56Shell 36Markdown 281227 Definitions

hammer-bootstrap: userland Rust en el producto + adelgazar busybox (Etapa C)

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 <noreply@anthropic.com>

20936e1f96e9cfdb935cf33703dfcae75038a424 · by sergio · (4 months ago) · parent 7baaf4afc · browse the tree at this commit

Semantic delta · 9

2 files · +125 −39

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<String> {
 }
 
 /// 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<u8>> = 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<String> = 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.
-pub fn product(
-    base: &RootfsHash,
-    base_cfg: &hammer_build::BuildConfig,
+/// 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<RootfsHash> {
-    // 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 {
+) -> Result<Vec<(String, ArtifactHash)>> {
+    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, base_cfg, store)?;
-        services.push((name.to_string(), h));
+        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<RootfsHash> {
+    // 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