# Plan — con qué conviene hacer lo de busybox, MEDIDO **Fecha:** 2026-09-22 · **Pregunta:** «botado busybox, ¿con qué conviene hacer lo mismo? ¿Hay que rusterizar o soberanizar algo vital, hay opciones modernas a cosas tradicionales, o hay una arquitectura mejor?» **Instrumento** (todo reproducible, nada escrito a mano): ```sh # grafo y clases: los 950 nodos con su clase, hash, perfiles y dependientes python3 -c 'import json;json.load(open("docs/state/build-state.json"))' # aristas y membresía: la MISMA función que usan el grafo, la hidratación y el repo firmado python3 -c 'import sys;sys.path.insert(0,"scripts");import yupana;yupana.cargar_colas()' # bytes: tamaño real de cada artefacto sellado cd /store && du -sk --apparent-size * # 1.435 artefactos, 59,8 GiB # runtime real: quién pide qué SONAME readelf -d /usr/bin/ ``` ## El hallazgo que ordena todo, y no es una pieza de userland > **La clausura que define una imagen es `build ∪ runtime`. Entonces cada imagen de takana lleva > adentro la cadena de compilación: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`, > `bison`, `flex`, `m4`, `meson`, `wasi-sdk`, `nodejs`.** Es **exactamente la forma del caso busybox**, y por eso vale la pena verlo dos veces: `busybox` nunca estuvo declarado en `targets.toml` —llegaba a los 7 perfiles por la clausura, porque 24 recetas lo pedían en `[deps] build`—, y sacarlo no costó escribir una línea de userland (paso 6 de `plan-botar-busybox.md`). Lo que ese paso arregló **para un nombre**, esto lo arregla **para la clase entera**. La definición está en `scripts/yupana.py:98` (`_deps`) y en `scripts/build-state.py:128`, y en las dos hay un comentario que explica por qué es la unión: `firefox` declaraba `runtime = ["gcc-libs"]`, nadie leía ese campo, y la imagen se armaba sin `libstdc++.so.6`. **El arreglo fue correcto y la unión es lo que hay que afinar, no lo que hay que deshacer** — ver el riesgo más abajo. ### Lo que cuesta, en nodos y en bytes Clausura de hoy contra «raíces + aristas de runtime», medido sobre los artefactos sellados: | perfil | nodos hoy | sólo-runtime | GiB hoy | GiB sólo-runtime | grasa | |---|---|---|---|---|---| | `base` | 85 | 48 | **1,35** | 0,71 | **47 %** | | `cli` | 152 | 103 | **2,57** | 1,56 | 39 % | | `servidor` | 185 | 132 | **4,56** | 2,09 | **54 %** | | `escritorio-kde` | 444 | 163 | **13,05** | 5,76 | **56 %** | | `escritorio-gnome` | 344 | 158 | 9,63 | 3,86 | 60 % | | `escritorio-cosmic` | 295 | 157 | 8,48 | 4,34 | 49 % | | `escritorio-sway` | 281 | 143 | 7,90 | 4,11 | 48 % | | `escritorio-mirada` | 41 | 17 | 0,79 | 0,20 | 75 % | Y los caminos, que es lo que lo hace concreto: | en la imagen | pesa | entra por | qué es | |---|---|---|---| | `llvm21` | **1.527 MiB** | `servidor` → `rust` → `llvm21` | libs estáticas + headers de LLVM | | `python3` | 209 MiB | **`base` → `iputils` → `python3`** | `ping` se construye con meson | | `go` | 222 MiB | `cli` → `fzf` → `go` | el toolchain de Go | | `binutils` | 127 MiB | `base` → `bwrap` → `binutils` | ensamblador y linker | | `perl` | 84 MiB | `base` → `git` → `perl` | los `configure` de OpenSSL/curl | | `cmake` | 94 MiB | `servidor` → `rust` → `cmake` | generador de builds | | `nodejs` | 86 MiB | `escritorio-kde` → `atuq` → `firefox` → `nodejs` | el JS del build de Gecko | `llvm21` es el caso de libro y se verificó **en el binario, no en la receta**: ``` readelf -d /store/…-rust/usr/bin/rustc ⇒ NEEDED librustc_driver-*.so, libc.so (NO libLLVM) readelf -d /store/…-lld21/usr/bin/lld ⇒ NEEDED libz.so.1, libgcc_s.so.1, libc.so recipes/llvm21.toml:75-76 ⇒ -DLLVM_BUILD_LLVM_DYLIB=OFF -DLLVM_LINK_LLVM_DYLIB=OFF ``` O sea: 1,5 G de LLVM viajan en la imagen del servidor para que **nadie** los abra. Y `servidor` es el perfil donde compilar **sí** es deliberado (`targets.toml:1725-1745`, «la caja que sirve el repo y construye, sí»): incluso ahí, el compilador es `rust` + `lld21`, no `llvm21`. ### Por qué es barato: **CERO reconstrucciones** Esto no toca ninguna receta ⇒ no toca ningún `ArtifactHash` ⇒ no invalida nada. Se cambia **cómo se proyecta**, no qué se construye. Es lo que lo pone primero: todo lo demás de este documento cuesta builds. ### Por qué es seguro: la ambigüedad es ENUMERABLE El miedo legítimo es la cicatriz de `gcc-libs`: si una lib dinámica se cae de la imagen, el binario sella, reproduce y no arranca. Medido sobre el cierre de `servidor`: > **de 185 nodos, sólo 28 contienen algún `.so`.** 144 no traen ni uno (todo el corpus es > `link = static`), y 13 no están en el store de este hub. Los 28: `alsa-lib binutils bzip2-shared cmus dbus elfutils-libdw ffmpeg gcc-libs gmp go libffi-shared libnl libudev-zero llvm21 mold mpc mpfr musl-fts musl-shared ncurses-shared perl python3 readline-shared sqlite-shared util-linux xz-shared zlib-shared zstd`. Entonces la regla correcta **no** es «usá sólo las aristas de runtime» (eso es lo que rompió firefox). Es: ``` cierre de imagen = raíces ∪ deps de runtime declaradas ∪ proveedores de los SONAME OBSERVADOS ``` y el instrumento para la tercera parte **ya existe y ya se corre**: `scripts/vigia-sonames.py` contesta «¿qué `.so` pide esta imagen que ningún artefacto de su cierre provee?». Hoy es un guardián *a posteriori*; acá pasa a ser la **definición**. ### Dónde se toca, y el criterio de terminado - `scripts/yupana.py` — una tercera vista además de `cargar_colas()` y `cargar_colas(solo_build=True)`: la de **imagen**. Las otras dos no se tocan: `radio` necesita la de build (re-hasheo) y el grafo de estado necesita la unión (cota de «qué hay que construir»). - `scripts/hydrate-profile.py` — hoy «proyecta al FHS el cierre ENTERO». - `scripts/repo-perfil.sh` y `scripts/build-state.py` — consumen la misma membresía. - ⚠ **Hay DOS linajes de imagen y este es el que no está curado.** El otro —`takana bootstrap product`— se arma de tres listas a mano (`STAGE1_COMPONENTS` 4, `USERLAND_COMPONENTS` 5, `SERVICE_COMPONENTS` 2) y por eso `product-image.sh` puede anunciarse «lean, SIN toolchain». La imagen del servidor es **producto + hidratación del perfil encima** (SDD 28 §«Receta del ensamblado»), y la segunda mitad es la que trae los 2,46 GiB. Unificar los linajes es su propia unidad de trabajo; **no hace falta para cobrar esto.** **Terminado cuando:** `hydrate-profile.py servidor` no proyecta `llvm21 python3 perl binutils cmake go`, `vigia-sonames.py` da **cero** huecos en los 8 perfiles, y `product-boot-test.sh` arranca. La cifra a batir: **4,56 → 2,09 GiB** en `servidor` y **13,05 → 5,76** en KDE. ⚠ **Un detalle que sale de paso y no es de este frente:** el **kernel no está en ningún perfil** (`linux*` → `perfiles: []`); lo pasa un `KERNEL=` de script. Lo que sí viaja en los 7 es `linux-headers`. La imagen declara los headers del kernel y no el kernel. --- ## Los tres grupos, porque mezclarlos es lo que cuesta el trabajo Igual que los tres frentes de busybox, acá la diferencia que importa es **cuántas reconstrucciones paga cada cosa** — `[deps] build` entra en `hash_inputs`, `[deps] runtime` no: | grupo | qué es | rebuilds | |---|---|---| | **1 · proyección** | cómo se arma la imagen; ninguna receta cambia | **0** | | **2 · hojas** | recetas con **0 consumidores**: nadie las declara | **0** (sólo ellas) | | **3 · campañas** | tocar una herramienta de build que N recetas declaran | **N** | --- ## Grupo 2 · Las hojas: cambio moderno, coste cero Todas verificadas con `yupana`: **0 consumidores**, o sea que tocarlas no invalida a nadie. ### 2.1 · `sudo` **y** `doas` en los 7 perfiles — y ninguno de los dos puede funcionar Dos herramientas para lo mismo, declaradas juntas en `base` (`targets.toml:58`) y heredadas por los otros 6 perfiles. `sudo` = 12,1 MiB, `doas` = 0,1 MiB. Pero el hallazgo es otro: ``` /store/…-sudo/usr/bin/sudo → -r-xr-xr-x (sin setuid) /store/…-doas/usr/bin/doas → -r-xr-xr-x (sin setuid) grep -rn "S_ISUID\|4755" crates/ → 0 hits que pongan el bit ``` Y las recetas dicen tres cosas distintas: | receta | qué afirma | ¿cierto? | |---|---|---| | `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | **no** | | `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | **no** | | `recipes/shadow.toml:15` | «el modelo de artefactos no puede ni expresar ni garantizar un setuid» | **sí** | Es la familia de [[needed-colgante-libstdcxx]] y [[subcomando-sin-driver]]: sella, reproduce y no corre. **Hoy es LATENTE**, y por eso nadie lo vio: el instalador sólo pone contraseña de root (`takana-live-install.sh`) y los `[[user]]` de las 7 recetas que los declaran son cuentas de servicio, que no escalan. Se vuelve real el día que la imagen tenga un usuario humano no-root — y entonces `sudo`, `doas`, `su` y `passwd` fallan los cuatro. **Orden correcto, y no es «poner sudo-rs»:** 1. **Decidir quién pone el bit** (el modelo de artefactos, la hidratación o el `.swm`). Es la conversación que `shadow.toml:18` ya pidió y que sigue abierta. 2. **Elegir UNA.** Si la respuesta es «la chica», `doas` ya ganó. Si es «la compatible con los runbooks», el reemplazo moderno es **`sudo-rs`** (Rust, auditado, drop-in de `sudo`) y de paso saca un setuid en C del producto. 3. Corregir las dos recetas que afirman algo falso, gane quien gane. ### 2.2 · `chrony` → `ntpd-rs` La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que dicen «firma inválida» y no «la hora» — está escrito en el propio `targets.toml`. `chrony` es C, 0 consumidores, sólo en `servidor`. `ntpd-rs` es el reemplazo Rust hecho exactamente para este nicho (cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de `uutils`. ⚠ Verificar antes de prometer: que cubra el modo *cliente* con el mismo contrato de arranque, y que su `[[service]]` encaje en arje (SDD 30). El criterio es `arjectl status` + reloj convergido en QEMU, no que compile. ### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo** `gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas: - Como **herramienta del usuario**: el reemplazo moderno es `sequoia` (`sq`, Rust). ⚠ No confundir con `recipes/sq.toml`, que es otro `sq` (neilotoole, Go, consultas a datos). - Como **infraestructura**: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de busybox, no la clase «reemplazar». ⚠ `libgcrypt` (18 consumidores) y `libgpg-error` (20) **se quedan** con o sin gnupg. --- ## Grupo 3 · Las campañas: buen cambio, factura grande ### 3.1 · `meson` → `muon`: matar el intérprete de Python del sandbox | dato | valor | |---|---| | recetas que declaran `meson` | **107** | | de esas, que declaran también `python3` | **107 (todas)** | | consumidores de `python3` en total | 302 | | `python3` en el store | **209,0 MiB** | | `meson` | 3,9 MiB | El precedente ya está sentado y es del propio repo: **`samurai`** (C, 0,6 MiB, 256 consumidores) reemplazó a ninja. `muon` es su hermano exacto: implementación en C de `meson.build`, sin Python, pensada para correr con samurai. Es el cambio que convierte «`ping` arrastra 209 MiB de intérprete» en un no-problema del lado del build, además del lado de la imagen. ⚠ **Cuesta 107 recetas re-selladas** (y sus consumidores): `deps.build` está en `hash_inputs`. `yupana radio meson` antes de tocar nada. Conviene **sólo pegado a otra invalidación grande**, no como cambio suelto. ⚠ Y se mide primero, no se adopta: muon **no cubre el 100 %** de meson. El criterio de decisión es cuántas de las 107 configuran con muon sin tocar el `meson.build`; con una muestra de 10 ya se sabe si el frente vale. ### 3.2 · `zlib` → `zlib-rs`: la lib C más consumida del corpus `zlib` tiene **122 consumidores** (+83 de `zlib-shared`) — más que ninguna otra librería del catálogo, y es un clásico de CVEs de memoria. `zlib-rs` publica la **misma ABI C** (`libz.a` / `libz.so.1`), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con `ripgrep`/`sd`/`fd`. ⚠ 122 recetas re-selladas: es **la campaña más cara de este documento**. Y ⚠ hay que verificar su estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a `uutils/util-linux`. **Cómo se decide sin pagar la campaña:** receta `zlib-rs` publicando el mismo SONAME, swap en **un** consumidor dinámico (`lld21` pide `libz.so.1`), y comparar. Si ahí anda, la campaña es una decisión de calendario; si no, costó una receta. ### 3.3 · Lo que NO es campaña aunque se parezca `openssl`: 55 consumidores, 44,1 MiB. **9 son recetas Rust** (`cargo-generate`, `cargo-outdated`, `cargo-udeps`, `cargo-cache`, `cosmic-settings-daemon`, `hurl`, `monolith`, `nu`, `rink`) que podrían usar `rustls` por *feature*. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ **el esfuerzo no compra bytes ni superficie**. Es trabajo del día que openssl se caiga sola. --- ## Correcciones al plan de busybox que salen de esta medición `plan-botar-busybox.md` cifró **23 applets de trabajo real**. Dos de las ocho piezas ya están hechas y una **no hace falta**: | pieza | applets | estado hoy | |---|---|---| | `psmisc` | `killall killall5 pstree fuser` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) | | `bc` | `bc dc` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) | | `awk` | `awk` | ✅ resuelto **escribiendo**: `akllay`, Rust, 3.531 líneas (`86915f26`) | | **`kmod`** | `insmod rmmod lsmod modprobe depmod modinfo` | **no hace falta: no hay módulos** | El detalle de `kmod`, que es el que cambia la cuenta: **los cuatro kernels que arman imágenes llevan `-d MODULES`** — `linux-generic.toml:69`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`. El único que conserva los módulos del `defconfig` es `recipes/linux.toml`, y ése **no arma ninguna imagen** (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del otro lado: «un único bzImage, sin módulos ⇒ una sola firma». ⇒ **los 6 applets de `kmod` son clase BORRAR, no clase escribir.** Vuelven el día que exista un módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día `kmod` es una receta, no seis applets. Con eso el trabajo restante del censo baja de 23 a **11** (`grep` 3, `sed` 1, gestor de `/dev` 3, `getty` 2, `nslookup` 1), y de esos `getty` **ya se sabe que es ADR** y no escritura. --- ## Lo único del producto que no sale de una receta: el cargador de arranque ``` recipes/grub*.toml ⇒ no existe scripts/iso-image.sh:37 ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}" scripts/install-image.sh:49 ⇒ idem · exige grub-mkimage, grub-bios-setup ``` O sea que **el GRUB que arranca una imagen de takana es el de la caja que la construyó**, y su versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI **ya se escapó** por EFI-stub (`EFI_STUB=1`, `metal-iso.sh:104`, y el ADR 0018 con vendor path propio + reconciliador de NVRAM, implementado y verificado en metal). La decisión es chica y está sin tomar: **o `grub` entra al corpus como receta, o la rama BIOS se retira** y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata. --- ## Arquitectura: los tres pendientes, por lo que cuestan HOY 1. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.** Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más rápido después de esto. 2. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`… 168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas** (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe. 3. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml` ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`. --- ## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas) | candidato | por qué no | |---|---| | `openssh` | no hay reemplazo Rust drop-in de un servidor SSH. Es el acceso a la caja: un experimento acá se paga con una máquina inalcanzable | | `linux-pam` | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada | | `cups` / `bluez` | 291,8 y 35,5 MiB en los 4 escritorios, **ya decidido a conciencia** (`targets.toml`: viajan como paquete y NO se arrancan como servicio). Es el punto 8 de PUBLICABLE, no un olvido | | `NetworkManager` | 50,3 MiB, pero los applets de los 4 escritorios le hablan **por D-Bus**. Es migración de llamadores, no borrado — la lección de `ifconfig` del censo de busybox | | el demonio de `dbus` | 124 consumidores son **clientes** de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge | | forkear `ripgrep`/`sd`/`fd` | ya cerrado en `plan-botar-busybox.md`: se usan como **librerías**, no se forkean | | deduplicar `work/sources` | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar *de paso* re-crea la carrera | --- ## Orden de ataque | # | unidad | rebuilds | gana | |---|---|---|---| | 1 | cierre de imagen `build` vs `runtime` (+ el vigía como definición) | **0** | −2,5 GiB en servidor, −7,3 en KDE, y el toolchain fuera del producto | | 2 | decidir el setuid y quedarse con **una** de `sudo`/`doas` | 0 | cierra un «sella y no corre» antes de que sea visible | | 3 | `kmod` a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita | | 4 | la rama BIOS: receta de `grub` o retirarla | 0 | ningún binario ajeno sin hash en el arranque | | 5 | `chrony` → `ntpd-rs` | 0 | una hoja C vital menos, con drop-in Rust | | 6 | **ADR 0012** | — | la granja deja de compilar de a uno | | 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox | | 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI | Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario. --- # Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22) **Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su motivo) y aplicada por `yupana.membresia_imagen()`. ## La regla que se implementó, y la que se descartó **Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin `libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz` llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es otra unidad de trabajo. **La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli` sigue llevando `mold` — «la caja que sirve el repo y construye, sí». ## Medido en disco, no calculado — hidratando de verdad con `--closure build|image` `hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es una afirmación sin testigo. | perfil | ficheros antes → después | tamaño antes → después | |---|---|---| | `base` | 15.743 → **8.228** | 1,4 G → **871 M** (−38 %) | | `servidor` | 40.235 → **11.516** | 4,9 G → **2,6 G** (−47 %) | ⚠ Los dos lados se hidrataron con los **mismos 12 nodos ausentes** de este hub (`chrony`, `cronie`, `rust`, `nftables`… están en el store del worker): la comparación es válida, el absoluto es cota inferior. Y ⚠ **`du` de los dos árboles en UN solo comando miente**: proyectan por hardlink y `du` deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado. Nodos por perfil (`build` = qué hay que construir, sigue igual; `imagen` = qué viaja): | perfil | build | imagen | fuera | |---|---|---|---| | `base` | 85 | **68** | 17 | | `cli` | 152 | **132** | 20 | | `servidor` | 185 | **165** | 20 | | `escritorio-mirada` | 41 | **23** | 18 | | `escritorio-kde` | 443 | **409** | 34 | | `escritorio-gnome` | 339 | **307** | 32 | | `escritorio-cosmic` | 295 | **260** | 35 | | `escritorio-sway` | 281 | **248** | 33 | El `base` hidratado ya no tiene `python3`, `perl`, `cmake`, `meson`, `binutils`, `nasm`, `bison`, `flex`, `m4`, `pkgconf`, `go` ni `rustc` — comprobado buscándolos en el árbol, no en la lista. Lo único que queda con ese nombre es `usr/share/cmake`, que son ficheros de datos que instala otra receta, no el programa. ## El instrumento que faltaba: `scripts/vigia-interpretes.py` El vigía de sonames dio **0 huecos en los 8 perfiles** con el toolchain fuera (`--imagen --fail`, `rc=0`). Pero **un `.so` no es la única dependencia de ejecución: un `#!` también**, y de eso no había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la imagen provee, con dos tablas —`POR_CONTRATO` (andamiaje de build horneado dentro de una librería) y `POR_DECISION` (lo que se aceptó perder)— y falla con `--fail` ante cualquier caso no declarado. **Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:** | hallazgo | qué era | qué se hizo | |---|---|---| | `gnome-tweaks` es un **programa Python entero** | sin `python3` la app no abre | `python3` declarado en `escritorio-gnome` | | `gdctl` de mutter (configurar salidas) | idem | idem | | el `kconf_update` de plasma-workspace | **migra la config al ACTUALIZAR**; si no corre, no migra y nada lo dice | `python3` declarado en `escritorio-kde` | O sea: `python3` (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y **no** en `base`, `cli`, `servidor`, `sway`, `cosmic` ni `mirada`, donde en `base` era el 17 %. Lo que queda inerte a propósito, contado y nombrado (`•` en la salida del vigía): los 10 scripts perl de `git` (`send-email`, `svn`, `cvs*`, `gitweb.cgi`), los 4 de `lm-sensors` (`sensors-detect`…), los diagnósticos de `libinput`, `routel`, `rrsync`, `lsusb.py`, los filtros de `aerc` y `helper-mux` de squid. Un `#!` que no resuelve **falla ruidoso y nombra al intérprete**; por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el `paquetes` del perfil. ⚠ **Lo que no se midió:** un binario que llame al intérprete con `system()` sin `#!`. No hay instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye `scripts/product-boot-test.sh` en QEMU, que esta jaula no puede correr. ## Lo que NO cambió, a propósito - **`build-state.py` sigue contando `clausura` = `build ∪ runtime`** — es «qué hay que construir» y para eso el toolchain hace falta. Ahora informa `imagen` al lado, en el mismo renglón. - **`repo-perfil.sh` sigue publicando el cierre entero**: un repositorio no es una imagen. Que se pueda instalar `cmake` desde el repo es correcto; que venga horneado en el rootfs, no. - **Las recetas**: ninguna. `[solo-build]` vive en el manifiesto y no en el `.toml` justamente para no entrar en `hash_inputs`. ## Guardado contra regresiones `scripts/targets.py --selftest` gana 6 casos con roturas a propósito sobre la poda (control sin poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje (`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban. --- # Paso 2 — HECHO: el bit de privilegio, y `doas` como único camino (2026-09-22) El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y la respuesta es un mecanismo con guardián, no un `chmod` en un script. ## El hueco medido: 26 binarios, todos inertes Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid o setgid, y mirando el modo real del artefacto: | lo trae | binarios | modo hoy | perfiles | |---|---|---|---| | `shadow` | `passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap` | `555` | 7 | | `util-linux` | `mount umount sulogin wall` | `555` | 7 | | `linux-pam` | **`unix_chkpwd`** | `555` | 5 (los escritorios) | | `dbus` | `dbus-daemon-launch-helper` | `555` | 7 | | `fuse3` | `fusermount3` | `555` | 3 | | `iputils` | `ping` | `555` | 7 | | `openssh` | `ssh-agent` | `555` | 1 | | `sudo` / `doas` | `sudo sudoedit` / `doas` | `555` | 7 | **Ninguno tiene el bit** y `grep -rn S_ISUID crates/` daba **cero**: no había nadie que lo pusiera. El más caro de la lista no es `sudo`: es **`unix_chkpwd`**, que es con lo que PAM verifica una contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie. Sigue siendo **LATENTE**, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los `[[user]]` de las recetas son cuentas de servicio, que no escalan. ## 1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia - **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN (`mode & 0o111`, `hash.rs:79`). Dos artefactos que difieran sólo en el setuid tienen el MISMO hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás. - **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store: un `chmod u+s` en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás. ⇒ Se declara en la receta (`[[setuid]]`, con `why` **obligatorio**) y lo aplica `takana setuid --apply `, que **rompe el enlace duro** antes del `chmod`. Y la mitad buena del diseño sale de ahí: la copia la crea quien ensambla, así que **queda a su nombre** — y el `unshare -r` de `install-image.sh` mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un `chown` en ningún lado. **Cero reconstrucciones**, verificado: el campo está fuera de `hash_inputs` (como `license`, `[[service]]` y `[[user]]`) y el hash de `doas` sigue siendo `b3:28bc42c2…`, sellado. ## 2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale ``` ✓ 4755 usr/bin/doas (doas) [enlace duro roto: el store queda intacto] ✓ ningún setuid/setgid sin declarar en ``` - *lo declarado está*: cada ruta existe y queda con el modo pedido (una declaración que apunta a un binario que la imagen no trae **falla**; es la trampa de [[catalogo-no-es-imagen]]). - **lo que está, está declarado**: ningún fichero del rootfs tiene el bit sin declararlo. Eso convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen. Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒ `rc=1`; una declaración que apunta a una ruta inexistente ⇒ `rc=1`. Más 6 tests en `crates/takana-core/src/setuid.rs`, incluido el que justifica el módulo entero: tras aplicar el bit **el artefacto del store sigue en `0555`** y el del rootfs es `4755`. ## 3 · ¿Qué significa que el hash no lo cubra? Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash` sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y creer que el hash lo protege. ## La elección: `doas`, y `sudo` sale de las imágenes Decisión del usuario con lo medido delante: | | `sudo` | `doas` | |---|---|---| | tamaño | **13 M** | **134 KB** | | config que instala | `/etc/sudoers` + `/etc/sudo.conf` | ninguna | | lo que autoriza tal cual | `root ALL=(ALL:ALL) ALL` — **a nadie más** | nada hasta que exista `doas.conf` | Viajaban **los dos** en los 7 perfiles y ninguno podía funcionar. Ahora `sudo` no está en ninguna imagen (su receta se queda en el catálogo, que no es la imagen) y `doas` lleva su `[[setuid]]`. ⚠ **Falta el `/etc/doas.conf`: sin él, `doas` rechaza todo.** No se inventó una regla acá — quién puede escalar es una decisión de la imagen (¿grupo `wheel`?, ¿qué usuario humano?) y **hoy las imágenes no tienen usuario humano**. Es el mismo hueco que el `/etc/shells` del §5.11 y es la unidad siguiente; declarar el bit ahora es lo que no depende de esa decisión. ## Dos comentarios que afirmaban algo falso, corregidos | receta | decía | realidad | |---|---|---| | `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | nadie lo re-aplicaba | | `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | no había hidratación que lo hiciera | | `recipes/shadow.toml:15` | «el modelo no puede ni expresar ni garantizar un setuid» | **la única que lo decía bien** | ## Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid El aviso de dueño saltó con `doas` y destapó otra cosa: **el store tiene artefactos de dos uid distintos.** Visto desde el anfitrión, `…-doas/usr/bin/doas` es del **uid 1000** (el usuario del worker, que llega con la cosecha) y `…-bash/bin/bash` es del **1001** (el hub). Dentro de la jaula el 1000 no está mapeado y se ve `nobody`. Medido sobre el rootfs de `base` recién hidratado: **5.984 ficheros de `sergio` y 1.159 de `nobody`**. Como `unshare -r` sólo mapea a 0 el uid que corre, **esos 1.159 salen a la imagen con dueño 65534** — y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su propia unidad de trabajo y queda anotada acá. ## Lo que queda, dicho para que no se lea como cubierto - **Los otros 25 binarios del inventario.** El mecanismo está; declararlos es una decisión de superficie de ataque, una línea por binario, y **no se tomó de paso**. El orden por lo que desbloquea: `unix_chkpwd` (sin él no hay login gráfico), `passwd`+`chage`+`chfn`+`chsh`+`gpasswd`, `mount`/`umount`, `fusermount3`, `ping`, `newuidmap`/`newgidmap`. - **El camino del `.swm`**: `Swm::from_recipe` lleva `deps`, `evidence` y `slots`, pero **no** `[[service]]`, `[[user]]` ni `[[setuid]]` ⇒ un `takana install` de un paquete no pone el bit. Es el mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo. - **`product-rootfs`**: el otro linaje (`takana bootstrap product`) no pasa por `hydrate-profile.py`, así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea. --- # Paso 3 — HECHO: el censo de busybox deja de pedir trabajo hecho (2026-09-22) El detalle está en `docs/plan-botar-busybox.md` §7. En corto, y todo con `rc` comprobado: - **`--auditar-destinos`** nuevo en `scripts/busybox-censo-proveedores.py`: el `destino` y el `estado_del_dueño` dejan de ser juicio. Regla mecánica: *si un paquete que viaja en la imagen provee el applet, ése es el destino*. **293 celdas estaban mal** (22 de `destino`, 271 de estado). `BORRAR` nunca se reescribe: es una decisión, y medir no la contradice. - **`kmod` (6) y el gestor de `/dev` (3) pasan a BORRAR**, medido en los `/boot/config-*` que publican los artefactos: 5 de los 6 kernels sellados traen MODULES off y **ningún artefacto del store publica `lib/modules`**. De paso, el vigía afirmaba «los cuatro kernels, cero `=m`» y son seis, con 13 módulos en el sexto — la conclusión aguanta, la afirmación no. - **`killall5`** prometía `psmisc`, que no lo trae: lo cubre `arje-zero` por función. A BORRAR. - **El vigía no veía las cadenas crudas de Rust**, o sea los guiones del producto: decía «nadie los invoca» con `insmod` retirado y `lib.rs:1388` invocándolo. Arreglado, con el mapa de líneas reales y una sexta regla anti-ruido para el `/toolchain/`. Probado con una rotura a propósito. **De 23 applets de trabajo real quedan 3**, y dos son el ADR de `getty`. El paso 3 del plan decía «23 → 11»; medido, es **23 → 3**. --- # Paso 4 — HECHO: las herramientas del ensamblado salen del store; el gestor de arranque es el único hueco (2026-09-22) El paso 4 estaba planteado como una disyuntiva: «o `grub` entra al corpus como receta, o la rama BIOS se retira». **Medir la cerró de un lado y abrió algo más grande del otro.** ## La rama BIOS no se puede retirar, y está medido en el repo > **Hetzner Cloud arranca por BIOS** — «medido en gioser: no existe `/sys/firmware/efi`; GPT + GRUB» > (SDD 28 §3.1). O sea que la imagen del **servidor de producción** es justamente la que necesita la rama BIOS. La disyuntiva se resuelve por el otro lado: el gestor de arranque tiene que entrar al corpus. ## Lo que apareció al mirar: el pipeline de imagen estaba roto en las TRES máquinas Los scripts tomaban sus herramientas del `PATH` del host. Medido en el hub, en su anfitrión (`raiz`) y en el worker `dev.gioser.net`: ``` grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE ``` **Ninguna imagen se podía armar en ninguna de las tres**, y sólo se veía al intentarlo, con un `falta cpio` que se lee como un descuido de la máquina. Y lo absurdo: **seis de las siete son nuestras y están selladas** — `util-linux` (sfdisk), `e2fsprogs` (mke2fs), `libarchive` (bsdcpio), `gzip`, `mtools`, `xorriso`, más `dosfstools`. Todas estáticas musl; **corren tal cual en el host**, comprobado ejecutándolas. ## Lo hecho **`scripts/lib/herramientas-desde-store.sh`** — resolvedor único: la herramienta sale del artefacto sellado **por hash vigente**, con el host como CAÍDA ruidosa (un binario del host no tiene hash ⇒ la imagen deja de ser reproducible por ahí, y eso se dice en el log) y abortando con las dos rutas nombradas si no está en ninguno. ⚠ **Por hash, no por fecha ni por el primer glob**: `iso-image.sh` resolvía xorriso con `ls store/*-xorriso/…|head -1` y `install-image-efi.sh` mtools con `ls -dt …|head -1`. Con dos artefactos del mismo paquete conviviendo —lo normal tras cualquier re-hash— el más reciente puede ser el viejo. Es la lección que `hydrate-profile.py` y `libs-compartidas-desde-store.sh` ya habían pagado. Y el store por defecto se **detecta**: en este hub el `./store` del clon está vacío y el de verdad es `/store`, así que un default mudo manda a diagnosticar al lugar equivocado. Cableados: `install-image.sh`, `install-image-efi.sh`, `iso-image.sh`, `metal-usb-sdboot.sh`. **`scripts/vigia-ensamblado.sh`** — el guardián: de dónde sale cada herramienta. ``` ✓ sfdisk util-linux · ✓ mke2fs e2fsprogs · ✓ bsdcpio libarchive · ✓ gzip · ✓ mformat/mcopy mtools ✓ xorriso · ✓ mkfs.vfat dosfstools ✗ grub (no hay receta) — grub-mkimage AUSENTE, grub-bios-setup AUSENTE == resumen: 8 sellada(s) · 0 del host (sin hash) · 2 ausente(s) ``` ## La prueba: una imagen EFI armada EN EL HUB, que antes era imposible ``` ROOTFS= KERNEL= STAGE=/store/.efi-stage \ sh scripts/install-image-efi.sh ⇒ rc=0 ==> imagen EFI creada: 297M en disco (sparse), 754M virtuales ``` Todas las herramientas del store (con su hash en el log), tabla GPT verificada con el `sfdisk` sellado (ESP 48M + root 512M + store 128M + estado 64M) y la ESP con las dos rutas del kernel más el initramfs, verificada por el propio script con `mdir`. ⚠ **Y el primer intento falló con EXDEV**, que es el otro arreglo de este paso: el staging **enlaza en duro** desde el rootfs y un enlace no cruza un montaje aunque sea el mismo disco ([[hardlink-cruza-mount-exdev]]). El fallo llegaba como una **pared de `cp: Invalid cross-device link`** —cientos de líneas— sin que ninguna dijera la causa. Ahora hay `sonda_hardlink`, FUNCIONAL (intenta un enlace de verdad, porque `store` y `work/out` son bind-mounts del mismo dispositivo y comparar `st_dev` no dispara), y dice qué montaje es cada uno: ``` ✗ EXDEV: no se puede ENLAZAR de '/store/…-product-rootfs' a '/work/sergio/work/.efi-stage'. origen está en: /store destino está en: /work/sergio Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs. ``` Es la misma sonda que `hydrate-profile.py` ya tenía; `install-image.sh` sólo tenía el comentario. ## Lo que queda, y es UNA receta - **El gestor de arranque BIOS.** Dos salidas, ninguna gratis: **`grub`** (lo que los scripts ya hablan) o **`limine`**, que cubre BIOS **y** UEFI de una y es mucho más chico. Es un build de granja, no un cambio de script, y por eso no entra en este paso. - **`metal-usb-sdboot.sh` arranca con un `systemd-bootx64.efi` sacado de un rootfs de Arch del home del usuario** — otro binario ajeno, sin hash y fuera del store, en el camino del metal. Anotado y no tocado: esa ruta se usa para bringup con menú, y cambiarla por EFI-stub (que ya existe) es su propia decisión. - Para cualquier objetivo **UEFI la cadena ya es soberana y no lleva gestor**: el bzImage *es* el binario EFI (ADR 0018), y ahora además se puede armar acá. --- # Paso 5 — HECHO: la hora la da `ntpd-rs`, en Rust (2026-09-22) `chrony` era la hoja C **más barata de cambiar** del corpus: 0 consumidores, declarada sólo en `servidor`. Tocarla no invalida a nadie, y lo que se gana es concreto: el demonio que **ajusta el reloj del sistema como root** deja de ser C. ## Sellado y verificado ``` recipes/ntpd-rs.toml v1.9.0 · commit 46ec9bb4d5b6cb24f814f5543d85b9138afb4cba (tag v1.9.0) b3:44e1e0079eb7d33ee37261db9cdc3e3b5432c7d08ca8ee0617a59b19c6eb4a84 ⇒ el hash del dry-run, idéntico al del sellado artefacto: 5,1 M · ntp-daemon y ntp-ctl ESTÁTICOS, 0 NEEDED · los dos contestan `--version` ntp-ctl validate contra el ejemplo que viaja ⇒ "Config looks good" ``` Se construyen **dos** de los tres binarios: el demonio y `ntp-ctl`, que es el equivalente de `chronyc` — sin él la caja no se puede inspeccionar, y eso sería una regresión frente a chrony. Como `cargo rustc --` exige UN target y `ntpd` produce tres, la fase `compile` itera, calcada de `recipes/cargo-edit.toml` (que existe por lo mismo): `+crt-static` no puede ir en `RUSTFLAGS` porque alcanza a los proc-macros y rustc aborta con «cannot produce proc-macro». ## Lo que NO se gana, medido antes de prometerlo: NTS sigue apagado `recipes/chrony.toml` apaga NTS porque necesita nettle o gnutls y **ninguna está en el corpus**. Acá el muro es el mismo un piso más arriba: el TLS de NTS lo hace `rustls`, y rustls necesita un *proveedor* de criptografía compilado — `aws-lc-rs` o `ring` — **y los dos son C** (`aws-lc-sys` arrastra cmake + asm; leído en el `Cargo.lock` del tag). Por eso el build va con `--no-default-features --features rustcrypto`: la criptografía del **protocolo** (AES-SIV, MD5) sí queda pura-Rust —`ntp-proto` la declara proveedor de primera clase y aborta con `compile_error!` si no hay ninguno— pero el NTS-KE queda sin TLS. ⇒ **NTP clásico, igual que chrony hoy.** Subir NTS pide un proveedor de rustls, no esta receta. ## El label cambia con el paquete `servicios` pasa de `"chronyd"` a `"ntpd-rs"`. Un label es el nombre del SERVICIO, no de la función: dejar `chronyd` apuntando a ntpd-rs haría que `arjectl status chronyd` mostrara algo que no es chrony. La card guarda el mismo contrato que la de chrony —sale **78** nombrando `/etc/ntpd-rs/ntp.toml`, con el ejemplo en `/usr/share/doc/ntpd-rs/ntp.toml.default`— porque a qué servidores NTP se pregunta es decisión del sitio. `chrony` se queda en el CATÁLOGO, que no es la imagen: su receta documenta media docena de lecciones sobre un `configure` que prueba el entorno, y borrarla sería tirar eso. ## Y el bloqueante que apareció: el `git` del corpus SÍ trae los helpers El primer intento de build murió en el `fetch`: ``` Cloning into bare repository '/work/repos/ntpd-rs.git'... git: 'remote-https' is not a git command. ``` **Y la causa no es la que este repo tenía escrita.** `CLAUDE.md` §2 dice que el git del corpus «no trae los helpers `remote-http`/`remote-https`». **Los trae** — están en su artefacto, medidos: ``` ls …-git/usr/libexec/git-core | grep -c remote-http ⇒ 2 git --exec-path ⇒ /usr/libexec/git-core (ruta COMPILADA) ls /usr/libexec/git-core | grep -c remote-http ⇒ 0 (el libexec de la IMAGEN, vacío) ``` git es un multiplexor que busca sus treinta ayudantes en un directorio **horneado al build**: la ruta FHS. Corrido desde el store, mira el `/usr/libexec/git-core` de la jaula, que no tiene ninguno. Lo único que faltaba era `GIT_EXEC_PATH`. Con eso, el MISMO binario clona por https **y empuja al espejo de GitHub** — comprobado con `ls-remote` y con `push --dry-run`, que llegó hasta el rechazo por non-fast-forward, o sea que habló TLS y autenticó. Arreglado donde se genera (`scripts/servidor/jaula-herramientas.sh`: `git` sale del bucle y lleva su `export GIT_EXEC_PATH="$b/usr/libexec/git-core"`) y en el envoltorio vivo `/work/sergio/bin/git`. ⇒ **El push doble desde la jaula deja de estar roto**, que era una molestia diaria documentada. --- # Paso 6 — HECHO: el ADR 0012, decidido e implementado (2026-09-22) Estaba PENDIENTE desde el 2026-07-22 con una nota que decía «no implementar sin resolver el dilema». El dilema se resolvió **midiendo las tres cosas que el propio ADR pedía**, y la primera resultó estar mal planteada — verlo es la mitad de la decisión. ## 1 · «¿Cuánto serializaría un lock por árbol?» — nada, y el ADR no lo había mirado El ADR descartaba a medias la opción A porque «el grafo es un diamante que converge ⇒ puede degenerar en serializar casi todo». Eso supone que un consumidor toca el árbol de su dep. **No lo toca:** ``` 1. store.has(h) → si está, vuelve sin tocar NINGÚN árbol 2. materialize_build_deps() → construye las deps (recursivo) y TERMINA 3. fetch → patches → vendor → compilar → sellar ← recién acá se toca el árbol PROPIO ``` - Un consumidor recibe el **artefacto sellado** de su dep como capa overlay, no su árbol ⇒ dos recetas distintas **nunca comparten su árbol propio** y no se serializan entre sí. - Los locks **no se anidan**: al tomar el lock del árbol propio, las deps ya están construidas y sus locks liberados ⇒ **no puede haber deadlock**, sin ordenar nada ni detectar ciclos. ## 2 · «¿Cuánto cuesta re-extraer y re-vendorear?» — 2,4 %, pero el disco duele Medido en el build real de `ntpd-rs` (el del paso 5), con las marcas de tiempo de su propio log: fetch **4,0 s** + `cargo vendor` **5,4 s** contra **6 min 25 s** de compilación. El tiempo no era el problema de la opción B; el disco sí: ese árbol pesa **707 MiB**, y B lo multiplica por build concurrente. ## 3 · «¿Espacio pico de la caché + copia?» — 12,4 GiB en 46 árboles Con los mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). La opción C duplica lo que esté en vuelo y **no ahorra el vendoreo** (que muta ⇒ va en la copia): compra los 4 s del extract al precio de duplicar el disco. ## La decisión: opción A, precisada Lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por RAII hasta el `seal` (`fetch::GuardaArbol`), más **re-chequeo de la tienda** al obtenerlo: el que esperó sale por cache hit en vez de reconstruir. El grano es el nombre —y no el árbol— porque el mirror git (`repos/.git`) también es estado compartido y su clave es sólo el nombre. ⚠ **El fd no se hereda**, y eso es el punto fino: Rust abre con `O_CLOEXEC`, así que ni `tar`, ni `git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que `CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio). ## Verificado, con control Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción: ``` A: fetch … → sealed B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s) → «caché: otro build lo selló mientras esperábamos el árbol» los dos rc=0 · UN artefacto ``` Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón de KDE del 22/07: ``` SIN lock: A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes B: OK CON lock: A: OK B: OK ``` Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de que el fichero de lock vive **fuera** de `sources/`, donde el propio `remove_dir_all` lo barrería. **Y no mueve ningún hash**: el artefacto de la prueba salió `b3:dd6b538af774…`, idéntico al que el grafo ya registraba. La batería del workspace pasa entera salvo un test de `swm` que ya estaba roto antes (de otro agente). ## Lo que habilita, y lo que NO decide `JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**. Ya no hay carrera. **Subir el paralelismo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G, y esta clase de máquina ya provocó un `global_oom`. Lo que cambia es que el límite pasa a ser RAM y disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el `flock` de `CLAUDE.md` §1 sigue haciendo falta por otra razón: serializa contra los scripts de la granja, que es control de RECURSOS. --- # Paso 7 — MEDIDO: `muon` es viable, pero hoy es NO-GO por dos defectos nombrados (2026-09-22) El paso 7 del plan pedía explícitamente **medir 10 antes de adoptar**, porque «muon no cubre el 100 % de meson». Se midió. El resultado no es el que la frase sugería: **el problema no es la cobertura del lenguaje meson — es una bandera del enlazador.** ## Cómo se midió - `muon 0.6.0` (tag, commit `1c825e41e2266be1bead999f7f65d9c763a7ca18`) construido acá: **una sola compilación C** de `src/amalgam.c` (6,7 s con el `cc` del lab) da el `muon-bootstrap`, y ése configura y compila el muon completo en **4,7 s / 156 objetivos**, usando su `samu` embebido. Dice **«meson compatibility version 1.7.99»**. Como receta sería trivial. - **Muestra SISTEMÁTICA, no elegida a dedo**: de las 107 recetas que declaran `meson`, ordenadas alfabéticamente, una cada 10,7 ⇒ 10 repartidas por todo el alfabeto. Salieron `accountsservice dbus-shared geocode-glib graphene iso-codes libgweather mesa pango-shared swaybg wl-clipboard` — incluida **`mesa`**, que es el proyecto meson más retorcido del corpus. - Fuente bajada de la URL/commit que pinea cada receta, y `muon setup` en el árbol. ## El hallazgo transversal: UNA bandera rompe TODAS las sondas del compilador Primera corrida: 9 de 10 fallan. Dos con un síntoma que no era de deps —dbus: «Could not find a 64-bit integer type»; graphene: «No aligned malloc function could be found»— y la causa estaba dos capas más abajo: ``` dbg executing cc … '-Wl,--fatal-warnings' dbg compiler stderr: 'error: unsupported linker arg: --fatal-warnings' c compiler: sizeof long long: -1 ``` muon añade `-Wl,--fatal-warnings` a las sondas que **compilan y ENLAZAN** (`src/functions/compiler.c:210`), y **zig lo rechaza de plano**. Entonces `cc.sizeof()` devuelve **-1** y `cc.has_function()` devuelve **false para todo** — y los proyectos que ramifican sobre eso abortan diciendo cualquier cosa menos la verdad. ⚠ **Y mi primer «control» no era un control**: puse `CC=$(command -v gcc || command -v cc)` y en esta jaula no hay gcc, así que `cc` resolvió al MISMO envoltorio de zig. Daba idéntico, y por un momento pareció que el defecto era de muon en cualquier compilador. Lo que lo aisló fue sacar muon del medio: ``` /work/sergio/bin/cc x.c -o x -Wl,--fatal-warnings ⇒ error: unsupported linker arg /work/sergio/bin/cc x.c -o x ⇒ compila y enlaza OK ``` **La causa raíz está en el propio muon, y su código lo sabe.** `src/script/runtime/toolchains.meson` detecta `zig ld` y lo trata como `lld`, que hereda de `ld` (GNU), con este TODO textual: «we need to support a way for the compiler to return a list of linkers to check, and then we can specify zig ld as its own linker type». Intenté justamente eso —registrar `zig-ld` con `fatal_warnings: []`— y **no toma efecto**: el camino real es «linker: lld (passed-through)», que no pasa por el `detect` del registro. Es trabajo aguas arriba, no un parche de una línea. **Lo que SÍ prueba que la bandera es todo el problema**: con un `cc` que la descarta, las mismas sondas dan los valores correctos (`sizeof(long long) = 8`, `aligned_alloc = true`). ⚠ **Y ese envoltorio NO es la solución**, aunque funcione: el lab **no entra en `hash_inputs`** ([[lab-fuera-de-hash-inputs]]), así que filtrar la bandera ahí haría que dos labs sellaran bytes distintos en la MISMA dirección del store. El arreglo tiene que estar donde el hash lo cubra. ## La muestra, con la bandera filtrada (o sea: lo que muon puede de verdad) | receta | resultado | clase | |---|---|---| | `dbus-shared` | **setup complete** | ✓ | | `iso-codes` | **setup complete** | ✓ | | `geocode-glib` | `program not found` (módulo gnome) | dep del entorno | | `libgweather` | módulo python `gi` ausente | dep del entorno | | `mesa` | pkg-config `vdpau` ausente | dep del entorno | | `pango-shared` | pkg-config `gobject-introspection` ausente | dep del entorno | | `swaybg` · `wl-clipboard` | pkg-config `wayland-client` ausente | dep del entorno | | `accountsservice` | su script de versión exige un clone git | artefacto de MI arnés | | **`graphene`** | **`conf.set10('HAVE_MEMALIGN', 1)` ⇒ «expected type bool, got int»** | **GAP de muon** | **Cobertura del lenguaje: 9 de 10 sin un solo hueco de sintaxis** — `mesa` incluida, que llega hasta resolver dependencias. El único gap real es de TIPOS, no de una función que falte: meson acepta un `int` en `configuration_data.set10()` y muon 0.6.0 exige `bool`. ## Veredicto: NO-GO hoy, y el gate son dos defectos, no una campaña 1. **`zig ld` tratado como GNU `ld`** ⇒ todas las sondas de enlace rotas. Es el TODO de muon; hay que hacerlo aguas arriba (o parchear el camino «passed-through», que es donde de verdad se decide). 2. **`conf.set10()` rechaza `int`.** Extrapolando la muestra, ~10 de las 107 tendrían algo de esta clase; cada uno es un arreglo chico aguas arriba o un parche por receta. Mientras 1 no esté, **el swap re-hashearía 107 recetas para obtener binarios configurados con sondas que mienten** — que es peor que no hacerlo. Y el premio tampoco es el que parecía: con el paso 1 hecho `python3` ya NO viaja en las imágenes, así que lo que compra el cambio es **la cadena de build**, no bytes de imagen. **No se commiteó `recipes/muon.toml` a propósito:** una receta sin sellar ensucia el grafo con un nodo `never` que el drenaje cuenta como deuda. Cuando el gate se cierre, la receta es de quince líneas y los comandos están medidos acá: `sh bootstrap.sh build-boot` → `build-boot/muon-bootstrap setup -Dlibcurl=disabled -Dlibarchive=disabled -Dlibpkgconf=disabled build` → `muon-bootstrap -C build samu`. --- # Paso 8 — MEDIDO Y SELLADO: `zlib-rs` funciona como drop-in, con tres reservas que cambian la forma de la campaña (2026-09-22) El paso 8 pedía «receta que publique el mismo SONAME, swap en UN consumidor, y comparar. Si ahí anda, la campaña es una decisión de calendario; si no, costó una receta». **Anda.** Y la comparación contra la `zlib` del corpus, con datos reales, dice cosas que no se podían suponer. ## Sellado ``` recipes/zlib-rs.toml v0.6.8 · commit 0254e07204884bfc902d6ebb68c8b703d0ea49cf (tag v0.6.8) b3:61fb18d1f2850c5c3152925d06c0bd19256caaaff97e1e3877f4f3bc72278d7e (el hash del dry-run) publica: usr/lib/{libz.a, libz.so.1, libz.so} · usr/include/{zlib.h, zconf.h} · pkgconfig/zlib.pc ``` Con los NOMBRES de zlib, para que un consumidor que hoy pide `-lz` y `#include ` no cambie una línea. Un consumidor compilado contra el **artefacto sellado** (header y lib suyos) corre y da los mismos bytes que el build de scratch — o sea que es determinista. ⚠ **El build necesitó un parche de dos líneas y medio**, y el porqué vale más que el parche: el crate que publica la ABI de C (`libz-rs-sys-cdylib`) está **excluido del workspace** por upstream, y compilarlo desde su subdirectorio no sirve acá porque el vendoreo del lab corre en la RAÍZ. Meterlo al workspace sin más tampoco: el crate **aborta a propósito** con `compile_error!("panic=\"abort\" is mandatory because unwinding to C is undefined behavior")`, porque el `[profile.release]` de un miembro se ignora. El parche sube el crate a `members`, pone `panic = "abort"` en el profile de la raíz **y actualiza `Cargo.lock`** (8 líneas), porque el vendoreo corre con `--locked`. ## Lo medido, con el corpus Silesia (15,7 MB) y el mismo binario contra las dos libs | nivel | zlib 1.3.1 | zlib-rs 0.6.8 | tamaño | tiempo | |---|---|---|---|---| | 1 | 7.050.606 B · 226,6 ms | 8.526.731 B · **95,1 ms** | **+20,9 %** | **2,4× más rápido** | | **6 (por defecto)** | 6.409.962 B · 760,4 ms | 6.457.822 B · **335,4 ms** | **+0,7 %** | **2,3× más rápido** | | 9 | 6.371.670 B · 1496,3 ms | **6.358.939 B** · **634,9 ms** | **−0,2 %** | **2,4× más rápido** | Round-trip correcto en los tres niveles. Al nivel por defecto cuesta **0,7 % de tamaño** y devuelve **2,3× de velocidad**; al 9 es más chica *y* más rápida; al 1 comprime bastante peor. ⚠ La primera corrida la hice con una entrada SINTÉTICA y daba +84 % al nivel 5. No era representativa: con datos reales el número es +0,7 %. Queda escrito porque el dato falso era mío y estuvo a punto de decidir el paso al revés. ## Las tres reservas, que son lo que cambia la forma de la campaña 1. **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Cualquier artefacto que EMBEBA datos comprimidos —páginas de manual gzipeadas, PNG, `.tar.gz`, un initramfs— **cambia de contenido**. No es no-determinismo (es determinista y distinto), pero la campaña de las 122 no es «reconstruir y obtener lo mismo»: **produce artefactos distintos**. 2. **`gzprintf`/`gzvprintf` no existen.** Medido comparando símbolos: de los 101 públicos de `libz.a` faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El resto que falta es interno de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`, `z_errmsg`). Un consumidor que llame `gzprintf` **no enlaza** ⇒ hay que mirarlo receta por receta. 3. **El consumidor necesita `-lunwind`.** Es Rust adentro de C: el `staticlib` arrastra el backtrace de std y sin el unwinder el enlace muere con `undefined symbol: _Unwind_Resume`. Está en el `zlib.pc` como `Libs.private`, pero un `-lz` a mano no alcanza. Es la perilla que el corpus ya conocía ([[lab-cargo-vendor-poliglota]]). Y un dato de tamaño para que nadie se sorprenda: el artefacto pasa de **537 K a 13 M** (`libz.a` de 422 K a 11 M), porque un `staticlib` de Rust trae su mundo. En el binario final sólo entra lo que se usa, pero el store lo paga. ## Veredicto **GO como variante, y la campaña es una decisión de calendario Y DE CONTENIDO** — que es más de lo que el plan suponía. La receta queda sellada en el catálogo con 0 consumidores y en ningún perfil: el siguiente paso natural es swapear UN consumidor real (`lld21` pide `libz.so.1`; `pigz` ejercita deflate a fondo) y correr su prueba, ahora que el artefacto existe y el enlace está caracterizado. ## Paso 8 bis — el swap en un consumidor REAL: `pigz` sobre `zlib-rs`, sellado y verificado La lib andando contra un programa de prueba propio no decide una campaña de 122 recetas. Lo que decide es si un consumidor **de verdad**, con su Makefile y su uso de la API, compila y corre **sin tocar su fuente**. `recipes/pigz-zlibrs.toml`: `b3:3a485edc…`, sellada. **pigz es el consumidor duro, y por eso se eligió**: no usa el `compress2` de manual sino los rincones de zlib — `inflateBack`, `inflateBackInit_`, `inflateBackEnd`, `deflatePending`, `deflatePrime`, `crc32_combine`, `deflateSetHeader`. Comprobado que zlib-rs exporta **los siete** antes de intentar el build. Y viaja en 6 perfiles. **Lo único que cambió respecto de `recipes/pigz.toml`:** la dep (`zlib-rs` en vez de `zlib`) y `LIBS="… -lz -lunwind"`. El `-lunwind` va en `LIBS` y NO en `LDFLAGS` porque el Makefile de pigz pone `$(LDFLAGS)` antes de los objetos y `$(LIBS)` al final (línea 15): en enlace estático el orden decide y el unwinder tiene que ir DESPUÉS de quien lo pide. Fuente, banderas y fases, idénticas — la comparación vale porque lo único distinto es la lib. ### Medido, con el corpus Silesia (15,7 MB) y un solo hilo para que el tiempo sea comparable | | `pigz` (zlib 1.3.1) | `pigz-zlibrs` | |---|---|---| | comprimir `-6` | 6.410.662 B · **0,726 s** | 6.455.201 B · **0,326 s** | | descomprimir (ejercita `inflateBack`) | 0,065 s | **0,059 s** | | tamaño del binario | 1.062.672 B | **3.971.304 B** | ⇒ **+0,69 % de tamaño comprimido, 2,23× más rápido comprimiendo**, y descompresión un pelo más rápida. El precio está en el binario: **+2,9 MB** (×3,7), que es el mundo de Rust que el `staticlib` arrastra. ### Interoperabilidad, en las tres direcciones que importan ``` canónico lee el .gz de zlib-rs ⇒ OK (sha256 del original recuperado) zlib-rs lee el .gz del canónico ⇒ OK el `gzip` del corpus lee los dos ⇒ OK ← tercera implementación, independiente ``` O sea: el formato es el mismo y el CRC cierra. Lo que difiere es **qué bytes elige el compresor**, que es la reserva nº 1 del paso 8 y sigue en pie. ### Lo que esto cambia en el veredicto El swap en un consumidor real **no costó ni una línea de su fuente**, sólo la bandera del unwinder. Con eso, de las tres reservas del paso 8 quedan dos: la salida no es byte-idéntica (decisión de contenido) y `gzprintf` no existe (hay que mirar receta por receta). El tamaño del binario se suma como tercera: para una imagen con muchos consumidores de zlib, +2,9 MB por binario no es gratis. ⚠ `scripts/yupana.py` gana el sufijo `-zlibrs` en `SUFIJOS_VARIANTE`: sin eso el invariante (c) marca `pigz`/`pigz-zlibrs` como «colisión-fuente ACCIONABLE», que es un falso positivo — y un guardián con falsos positivos se empieza a ignorar. Comprobado: `yupana duplicados` sigue en «ninguna».