# Plan — botar busybox, MEDIDO **Fecha:** 2026-09-21 · **Instrumento:** los 401 symlinks de applet del artefacto sellado `2a2b1280…-busybox` (contados en el store del worker), cruzados con el estado y la membresía de perfil de cada receta en `docs/state/build-state.json` y con `grep '[deps]' recipes/*.toml`. **Tabla applet-por-applet:** `docs/state/busybox-applets.tsv` (401 filas, generada, no escrita a mano). ## Por qué existe este documento La pregunta fue «¿qué hay que hacer para botar busybox?», con la sospecha de que faltaba escribir mucho userland en Rust. **El número dice lo contrario, y el hueco está en otro lado:** > De los 401 applets, **148 ya tienen dueño sellado Y en perfil** (el applet es redundante en la > imagen), **125 tienen dueño sellado que NO viaja en ninguna imagen** (trabajo hecho y no cobrado), > y de los **128 sin dueño, 105 son basura que se borra**. El trabajo de escritura real son > **23 applets** repartidos en 8 piezas. Es la lección de `foot` y del §D de `plan-barrido-perfiles-y-servicios.md` otra vez: `uutils`, `findutils`, `findutils-xargs`, `diffutils` y **`brush`** están todos `sealed` con `perfiles: []`. Se construyeron, se sellaron, y ninguna imagen los lleva. ## Son TRES frentes, no uno — y esto es lo que hace que «botar busybox» se lea como imposible Mezclarlos es el error que cuesta el trabajo. Tienen dificultad, dueño y riesgo distintos: | frente | qué es busybox ahí | dificultad | |---|---|---| | **A · Sandbox de build** | `[deps] build = [… "busybox" …]` en **24 recetas** — es el `sh`+coreutils que el sandbox del lab trae (`recipes/jq.toml:5-7`, `recipes/zlib-ng.toml:11`). Más el `/toolchain` mixto de Alpine con el swap de `docs/11-bootstrap.md:234-240` | media — no es runtime, no toca el producto | | **B · Runtime del producto** | los applets de `/bin` y `/sbin` de las imágenes. `USERLAND_COMPONENTS` (`crates/takana-bootstrap/src/lib.rs:406`) ya los ensombrece con los binarios Rust y retira el homónimo de los otros dirs (`lib.rs:676`) | **baja — la máquina ya está construida, falta munición** | | **C · Stage 1 / self-host / atestación** | `STAGE1_COMPONENTS` (`lib.rs:172`, el 4/4), `ATTEST_PATHS` como raíz-de-confianza (`lib.rs:465`), y el `exec` de las cards de `getty` y `sshd` (`lib.rs:429`, `:240`) | **alta — es el ADR 0008, y toca la evidencia de reproducibilidad `of_tree=9adefb82…`** | ⚠ **La membresía de perfil de busybox es del frente A, no del B.** `busybox` figura en `cli`, `servidor`, los cuatro escritorios y `metal-tigerlake`, pero **no está declarado como raíz en `docs/state/targets.toml`** (los únicos hits ahí son comentarios). Llega por la clausura, porque 24 recetas lo declaran build-dep: `abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng`. **Sacarlo de las imágenes empieza por ahí y no requiere escribir ni una línea de userland.** ## El reparto de los 401 | estado del dueño | n | qué significa | |---|---|---| | **sellada + en perfil** | 148 | el reemplazo ya viaja en la imagen: el applet es redundante hoy | | **sellada, sin perfil** | 125 | construido, sellado, y no viaja: falta declararlo | | **sin dueño** | 128 | de los cuales **105 son `BORRAR`** y **23 son trabajo real** | ### 148 · Ya cubiertos y en la imagen — el applet sobra `util-linux` 48 · `iproute2` 19 · `procps-ng` 12 · `shadow` 14 · `kbd` 12 · `xz` 6 · `iputils` 5 · `e2fsprogs` 5 · `bzip2` 3 · `cronie` 3 · `dhcpcd` 3 · `ncurses` 2 · `dosfstools` 2 · `chrony` 2 · `vim` 2 · `less` `lsof` `pciutils` `usbutils` `mandoc` `socat` `patch` `binutils` `tar` `wget` 1 c/u. ⚠ **24 de estos 148 NO son drop-in**, y están marcados en el TSV. El caso grande es la red: `iproute2` da `ip`/`ss`, **no** `ifconfig`/`route`/`arp`/`netstat` — eso es net-tools, que no está en el corpus. Igual con `addgroup`/`adduser` (shadow da `groupadd`/`useradd`), `udhcpc` (dhcpcd es otro cliente con otro contrato de hooks) y `nc` (socat tiene otra sintaxis). **Cada uno de esos 24 es una migración de llamadores, no un borrado.** Mirar quién los invoca en `scripts/` y en las cards antes. ### 125 · Sellados y sin viajar — la munición que ya está fabricada | dueño | n | qué cubre | |---|---|---| | `uutils` | 90 | todo el núcleo POSIX: `ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown …` | | `arje-zero` | 20 | `init linuxrc runlevel halt poweroff reboot run-init switch_root` + los 12 de runit (`runsv sv svc svlogd chpst envdir setuidgid softlimit start-stop-daemon …`) | | `hammerd` | 4 | `syslogd klogd logger logread` | | `brush` | 3 | **`sh ash hush`** | | `gzip` | 3 | `gzip gunzip zcat` | | `diffutils` | 2 | `diff cmp` | | `findutils` / `findutils-xargs` / `libarchive` | 3 | `find` · `xargs` · `cpio` | ⚠ **CORRECCIÓN (2026-09-21, al ejecutar el paso 1): los 24 de `arje-zero` y `hammerd` NO son gratis, y esta tabla decía mal.** Esas dos recetas reemplazan la **función**, no el **comando**: cada una instala UN binario (`-p arje-zero`, `-p hammerd`), así que no existe un `poweroff`, un `logger` ni un `setuidgid` que las respalde. «Cubierto» significaba «nadie necesita correr esto», y para 5 de los 24 era falso. Ver los indultos del paso 1. ### 128 · Sin dueño **105 se borran** (no se reemplazan, salen del `defconfig`): `dpkg dpkg-deb rpm rpm2cpio httpd ftpd ftpget ftpput tftp tftpd telnet telnetd inetd dnsd sendmail popmaildir lpd lpq lpr ubi* nand* nbd-client i2c* hdparm fdformat setserial powertop bootchartd fbsplash conspy ether-wake udhcpd udpsvd tcpsvd watchdog whois tree which unzip …` (lista completa en el TSV). **23 son el trabajo real:** | pieza | applets | qué es | |---|---|---| | **kmod** | `insmod rmmod lsmod modprobe depmod modinfo` (6) | no hay receta kmod en el corpus. En Rust: `finit_module` + parseo de `modules.dep`. Bien acotado | | **psmisc** | `killall killall5 pstree fuser` (4) | **`procps-ng` NO los trae.** Traer receta psmisc (C) o escribir | | **grep POSIX** | `grep egrep fgrep` (3) | `grep` GNU está sellado (sin perfil) como puente. El Rust nativo se escribe sobre `grep-matcher`/`grep-regex`/`grep-searcher` | | **gestor de /dev** | `mdev makedevs uevent` (3) | decisión: lo absorbe arje, o entra una receta eudev/mdevd | | **bc** | `bc dc` (2) | traer receta | | **getty** | `getty cttyhack` (2) | hoy la card de getty ejecuta `/bin/busybox` (`lib.rs:429`) | | **awk** | `awk` (1) | decisión abierta, ver abajo | | **sed** | `sed` (1) | `sed` GNU sellado y en los 7 perfiles; el Rust nativo está por escribir | | **dns** | `nslookup` (1) | traer receta bind-tools/doggo | ## Las tres clases de reemplazo — y cuándo forkear (nunca) La pregunta era si hay que forkear los que «existen pero no son exactos». **No, y hay tres clases distintas que conviene no mezclar:** **1 · Adopción exacta.** `uutils/coreutils`, `uutils/findutils`, `uutils/diffutils`: reimplementación madura, MIT, receta Cargo con commit pinneado (ADR 0006), multicall. El patrón está probado y documentado en la cabecera de `recipes/uutils.toml`. Lo que falta de esta clase es **`uutils/procps`** y **`uutils/util-linux`** — receta calcada, coste bajo. ⚠ **Verificar su estado upstream antes de prometer los 60 applets que cubrirían**: `util-linux` en Rust era embrionario. **2 · «Existe pero no es exacto» — y forkear es la trampa.** `ripgrep` no es `grep` POSIX, `sd` no es `sed`, `fd` no es `find` (por eso la receta buena es `uutils/findutils` y no `fd`), `procs` no es `ps`, `bottom` no es `top`, `helix` no es `vi`. Todas están selladas y en los 7 perfiles, y **ninguna sirve de reemplazo**: cambian las banderas. Forkear ripgrep para que hable POSIX es peor que escribir — heredás su arquitectura de recursión+ignore y peleás contra ella para siempre. Lo correcto es usarlas **como librerías**: `grep-matcher`/`grep-regex`/`grep-searcher` están publicadas justamente para eso, y un `grep` POSIX encima son unos cientos de líneas. **3 · `awk` es el caso feo, y hay que decidirlo, no resolverlo de paso.** `recipes/goawk.toml` existe y está sellada, pero **goawk es Go, no Rust** — no cumple la premisa de soberanía de cadena de la Etapa C. `frawk` es Rust pero es otro dialecto (JIT, semántica propia). `gawk` es C con extensiones GNU que los `configure` del corpus sí usan. Las tres opciones son legítimas y ninguna es gratis. ## El bloqueante, y la buena noticia **Sin `sh` POSIX no se bota busybox en ningún frente**: es el `exec` de las cards, el `/bin/sh` del producto y el shell del sandbox. Lo esperado era que fuera un proyecto de meses. **`recipes/brush.toml` (0.4.0, reubeno/brush, MIT, Rust, compat bash) está `sealed`.** Compila estático musl con zig cc y produce `/usr/bin/brush`. ⚠ **Y está sin validar como `sh`.** La receta es un import crudo de nixpkgs y lo dice en su primera línea: «PUNTO DE PARTIDA, no final». No está en ningún perfil, nadie lo ejecutó como `/bin/sh`, y el sandbox de build es el consumidor más duro que existe (`configure` de autotools). **La unidad de trabajo #1 es medirlo, no adoptarlo.** ## Orden de ataque Cada paso es una unidad de trabajo con su criterio de terminado. Los tres primeros **no tocan el frente C** y se pueden hacer hoy. 1. ~~**Recortar el `defconfig`.**~~ **HECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.** 2. **Declarar la munición que ya existe.** `uutils`, `findutils`, `findutils-xargs`, `diffutils`, `brush`, `gzip`, `libarchive` a los perfiles de `targets.toml`. Terminado cuando `build-state.py` los marca con perfil y la imagen los lleva. 3. ~~**Medir `brush` como `/bin/sh`.**~~ **HECHO, 2026-09-21 — veredicto abajo: no sirve todavía para el sandbox.** Queda pendiente medirlo como `/bin/sh` del PRODUCTO (cards + `product-boot-test.sh`), que es un consumidor distinto y mucho más blando. 4. **Las dos recetas de adopción baratas** (`uutils/procps`, `uutils/util-linux`), previa verificación de madurez upstream. Cubren ~60 applets de los 148 «ya cubiertos por C». 5. **`grep` y `sed` POSIX en Rust.** Es la escritura de mejor relación coste/beneficio y desbloquea el `/toolchain` del frente A. 6. **Migrar los 24 build-deps.** Cambiar `busybox` por el conjunto real en `[deps] build` de las 24 recetas. Terminado cuando `busybox` sale de los 7 perfiles. 7. **La cola larga:** kmod, psmisc, gestor de /dev, getty, bc, dns. Después del shell, no antes. 8. **ADR — `awk`**, y **ADR — el frente C** (sustituir busybox en `STAGE1_COMPONENTS` y `ATTEST_PATHS`). No antes de que 1–7 estén cerrados. ## La métrica, y el guardián que falta `docs/state/busybox-applets.tsv` convierte esto en un número que baja: **401 → 272 → … → 0**. Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con `build-state.json`. **Falta un guardián**, y sin él el paso 1 se deshace solo: un script que falle si alguna receta, script o card invoca un applet que ya salió del `defconfig`. Hoy nada lo detectaría — el fallo aparecería en boot, en otra máquina, semanas después. ## Lo que este documento NO midió (y hay que decirlo así) - **El reparto applet→destino es juicio, no medida.** Lo medido son los 401 symlinks, el estado `sealed`/perfiles de cada receta y las 24 recetas con `busybox` en `[deps] build`. La asignación de dueño la escribí yo y hay que revisarla receta a receta. - **No se verificó la equivalencia de banderas de ninguna pareja.** Los 24 marcados `NO-drop-in` son los que sé que difieren; el resto dice `sin-verificar` y eso es literal, no una forma de decir «va a andar». - **No se midió qué applets usa el producto de verdad.** El `defconfig` instala 401 porque nadie eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1. - **`uutils/procps` y `uutils/util-linux` no se verificaron upstream.** Los ~60 applets que les atribuyo son una hipótesis de trabajo. --- # Veredicto del paso 3 — `brush` como `/bin/sh`, MEDIDO **Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, todo bajo `flock -o work/.farm-build.lock` · **Contra qué:** busybox 1.36.1 `sh` (ash) como control, en la misma caja y la misma corrida. > **No sirve todavía como `/bin/sh` del SANDBOX DE BUILD.** Pasa el banco POSIX igual que busybox, > parsea todo el corpus, y aun así **0 de 3 recetas autotools construyen** (control: 3/3). Lo que > falla no es su parser: es que el `libtool` que su `configure` genera sale con un nivel de escapado > de menos y después no lo parsea ningún shell, ni él ni busybox. ## Lo que se construyó `takana build recipes/brush.toml` en el worker selló **`b3:715237c78edd…`** — **el mismo hash que `build-state.json` traía del otro hub.** Dos máquinas distintas, bytes idénticos: reproducibilidad confirmada de paso, que no era el objetivo de la prueba. Binario: ELF x86-64 **estático** (sin loader), `brush 0.4.0`, **7,25 MB** — contra **1,23 MB** del busybox que trae los 401 applets. Botar busybox por brush, hoy, **engorda** la imagen. ## Lo que PASA | prueba | brush | control busybox | |---|---|---| | **Banco POSIX** — 57 casos (`scripts/sh-banco-posix.sh`) | **56/57** | 56/57 | | **Las 1130 fases de shell del corpus** (`sh -n` sobre las fases de las 549 recetas que las tienen) | **0 rechazadas** | 0 rechazadas | | **`configure` de ffmpeg** — 8345 líneas, `sh -n` | rc=0 | rc=0 | | **Invocado como `sh`** (argv[0]) | ✅ | ✅ | | **El patrón de las cards de arje** (`sh -c '…; exec …'`) | ✅ | ✅ | La única divergencia del banco: **`shift` más allá de `$#` devuelve 2**, y POSIX/busybox/bash devuelven 1. Cosmético, pero un `set -e` que haga `shift` de más se comporta distinto. ⚠ **Y el banco NO es prueba de suficiencia** — esto es lo que hay que recordar del ejercicio: **brush lo pasa entero y aun así no construye una sola receta autotools.** Un banco de casos sintéticos mide lo que a uno se le ocurre preguntar; el consumidor real mide lo que hay. ## Lo que FALLA **Tres recetas autotools reales, con el único cambio de `/bin/sh` en una copia del lab:** ```sh cp -al /opt/takana/.dev-fs/alpine $L/alpine # copia con hardlinks, el lab compartido intacto cp …-brush/usr/bin/brush $L/alpine/bin/brush ln -sfn /bin/brush $L/alpine/bin/sh # ← lo ÚNICO que cambia; sed/grep/awk siguen siendo busybox flock -o work/.farm-build.lock env TAKANA_ROOTFS=$L/alpine \ ./target/release/takana --store build recipes/.toml ``` | receta | control (busybox sh) | brush | |---|---|---| | `flex` | ✅ sellada | ❌ `make[1]: Error 2` | | `pkgconf` | ✅ sellada | ❌ `./libtool: syntax error at line 368 col 30` | | `oniguruma` | ✅ sellada | ❌ `make[1]: Error 2` | ### La causa, hasta donde está medida **El `configure` corre entero y bien bajo brush.** Lo que sale mal es el `libtool` que ese `configure` GENERA. Regenerado el mismo fichero con cada shell y comparado, **25 líneas difieren, y todas en el mismo sentido: falta un nivel de escapado.** ``` control (busybox) brush línea 62 ECHO="printf %s\\n" ECHO="printf %s\n" línea 132 file_magic_cmd="\$MAGIC_CMD" file_magic_cmd="$MAGIC_CMD" línea 176 …'s/^.*[ ]\\([ABCDGIRSTW]… …'s/^.*[ ]\([ABCDGIRSTW]… ``` Ese fichero ya no lo parsea **nadie** — ni brush ni busybox lo aceptan (`rc=2` los dos, misma línea 368). Y el contraprueba cierra el caso: **el `libtool` del control lo parsea brush sin quejarse (`rc=0`)**. ⇒ El parser de brush está bien; lo que difiere es lo que su shell PRODUCE. ### El mecanismo, encontrado (2026-09-21, después de escribir lo de arriba) Primero se escribió acá que la causa no estaba determinada, tras descartar las tres sospechas obvias —`echo` con barras invertidas, sustitución de comando preservando `\\`, y el `func_quote_for_eval` de libtool aplicado a mano—, que dan **idéntico** en ambos shells. La causa apareció bajando al `config.status` que genera el `libtool`. **Es de una línea:** ```sh V=hola echo "a: [`echo \\$V`]" # POSIX: $V · brush: \hola echo "b: [`echo \\\\`]" # POSIX: \ · brush: \\ echo "c: [`echo \$V`]" # POSIX: hola · brush: $V echo "d: [$(echo \\$V)]" # POSIX: \hola · brush: \hola ✅ ``` **brush aplica a las comillas invertidas las reglas de `$( )`.** POSIX manda otra cosa: dentro de `` `…` `` la barra invertida conserva su significado literal **salvo** cuando precede a `$`, `` ` `` o `\`, donde se ELIMINA antes de parsear el texto del comando (POSIX.1-2024, 2.6.3). Que el caso **d** sí esté bien es lo que delata la causa. Por qué rompe libtool: el `config.status` generado trae este bucle, que decide si cada variable necesita escapado antes de escribirla en el `libtool`: ```sh for var in SHELL ECHO PATH_SEPARATOR SED GREP …; do case `eval \\$ECHO \\""\\$$var"\\"` in *[\\\`\"\$]*) eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;; *) eval "lt_$var=\\\"\$$var\\\"" ;; esac done ``` Bajo brush ese `case` evalúa a **cadena vacía**, así que **todas** las variables caen en la rama sin escapar. De ahí las 25 líneas y de ahí que el `configure` termine bien: no falla nada, sólo sale mal escrito. **Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394).** ## Y el otro dato, que no es un fallo pero decide **Latencia de arranque: 813 µs contra 91 µs de busybox — 8,9×** (300 invocaciones de `sh -c :`). Un `configure` de autotools levanta decenas de miles de shells: a ese factor, el sandbox entero se vuelve medible más lento. Es un argumento independiente del bug, y no se arregla arreglando el bug. ## Qué queda de esto 1. **brush NO entra al sandbox de build.** El frente A sigue con busybox hasta que esto se cierre. 2. **El frente del producto sigue abierto**: las cards, la getty y la shell interactiva no generan libtool. Ahí brush no tiene ninguna evidencia en contra — pero tampoco se probó, y el `product-boot-test.sh` es la prueba que falta. 3. **Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394)**, con el reproductor de una línea, la tabla de los cuatro casos y el fragmento de `config.status`. 4. **El paso 5 del plan (grep/sed POSIX en Rust) sube de prioridad**, porque no depende del shell. 5. **No se tocó el lab compartido**: copia con hardlinks y stores tirables. El worker siguió compilando su cola en paralelo, serializado por el `flock`. ## ¿Parchar, o buscar otra opción? **Primero lo que cierra la pregunta barata: adelantar el pin NO sirve.** Nuestra receta apunta a `96a26d0c` (tag `brush-shell-v0.4.0`, mayo 2026), que sigue siendo el último release. Se construyó **`main` de hoy** (`737dd57e`, 2026-09-21 — cuatro meses y medio de trabajo encima) y **reproduce idéntico**, los cuatro casos. No hay nada que esperar de la rama. Lo que queda, en orden de coste: | opción | qué cuesta | qué deja | |---|---|---| | **Esperar a #1394** | cero | el upstream es activísimo (issue #1393 de anteayer) y el bug es acotado, pero no es nuestro calendario | | **Parchar brush en la receta** | un `.patch` en el árbol, como ya hacen 13 recetas | ⚠ es un parche **al parser de un shell**, que es el componente más cargado de todos. Y el bug apareció en el PRIMER build real: no es prueba de que sea el único | | **dash** | receta nueva, C, ~200 KB. Es el `sh` de referencia POSIX | resuelve el sandbox YA — y no avanza nada la soberanía en Rust | | **bash como `/bin/sh` del producto** | cero: ya está sellada Y en los 7 perfiles | tapa el frente B sin escribir nada. No sirve para el sandbox sin medirlo antes | | **Dejar busybox donde está** | cero | es lo que dice el plan: el shell NO es por donde se empieza | **La recomendación es la última, y no por conservadurismo.** El plan ya ordenaba los pasos 1, 2 y 5 —recortar el defconfig, declarar lo sellado, escribir `grep`/`sed` POSIX— **sin depender del shell**, y valen 129 applets y dos piezas de userland. Gastar el turno en pelear el shell es empezar por el único frente que está bloqueado por terceros. Lo que sí conviene hacer ya, porque es gratis: **suscribirse a #1394** y **medir brush como `/bin/sh` del PRODUCTO** (`product-boot-test.sh` + las cards), que es un consumidor que no genera libtool y donde brush no tiene ninguna evidencia en contra. --- # Paso 1 — el recorte, HECHO y MEDIDO **Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, bajo `flock -o`, contra un store tirable y con un directorio de recetas por hardlinks — el `recipes/` del worker no se tocó. > **401 → 277 applets.** 124 retirados, 5 indultados, binario de **1.230.976 → 977.096 bytes** > (−20,6 %). Nuevo hash `b3:a24fdbf6…` (el vigente era `2a2b1280…`). Y **3 de las 24 recetas que > declaran busybox build-dep se reconstruyeron y sellaron con el recortado**: el sandbox sigue en pie. ## Lo que el ejercicio corrigió del plan **Mi tabla decía que los 24 applets de `arje-zero`/`hammerd` eran gratis. Es falso para 5 de ellos.** Esas recetas instalan **un binario cada una** (`-p arje-zero`, `-p hammerd`): reemplazan la FUNCIÓN, no el COMANDO. No hay un `poweroff` ni un `setuidgid` detrás. Los cinco indultos, con su razón: | indultado | por qué se queda | |---|---| | `setuidgid` | la card de **gitea** hace `setuidgid gitea` (`recipes/gitea.toml:89`, «applet de busybox, comprobado presente»). Sin él el servicio arranca, muere y reintenta para siempre | | `switch_root` | el `/init` del initramfs del instalador lo ejecuta (`scripts/takana-live-install.sh:375`), **antes** de que arje exista | | `poweroff` · `halt` · `reboot` | **nada más en el corpus apaga la máquina.** arje-zero es PID 1, no una CLI. Quitarlos deja un producto que no se puede apagar desde la shell | Los tres primeros los encontró el vigía; los tres últimos, preguntarse qué instala arje-zero de verdad en vez de creerle a mi propia tabla. ## El vigía — y por qué un vigía ingenuo no sirve `scripts/busybox-vigia.py` falla si alguna receta, script o card invoca un applet retirado. **Lee la lista de la propia receta** (`RETIRADOS='…'`) cruzada con la columna `simbolo_kconfig` del TSV, así que no hay dos listas que se desincronicen. Su primera versión daba **91 hits y ninguno real**. Las cuatro reglas que lo bajaron a 0, todas medidas contra falsos positivos concretos: | regla | qué mataba | |---|---| | quitar comentarios antes de buscar | `# busybox provee sendmail, no mail` (logrotate), `/// los dos fsync no son prolijidad` | | en Rust/Python, mirar sólo cadenas **que parezcan una línea de comandos** | `"init"` como nombre de capacidad, `tree` como variable, `rx = re.compile(…)` | | ignorar nombres que el propio script define como función | `$(ts)` en los seis scripts de `scripts/farm/` | | ignorar patrones de `case` y `command -v X` | `proc\|sys\|dev\|init) continue ;;` · `command -v dpkg` en el censo de la mudanza | Y `[` / `[[` no se vigilan: son palabras del shell, y todo script bash del hub los usa sin que eso sea una llamada a un applet. ⚠ **El vigía mira ESTE repo.** No ve los `configure` de terceros que corren dentro del sandbox, que son quienes podrían llamar a `which` o `unzip`. Por eso la verificación de abajo no es opcional. ## El mapa applet → símbolo NO se deriva por mayúsculas Sale de las líneas `//applet:` del fuente de busybox 1.36.1 y vive en la columna `simbolo_kconfig` del TSV. Poner el nombre en mayúsculas **falla en 6 casos medidos**: `[`→`TEST1`, `[[`→`TEST2`, `arch`→`BB_ARCH`, `nbd-client`→`NBDCLIENT`, `sh`→`SH_IS_ASH`, `sysctl`→`BB_SYSCTL`. Dos de esos seis estaban en la lista a retirar. ## Verificación | qué | resultado | |---|---| | applets tras el recorte | **277** (401 − 124), exactamente los previstos | | los 5 indultados presentes | ✅ y también `sh` y `getty` | | los que debían irse | ✅ `httpd telnetd dpkg rpm init syslogd runsv tree which unzip` fuera | | **el sandbox sigue construyendo** | ✅ `zlib-ng`, `jq` y `logrotate` reconstruidas y selladas con el busybox recortado | ⚠ **Son 3 de las 24, no las 24.** Las otras 21 —entre ellas `linux`, `llama-cpp`, `protobuf`, `qdrant`— no se probaron: son horas de cómputo en una caja que está con la campaña de KDE. Si alguna usa un applet retirado, se verá en su próximo build y el vigía **no** lo habría anticipado, porque el llamador estaría en un `configure` de terceros. ## El coste, dicho antes de que sorprenda **Cambiar esta lista re-hashea busybox, y con él las 24 recetas que lo declaran build-dep**: `abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng`. **`linux` está en la lista**: el kernel se reconstruye. La granja lo hará en su propio ciclo; no se disparó a mano. ## El arranque — PROBADO en QEMU, contra una línea base **2026-09-21.** Se ensamblaron **dos** Stage 1 completos y se arrancaron los dos en QEMU, para que cualquier diferencia se pueda atribuir al recorte y no al azar: | | rootfs sellado | applets en `/bin /sbin /usr/bin /usr/sbin` | qemu | |---|---|---|---| | **base** (busybox sin recortar) | `b3:1a3823b4…` | 400 | `rc=0` | | **recortado** | `b3:3aa8cd2d…` | **278** | `rc=0` | Los dos arrancan, los dos dan shell y los dos se apagan limpio. **Diff de las dos consolas: 35 líneas, y ninguna es funcional** — frecuencia del TSC, direcciones del RAMDISK, BogoMIPS, hora del RTC. La única diferencia real es la que se buscaba: ``` ~ # ls /bin /sbin /usr/bin /usr/sbin | wc -l 410 ← base 288 ← recortado ``` Criterios del runbook `stage1-vm-boot.md` §6, verificados en el recortado: - **PID 1** — `arje-zero` incarna y corre. - **Seed** — la card se carga: la prueba es que la `console-getty` llegó a incarnar. - **getty → shell** — `/bin/busybox getty -n -l /bin/sh 115200 console` da prompt `~ #`, y `echo MARCA-SHELL-VIVA` responde. **`getty` y `sh` sobrevivieron al recorte, que era el riesgo.** - **`poweroff -f`** — `ACPI: PM: Preparing to enter system sleep state S5` / `reboot: Power down`. Uno de los cinco indultados, probado **en vivo**: sin él la caja no se apaga. ⚠ **Cómo se corrió, porque condiciona el resultado:** en `dev.gioser.net`, con **QEMU en TCG (emulación pura)** — el LXC no expone `/dev/kvm`, aunque el CPU sí trae los flags. Por eso el guion se le manda por consola serie con esperas generosas. Hubo que instalar `qemu-system-x86-core` (dnf) e ingerir de nuevo la semilla de Stage 0, que no estaba en el store del worker; sí salió con el mismo hash que el manifiesto ya anotaba (`3ce721ec…`). ⚠ **Es Stage 1, no el producto.** Se eligió porque es donde vive busybox (`musl busybox hammerd arje-zero`) y porque necesitaba 2 artefactos en vez de 8. `product-boot-test.sh` —que además levanta sshd y netup— sigue sin correrse: le faltan `netup openssh uutils findutils-xargs diffutils ripgrep` en el store del worker. ## Lo que queda del paso 1 - **Las otras 21 dependientes**, cuando la granja llegue. - El siguiente corte natural son los `BORRAR` que no se tocaron por prudencia y los 272 que siguen: el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (`grep`/`sed` POSIX). --- # El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21 **Instrumento:** `scripts/sh-banco-diferencial.py` · **casos de regresión:** `scripts/fixtures/sh-casos/` · **dónde:** el hub, contra los tres shells sellados que ya están en `/store` (`2a2b1280…-busybox`, `715237c7…-brush`, `87bf1a59…-bash`). No tocó la granja ni el lock. ## Por qué, si ya existía `sh-banco-posix.sh` El veredicto del paso 3 dejó escrita la lección y no el instrumento: > **brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.** Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial pregunta otra cosa, y no hace falta saber la respuesta de antemano: > **¿este shell hace LO MISMO que el control, sobre el material que hay?** Tres decisiones de diseño, y las tres se ganaron con el bug de brush: 1. **Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout.** El `libtool` mal escapado salía con `rc=0` y stdout idéntico: **ningún banco de stdout puede verlo**. Cada caso corre en un directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que escribió. El caso `10-escribe-arbol.sh` diverge **sólo en el árbol** — es la demostración de que esta columna hace falta. 2. **El esperado es el control, no una aserción.** Un caso no declara resultado: declara material. Eso permite tirarle al banco las 1130 fases del corpus y 26 `configure`/`libtool` reales sin escribir una línea de expectativa. 3. **Toda divergencia se CONFIRMA** volviendo a correr control y candidato. Un caso que imprime un PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía `$$`); un shell que diverge **de sí mismo** es un hallazgo distinto y peor. Sin esto el banco se llena de ruido y deja de leerse, que es como muere un banco. Además: todos los shells se invocan por un symlink `sh` (argv[0] igual para todos, que es como se despliegan), con el **mismo userland busybox en el `PATH`** —las divergencias tienen que venir del shell, no de un `sed` distinto— y con `PATH` apuntando primero al shell bajo prueba, así un `sh` recursivo cae en ÉL y no en el del sistema. ## Lo medido — 1273 casos, 5,5 s ``` scripts/sh-banco-diferencial.py --gen --phases \ --files '/work/sergio/work/sources/*/configure' \ --files '/work/sergio/work/sources/*/libtool' \ control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash ``` | fuente | casos | modo | brush | bash | |---|---|---|---|---| | `cases` — regresiones conocidas | 10 | ejecutado | **4 divergen** | 0 | | `gen` — tortura de comillas | 107 | ejecutado | **31 divergen** | 8 | | `phases` — las fases de las 549 recetas | 1130 | `sh -n` | **0** | 0 | | `files` — `configure` y `libtool` reales | 26 | `sh -n` | **0** | 0 | **Los 1156 casos de parseo no divergen en ningún shell.** Confirma lo que el paso 3 ya decía —el parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol comparado. **Sirve sobre todo para saber dónde NO buscar.** ## Lo que el generador encontró SOLO El generador barre el producto cartesiano `forma de sustitución × contexto × profundidad de barras invertidas × carácter objetivo` (107 casos). **No sabe que el bug #1394 existe, y lo escupe entero:** ``` ✗ [brush] tortura/bt-desnudo-b1-var control: [hola] brush: [$V] ✗ [brush] tortura/bt-desnudo-b2-var control: [$V] brush: [\hola] ✗ [brush] tortura/bt-desnudo-b2-literal control: [x] brush: [\x] ✗ [brush] libtool/case-de-config-status ← el bucle REAL de config.status, divergente ``` Ése es el punto del ejercicio: **el bug de brush apareció en el primer build real, después de sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco segundos, sin saber qué buscaba.** ## Un hallazgo NUEVO, y no es del frente A El fixture `08-trap-exit-y-estado.sh` destapó algo que no estaba en el veredicto del paso 3: ```sh ( trap "echo trap-sub" EXIT; true ); echo despues # busybox → trap-sub / despues bash → trap-sub / despues brush → despues ``` **brush no ejecuta NUNCA el `trap … EXIT` de un subshell**, ni con `exit` explícito. Es independiente de #1394 y, a diferencia de aquél, **golpea el frente B**: un script de limpieza que confía en su trap no limpia, en silencio, con `rc=0`. La frase del veredicto —«en el producto brush no tiene ninguna evidencia en contra»— ya no se sostiene: ahora la tiene. ⚠ **Y upstream YA LO SABÍA — pero no como issue, sino dentro de su propia suite de compatibilidad**, con el caso casi calcado al fixture y marcado `known_failure`: ```yaml # brush-shell/tests/cases/compat/builtins/trap.yaml:226 - name: "subshell can set its own EXIT trap" known_failure: true # TODO(traps): EXIT trap in subshell ``` Y no era uno sino tres TODOs para el mismo síntoma: `:226` subshell `( )`, `:269` sustitución de comandos, `:323` sustitución de procesos. **Lección para el banco: un `known_failure` en la suite ajena es evidencia de primera y cuesta un `gh api` mirarla — antes de escribir «hallazgo nuevo», buscar en las PRUEBAS del candidato, no sólo en sus issues.** Lo nuestro seguía aportando: que las tres variantes son probablemente un mismo bug (`on_exit()` sólo lo llaman los puntos de entrada de nivel superior; el subshell de `interp.rs:662-683` clona el shell y devuelve el exit code sin pasar por ahí), que también pega en pipeline y en segundo plano, y que es **silencioso**. **Reportado: [reubeno/brush#1396](https://github.com/reubeno/brush/issues/1396).** Y se confirmó la tercera, ya conocida: `shift` más allá de `$#` devuelve 2 (POSIX/busybox/bash: 1). ## Las 8 de bash, que enseñan a leer el banco bash diverge en 8 casos del generador, **todos de la misma familia**: comilla o backtick sin cerrar dentro de `` `…` ``, donde busybox da `rc=2` y bash acepta. No es que bash esté mal — es que **el control es una referencia, no la verdad**. Cuando el candidato diverge, hay que ir a POSIX; el banco dice dónde mirar, no quién tiene razón. ## Latencia, de paso 300 invocaciones de `sh -c :` por shell, en el hub: | shell | µs/invocación | binario | |---|---|---| | busybox | 464 | 1,17 MB | | bash | 864 | 1,57 MB | | brush | 1724 | 6,92 MB | **brush/busybox = 3,7×** acá, contra el 8,9× medido en el worker: esta cifra incluye el `fork`+`exec` de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la misma y el argumento del veredicto no cambia. ## Lo que este banco NO mide (y hay que decirlo así) - **No EJECUTA las fases del corpus** — sólo las parsea. Ejecutarlas necesita el sandbox y sus fuentes, que es la extensión natural y la que cerraría el caso: correr un `configure` real bajo el candidato y **comparar el árbol generado** contra el del control. La maquinaria de comparación ya está; falta engancharla al sandbox. - **El generador sólo tortura comillas.** Faltan familias enteras: expansión de parámetros, `IFS` y división en campos, aritmética, here-docs, señales y control de trabajos. - **Nada de interactivo** — ni edición de línea, ni historial, ni `job control`. - **El árbol no mira permisos finos ni mtimes**, sólo ruta + bit de ejecución + contenido. - **El banco no prueba suficiencia.** Es la misma advertencia de siempre, y ahora con dos instrumentos que la ilustran en vez de uno. --- # La superficie REAL de las cards, MEDIDA — 2026-09-21 **Instrumento:** `scripts/sh-superficie-cards.py` (regenera el número; la tabla no se escribe a mano) · **salida:** `work/sh-superficie-cards.tsv`. La pregunta: **si mañana hubiera un `/bin/sh` propio, ¿qué tendría que saber hacer para que el PRODUCTO arranque?** No qué dice POSIX ni qué usa autotools — qué usan las cards que arje ejecuta. ## Las cuatro poblaciones | población | dónde | cards | con shell | |---|---|---|---| | servicios de paquete | `recipes/*.toml` · `[[service]]` (emisor: `Service::card`) | 29 | **21** | | empotradas | `takana-bootstrap` · `STAGE1_SEED_CARD` + `SSHD_SERVICE_CARD` | 3 | **1** | | servidor de producción | `scripts/servidor/cards/*.json` | 1 | **1** | | la mudanza | `scripts/mudanza/formatos/arje.py:35` | plantilla | n/a | > **23 de 33 cards (69 %) ejecutan un fragmento de shell; 10 son `exec` directo.** La mudanza no se cuenta por card porque su contenido sale del censo de la máquina que se muda. Su superficie es la de la plantilla, y la plantilla entera es: **`cd VALOR && exec VALOR`**. ⚠ **Hallazgo de paso, y hacía falta para medir bien: en una Card, `argv` va SIN `argv[0]`** — arje lo pone desde `exec`. Lo dice la card de `hammerd`, cuyo `argv` es `["--store","/store",…]` a secas. Por eso conviven dos idiomas que parecen incompatibles y no lo son: `exec=/bin/busybox argv=["sh","-c",…]` (recetas: despacho multicall) y `exec=/bin/sh argv=["-c",…]` (la mudanza). Leer el `argv` sin anteponer el `exec` hace que uno de los dos desaparezca de la cuenta. ## Lo que las cards USAN ``` secuencia ; 23 ███████████████████████ y/o && || 21 █████████████████████ redirección > 18 ██████████████████ grupo { } 18 ██████████████████ variable $x 4 ████ bucle while 2 ██ aritmética $(( )) 2 ██ tubería | 2 ██ sustitución $( ) 2 ██ case 1 █ bucle for 1 █ redirección 2> 1 █ glob * 1 █ ``` **Y lo que NO aparece NI UNA VEZ:** `if`, funciones, `until`, `${…}` (ninguna expansión de parámetros: ni `:-`, ni `#`, ni `%`), posicionales `$1`/`$@`, `$?`, subshells `( )`, here-docs, `!`, segundo plano `&`, y **sustitución con comillas invertidas** — la construcción que tumbó a brush en el sandbox **no la usa ninguna card**. El idioma es uno solo y se repite 18 veces: ```sh test -f /etc/x/x.conf || { echo 'x: falta … — es config del SITIO' >&2; exit 78; }; exec /usr/bin/x ``` **Builtins que el shell tiene que traer: 8.** `exit`×44 `echo`×31 `exec`×22 `test`×20 `cd`×8 `[`×4 `command`×3 `continue`×1. **Órdenes externas que la imagen tiene que traer: 14.** `mkdir`×14 `grep`×11 `chown`×4 `id`×4 `netup`×2 `ssh-keygen`×2 `chmod`×2 `mount`×2 `nft` `hostname` `basename` `blkid` `df` `awk`. ## El cruce con el recorte — y una CORRECCIÓN ``` siguen en busybox: 11 · no son applets (otro paquete): 3 (netup, nft, ssh-keygen) · RETIRADAS: 0 ✓ ninguna card invoca un applet retirado por el recorte 401 → 277. ``` ⚠ **Acá se escribió que esto «es el guardián que el plan pedía y nadie tenía». Es falso: el guardián EXISTE** — `scripts/busybox-vigia.py`, escrito con el paso 1, que lee los retirados de `recipes/busybox.toml`, los cruza con la columna `simbolo_kconfig` del TSV y **falla si alguna receta, script o card invoca uno**. Corrido hoy: `123 applets retirados de la receta · OK — nadie los invoca`, `rc=0`. La sección «Falta un guardián» de más arriba quedó vieja el mismo día. Lo que el cruce de este instrumento agrega es otra cosa, y conviene no confundirlas: el vigía contesta «¿alguien invoca algo que ya no está?» (una puerta, con `rc≠0`); esto contesta **«¿qué necesitan las cards para arrancar?»** — inventario, no puerta, y por eso incluye las que no son applets (`netup`, `nft`, `ssh-keygen`), que al vigía no le importan y a un perfil de imagen sí. ## Y los 23 fragmentos, por el banco diferencial ``` scripts/sh-superficie-cards.py --dump-dir /tmp/frags scripts/sh-banco-diferencial.py --no-cases --files '/tmp/frags/*.sh' control=… brush=… bash=… brush DIVERGE=0 bash DIVERGE=0 ``` Las 23 parsean idéntico en los tres shells. Es `sh -n`, no ejecución —montan discos y levantan daemons—, así que dice que el lenguaje alcanza, no que el comportamiento coincida. ## Dos bugs en una card, encontrados por el camino — y ARREGLADOS `scripts/servidor/cards/montar-trabajo.json` venía sin comillas en tres sitios (dos bugs, uno de ellos por duplicado), y los dos fallan en silencio. **Comprobados, no deducidos** — y **no fue un accidente de edición posterior: la card nació así en su único commit** (`3b305642`), o sea que las comillas se perdieron antes de entrar al árbol. **Bug 1 — el `awk` sin entrecomillar. Falla SIEMPRE:** ```sh df -h / | awk NR==2{print } # awk: cmd. line:1: NR==2{print ← error de sintaxis, rc=1 df -h / | awk 'NR==2{print $4}' # 13G ← lo que se quiso escribir ``` El `echo` final imprimía «`montar-trabajo: /vvv libres`», sin el número, en cada arranque. **Bug 2 — el patrón de `grep` sin entrecomillar, en las dos comprobaciones de montaje.** Es LATENTE: ```sh grep -q /vvv /proc/mounts # matchea cualquier línea que CONTENGA /vvv (p.ej. /vvv/algo) grep -q " /vvv " /proc/mounts # con comillas: sólo el punto de montaje /vvv ``` Para que muerda hace falta otra línea de `/proc/mounts` que contenga la subcadena —`/vvv/algo` montado aparte, o un `/mnt/vvvv`—; con `/vvv` montado de verdad o ausente, acertaba. Igual en `grep -q /work/sergio`, donde un `/work/sergio2` bastaría. **Arreglado, y probado con la maquinaria de siempre:** el fragmento se corrió entero con `/proc/mounts` sustituido por un fichero de prueba y con `mkdir`/`chmod`/`chown`/`mount`/`blkid` stubbeados, así que nada mutó la máquina. Los dos escenarios, antes y después: | escenario | antes | después | |---|---|---| | `/proc/mounts` sólo trae `/vvv/sergio` (el falso positivo) | **no montaba `/vvv`** y el `echo` salía sin número | `mount /dev/…-trabajo /vvv` + `170G libres` | | `/vvv` montado de verdad | (acertaba) | no vuelve a montar, `170G libres` | El fragmento nuevo parsea en busybox, bash y brush. **Bug 3 — las variables desnudas, y ésta se comía un usuario entero.** Arreglado en la misma tanda junto con los dos `echo` de error, que iban sin comillas (inocuo hoy: ningún glob en el texto, y se comprobó que el mensaje y el `rc=78` salen idénticos antes y después). El que sí tenía diente era el bucle de `/home`: ```sh # antes for h in /home/*; do u=$(basename $h); id -u $u >/dev/null 2>&1 || continue mkdir -p /run/user/$(id -u $u); chown $u /run/user/$(id -u $u); … # después for h in /home/*; do u=$(basename "$h"); uid=$(id -u "$u" 2>/dev/null) || continue mkdir -p "/run/user/$uid"; chown "$u" "/run/user/$uid"; … ``` Con un directorio llamado `juan perez` en `/home`, el `$u` desnudo se partía en dos campos y `id -u juan perez` fallaba ⇒ **el `continue` saltaba a ese usuario en silencio y se quedaba sin su `/run/user/`**. Medido con el mismo banco de stubs, sobre un `/home` de prueba con `juan perez`, `root` y `nadie`: ``` viejo usuarios que reciben chown: root nuevo usuarios que reciben chown: juan perez|root ``` `nadie` no recibe en ninguno de los dos, que es lo correcto: no está en `passwd`. De paso, `id -u` se llamaba **cuatro veces por usuario** (una para el test y tres para las órdenes); ahora una sola, izada a `uid`. Eso no era el bug pero era la forma limpia de entrecomillarlo, y se ve en la propia medición: las órdenes externas pasaron de `id×4` a `id×1`. ## Qué dice esto sobre el `sh` propio El consumidor del PRODUCTO es minúsculo y está acotado: **8 builtins, 13 construcciones, ninguna expansión de parámetros, ningún `if`, ninguna función**. Eso no es «escribir un shell»: eso es un intérprete de listas `&&`/`||` con `test`, `echo`, `exec` y redirección — semanas, no meses. **El sandbox de build sigue siendo otro planeta**, y es el que conviene no mezclar. ## Lo que esta medición NO dice - **Detección por patrón sobre un escáner que respeta comillas, no un parseo.** La primera versión contaba 11 backticks que eran prosa dentro de mensajes de error (`` `minga init` ``) y una docena de «órdenes externas» que eran palabras sueltas de esa prosa. Está arreglado —y el cuerpo de cada sustitución se analiza como shell aparte, que es como aparecieron el `df` y el `awk`— pero sigue siendo aproximación: `command -v journalctl` cuenta `command`, no `journalctl`. - **No mide la shell INTERACTIVA de `getty`**, que es otro consumidor y no ejecuta fragmentos. - **No mide `scripts/`**, que es su propia unidad de trabajo y es mucho más grande. - **No mide las cards REALES de la mudanza**: dependen de un censo que no está en el árbol. - **No ejecuta los fragmentos**, sólo los parsea. --- # La superficie de `scripts/`, MEDIDA — 2026-09-21 **Instrumento:** `scripts/sh-superficie-scripts.py` · **escáner compartido:** `scripts/lib/sh_analisis.py` · **salida:** `work/sh-superficie-scripts.tsv`. ⚠ **Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error.** Las cards las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio hoy. `scripts/` lo ejecutan el hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento. Lo que sí importa hoy acá es otra cosa: > **¿algún guion se declara POSIX y no lo es?** Porque ése se rompe HOY: en la imagen `/bin/sh` es > el ash de busybox, no bash. ## Las cuatro poblaciones, y quién las parsea De 118 ficheros descartados por no ser shell (`.html`, `.c`, `.mp4`, `.toml`…): | población | n | líneas | parsea ash | parsea bash | |---|---|---|---|---| | **POSIX** (`#!/bin/sh`, `#!/usr/bin/env sh`) | 81 | 10 167 | **81/81** | 81/81 | | **bash** (`#!/bin/bash`, `#!/usr/bin/env bash`) | 76 | 10 269 | **48/76** | 76/76 | | **EMPOTRADOS** (un `/init` escrito por heredoc) | 21 | 714 | **21/21** | 21/21 | | **`source`-only** (`.sh` sin shebang) | 6 | 138 | 6/6 | 6/6 | > **Cero guiones se declaran POSIX y fallan bajo ash.** Ni los 81 sueltos ni los 21 empotrados. La clasificación es por la **primera línea**, no por «contiene un shebang»: un `grep -rl '^#!/bin/sh'` da 86 porque cuenta los scripts bash que EMPOTRAN un `#!/bin/sh` en un heredoc. Con ese grep, `mirada-usb.sh` y `selfhost-verify.sh` aparecían como «POSIX roto bajo ash» — y son bash legítimos. **Los 21 empotrados son la población que importa para el producto**, y hoy nadie los parsea: son los `/init` de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros (`takana-live-install.sh` ×3, `metal-desktop-image.sh` ×5, `install-image-efi.sh` ×2, `disk-image.sh`, `metal-iso.sh`, `metal-usb-sdboot.sh`, `mirada-usb.sh`, `selfhost-verify.sh`, `cc-por-zig.sh`, `jaula-herramientas.sh`, `atuq-en-cosmic.sh`, `dunst-headless.sh`, `metal-desktop-image-dual.sh`). **Los 21 los parsea el ash.** ## Por qué los 28 bash son bash: arrays, y casi nada más | bashismo | guiones | |---|---| | **`array=( )`** | **31** | | `declare`/`typeset` | 7 | | `+=` en asignación | 6 | | `[[ ]]` | 5 | | `<<<` | 4 | | `<( )` | 4 | | `${arr[i]}` | 3 | | `(( ))` | 3 | | `mapfile` | 1 | No aparece ninguna vez: `${v//x/y}`, `${v^^}`, `function nombre`, `PIPESTATUS`/`BASH_*`, `echo -e`, `read -a/-d`, `source` en vez de `.`. **De los 28 que el ash no parsea, 26 lo son por arrays y nada más.** Ése es el presupuesto completo de un `sh` propio que quisiera correr la herramienta del proyecto: **arrays indexados**. Los otros dos suman `[[ ]]`, `<( )` y `mapfile`. ⚠ **Parsear no es correr.** 48 de los 76 `#!bash` parsean tal cual bajo ash y eso NO significa que funcionen: `local`, `echo -e` o un `trap` de subshell parsean en los dos y se portan distinto (lo de brush#1396 es justo eso). La tabla mide sintaxis. ## La superficie del lenguaje Los 81 POSIX usan **todo** lo que las cards no usan: `if` 63/81, funciones 30/81, `${…}` 74/81, posicionales 66/81, here-docs 22/81, `case` 26/81, `until` 3/81. **Comparado con las cards —8 builtins, ningún `if`, ninguna función— es otro lenguaje.** Builtins en todo `scripts/`: 27 (`echo`×1996, `[`×1270, `exit`×473, `export`×239, `printf`×236, `trap`×40, `local`×19, `ulimit`×3…). Órdenes externas: **257 por nombre desnudo** (las resuelve el `PATH`) y **35 por ruta absoluta** (no pasan por `PATH` y por lo tanto **no pueden ser un applet**). Cruzadas con el recorte: **0 applets retirados** — que es lo que dice `busybox-vigia.py`, y ahora los dos instrumentos coinciden. ## Los siete bugs del INSTRUMENTO, que son la parte que hay que contar Cada número de arriba salió mal primero. Vale más anotar esto que la tabla: 1. **Comentarios.** El escáner no los quitaba, y en este repo los comentarios son prosa llena de `` `backticks` ``: medido en `scripts/farm/cosecha-cron.sh`, **89 backticks y los 89 dentro de comentarios**. Daba «sustitución `` `…` `` 75/81» cuando la cifra real es 9/81. 2. **`shlex` sobre el fichero entero.** Se desincroniza —no es un parser de shell— y una comilla suelta desplaza el estado de TODO lo que sigue: `takana-live-install.sh` daba la orden `init` porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Reescrito sobre el texto que el escáner ya deja sin comillas. 3. **Cuerpos de heredoc.** No son shell del script que los contiene: `scripts/desplegar-strip.sh` empotra un programa de **Python**, y su `for p in glob.glob(…)` aparecía como la orden `p`. Sacarlos bajó las órdenes externas de 680 a 257 — o sea que **el 62 % del inventario era ruido**. 4. **Patrones de `case`.** `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía razón. Ahora hay estado de `case`. 5. **Cuerpos de `$(( ))`.** `i=$((i+1))` daba la orden `i+1`. 6. **Descriptores de fichero.** `>&2` daba la orden `2`. 7. **Nombre desnudo vs ruta absoluta.** `exec switch_root /newroot /sbin/init` marcaba el applet `init`; pero una ruta absoluta no se resuelve por `PATH` y no puede ser un applet — en el producto ese `/sbin/init` es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso **mide algo útil**: 35 órdenes van por ruta absoluta y no dependen del `PATH`. **Ninguno de los siete se veía en la salida.** Todos daban números plausibles. Es la misma lección del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento en vez de al shell. ## Una librería, no dos copias El escáner vive en `scripts/lib/sh_analisis.py` y lo importan los dos instrumentos. Duplicarlo era repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un campo distinto mal. Comprobado que la extracción no cambió nada: la salida de `sh-superficie-cards.py` es byte-idéntica antes y después (y su md5 se repite entre corridas — el orden de los empates se volvió determinista a propósito, para que la salida sea diffable). Y los arreglos 3–7 **corrigieron también la medición de las cards**: apareció `sleep`, que el tokenizador viejo se comía, y desapareció `i+1`. ## Lo que esto NO mide - **`|` dentro de un patrón de `case`** se cuenta como tubería. Inflaba poco y no se arregló: hace falta gramática, no regex. - **`sh -n` no es ejecución.** Nada de esto dice que los 48 `#!bash` que el ash parsea funcionen bajo ash. - **No se midió qué guion corre DÓNDE.** Que `takana-install.sh` sea bash no obliga a nada porque corre en el hub; el que corre dentro de la imagen es `takana-live-install.sh`, y ése es POSIX. La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son por construcción. --- # `simi` — el `/bin/sh` propio, ESCRITO y MEDIDO (2026-09-21) **Crate:** `crates/simi/` (4448 líneas, `libc` y nada más) · **receta:** `recipes/simi.toml`, **sellada** en `b3:46ff6fcf…` · **nombre:** *simi* es «boca, lengua» en quechua — es cómo se le habla a la máquina. `rimay` estaba tomado (la familia de embeddings de tawasuyu). ## El veredicto, primero Banco diferencial contra el ash de busybox 1.36.1, **159 casos** — 10 fixtures de regresión, 107 de tortura de comillas, los 23 fragmentos de las cards y los 19 `/init` empotrados: | shell | divergencias duras | binario | latencia | |---|---|---|---| | **simi** (sellada, estática) | **0** | **0,48 MB** | **1,04×** el ash | | bash 5.3.0 | 8 | 1,57 MB | 1,3× | | brush 0.4.0 | 35 | 6,92 MB | 4,1× | Incluye el **bucle real de `config.status`** que genera cualquier `configure` de autotools — el que tumbó a brush. Y 47 pruebas propias del crate en verde. ## SELLADA, y medida contra el artefacto (2026-09-21) `takana build recipes/simi.toml` en el worker, bajo `flock -o work/.farm-build.lock`: ``` sealed path=./store/46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834-simi b3:46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834 rc=0 ``` El artefacto trae exactamente dos ficheros: `usr/bin/simi` y su `.hammer/recipe.toml`. | qué | medido sobre el ARTEFACTO | |---|---| | tipo | ELF x86-64, **estático**, stripped | | tamaño | **498 736 bytes** (0,48 MB) | | `scripts/static-audit.sh simi` | «estáticos de verdad: 1 · MIENTEN: 0» | | banco diferencial, 159 casos vs el ash | **0 divergencias** (bash: 8 · brush: 35) | | latencia de arranque | **468 µs** contra **449 µs** del ash — **1,04×** (brush: 1569 µs, 3,5×) | ⚠ **La latencia del estático musl no es la del build nativo**: el build del hub (glibc, dinámico) daba 865 µs, o sea 1,4× el ash. El estático baja a 1,04×. **Medir el binario que viaja, no el que se compila a mano** — la diferencia acá fue de un 35 % y habría quedado escrita como si fuera del shell. ⚠ Todavía NO está en ningún perfil de `docs/state/targets.toml`. Declararla es meterla en la raíz de confianza del arranque, y eso es el paso 3 de abajo. ## El alcance salió de la medición, no de POSIX | consumidor | qué necesita | estado | |---|---|---| | las 23 cards de arje con shell | 8 builtins, 13 construcciones, ningún `if`, ninguna función | ✅ | | los 21 `/init` empotrados | + `if`, funciones, `case`, aquí-documentos, `${…}`, posicionales, `&` | ✅ | | `takana-live-install.sh` (corre en la imagen viva) | POSIX entero | ✅ parsea; sin correr | | la shell interactiva de `getty` | edición de línea, historial, control de trabajos | ❌ **no está** | Lo que **no** trae, dicho en vez de fingido: control de trabajos, edición de línea, historial, `alias`, `getopts`, `times`, `[[ ]]`, arrays. El shell de `getty` es otro consumidor y sigue pendiente. ## Los siete bugs — que es la parte que enseña Ninguno se veía en la salida. Todos daban resultados plausibles, y **los seis primeros los destapó el banco diferencial**, no el juicio: 1. **El lexer fundía lo entrecomillado con lo desnudo.** `x="a b"` quedaba como UN seg entrecomillado, el parser ya no veía la asignación —el prefijo antes del `=` tiene que ser desnudo— y **la ejecutaba como una orden**. El error decía «no encontrado», que manda a buscar al lugar equivocado. Ahora el comillado es por carácter. 2. **La marca de escape interna chocaba con las barras reales.** La primera versión usaba `\` como marca y la quitaba al final, así que `$(echo \\$V)` perdía la barra que la sustitución había PRODUCIDO (ash: `\hola`, simi: `hola`). **La quita de comillas es sobre las comillas de la palabra, nunca sobre el resultado de una expansión** — y eso no se representa con un escape dentro del texto: hace falta una máscara de protección por carácter. 3. **`set -e` no se suspendía dentro de una función llamada desde una condición.** Ahora es un contador DINÁMICO, y por eso entra solo donde tiene que entrar. 4. **Los delimitadores de `$( )` y `${ }` no respetaban la barra invertida**: `$(echo \)` cerraba la sustitución cuando el paréntesis estaba escapado y no debía cerrar nada. 5. **Las clases `[…]` no entendían `\`** — y `*[\\\`\"\$]*` es literalmente el patrón del `config.status` de libtool. 6. **Dentro de comillas dobles, el cuerpo de un backtick sigue entre comillas**, así que la barra también se elimina ante `"`. Sin eso, dos casos de la tortura divergían en los dos sentidos. 7. **Y uno que el banco NO vio:** el `trap EXIT` **heredado** se disparaba en el subshell. POSIX §2.12 manda resetearlo al entrar — es la mitad complementaria de brush#1396, y al revés. Lo encontró la prueba de regresión del crate, porque ningún caso del banco lo probaba. **Los dos oráculos hacen falta**, y el caso quedó agregado al fixture 08. Las dos invariantes que el fichero de ejecución sostiene a propósito, las dos porque se midieron rotas en el candidato ajeno: **el `trap EXIT` de un subshell corre** (brush#1396) y **`exec` reemplaza el proceso** (si el shell forkea, arje supervisa al shell y no al daemon). ## Lo que queda, en orden 1. ~~**Construir la receta en el lab** y sellarla.~~ **HECHO** — `b3:46ff6fcf…`, ver arriba. 2. **ADR** — escribir un shell propio y meterlo en `ATTEST_PATHS` es una decisión de frontera, y el nombre `simi` también. Este documento la registra; el ADR la decide. 3. **Cablear `/bin/sh`**: `USERLAND_COMPONENTS` + el retiro del homónimo (`lib.rs:676`). Es frente C y toca la raíz de confianza del arranque. 4. **Medir EJECUTANDO**, no sólo parseando: los `/init` en un arranque de QEMU (`product-boot-test.sh`) y `takana-live-install.sh` en la imagen viva. 5. **La shell interactiva de `getty`**, que es el consumidor que falta entero. 6. **El sandbox de build sigue con busybox**, y con razón: eso es otro planeta y no está prometido.