tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 466972e3010bda77ec242cf40c53abc43c948992 docs/plan-botar-busybox.md Markdown · 1217 siq'ikuna · 69.4 KB Ñawpa willay ch'uya

busybox: 79 → 69 applets sin otro dueño, y ninguno de los diez costó escribir userland 466972e30 · Sergio ·

1# Plan — botar busybox, MEDIDO
2
3**Fecha:** 2026-09-21 · **Instrumento:** los 401 symlinks de applet del artefacto sellado
4`2a2b1280…-busybox` (contados en el store del worker), cruzados con el estado y la membresía de
5perfil de cada receta en `docs/state/build-state.json` y con `grep '[deps]' recipes/*.toml`.
6**Tabla applet-por-applet:** `docs/state/busybox-applets.tsv` (401 filas, generada, no escrita a mano).
7
8## Por qué existe este documento
9
10La pregunta fue «¿qué hay que hacer para botar busybox?», con la sospecha de que faltaba escribir
11mucho userland en Rust. **El número dice lo contrario, y el hueco está en otro lado:**
12
13> De los 401 applets, **148 ya tienen dueño sellado Y en perfil** (el applet es redundante en la
14> imagen), **125 tienen dueño sellado que NO viaja en ninguna imagen** (trabajo hecho y no cobrado),
15> y de los **128 sin dueño, 105 son basura que se borra**. El trabajo de escritura real son
16> **23 applets** repartidos en 8 piezas.
17
18Es la lección de `foot` y del §D de `plan-barrido-perfiles-y-servicios.md` otra vez: `uutils`,
19`findutils`, `findutils-xargs`, `diffutils` y **`brush`** están todos `sealed` con `perfiles: []`.
20Se construyeron, se sellaron, y ninguna imagen los lleva.
21
22## Son TRES frentes, no uno — y esto es lo que hace que «botar busybox» se lea como imposible
23
24Mezclarlos es el error que cuesta el trabajo. Tienen dificultad, dueño y riesgo distintos:
25
26| frente | qué es busybox ahí | dificultad |
27|---|---|---|
28| **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 |
29| **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** |
30| **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…`** |
31
32⚠ **La membresía de perfil de busybox es del frente A, no del B.** `busybox` figura en `cli`,
33`servidor`, los cuatro escritorios y `metal-tigerlake`, pero **no está declarado como raíz en
34`docs/state/targets.toml`** (los únicos hits ahí son comentarios). Llega por la clausura, porque 24
35recetas lo declaran build-dep: `abseil-cpp chrony cronie crun dwarves firmware-tigerlake
36ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate
37nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng`. **Sacarlo de
38las imágenes empieza por ahí y no requiere escribir ni una línea de userland.**
39
40## El reparto de los 401
41
42| estado del dueño | n | qué significa |
43|---|---|---|
44| **sellada + en perfil** | 148 | el reemplazo ya viaja en la imagen: el applet es redundante hoy |
45| **sellada, sin perfil** | 125 | construido, sellado, y no viaja: falta declararlo |
46| **sin dueño** | 128 | de los cuales **105 son `BORRAR`** y **23 son trabajo real** |
47
48### 148 · Ya cubiertos y en la imagen — el applet sobra
49
50`util-linux` 48 · `iproute2` 19 · `procps-ng` 12 · `shadow` 14 · `kbd` 12 · `xz` 6 · `iputils` 5 ·
51`e2fsprogs` 5 · `bzip2` 3 · `cronie` 3 · `dhcpcd` 3 · `ncurses` 2 · `dosfstools` 2 · `chrony` 2 ·
52`vim` 2 · `less` `lsof` `pciutils` `usbutils` `mandoc` `socat` `patch` `binutils` `tar` `wget` 1 c/u.
53
54⚠ **24 de estos 148 NO son drop-in**, y están marcados en el TSV. El caso grande es la red:
55`iproute2` da `ip`/`ss`, **no** `ifconfig`/`route`/`arp`/`netstat` — eso es net-tools, que no está en
56el corpus. Igual con `addgroup`/`adduser` (shadow da `groupadd`/`useradd`), `udhcpc` (dhcpcd es otro
57cliente con otro contrato de hooks) y `nc` (socat tiene otra sintaxis). **Cada uno de esos 24 es una
58migración de llamadores, no un borrado.** Mirar quién los invoca en `scripts/` y en las cards antes.
59
60### 125 · Sellados y sin viajar — la munición que ya está fabricada
61
62| dueño | n | qué cubre |
63|---|---|---|
64| `uutils` | 90 | todo el núcleo POSIX: `ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown …` |
65| `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 …`) |
66| `hammerd` | 4 | `syslogd klogd logger logread` |
67| `brush` | 3 | **`sh ash hush`** |
68| `gzip` | 3 | `gzip gunzip zcat` |
69| `diffutils` | 2 | `diff cmp` |
70| `findutils` / `findutils-xargs` / `libarchive` | 3 | `find` · `xargs` · `cpio` |
71
72⚠ **CORRECCIÓN (2026-09-21, al ejecutar el paso 1): los 24 de `arje-zero` y `hammerd` NO son
73gratis, y esta tabla decía mal.** Esas dos recetas reemplazan la **función**, no el **comando**:
74cada una instala UN binario (`-p arje-zero`, `-p hammerd`), así que no existe un `poweroff`, un
75`logger` ni un `setuidgid` que las respalde. «Cubierto» significaba «nadie necesita correr esto»,
76y para 5 de los 24 era falso. Ver los indultos del paso 1.
77
78### 128 · Sin dueño
79
80**105 se borran** (no se reemplazan, salen del `defconfig`): `dpkg dpkg-deb rpm rpm2cpio httpd ftpd
81ftpget ftpput tftp tftpd telnet telnetd inetd dnsd sendmail popmaildir lpd lpq lpr ubi* nand*
82nbd-client i2c* hdparm fdformat setserial powertop bootchartd fbsplash conspy ether-wake udhcpd
83udpsvd tcpsvd watchdog whois tree which unzip …` (lista completa en el TSV).
84
85**23 son el trabajo real:**
86
87| pieza | applets | qué es |
88|---|---|---|
89| **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 |
90| **psmisc** | `killall killall5 pstree fuser` (4) | **`procps-ng` NO los trae.** Traer receta psmisc (C) o escribir |
91| **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` |
92| **gestor de /dev** | `mdev makedevs uevent` (3) | decisión: lo absorbe arje, o entra una receta eudev/mdevd |
93| **bc** | `bc dc` (2) | traer receta |
94| **getty** | `getty cttyhack` (2) | hoy la card de getty ejecuta `/bin/busybox` (`lib.rs:429`) |
95| **awk** | `awk` (1) | decisión abierta, ver abajo |
96| **sed** | `sed` (1) | `sed` GNU sellado y en los 7 perfiles; el Rust nativo está por escribir |
97| **dns** | `nslookup` (1) | traer receta bind-tools/doggo |
98
99## Las tres clases de reemplazo — y cuándo forkear (nunca)
100
101La pregunta era si hay que forkear los que «existen pero no son exactos». **No, y hay tres clases
102distintas que conviene no mezclar:**
103
104**1 · Adopción exacta.** `uutils/coreutils`, `uutils/findutils`, `uutils/diffutils`: reimplementación
105madura, MIT, receta Cargo con commit pinneado (ADR 0006), multicall. El patrón está probado y
106documentado en la cabecera de `recipes/uutils.toml`. Lo que falta de esta clase es
107**`uutils/procps`** y **`uutils/util-linux`** — receta calcada, coste bajo. ⚠ **Verificar su estado
108upstream antes de prometer los 60 applets que cubrirían**: `util-linux` en Rust era embrionario.
109
110**2 · «Existe pero no es exacto» — y forkear es la trampa.** `ripgrep` no es `grep` POSIX, `sd` no es
111`sed`, `fd` no es `find` (por eso la receta buena es `uutils/findutils` y no `fd`), `procs` no es
112`ps`, `bottom` no es `top`, `helix` no es `vi`. Todas están selladas y en los 7 perfiles, y **ninguna
113sirve de reemplazo**: cambian las banderas. Forkear ripgrep para que hable POSIX es peor que
114escribir — heredás su arquitectura de recursión+ignore y peleás contra ella para siempre. Lo correcto
115es usarlas **como librerías**: `grep-matcher`/`grep-regex`/`grep-searcher` están publicadas
116justamente para eso, y un `grep` POSIX encima son unos cientos de líneas.
117
118**3 · `awk` es el caso feo, y hay que decidirlo, no resolverlo de paso.** `recipes/goawk.toml` existe
119y está sellada, pero **goawk es Go, no Rust** — no cumple la premisa de soberanía de cadena de la
120Etapa C. `frawk` es Rust pero es otro dialecto (JIT, semántica propia). `gawk` es C con extensiones
121GNU que los `configure` del corpus sí usan. Las tres opciones son legítimas y ninguna es gratis.
122
123## El bloqueante, y la buena noticia
124
125**Sin `sh` POSIX no se bota busybox en ningún frente**: es el `exec` de las cards, el `/bin/sh` del
126producto y el shell del sandbox. Lo esperado era que fuera un proyecto de meses.
127
128**`recipes/brush.toml` (0.4.0, reubeno/brush, MIT, Rust, compat bash) está `sealed`.** Compila
129estático musl con zig cc y produce `/usr/bin/brush`.
130
131⚠ **Y está sin validar como `sh`.** La receta es un import crudo de nixpkgs y lo dice en su primera
132línea: «PUNTO DE PARTIDA, no final». No está en ningún perfil, nadie lo ejecutó como `/bin/sh`, y el
133sandbox de build es el consumidor más duro que existe (`configure` de autotools). **La unidad de
134trabajo #1 es medirlo, no adoptarlo.**
135
136## Orden de ataque
137
138Cada paso es una unidad de trabajo con su criterio de terminado. Los tres primeros **no tocan el
139frente C** y se pueden hacer hoy.
140
1411. ~~**Recortar el `defconfig`.**~~ **HECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.**
1422. **Declarar la munición que ya existe.** `uutils`, `findutils`, `findutils-xargs`, `diffutils`,
143   `brush`, `gzip`, `libarchive` a los perfiles de `targets.toml`. Terminado cuando `build-state.py`
144   los marca con perfil y la imagen los lleva.
1453. ~~**Medir `brush` como `/bin/sh`.**~~ **HECHO, 2026-09-21 — veredicto abajo: no sirve todavía
146   para el sandbox.** Queda pendiente medirlo como `/bin/sh` del PRODUCTO (cards + `product-boot-test.sh`),
147   que es un consumidor distinto y mucho más blando.
1484. **Las dos recetas de adopción baratas** (`uutils/procps`, `uutils/util-linux`), previa
149   verificación de madurez upstream. Cubren ~60 applets de los 148 «ya cubiertos por C».
1505. **`grep` y `sed` POSIX en Rust.** Es la escritura de mejor relación coste/beneficio y desbloquea
151   el `/toolchain` del frente A.
1526. **Migrar los 24 build-deps.** Cambiar `busybox` por el conjunto real en `[deps] build` de las 24
153   recetas. Terminado cuando `busybox` sale de los 7 perfiles.
1547. **La cola larga:** kmod, psmisc, gestor de /dev, getty, bc, dns. Después del shell, no antes.
1558. **ADR — `awk`**, y **ADR — el frente C** (sustituir busybox en `STAGE1_COMPONENTS` y
156   `ATTEST_PATHS`). No antes de que 1–7 estén cerrados.
157
158## La métrica, y el guardián que falta
159
160`docs/state/busybox-applets.tsv` convierte esto en un número que baja: **401 → 272 → … → 0**.
161Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con `build-state.json`.
162
163**Falta un guardián**, y sin él el paso 1 se deshace solo: un script que falle si alguna receta,
164script o card invoca un applet que ya salió del `defconfig`. Hoy nada lo detectaría — el fallo
165aparecería en boot, en otra máquina, semanas después.
166
167## Lo que este documento NO midió (y hay que decirlo así)
168
169- **El reparto applet→destino es juicio, no medida.** Lo medido son los 401 symlinks, el estado
170  `sealed`/perfiles de cada receta y las 24 recetas con `busybox` en `[deps] build`. La asignación de
171  dueño la escribí yo y hay que revisarla receta a receta.
172- **No se verificó la equivalencia de banderas de ninguna pareja.** Los 24 marcados `NO-drop-in` son
173  los que sé que difieren; el resto dice `sin-verificar` y eso es literal, no una forma de decir «va
174  a andar».
175- **No se midió qué applets usa el producto de verdad.** El `defconfig` instala 401 porque nadie
176  eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1.
177- **`uutils/procps` y `uutils/util-linux` no se verificaron upstream.** Los ~60 applets que les
178  atribuyo son una hipótesis de trabajo.
179
180---
181
182# Veredicto del paso 3 — `brush` como `/bin/sh`, MEDIDO
183
184**Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, todo bajo `flock -o work/.farm-build.lock` ·
185**Contra qué:** busybox 1.36.1 `sh` (ash) como control, en la misma caja y la misma corrida.
186
187> **No sirve todavía como `/bin/sh` del SANDBOX DE BUILD.** Pasa el banco POSIX igual que busybox,
188> parsea todo el corpus, y aun así **0 de 3 recetas autotools construyen** (control: 3/3). Lo que
189> falla no es su parser: es que el `libtool` que su `configure` genera sale con un nivel de escapado
190> de menos y después no lo parsea ningún shell, ni él ni busybox.
191
192## Lo que se construyó
193
194`takana build recipes/brush.toml` en el worker selló **`b3:715237c78edd…`** — **el mismo hash que
195`build-state.json` traía del otro hub.** Dos máquinas distintas, bytes idénticos: reproducibilidad
196confirmada de paso, que no era el objetivo de la prueba.
197
198Binario: ELF x86-64 **estático** (sin loader), `brush 0.4.0`, **7,25 MB** — contra **1,23 MB** del
199busybox que trae los 401 applets. Botar busybox por brush, hoy, **engorda** la imagen.
200
201## Lo que PASA
202
203| prueba | brush | control busybox |
204|---|---|---|
205| **Banco POSIX** — 57 casos (`scripts/sh-banco-posix.sh`) | **56/57** | 56/57 |
206| **Las 1130 fases de shell del corpus** (`sh -n` sobre las fases de las 549 recetas que las tienen) | **0 rechazadas** | 0 rechazadas |
207| **`configure` de ffmpeg** — 8345 líneas, `sh -n` | rc=0 | rc=0 |
208| **Invocado como `sh`** (argv[0]) | ✅ | ✅ |
209| **El patrón de las cards de arje** (`sh -c '…; exec …'`) | ✅ | ✅ |
210
211La única divergencia del banco: **`shift` más allá de `$#` devuelve 2**, y POSIX/busybox/bash
212devuelven 1. Cosmético, pero un `set -e` que haga `shift` de más se comporta distinto.
213
214⚠ **Y el banco NO es prueba de suficiencia** — esto es lo que hay que recordar del ejercicio:
215**brush lo pasa entero y aun así no construye una sola receta autotools.** Un banco de casos
216sintéticos mide lo que a uno se le ocurre preguntar; el consumidor real mide lo que hay.
217
218## Lo que FALLA
219
220**Tres recetas autotools reales, con el único cambio de `/bin/sh` en una copia del lab:**
221
222```sh
223cp -al /opt/takana/.dev-fs/alpine  $L/alpine       # copia con hardlinks, el lab compartido intacto
224cp   …-brush/usr/bin/brush         $L/alpine/bin/brush
225ln -sfn /bin/brush                 $L/alpine/bin/sh   # ← lo ÚNICO que cambia; sed/grep/awk siguen siendo busybox
226flock -o work/.farm-build.lock env TAKANA_ROOTFS=$L/alpine \
227  ./target/release/takana --store <store-tirable> build recipes/<r>.toml
228```
229
230| receta | control (busybox sh) | brush |
231|---|---|---|
232| `flex` | ✅ sellada | ❌ `make[1]: Error 2` |
233| `pkgconf` | ✅ sellada | ❌ `./libtool: syntax error at line 368 col 30` |
234| `oniguruma` | ✅ sellada | ❌ `make[1]: Error 2` |
235
236### La causa, hasta donde está medida
237
238**El `configure` corre entero y bien bajo brush.** Lo que sale mal es el `libtool` que ese
239`configure` GENERA. Regenerado el mismo fichero con cada shell y comparado, **25 líneas difieren, y
240todas en el mismo sentido: falta un nivel de escapado.**
241
242```
243                        control (busybox)                     brush
244línea 62    ECHO="printf %s\\n"                      ECHO="printf %s\n"
245línea 132   file_magic_cmd="\$MAGIC_CMD"             file_magic_cmd="$MAGIC_CMD"
246línea 176   …'s/^.*[ ]\\([ABCDGIRSTW]…               …'s/^.*[ ]\([ABCDGIRSTW]…
247```
248
249Ese fichero ya no lo parsea **nadie** — ni brush ni busybox lo aceptan (`rc=2` los dos, misma línea
250368). Y el contraprueba cierra el caso: **el `libtool` del control lo parsea brush sin quejarse
251(`rc=0`)**. ⇒ El parser de brush está bien; lo que difiere es lo que su shell PRODUCE.
252
253### El mecanismo, encontrado (2026-09-21, después de escribir lo de arriba)
254
255Primero se escribió acá que la causa no estaba determinada, tras descartar las tres sospechas
256obvias —`echo` con barras invertidas, sustitución de comando preservando `\\`, y el
257`func_quote_for_eval` de libtool aplicado a mano—, que dan **idéntico** en ambos shells. La causa
258apareció bajando al `config.status` que genera el `libtool`. **Es de una línea:**
259
260```sh
261V=hola
262echo "a: [`echo \\$V`]"     # POSIX: $V      · brush: \hola
263echo "b: [`echo \\\\`]"     # POSIX: \       · brush: \\
264echo "c: [`echo \$V`]"      # POSIX: hola    · brush: $V
265echo "d: [$(echo \\$V)]"    # POSIX: \hola   · brush: \hola   ✅
266```
267
268**brush aplica a las comillas invertidas las reglas de `$( )`.** POSIX manda otra cosa: dentro de
269`` `…` `` la barra invertida conserva su significado literal **salvo** cuando precede a `$`,
270`` ` `` o `\`, donde se ELIMINA antes de parsear el texto del comando (POSIX.1-2024, 2.6.3). Que el
271caso **d** sí esté bien es lo que delata la causa.
272
273Por qué rompe libtool: el `config.status` generado trae este bucle, que decide si cada variable
274necesita escapado antes de escribirla en el `libtool`:
275
276```sh
277for var in SHELL ECHO PATH_SEPARATOR SED GREP …; do
278    case `eval \\$ECHO \\""\\$$var"\\"` in
279    *[\\\`\"\$]*)  eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;;
280    *)             eval "lt_$var=\\\"\$$var\\\"" ;;
281    esac
282done
283```
284
285Bajo brush ese `case` evalúa a **cadena vacía**, así que **todas** las variables caen en la rama sin
286escapar. De ahí las 25 líneas y de ahí que el `configure` termine bien: no falla nada, sólo sale mal
287escrito.
288
289**Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394).**
290
291## Y el otro dato, que no es un fallo pero decide
292
293**Latencia de arranque: 813 µs contra 91 µs de busybox — 8,9×** (300 invocaciones de `sh -c :`).
294Un `configure` de autotools levanta decenas de miles de shells: a ese factor, el sandbox entero se
295vuelve medible más lento. Es un argumento independiente del bug, y no se arregla arreglando el bug.
296
297## Qué queda de esto
298
2991. **brush NO entra al sandbox de build.** El frente A sigue con busybox hasta que esto se cierre.
3002. **El frente del producto sigue abierto**: las cards, la getty y la shell interactiva no generan
301   libtool. Ahí brush no tiene ninguna evidencia en contra — pero tampoco se probó, y el
302   `product-boot-test.sh` es la prueba que falta.
3033. **Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394)**, con
304   el reproductor de una línea, la tabla de los cuatro casos y el fragmento de `config.status`.
3054. **El paso 5 del plan (grep/sed POSIX en Rust) sube de prioridad**, porque no depende del shell.
3065. **No se tocó el lab compartido**: copia con hardlinks y stores tirables. El worker siguió
307   compilando su cola en paralelo, serializado por el `flock`.
308
309## ¿Parchar, o buscar otra opción?
310
311**Primero lo que cierra la pregunta barata: adelantar el pin NO sirve.** Nuestra receta apunta a
312`96a26d0c` (tag `brush-shell-v0.4.0`, mayo 2026), que sigue siendo el último release. Se construyó
313**`main` de hoy** (`737dd57e`, 2026-09-21 — cuatro meses y medio de trabajo encima) y **reproduce
314idéntico**, los cuatro casos. No hay nada que esperar de la rama.
315
316Lo que queda, en orden de coste:
317
318| opción | qué cuesta | qué deja |
319|---|---|---|
320| **Esperar a #1394** | cero | el upstream es activísimo (issue #1393 de anteayer) y el bug es acotado, pero no es nuestro calendario |
321| **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 |
322| **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 |
323| **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 |
324| **Dejar busybox donde está** | cero | es lo que dice el plan: el shell NO es por donde se empieza |
325
326**La recomendación es la última, y no por conservadurismo.** El plan ya ordenaba los pasos 1, 2 y 5
327—recortar el defconfig, declarar lo sellado, escribir `grep`/`sed` POSIX— **sin depender del shell**,
328y valen 129 applets y dos piezas de userland. Gastar el turno en pelear el shell es empezar por el
329único frente que está bloqueado por terceros.
330
331Lo que sí conviene hacer ya, porque es gratis: **suscribirse a #1394** y **medir brush como `/bin/sh`
332del PRODUCTO** (`product-boot-test.sh` + las cards), que es un consumidor que no genera libtool y
333donde brush no tiene ninguna evidencia en contra.
334
335
336---
337
338# Paso 1 — el recorte, HECHO y MEDIDO
339
340**Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, bajo `flock -o`, contra un store tirable y con
341un directorio de recetas por hardlinks — el `recipes/` del worker no se tocó.
342
343> **401 → 277 applets.** 124 retirados, 5 indultados, binario de **1.230.976 → 977.096 bytes**
344> (−20,6 %). Nuevo hash `b3:a24fdbf6…` (el vigente era `2a2b1280…`). Y **3 de las 24 recetas que
345> declaran busybox build-dep se reconstruyeron y sellaron con el recortado**: el sandbox sigue en pie.
346
347## Lo que el ejercicio corrigió del plan
348
349**Mi tabla decía que los 24 applets de `arje-zero`/`hammerd` eran gratis. Es falso para 5 de ellos.**
350Esas recetas instalan **un binario cada una** (`-p arje-zero`, `-p hammerd`): reemplazan la FUNCIÓN,
351no el COMANDO. No hay un `poweroff` ni un `setuidgid` detrás. Los cinco indultos, con su razón:
352
353| indultado | por qué se queda |
354|---|---|
355| `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 |
356| `switch_root` | el `/init` del initramfs del instalador lo ejecuta (`scripts/takana-live-install.sh:375`), **antes** de que arje exista |
357| `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 |
358
359Los tres primeros los encontró el vigía; los tres últimos, preguntarse qué instala arje-zero de
360verdad en vez de creerle a mi propia tabla.
361
362## El vigía — y por qué un vigía ingenuo no sirve
363
364`scripts/busybox-vigia.py` falla si alguna receta, script o card invoca un applet retirado. **Lee la
365lista de la propia receta** (`RETIRADOS='…'`) cruzada con la columna `simbolo_kconfig` del TSV, así
366que no hay dos listas que se desincronicen.
367
368Su primera versión daba **91 hits y ninguno real**. Las cuatro reglas que lo bajaron a 0, todas
369medidas contra falsos positivos concretos:
370
371| regla | qué mataba |
372|---|---|
373| quitar comentarios antes de buscar | `# busybox provee sendmail, no mail` (logrotate), `/// los dos fsync no son prolijidad` |
374| 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(…)` |
375| ignorar nombres que el propio script define como función | `$(ts)` en los seis scripts de `scripts/farm/` |
376| ignorar patrones de `case` y `command -v X` | `proc\|sys\|dev\|init) continue ;;` · `command -v dpkg` en el censo de la mudanza |
377
378Y `[` / `[[` no se vigilan: son palabras del shell, y todo script bash del hub los usa sin que eso
379sea una llamada a un applet.
380
381⚠ **El vigía mira ESTE repo.** No ve los `configure` de terceros que corren dentro del sandbox, que
382son quienes podrían llamar a `which` o `unzip`. Por eso la verificación de abajo no es opcional.
383
384## El mapa applet → símbolo NO se deriva por mayúsculas
385
386Sale de las líneas `//applet:` del fuente de busybox 1.36.1 y vive en la columna `simbolo_kconfig`
387del TSV. Poner el nombre en mayúsculas **falla en 6 casos medidos**: `[`→`TEST1`, `[[`→`TEST2`,
388`arch`→`BB_ARCH`, `nbd-client`→`NBDCLIENT`, `sh`→`SH_IS_ASH`, `sysctl`→`BB_SYSCTL`. Dos de esos seis
389estaban en la lista a retirar.
390
391## Verificación
392
393| qué | resultado |
394|---|---|
395| applets tras el recorte | **277** (401 − 124), exactamente los previstos |
396| los 5 indultados presentes | ✅ y también `sh` y `getty` |
397| los que debían irse | ✅ `httpd telnetd dpkg rpm init syslogd runsv tree which unzip` fuera |
398| **el sandbox sigue construyendo** | ✅ `zlib-ng`, `jq` y `logrotate` reconstruidas y selladas con el busybox recortado |
399
400⚠ **Son 3 de las 24, no las 24.** Las otras 21 —entre ellas `linux`, `llama-cpp`, `protobuf`,
401`qdrant`— no se probaron: son horas de cómputo en una caja que está con la campaña de KDE. Si alguna
402usa un applet retirado, se verá en su próximo build y el vigía **no** lo habría anticipado, porque el
403llamador estaría en un `configure` de terceros.
404
405## El coste, dicho antes de que sorprenda
406
407**Cambiar esta lista re-hashea busybox, y con él las 24 recetas que lo declaran build-dep**:
408`abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl
409libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant
410sof-firmware valkey wireless-regdb zlib-ng`. **`linux` está en la lista**: el kernel se reconstruye.
411La granja lo hará en su propio ciclo; no se disparó a mano.
412
413## El arranque — PROBADO en QEMU, contra una línea base
414
415**2026-09-21.** Se ensamblaron **dos** Stage 1 completos y se arrancaron los dos en QEMU, para que
416cualquier diferencia se pueda atribuir al recorte y no al azar:
417
418| | rootfs sellado | applets en `/bin /sbin /usr/bin /usr/sbin` | qemu |
419|---|---|---|---|
420| **base** (busybox sin recortar) | `b3:1a3823b4…` | 400 | `rc=0` |
421| **recortado** | `b3:3aa8cd2d…` | **278** | `rc=0` |
422
423Los dos arrancan, los dos dan shell y los dos se apagan limpio. **Diff de las dos consolas: 35
424líneas, y ninguna es funcional** — frecuencia del TSC, direcciones del RAMDISK, BogoMIPS, hora del
425RTC. La única diferencia real es la que se buscaba:
426
427```
428~ # ls /bin /sbin /usr/bin /usr/sbin | wc -l
429410     ← base
430288     ← recortado
431```
432
433Criterios del runbook `stage1-vm-boot.md` §6, verificados en el recortado:
434
435- **PID 1** — `arje-zero` incarna y corre.
436- **Seed** — la card se carga: la prueba es que la `console-getty` llegó a incarnar.
437- **getty → shell** — `/bin/busybox getty -n -l /bin/sh 115200 console` da prompt `~ #`, y
438  `echo MARCA-SHELL-VIVA` responde. **`getty` y `sh` sobrevivieron al recorte, que era el riesgo.**
439- **`poweroff -f`** — `ACPI: PM: Preparing to enter system sleep state S5` / `reboot: Power down`.
440  Uno de los cinco indultados, probado **en vivo**: sin él la caja no se apaga.
441
442⚠ **Cómo se corrió, porque condiciona el resultado:** en `dev.gioser.net`, con **QEMU en TCG
443(emulación pura)** — el LXC no expone `/dev/kvm`, aunque el CPU sí trae los flags. Por eso el guion
444se le manda por consola serie con esperas generosas. Hubo que instalar `qemu-system-x86-core` (dnf)
445e ingerir de nuevo la semilla de Stage 0, que no estaba en el store del worker; sí salió con el
446mismo hash que el manifiesto ya anotaba (`3ce721ec…`).
447
448⚠ **Es Stage 1, no el producto.** Se eligió porque es donde vive busybox (`musl busybox hammerd
449arje-zero`) y porque necesitaba 2 artefactos en vez de 8. `product-boot-test.sh` —que además levanta
450sshd y netup— sigue sin correrse: le faltan `netup openssh uutils findutils-xargs diffutils
451ripgrep` en el store del worker.
452
453## Lo que queda del paso 1
454- **Las otras 21 dependientes**, cuando la granja llegue.
455- El siguiente corte natural son los `BORRAR` que no se tocaron por prudencia y los 272 que siguen:
456  el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (`grep`/`sed` POSIX).
457
458---
459
460# El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21
461
462**Instrumento:** `scripts/sh-banco-diferencial.py` · **casos de regresión:**
463`scripts/fixtures/sh-casos/` · **dónde:** el hub, contra los tres shells sellados que ya están en
464`/store` (`2a2b1280…-busybox`, `715237c7…-brush`, `87bf1a59…-bash`). No tocó la granja ni el lock.
465
466## Por qué, si ya existía `sh-banco-posix.sh`
467
468El veredicto del paso 3 dejó escrita la lección y no el instrumento:
469
470> **brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.**
471
472Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial
473pregunta otra cosa, y no hace falta saber la respuesta de antemano:
474
475> **¿este shell hace LO MISMO que el control, sobre el material que hay?**
476
477Tres decisiones de diseño, y las tres se ganaron con el bug de brush:
478
4791. **Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout.** El `libtool` mal escapado salía
480   con `rc=0` y stdout idéntico: **ningún banco de stdout puede verlo**. Cada caso corre en un
481   directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que
482   escribió. El caso `10-escribe-arbol.sh` diverge **sólo en el árbol** — es la demostración de que
483   esta columna hace falta.
4842. **El esperado es el control, no una aserción.** Un caso no declara resultado: declara material.
485   Eso permite tirarle al banco las 1130 fases del corpus y 26 `configure`/`libtool` reales sin
486   escribir una línea de expectativa.
4873. **Toda divergencia se CONFIRMA** volviendo a correr control y candidato. Un caso que imprime un
488   PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía
489   `$$`); un shell que diverge **de sí mismo** es un hallazgo distinto y peor. Sin esto el banco se
490   llena de ruido y deja de leerse, que es como muere un banco.
491
492Además: todos los shells se invocan por un symlink `sh` (argv[0] igual para todos, que es como se
493despliegan), con el **mismo userland busybox en el `PATH`** —las divergencias tienen que venir del
494shell, no de un `sed` distinto— y con `PATH` apuntando primero al shell bajo prueba, así un `sh`
495recursivo cae en ÉL y no en el del sistema.
496
497## Lo medido — 1273 casos, 5,5 s
498
499```
500scripts/sh-banco-diferencial.py --gen --phases \
501    --files '/work/sergio/work/sources/*/configure' \
502    --files '/work/sergio/work/sources/*/libtool' \
503    control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
504```
505
506| fuente | casos | modo | brush | bash |
507|---|---|---|---|---|
508| `cases` — regresiones conocidas | 10 | ejecutado | **4 divergen** | 0 |
509| `gen` — tortura de comillas | 107 | ejecutado | **31 divergen** | 8 |
510| `phases` — las fases de las 549 recetas | 1130 | `sh -n` | **0** | 0 |
511| `files` — `configure` y `libtool` reales | 26 | `sh -n` | **0** | 0 |
512
513**Los 1156 casos de parseo no divergen en ningún shell.** Confirma lo que el paso 3 ya decía —el
514parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol
515comparado. **Sirve sobre todo para saber dónde NO buscar.**
516
517## Lo que el generador encontró SOLO
518
519El generador barre el producto cartesiano `forma de sustitución × contexto × profundidad de barras
520invertidas × carácter objetivo` (107 casos). **No sabe que el bug #1394 existe, y lo escupe entero:**
521
522```
523✗ [brush] tortura/bt-desnudo-b1-var    control: [hola]    brush: [$V]
524✗ [brush] tortura/bt-desnudo-b2-var    control: [$V]      brush: [\hola]
525✗ [brush] tortura/bt-desnudo-b2-literal control: [x]      brush: [\x]
526✗ [brush] libtool/case-de-config-status  ← el bucle REAL de config.status, divergente
527```
528
529Ése es el punto del ejercicio: **el bug de brush apareció en el primer build real, después de
530sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco
531segundos, sin saber qué buscaba.**
532
533## Un hallazgo NUEVO, y no es del frente A
534
535El fixture `08-trap-exit-y-estado.sh` destapó algo que no estaba en el veredicto del paso 3:
536
537```sh
538( trap "echo trap-sub" EXIT; true ); echo despues
539# busybox → trap-sub / despues      bash → trap-sub / despues      brush → despues
540```
541
542**brush no ejecuta NUNCA el `trap … EXIT` de un subshell**, ni con `exit` explícito. Es independiente
543de #1394 y, a diferencia de aquél, **golpea el frente B**: un script de limpieza que confía en su
544trap no limpia, en silencio, con `rc=0`. La frase del veredicto —«en el producto brush no tiene
545ninguna evidencia en contra»— ya no se sostiene: ahora la tiene.
546
547⚠ **Y upstream YA LO SABÍA — pero no como issue, sino dentro de su propia suite de compatibilidad**,
548con el caso casi calcado al fixture y marcado `known_failure`:
549
550```yaml
551# brush-shell/tests/cases/compat/builtins/trap.yaml:226
552- name: "subshell can set its own EXIT trap"
553  known_failure: true # TODO(traps): EXIT trap in subshell
554```
555
556Y no era uno sino tres TODOs para el mismo síntoma: `:226` subshell `( )`, `:269` sustitución de
557comandos, `:323` sustitución de procesos. **Lección para el banco: un `known_failure` en la suite
558ajena es evidencia de primera y cuesta un `gh api` mirarla — antes de escribir «hallazgo nuevo»,
559buscar en las PRUEBAS del candidato, no sólo en sus issues.** Lo nuestro seguía aportando: que las
560tres variantes son probablemente un mismo bug (`on_exit()` sólo lo llaman los puntos de entrada de
561nivel superior; el subshell de `interp.rs:662-683` clona el shell y devuelve el exit code sin
562pasar por ahí), que también pega en pipeline y en segundo plano, y que es **silencioso**.
563
564**Reportado: [reubeno/brush#1396](https://github.com/reubeno/brush/issues/1396).**
565
566Y se confirmó la tercera, ya conocida: `shift` más allá de `$#` devuelve 2 (POSIX/busybox/bash: 1).
567
568## Las 8 de bash, que enseñan a leer el banco
569
570bash diverge en 8 casos del generador, **todos de la misma familia**: comilla o backtick sin cerrar
571dentro de `` `…` ``, donde busybox da `rc=2` y bash acepta. No es que bash esté mal — es que **el
572control es una referencia, no la verdad**. Cuando el candidato diverge, hay que ir a POSIX; el banco
573dice dónde mirar, no quién tiene razón.
574
575## Latencia, de paso
576
577300 invocaciones de `sh -c :` por shell, en el hub:
578
579| shell | µs/invocación | binario |
580|---|---|---|
581| busybox | 464 | 1,17 MB |
582| bash | 864 | 1,57 MB |
583| brush | 1724 | 6,92 MB |
584
585**brush/busybox = 3,7×** acá, contra el 8,9× medido en el worker: esta cifra incluye el `fork`+`exec`
586de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la
587misma y el argumento del veredicto no cambia.
588
589## Lo que este banco NO mide (y hay que decirlo así)
590
591- **No EJECUTA las fases del corpus** — sólo las parsea. Ejecutarlas necesita el sandbox y sus
592  fuentes, que es la extensión natural y la que cerraría el caso: correr un `configure` real bajo el
593  candidato y **comparar el árbol generado** contra el del control. La maquinaria de comparación ya
594  está; falta engancharla al sandbox.
595- **El generador sólo tortura comillas.** Faltan familias enteras: expansión de parámetros, `IFS` y
596  división en campos, aritmética, here-docs, señales y control de trabajos.
597- **Nada de interactivo** — ni edición de línea, ni historial, ni `job control`.
598- **El árbol no mira permisos finos ni mtimes**, sólo ruta + bit de ejecución + contenido.
599- **El banco no prueba suficiencia.** Es la misma advertencia de siempre, y ahora con dos
600  instrumentos que la ilustran en vez de uno.
601
602---
603
604# La superficie REAL de las cards, MEDIDA — 2026-09-21
605
606**Instrumento:** `scripts/sh-superficie-cards.py` (regenera el número; la tabla no se escribe a
607mano) · **salida:** `work/sh-superficie-cards.tsv`.
608
609La pregunta: **si mañana hubiera un `/bin/sh` propio, ¿qué tendría que saber hacer para que el
610PRODUCTO arranque?** No qué dice POSIX ni qué usa autotools — qué usan las cards que arje ejecuta.
611
612## Las cuatro poblaciones
613
614| población | dónde | cards | con shell |
615|---|---|---|---|
616| servicios de paquete | `recipes/*.toml` · `[[service]]` (emisor: `Service::card`) | 29 | **21** |
617| empotradas | `takana-bootstrap` · `STAGE1_SEED_CARD` + `SSHD_SERVICE_CARD` | 3 | **1** |
618| servidor de producción | `scripts/servidor/cards/*.json` | 1 | **1** |
619| la mudanza | `scripts/mudanza/formatos/arje.py:35` | plantilla | n/a |
620
621> **23 de 33 cards (69 %) ejecutan un fragmento de shell; 10 son `exec` directo.**
622
623La mudanza no se cuenta por card porque su contenido sale del censo de la máquina que se muda. Su
624superficie es la de la plantilla, y la plantilla entera es: **`cd VALOR && exec VALOR`**.
625
626⚠ **Hallazgo de paso, y hacía falta para medir bien: en una Card, `argv` va SIN `argv[0]`** — arje
627lo pone desde `exec`. Lo dice la card de `hammerd`, cuyo `argv` es `["--store","/store",…]` a
628secas. Por eso conviven dos idiomas que parecen incompatibles y no lo son:
629`exec=/bin/busybox argv=["sh","-c",…]` (recetas: despacho multicall) y `exec=/bin/sh
630argv=["-c",…]` (la mudanza). Leer el `argv` sin anteponer el `exec` hace que uno de los dos
631desaparezca de la cuenta.
632
633## Lo que las cards USAN
634
635```
636   secuencia ;         23  ███████████████████████
637   y/o && ||           21  █████████████████████
638   redirección >       18  ██████████████████
639   grupo { }           18  ██████████████████
640   variable $x          4  ████
641   bucle while          2  ██
642   aritmética $(( ))    2  ██
643   tubería |            2  ██
644   sustitución $( )     2  ██
645   case                 1  █
646   bucle for            1  █
647   redirección 2>       1  █
648   glob *               1  █
649```
650
651**Y lo que NO aparece NI UNA VEZ:** `if`, funciones, `until`, `${…}` (ninguna expansión de
652parámetros: ni `:-`, ni `#`, ni `%`), posicionales `$1`/`$@`, `$?`, subshells `( )`, here-docs,
653`!`, segundo plano `&`, y **sustitución con comillas invertidas** — la construcción que tumbó a
654brush en el sandbox **no la usa ninguna card**.
655
656El idioma es uno solo y se repite 18 veces:
657
658```sh
659test -f /etc/x/x.conf || { echo 'x: falta … — es config del SITIO' >&2; exit 78; }; exec /usr/bin/x
660```
661
662**Builtins que el shell tiene que traer: 8.** `exit`×44 `echo`×31 `exec`×22 `test`×20 `cd`×8
663`[`×4 `command`×3 `continue`×1.
664
665**Órdenes externas que la imagen tiene que traer: 14.** `mkdir`×14 `grep`×11 `chown`×4 `id`×4
666`netup`×2 `ssh-keygen`×2 `chmod`×2 `mount`×2 `nft` `hostname` `basename` `blkid` `df` `awk`.
667
668## El cruce con el recorte — y una CORRECCIÓN
669
670```
671siguen en busybox: 11 · no son applets (otro paquete): 3 (netup, nft, ssh-keygen) · RETIRADAS: 0
672✓ ninguna card invoca un applet retirado por el recorte 401 → 277.
673```
674
675⚠ **Acá se escribió que esto «es el guardián que el plan pedía y nadie tenía». Es falso: el
676guardián EXISTE** — `scripts/busybox-vigia.py`, escrito con el paso 1, que lee los retirados de
677`recipes/busybox.toml`, los cruza con la columna `simbolo_kconfig` del TSV y **falla si alguna
678receta, script o card invoca uno**. Corrido hoy: `123 applets retirados de la receta · OK — nadie
679los invoca`, `rc=0`. La sección «Falta un guardián» de más arriba quedó vieja el mismo día.
680
681Lo que el cruce de este instrumento agrega es otra cosa, y conviene no confundirlas: el vigía
682contesta «¿alguien invoca algo que ya no está?» (una puerta, con `rc≠0`); esto contesta **«¿qué
683necesitan las cards para arrancar?»** — inventario, no puerta, y por eso incluye las que no son
684applets (`netup`, `nft`, `ssh-keygen`), que al vigía no le importan y a un perfil de imagen sí.
685
686## Y los 23 fragmentos, por el banco diferencial
687
688```
689scripts/sh-superficie-cards.py --dump-dir /tmp/frags
690scripts/sh-banco-diferencial.py --no-cases --files '/tmp/frags/*.sh' control=… brush=… bash=…
691   brush   DIVERGE=0   bash   DIVERGE=0
692```
693
694Las 23 parsean idéntico en los tres shells. Es `sh -n`, no ejecución —montan discos y levantan
695daemons—, así que dice que el lenguaje alcanza, no que el comportamiento coincida.
696
697## Dos bugs en una card, encontrados por el camino — y ARREGLADOS
698
699`scripts/servidor/cards/montar-trabajo.json` venía sin comillas en tres sitios (dos bugs, uno de
700ellos por duplicado), y los dos fallan en silencio. **Comprobados, no deducidos** — y **no fue un
701accidente de edición posterior: la card nació así en su único commit** (`3b305642`), o sea que las
702comillas se perdieron antes de entrar al árbol.
703
704**Bug 1 — el `awk` sin entrecomillar. Falla SIEMPRE:**
705
706```sh
707df -h / | awk NR==2{print }     # awk: cmd. line:1: NR==2{print  ← error de sintaxis, rc=1
708df -h / | awk 'NR==2{print $4}' # 13G                            ← lo que se quiso escribir
709```
710
711El `echo` final imprimía «`montar-trabajo: /vvv  libres`», sin el número, en cada arranque.
712
713**Bug 2 — el patrón de `grep` sin entrecomillar, en las dos comprobaciones de montaje.** Es LATENTE:
714
715```sh
716grep -q  /vvv  /proc/mounts     # matchea cualquier línea que CONTENGA /vvv (p.ej. /vvv/algo)
717grep -q " /vvv " /proc/mounts   # con comillas: sólo el punto de montaje /vvv
718```
719
720Para que muerda hace falta otra línea de `/proc/mounts` que contenga la subcadena —`/vvv/algo`
721montado aparte, o un `/mnt/vvvv`—; con `/vvv` montado de verdad o ausente, acertaba. Igual en
722`grep -q  /work/sergio`, donde un `/work/sergio2` bastaría.
723
724**Arreglado, y probado con la maquinaria de siempre:** el fragmento se corrió entero con
725`/proc/mounts` sustituido por un fichero de prueba y con `mkdir`/`chmod`/`chown`/`mount`/`blkid`
726stubbeados, así que nada mutó la máquina. Los dos escenarios, antes y después:
727
728| escenario | antes | después |
729|---|---|---|
730| `/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` |
731| `/vvv` montado de verdad | (acertaba) | no vuelve a montar, `170G libres` |
732
733El fragmento nuevo parsea en busybox, bash y brush.
734
735**Bug 3 — las variables desnudas, y ésta se comía un usuario entero.** Arreglado en la misma tanda
736junto con los dos `echo` de error, que iban sin comillas (inocuo hoy: ningún glob en el texto, y se
737comprobó que el mensaje y el `rc=78` salen idénticos antes y después). El que sí tenía diente era
738el bucle de `/home`:
739
740```sh
741# antes
742for h in /home/*; do u=$(basename $h); id -u $u >/dev/null 2>&1 || continue
743                     mkdir -p /run/user/$(id -u $u); chown $u /run/user/$(id -u $u); …
744# después
745for h in /home/*; do u=$(basename "$h"); uid=$(id -u "$u" 2>/dev/null) || continue
746                     mkdir -p "/run/user/$uid"; chown "$u" "/run/user/$uid"; …
747```
748
749Con un directorio llamado `juan perez` en `/home`, el `$u` desnudo se partía en dos campos y
750`id -u juan perez` fallaba ⇒ **el `continue` saltaba a ese usuario en silencio y se quedaba sin su
751`/run/user/<uid>`**. Medido con el mismo banco de stubs, sobre un `/home` de prueba con
752`juan perez`, `root` y `nadie`:
753
754```
755viejo  usuarios que reciben chown: root
756nuevo  usuarios que reciben chown: juan perez|root
757```
758
759`nadie` no recibe en ninguno de los dos, que es lo correcto: no está en `passwd`.
760
761De paso, `id -u` se llamaba **cuatro veces por usuario** (una para el test y tres para las órdenes);
762ahora una sola, izada a `uid`. Eso no era el bug pero era la forma limpia de entrecomillarlo, y se
763ve en la propia medición: las órdenes externas pasaron de `id×4` a `id×1`.
764
765## Qué dice esto sobre el `sh` propio
766
767El consumidor del PRODUCTO es minúsculo y está acotado: **8 builtins, 13 construcciones, ninguna
768expansión de parámetros, ningún `if`, ninguna función**. Eso no es «escribir un shell»: eso es un
769intérprete de listas `&&`/`||` con `test`, `echo`, `exec` y redirección — semanas, no meses. **El
770sandbox de build sigue siendo otro planeta**, y es el que conviene no mezclar.
771
772## Lo que esta medición NO dice
773
774- **Detección por patrón sobre un escáner que respeta comillas, no un parseo.** La primera versión
775  contaba 11 backticks que eran prosa dentro de mensajes de error (`` `minga init` ``) y una docena
776  de «órdenes externas» que eran palabras sueltas de esa prosa. Está arreglado —y el cuerpo de cada
777  sustitución se analiza como shell aparte, que es como aparecieron el `df` y el `awk`— pero sigue
778  siendo aproximación: `command -v journalctl` cuenta `command`, no `journalctl`.
779- **No mide la shell INTERACTIVA de `getty`**, que es otro consumidor y no ejecuta fragmentos.
780- **No mide `scripts/`**, que es su propia unidad de trabajo y es mucho más grande.
781- **No mide las cards REALES de la mudanza**: dependen de un censo que no está en el árbol.
782- **No ejecuta los fragmentos**, sólo los parsea.
783
784---
785
786# La superficie de `scripts/`, MEDIDA — 2026-09-21
787
788**Instrumento:** `scripts/sh-superficie-scripts.py` · **escáner compartido:**
789`scripts/lib/sh_analisis.py` · **salida:** `work/sh-superficie-scripts.tsv`.
790
791⚠ **Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error.** Las cards las ejecuta
792el producto ⇒ su superficie es el contrato de un `/bin/sh` propio hoy. `scripts/` lo ejecutan el
793hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento.
794Lo que sí importa hoy acá es otra cosa:
795
796> **¿algún guion se declara POSIX y no lo es?** Porque ése se rompe HOY: en la imagen `/bin/sh` es
797> el ash de busybox, no bash.
798
799## Las cuatro poblaciones, y quién las parsea
800
801De 118 ficheros descartados por no ser shell (`.html`, `.c`, `.mp4`, `.toml`…):
802
803| población | n | líneas | parsea ash | parsea bash |
804|---|---|---|---|---|
805| **POSIX** (`#!/bin/sh`, `#!/usr/bin/env sh`) | 81 | 10 167 | **81/81** | 81/81 |
806| **bash** (`#!/bin/bash`, `#!/usr/bin/env bash`) | 76 | 10 269 | **48/76** | 76/76 |
807| **EMPOTRADOS** (un `/init` escrito por heredoc) | 21 | 714 | **21/21** | 21/21 |
808| **`source`-only** (`.sh` sin shebang) | 6 | 138 | 6/6 | 6/6 |
809
810> **Cero guiones se declaran POSIX y fallan bajo ash.** Ni los 81 sueltos ni los 21 empotrados.
811
812La clasificación es por la **primera línea**, no por «contiene un shebang»: un `grep -rl '^#!/bin/sh'`
813da 86 porque cuenta los scripts bash que EMPOTRAN un `#!/bin/sh` en un heredoc. Con ese grep,
814`mirada-usb.sh` y `selfhost-verify.sh` aparecían como «POSIX roto bajo ash» — y son bash legítimos.
815
816**Los 21 empotrados son la población que importa para el producto**, y hoy nadie los parsea: son los
817`/init` de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros
818(`takana-live-install.sh` ×3, `metal-desktop-image.sh` ×5, `install-image-efi.sh` ×2, `disk-image.sh`,
819`metal-iso.sh`, `metal-usb-sdboot.sh`, `mirada-usb.sh`, `selfhost-verify.sh`, `cc-por-zig.sh`,
820`jaula-herramientas.sh`, `atuq-en-cosmic.sh`, `dunst-headless.sh`, `metal-desktop-image-dual.sh`).
821**Los 21 los parsea el ash.**
822
823## Por qué los 28 bash son bash: arrays, y casi nada más
824
825| bashismo | guiones |
826|---|---|
827| **`array=( )`** | **31** |
828| `declare`/`typeset` | 7 |
829| `+=` en asignación | 6 |
830| `[[ ]]` | 5 |
831| `<<<` | 4 |
832| `<( )` | 4 |
833| `${arr[i]}` | 3 |
834| `(( ))` | 3 |
835| `mapfile` | 1 |
836
837No aparece ninguna vez: `${v//x/y}`, `${v^^}`, `function nombre`, `PIPESTATUS`/`BASH_*`, `echo -e`,
838`read -a/-d`, `source` en vez de `.`.
839
840**De los 28 que el ash no parsea, 26 lo son por arrays y nada más.** Ése es el presupuesto completo
841de un `sh` propio que quisiera correr la herramienta del proyecto: **arrays indexados**. Los otros
842dos suman `[[ ]]`, `<( )` y `mapfile`.
843
844⚠ **Parsear no es correr.** 48 de los 76 `#!bash` parsean tal cual bajo ash y eso NO significa que
845funcionen: `local`, `echo -e` o un `trap` de subshell parsean en los dos y se portan distinto (lo de
846brush#1396 es justo eso). La tabla mide sintaxis.
847
848## La superficie del lenguaje
849
850Los 81 POSIX usan **todo** lo que las cards no usan: `if` 63/81, funciones 30/81, `${…}` 74/81,
851posicionales 66/81, here-docs 22/81, `case` 26/81, `until` 3/81. **Comparado con las cards —8
852builtins, ningún `if`, ninguna función— es otro lenguaje.** Builtins en todo `scripts/`: 27
853(`echo`×1996, `[`×1270, `exit`×473, `export`×239, `printf`×236, `trap`×40, `local`×19, `ulimit`×3…).
854
855Órdenes externas: **257 por nombre desnudo** (las resuelve el `PATH`) y **35 por ruta absoluta** (no
856pasan por `PATH` y por lo tanto **no pueden ser un applet**). Cruzadas con el recorte:
857**0 applets retirados** — que es lo que dice `busybox-vigia.py`, y ahora los dos instrumentos
858coinciden.
859
860## Los siete bugs del INSTRUMENTO, que son la parte que hay que contar
861
862Cada número de arriba salió mal primero. Vale más anotar esto que la tabla:
863
8641. **Comentarios.** El escáner no los quitaba, y en este repo los comentarios son prosa llena de
865   `` `backticks` ``: medido en `scripts/farm/cosecha-cron.sh`, **89 backticks y los 89 dentro de
866   comentarios**. Daba «sustitución `` `…` `` 75/81» cuando la cifra real es 9/81.
8672. **`shlex` sobre el fichero entero.** Se desincroniza —no es un parser de shell— y una comilla
868   suelta desplaza el estado de TODO lo que sigue: `takana-live-install.sh` daba la orden `init`
869   porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Reescrito sobre el texto
870   que el escáner ya deja sin comillas.
8713. **Cuerpos de heredoc.** No son shell del script que los contiene: `scripts/desplegar-strip.sh`
872   empotra un programa de **Python**, y su `for p in glob.glob(…)` aparecía como la orden `p`.
873   Sacarlos bajó las órdenes externas de 680 a 257 — o sea que **el 62 % del inventario era ruido**.
8744. **Patrones de `case`.** `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden
875   invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía
876   razón. Ahora hay estado de `case`.
8775. **Cuerpos de `$(( ))`.** `i=$((i+1))` daba la orden `i+1`.
8786. **Descriptores de fichero.** `>&2` daba la orden `2`.
8797. **Nombre desnudo vs ruta absoluta.** `exec switch_root /newroot /sbin/init` marcaba el applet
880   `init`; pero una ruta absoluta no se resuelve por `PATH` y no puede ser un applet — en el
881   producto ese `/sbin/init` es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso
882   **mide algo útil**: 35 órdenes van por ruta absoluta y no dependen del `PATH`.
883
884**Ninguno de los siete se veía en la salida.** Todos daban números plausibles. Es la misma lección
885del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento
886en vez de al shell.
887
888## Una librería, no dos copias
889
890El escáner vive en `scripts/lib/sh_analisis.py` y lo importan los dos instrumentos. Duplicarlo era
891repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un
892campo distinto mal. Comprobado que la extracción no cambió nada: la salida de
893`sh-superficie-cards.py` es byte-idéntica antes y después (y su md5 se repite entre corridas — el
894orden de los empates se volvió determinista a propósito, para que la salida sea diffable).
895
896Y los arreglos 3–7 **corrigieron también la medición de las cards**: apareció `sleep`, que el
897tokenizador viejo se comía, y desapareció `i+1`.
898
899## Lo que esto NO mide
900
901- **`|` dentro de un patrón de `case`** se cuenta como tubería. Inflaba poco y no se arregló: hace
902  falta gramática, no regex.
903- **`sh -n` no es ejecución.** Nada de esto dice que los 48 `#!bash` que el ash parsea funcionen
904  bajo ash.
905- **No se midió qué guion corre DÓNDE.** Que `takana-install.sh` sea bash no obliga a nada porque
906  corre en el hub; el que corre dentro de la imagen es `takana-live-install.sh`, y ése es POSIX.
907  La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son
908  por construcción.
909
910
911---
912
913# `simi` — el `/bin/sh` propio, ESCRITO y MEDIDO (2026-09-21)
914
915**Crate:** `crates/simi/` (4448 líneas, `libc` y nada más) · **receta:** `recipes/simi.toml`,
916**sellada** en `b3:46ff6fcf…` · **nombre:** *simi* es «boca, lengua» en quechua — es cómo se
917le habla a la máquina. `rimay` estaba tomado (la familia de embeddings de tawasuyu).
918
919## El veredicto, primero
920
921Banco diferencial contra el ash de busybox 1.36.1, **159 casos** — 10 fixtures de regresión, 107 de
922tortura de comillas, los 23 fragmentos de las cards y los 19 `/init` empotrados:
923
924| shell | divergencias duras | binario | latencia |
925|---|---|---|---|
926| **simi** (sellada, estática) | **0** | **0,48 MB** | **1,04×** el ash |
927| bash 5.3.0 | 8 | 1,57 MB | 1,3× |
928| brush 0.4.0 | 35 | 6,92 MB | 4,1× |
929
930Incluye el **bucle real de `config.status`** que genera cualquier `configure` de autotools — el que
931tumbó a brush. Y 47 pruebas propias del crate en verde.
932
933## SELLADA, y medida contra el artefacto (2026-09-21)
934
935`takana build recipes/simi.toml` en el worker, bajo `flock -o work/.farm-build.lock`:
936
937```
938sealed path=./store/46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834-simi
939b3:46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834      rc=0
940```
941
942El artefacto trae exactamente dos ficheros: `usr/bin/simi` y su `.hammer/recipe.toml`.
943
944| qué | medido sobre el ARTEFACTO |
945|---|---|
946| tipo | ELF x86-64, **estático**, stripped |
947| tamaño | **498 736 bytes** (0,48 MB) |
948| `scripts/static-audit.sh simi` | «estáticos de verdad: 1 · MIENTEN: 0» |
949| banco diferencial, 159 casos vs el ash | **0 divergencias** (bash: 8 · brush: 35) |
950| latencia de arranque | **468 µs** contra **449 µs** del ash — **1,04×** (brush: 1569 µs, 3,5×) |
951| `scripts/verificar-repro.sh simi` | **`✓ simi REPRODUCE`** — bit a bit, apartando el artefacto y reconstruyendo |
952
953⚠ **La latencia del estático musl no es la del build nativo**: el build del hub (glibc, dinámico)
954daba 865 µs, o sea 1,4× el ash. El estático baja a 1,04×. **Medir el binario que viaja, no el que se
955compila a mano** — la diferencia acá fue de un 35 % y habría quedado escrita como si fuera del shell.
956
957⚠ Todavía NO está en ningún perfil de `docs/state/targets.toml`. Declararla es meterla en la raíz de
958confianza del arranque, y eso es el paso 3 de abajo.
959
960⚠ **Y el artefacto vive SÓLO en el store del worker.** El worker sella con `PROMOTE=0` y la cosecha
961baja el **manifiesto**, no el store: el hub no lo tiene. Promoverlo es otro paso.
962
963### Dos trampas operativas que costaron dos corridas
964
965**1. `--store <tirable>` NO sirve para verificar reproducibilidad, y falla con un error que no dice
966nada.** Un store vacío da `Error: io: No such file or directory (os error 2)` a secas, sin decir qué
967falta, porque las DEPS del build no están ahí. El método correcto ya estaba escrito y es
968`scripts/verificar-repro.sh`: **aparta** el artefacto dentro del MISMO store (un rename, que sobrevive
969a un kill) y reconstruye, así las deps siguen en su sitio. Dos corridas perdidas por no leer el
970instrumento que ya existía antes de inventar uno.
971
972**2. La cosecha BORRA una receta sembrada a mano.** `cosecha-cron.sh` siembra hub→worker con
973`rsync --delete`, y el hub del que siembra es **gioser**, no el clon donde se trabaja. Entre el build
974y la verificación, el worker perdió `recipes/simi.toml` —el artefacto sellado sobrevivió, la receta
975no— y el verificador informó `REPRODUCEN: 0 · sin artefacto: 0`, o sea **nada**, porque su primer
976`[ -f "$f" ]` falla y salta en silencio. Hasta que gioser haga `pull` del commit, cualquier corrida en
977el worker necesita reponer la receta primero. Comprobado de paso que el hash NO cambia al editar los
978comentarios de la receta: `hash_inputs` es una lista explícita de campos.
979
980## El alcance salió de la medición, no de POSIX
981
982| consumidor | qué necesita | estado |
983|---|---|---|
984| las 23 cards de arje con shell | 8 builtins, 13 construcciones, ningún `if`, ninguna función | ✅ |
985| los 21 `/init` empotrados | + `if`, funciones, `case`, aquí-documentos, `${…}`, posicionales, `&` | ✅ |
986| `takana-live-install.sh` (corre en la imagen viva) | POSIX entero | ✅ parsea; sin correr |
987| la shell interactiva de `getty` | edición de línea, historial, control de trabajos | ❌ **no está** |
988
989Lo que **no** trae, dicho en vez de fingido: control de trabajos, edición de línea, historial,
990`alias`, `getopts`, `times`, `[[ ]]`, arrays. El shell de `getty` es otro consumidor y sigue
991pendiente.
992
993## Los siete bugs — que es la parte que enseña
994
995Ninguno se veía en la salida. Todos daban resultados plausibles, y **los seis primeros los destapó
996el banco diferencial**, no el juicio:
997
9981. **El lexer fundía lo entrecomillado con lo desnudo.** `x="a  b"` quedaba como UN seg
999   entrecomillado, el parser ya no veía la asignación —el prefijo antes del `=` tiene que ser
1000   desnudo— y **la ejecutaba como una orden**. El error decía «no encontrado», que manda a buscar al
1001   lugar equivocado. Ahora el comillado es por carácter.
10022. **La marca de escape interna chocaba con las barras reales.** La primera versión usaba `\` como
1003   marca y la quitaba al final, así que `$(echo \\$V)` perdía la barra que la sustitución había
1004   PRODUCIDO (ash: `\hola`, simi: `hola`). **La quita de comillas es sobre las comillas de la
1005   palabra, nunca sobre el resultado de una expansión** — y eso no se representa con un escape
1006   dentro del texto: hace falta una máscara de protección por carácter.
10073. **`set -e` no se suspendía dentro de una función llamada desde una condición.** Ahora es un
1008   contador DINÁMICO, y por eso entra solo donde tiene que entrar.
10094. **Los delimitadores de `$( )` y `${ }` no respetaban la barra invertida**: `$(echo \)` cerraba la
1010   sustitución cuando el paréntesis estaba escapado y no debía cerrar nada.
10115. **Las clases `[…]` no entendían `\`** — y `*[\\\`\"\$]*` es literalmente el patrón del
1012   `config.status` de libtool.
10136. **Dentro de comillas dobles, el cuerpo de un backtick sigue entre comillas**, así que la barra
1014   también se elimina ante `"`. Sin eso, dos casos de la tortura divergían en los dos sentidos.
10157. **Y uno que el banco NO vio:** el `trap EXIT` **heredado** se disparaba en el subshell. POSIX
1016   §2.12 manda resetearlo al entrar — es la mitad complementaria de brush#1396, y al revés. Lo
1017   encontró la prueba de regresión del crate, porque ningún caso del banco lo probaba. **Los dos
1018   oráculos hacen falta**, y el caso quedó agregado al fixture 08.
1019
1020Las dos invariantes que el fichero de ejecución sostiene a propósito, las dos porque se midieron
1021rotas en el candidato ajeno: **el `trap EXIT` de un subshell corre** (brush#1396) y **`exec`
1022reemplaza el proceso** (si el shell forkea, arje supervisa al shell y no al daemon).
1023
1024## Lo que queda, en orden
1025
10261. ~~**Construir la receta en el lab** y sellarla.~~ **HECHO** — `b3:46ff6fcf…`, ver arriba.
10272. **ADR** — escribir un shell propio y meterlo en `ATTEST_PATHS` es una decisión de frontera, y el
1028   nombre `simi` también. Este documento la registra; el ADR la decide.
10293. **Cablear `/bin/sh`**: `USERLAND_COMPONENTS` + el retiro del homónimo (`lib.rs:676`). Es frente C
1030   y toca la raíz de confianza del arranque.
10314. **Medir EJECUTANDO**, no sólo parseando: los `/init` en un arranque de QEMU
1032   (`product-boot-test.sh`) y `takana-live-install.sh` en la imagen viva.
10335. **La shell interactiva de `getty`**, que es el consumidor que falta entero.
10346. **El sandbox de build sigue con busybox**, y con razón: eso es otro planeta y no está prometido.
1035
1036
1037---
1038
1039# El censo de proveedores — el reparto applet→dueño, MEDIDO (2026-09-21)
1040
1041**Instrumento:** `scripts/busybox-censo-proveedores.py` · **columnas nuevas del TSV:**
1042`proveedor_medido`, `medido_el` · **qué mide:** índice inverso de
1043`<store>/<hash>-<paquete>/{bin,sbin,usr/bin,usr/sbin,libexec}` sobre los 1407 artefactos del store
1044del hub, cruzado con los 277 applets vivos.
1045
1046## Por qué había que medirlo
1047
1048Este documento decía, arriba y en letra clara: «**el reparto applet→destino es juicio, no medida**.
1049La asignación de dueño la escribí yo y hay que revisarla receta a receta». Revisada:
1050
1051| | n | qué significa |
1052|---|---|---|
1053| el dueño que el TSV nombra **sí lo provee** | 167 | el reparto era correcto |
1054| **lo provee OTRO paquete** | 31 | el dueño estaba mal escrito, pero hay sustituto |
1055| **no lo provee NADIE salvo busybox** | **79** | el trabajo real, y son 56 más de los 23 previstos |
1056
1057> **Los 23 applets «sin dueño» eran 79.** No porque falte escribir más userland, sino porque
1058> **nueve paquetes que el TSV daba por dueños no instalan el binario que se les atribuía.**
1059
1060## Lo que aparece cuando se mira el artefacto en vez de la tabla
1061
1062**`xz` es el caso que más duele, y no es un olvido: es la receta haciendo lo que pide.**
1063`recipes/xz.toml` pasa `--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo
1064--disable-scripts` ⇒ el artefacto sellado tiene **`usr/bin` vacío**: es liblzma y nada más. Y `xz`
1065figura **en seis perfiles**. O sea que la imagen declara `xz` y el único `xz` que hay adentro es el
1066applet de busybox. Lo mismo, por la misma razón, con **`ncurses`**: su receta instala
1067`make install.libs install.includes` ⇒ ni `clear`, ni `reset`, ni `tput`, ni `infocmp`.
1068
1069**Es un patrón, no dos accidentes: una receta de LIBRERÍA no es un paquete de HERRAMIENTAS**, y
1070`build-state.json` no distingue entre las dos — las dos salen `sealed` y con sus perfiles. Es
1071[[catalogo-no-es-imagen]] un piso más abajo: ahí el paquete existía y no viajaba; acá **viaja y no
1072trae el binario**. Ninguna métrica actual lo ve; el censo sí.
1073
1074Los demás huecos nuevos, por qué cada uno:
1075
1076| dueño | applets que NO provee | causa |
1077|---|---|---|
1078| `iproute2` | 18 | 12 son net-tools (ya marcados NO-drop-in) y **6 son nombres internos de busybox** — `ipaddr iplink ipneigh iproute iprule iptunnel` son `ip addr`/`ip link`/… escritos como applet. **No son comandos de nadie: se BORRAN del defconfig**, no se reemplazan |
1079| `shadow` | 8 | `addgroup adduser delgroup deluser mkpasswd cryptpw` ya estaban marcados; `add-shell` y `remove-shell` son de debianutils y nadie los trae |
1080| `xz` | 6 | receta de librería (arriba) |
1081| `kbd` | 4 | `loadfont loadkmap dumpkmap` son los NOMBRES de busybox de `setfont`/`loadkeys`/`dumpkeys`, que kbd **sí** trae ⇒ es NO-drop-in de nombre, no hueco de función. `vlock` no lo trae nadie |
1082| `iputils` | 3 | trae `ping arping tracepath clockdiff`; `traceroute`/`traceroute6` son otro paquete y `ping6` es `ping -6` |
1083| `ncurses` | 2 | receta de librería (arriba) |
1084| `util-linux` | 2 | `hd` es el alias de `hexdump`, que sí trae ⇒ falso hueco. **`setpriv` es hueco real**: el `configure` lo desactivó |
1085| `uutils` | 2 | `arch` y `hostname` — ver abajo, los cubre el feature |
1086| `vim` | 1 | instala `vim ex view rvim vimdiff xxd`, **no `vi`**: falta el symlink, una línea |
1087| `cronie` | 1 | `run-parts` es de debianutils |
1088| `libarchive` | 1 | instala `bsdcpio`, no `cpio` |
1089| `socat` `chrony` `dhcpcd` | 6 | los NO-drop-in ya conocidos |
1090
1091## Y tres correcciones al revés — el dueño estaba mal, pero el sustituto EXISTE
1092
1093Los 31 «otro proveedor» son casi todos `coreutils` (GNU, sellado, sin perfil) cubriendo lo que
1094`uutils` no traía. Los tres que cambian una conclusión del plan:
1095
1096- **`switch_root` lo trae `util-linux`** — es uno de los cinco indultos del paso 1, y tiene
1097  sustituto real. (El `/init` del initramfs sigue necesitando el de busybox porque ahí no hay
1098  util-linux, pero eso es el initramfs, no la imagen.)
1099- **`sulogin` lo trae `util-linux`**, no shadow. **`partprobe` lo trae `parted`**, no util-linux.
1100- **`getty` tiene sustituto: `agetty`, de `util-linux`, que está en los 7 perfiles.** El TSV lo
1101  daba como `getty-ESCRIBIR`. No es escritura: es cambiar el `exec` de la card
1102  (`takana-bootstrap/src/lib.rs:240`) y medirlo. Lo que sigue faltando entero es la **shell
1103  interactiva**, que es otro consumidor — `simi` no trae edición de línea ni historial.
1104
1105## `uutils` no traía 25 de sus 90 — arreglado en la receta
1106
1107El artefacto sellado `b3:9005da83…` instala **78 symlinks**, no los 90 que este documento le
1108atribuía, y el propio multicall lo dice sin ambigüedad:
1109
1110```
1111$ …-uutils/usr/bin/coreutils chmod --version
1112coreutils: unknown program 'chmod'
1113```
1114
1115Faltaban exactamente estos 25: `arch chgrp chmod chown chroot groups hostid hostname id install
1116kill logname mkfifo mknod nice nohup nproc stat stty sync timeout uname users who whoami`. La causa
1117es que `recipes/uutils.toml` construía con las **default features** (`feat_common_core`), y el
1118`Cargo.toml` del tag pineado pone el resto detrás de `feat_os_unix_musl` — que cubre **esos 25 y
1119ninguno más**. Arreglado con un flag en la receta.
1120
1121⚠ **Esto importaba más de lo que parece:** `USERLAND_COMPONENTS` hidrata `uutils` DESPUÉS de
1122busybox para que lo ensombrezca, y **lo que no existe no ensombrece nada**. O sea que en el producto
1123de hoy `chmod`, `chown`, `id`, `uname` y `stat` los sigue dando busybox, con el userland Rust
1124instalado encima y la métrica diciendo que están cubiertos.
1125
1126⚠ **Lo que el feature NO arregla:** `who`, `users`, `uptime` y `pinky` compilan contra los stubs de
1127utmpx de musl y **contestan vacío**. No es regresión (el `who` de busybox en musl está igual de
1128ciego), pero tampoco es sustitución.
1129
1130## Qué cambia en el orden de ataque
1131
11321. **`xz` y `ncurses` necesitan su paquete de herramientas** — variante de receta, no perilla: tocar
1133   `--disable-xz` re-hashea xz y arrastra a todos sus dependientes. Patrón `elfutils-libdw`.
11342. **Los 6 `ipaddr/iplink/…` salen del defconfig** en el próximo recorte: 277 → 271 sin escribir nada.
11353. **`vi` en la receta de vim** y **`cpio`→`bsdcpio` en libarchive**: un symlink cada uno.
11364. **`getty` deja de ser escritura** y pasa a ser migración de card a `agetty`.
11375. **Falta un guardián nuevo**, hermano del vigía de applets retirados: uno que falle cuando una
1138   receta declare un perfil y su artefacto **no instale el binario homónimo**. El censo es el
1139   instrumento; falta cablearlo a CI.
1140
1141## Lo que este censo NO mide
1142
1143- **Equivalencia de nombre, no de banderas.** `nc` de socat cuenta como proveedor aunque su sintaxis
1144  difiera. La columna `equivalencia` del TSV sigue haciendo falta y sigue diciendo `sin-verificar`
1145  en 230 filas.
1146- **El store del HUB.** Un artefacto sellado sólo en el worker no aparece; `build-state.json` dice
1147  `sealed` igual. Los 79 hay que releerlos como «no está en el store del hub».
1148- **Qué applets EJECUTA el producto de verdad**, que sigue sin medirse y sigue siendo lo que
1149  abarataría todo lo demás.
1150
1151## Lo hecho con el censo en la mano (2026-09-21, misma tanda)
1152
1153> **El número que baja: 79 → 69 applets que sólo provee busybox**, y ninguno de los diez
1154> costó escribir userland.
1155
1156| unidad | estado | sello |
1157|---|---|---|
1158| `uutils` con `feat_os_unix_musl` | ✅ **79 → 106 applets**, 27 nuevos, 0 perdidos | `b3:614ff653…` |
1159| `recipes/xz-tools.toml` — variante con las herramientas | ✅ 23 binarios, estático, round-trip probado | `b3:194c7d69…` |
1160| `vi` en `recipes/vim.toml` | ✅ `usr/bin/vi → vim` | `b3:d303ec31…` |
1161| `cpio` en `recipes/libarchive.toml` | ✅ `usr/bin/cpio → bsdcpio` | `b3:3f999ff8…` |
1162| **paso 2 del plan** — declarar la munición en `targets.toml` | ✅ `uutils findutils findutils-xargs diffutils gzip grep xz-tools` en `base` | — |
1163| guardián de regresiones (`--guardian`) | ✅ probado en los dos sentidos | — |
1164
1165Los cuatro artefactos sellaron con **el mismo hash en el store tirable y en `/store`** —dos
1166`work_root` distintos, bytes idénticos—, o sea reproducibilidad confirmada de paso, que no era el
1167objetivo. Y el reparto del TSV mejoró solo: «el dueño que el TSV nombra sí lo provee» pasó de
1168**167 a 194**, porque los 25 que antes cubría el `coreutils` de GNU ahora los da el `uutils` al que
1169el TSV se los atribuía.
1170
1171⚠ **`xz-tools` salió DINÁMICO en la primera corrida** —`interpreter /lib/ld-musl-x86_64.so.1`—
1172pese al `link = "static"` de la receta, y funcionaba perfectamente: comprimía y descomprimía. Es el
1173relink de libtool, que ya documentan `libarchive.toml` y `bluez.toml`: **hace falta
1174`LDFLAGS=-all-static` en `compile` Y en `install`**, y sin él el `link` de la receta se ignora en
1175silencio. Lo delató mirar el `file` del binario, no una prueba que fallara — [[link-static-mentira-libtool]].
1176
1177**`xz-tools` es el tercer caso de la familia de `zstd-cli`**, y eso ya estaba escrito en
1178`targets.toml`: «la receta canónica construye sólo `lib/` y su artefacto no tiene `usr/bin`;
1179declararla no habría arreglado nada». Lo mismo pasó con `zlib-shared`, `libffi-shared` y
1180`musl-shared`. **Cuatro veces la misma forma**: la receta publica la lib, la imagen declara el
1181paquete, y el binario no está. El censo es el primer instrumento que lo ve sin que se rompa nada
1182primero.
1183
1184**El guardián vigila pérdidas, no un número**, y esa decisión es el punto:
1185
1186```
1187✗ REGRESIÓN: 2 applets PERDIERON su proveedor y vuelven a depender de busybox:
1188    xz             lo proveía xz
1189    xzcat          lo proveía xz
1190```
1191
1192Un umbral («no más de 79 sin dueño») hay que subirlo cada vez que se retira un applet, y a la
1193tercera nadie lo sube con criterio — [[umbral-a-mano-envejece]]. Una pérdida, en cambio, es siempre
1194una regresión. Probado en los dos sentidos, que es lo que hace falta para saber que un guardián
1195sirve: `rc=0` contra `/store` completo, `rc=1` contra un store mutilado, nombrando applet y
1196proveedor perdido.
1197
1198**Lo que NO se hizo, y por qué:** `ncurses-tools` (para `clear`, `reset`, `tput`) parece el gemelo
1199de `xz-tools` y no lo es. La receta canónica instala `install.libs install.includes` y **tampoco
1200instala la base de datos terminfo**: una variante con los `progs` obliga a decidir si terminfo entra
1201en la imagen (~7 MB) o si basta con los `--with-fallbacks` ya compilados dentro de la lib. Es una
1202decisión de producto, no un enlace que faltaba — y se decide, no se hace de paso.
1203
1204### Y el barrido general, con su límite
1205
1206Buscado el mismo patrón en TODO el corpus —recetas declaradas en algún perfil cuyo directorio de
1207binarios existe y está VACÍO— salen **dos, y las dos son la familia xz**: `xz` (6 perfiles) y
1208`xz-shared` (8 perfiles). O sea que el caso está acotado y `xz-tools` lo cierra.
1209
1210⚠ **Pero el barrido no habría encontrado `ncurses`**, y conviene decir por qué: su receta no deja un
1211`usr/bin` vacío, **no crea el directorio**, y eso es indistinguible de una librería legítima — `zlib`
1212tampoco trae binarios y está bien así. Sin una declaración por receta de «qué binarios publico», no
1213hay señal que separar del ruido. **Lo que da la señal es el TSV de applets**, porque es lo único del
1214repo que escribe la EXPECTATIVA (este applet debería venir de este paquete) contra la cual medir.
1215Es la misma forma que [[subcomando-sin-driver]]: el problema no se ve mirando el artefacto, se ve
1216cruzándolo con lo que alguien prometió que tendría.
1217

plan-botar-busybox.md

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:

frentequé 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-240media — no es runtime, no toca el producto
B · Runtime del productolos 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ónSTAGE1_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ñonqué significa
sellada + en perfil148el reemplazo ya viaja en la imagen: el applet es redundante hoy
sellada, sin perfil125construido, sellado, y no viaja: falta declararlo
sin dueño128de 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ñonqué cubre
uutils90todo el núcleo POSIX: ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown …
arje-zero20init linuxrc runlevel halt poweroff reboot run-init switch_root + los 12 de runit (runsv sv svc svlogd chpst envdir setuidgid softlimit start-stop-daemon …)
hammerd4syslogd klogd logger logread
brush3sh ash hush
gzip3gzip gunzip zcat
diffutils2diff cmp
findutils / findutils-xargs / libarchive3find · 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:

piezaappletsqué es
kmodinsmod rmmod lsmod modprobe depmod modinfo (6)no hay receta kmod en el corpus. En Rust: finit_module + parseo de modules.dep. Bien acotado
psmisckillall killall5 pstree fuser (4)procps-ng NO los trae. Traer receta psmisc (C) o escribir
grep POSIXgrep 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 /devmdev makedevs uevent (3)decisión: lo absorbe arje, o entra una receta eudev/mdevd
bcbc dc (2)traer receta
gettygetty cttyhack (2)hoy la card de getty ejecuta /bin/busybox (lib.rs:429)
awkawk (1)decisión abierta, ver abajo
sedsed (1)sed GNU sellado y en los 7 perfiles; el Rust nativo está por escribir
dnsnslookup (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í)


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

pruebabrushcontrol busybox
Banco POSIX — 57 casos (scripts/sh-banco-posix.sh)56/5756/57
Las 1130 fases de shell del corpus (sh -n sobre las fases de las 549 recetas que las tienen)0 rechazadas0 rechazadas
configure de ffmpeg — 8345 líneas, sh -nrc=0rc=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:

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 <store-tirable> build recipes/<r>.toml
recetacontrol (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:

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:

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.

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, 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ónqué cuestaqué deja
Esperar a #1394ceroel upstream es activísimo (issue #1393 de anteayer) y el bug es acotado, pero no es nuestro calendario
Parchar brush en la recetaun .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
dashreceta nueva, C, ~200 KB. Es el sh de referencia POSIXresuelve el sandbox YA — y no avanza nada la soberanía en Rust
bash como /bin/sh del productocero: ya está sellada Y en los 7 perfilestapa el frente B sin escribir nada. No sirve para el sandbox sin medirlo antes
Dejar busybox donde estáceroes 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:

indultadopor qué se queda
setuidgidla 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_rootel /init del initramfs del instalador lo ejecuta (scripts/takana-live-install.sh:375), antes de que arje exista
poweroff · halt · rebootnada 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:

reglaqué 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 Xproc|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 recorte277 (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 selladoapplets en /bin /sbin /usr/bin /usr/sbinqemu
base (busybox sin recortar)b3:1a3823b4…400rc=0
recortadob3:3aa8cd2d…278rc=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:

⚠ 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


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
fuentecasosmodobrushbash
cases — regresiones conocidas10ejecutado4 divergen0
gen — tortura de comillas107ejecutado31 divergen8
phases — las fases de las 549 recetas1130sh -n00
files — configure y libtool reales26sh -n00

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:

( 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:

# 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.

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ónbinario
busybox4641,17 MB
bash8641,57 MB
brush17246,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í)


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óndóndecardscon shell
servicios de paqueterecipes/*.toml · [[service]] (emisor: Service::card)2921
empotradastakana-bootstrap · STAGE1_SEED_CARD + SSHD_SERVICE_CARD31
servidor de producciónscripts/servidor/cards/*.json11
la mudanzascripts/mudanza/formatos/arje.py:35plantillan/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:

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:

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:

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:

escenarioantesdespués
/proc/mounts sólo trae /vvv/sergio (el falso positivo)no montaba /vvv y el echo salía sin númeromount /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:

# 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/<uid>. 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


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ónnlíneasparsea ashparsea bash
POSIX (#!/bin/sh, #!/usr/bin/env sh)8110 16781/8181/81
bash (#!/bin/bash, #!/usr/bin/env bash)7610 26948/7676/76
EMPOTRADOS (un /init escrito por heredoc)2171421/2121/21
source-only (.sh sin shebang)61386/66/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

bashismoguiones
array=( )31
declare/typeset7
+= en asignación6
[[ ]]5
<<<4
<( )4
${arr[i]}3
(( ))3
mapfile1

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


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:

shelldivergencias durasbinariolatencia
simi (sellada, estática)00,48 MB1,04× el ash
bash 5.3.081,57 MB1,3×
brush 0.4.0356,92 MB4,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
tipoELF x86-64, estático, stripped
tamaño498 736 bytes (0,48 MB)
scripts/static-audit.sh simi«estáticos de verdad: 1 · MIENTEN: 0»
banco diferencial, 159 casos vs el ash0 divergencias (bash: 8 · brush: 35)
latencia de arranque468 µs contra 449 µs del ash — 1,04× (brush: 1569 µs, 3,5×)
scripts/verificar-repro.sh simi✓ simi REPRODUCE — bit a bit, apartando el artefacto y reconstruyendo

⚠ 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.

⚠ Y el artefacto vive SÓLO en el store del worker. El worker sella con PROMOTE=0 y la cosecha baja el manifiesto, no el store: el hub no lo tiene. Promoverlo es otro paso.

Dos trampas operativas que costaron dos corridas

1. --store <tirable> NO sirve para verificar reproducibilidad, y falla con un error que no dice nada. Un store vacío da Error: io: No such file or directory (os error 2) a secas, sin decir qué falta, porque las DEPS del build no están ahí. El método correcto ya estaba escrito y es scripts/verificar-repro.sh: aparta el artefacto dentro del MISMO store (un rename, que sobrevive a un kill) y reconstruye, así las deps siguen en su sitio. Dos corridas perdidas por no leer el instrumento que ya existía antes de inventar uno.

2. La cosecha BORRA una receta sembrada a mano. cosecha-cron.sh siembra hub→worker con rsync --delete, y el hub del que siembra es gioser, no el clon donde se trabaja. Entre el build y la verificación, el worker perdió recipes/simi.toml —el artefacto sellado sobrevivió, la receta no— y el verificador informó REPRODUCEN: 0 · sin artefacto: 0, o sea nada, porque su primer [ -f "$f" ] falla y salta en silencio. Hasta que gioser haga pull del commit, cualquier corrida en el worker necesita reponer la receta primero. Comprobado de paso que el hash NO cambia al editar los comentarios de la receta: hash_inputs es una lista explícita de campos.

El alcance salió de la medición, no de POSIX

consumidorqué necesitaestado
las 23 cards de arje con shell8 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 gettyedició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 delconfig.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.

El censo de proveedores — el reparto applet→dueño, MEDIDO (2026-09-21)

Instrumento: scripts/busybox-censo-proveedores.py · columnas nuevas del TSV: proveedor_medido, medido_el · qué mide: índice inverso de <store>/<hash>-<paquete>/{bin,sbin,usr/bin,usr/sbin,libexec} sobre los 1407 artefactos del store del hub, cruzado con los 277 applets vivos.

Por qué había que medirlo

Este documento decía, arriba y en letra clara: «el reparto applet→destino es juicio, no medida. La asignación de dueño la escribí yo y hay que revisarla receta a receta». Revisada:

nqué significa
el dueño que el TSV nombra sí lo provee167el reparto era correcto
lo provee OTRO paquete31el dueño estaba mal escrito, pero hay sustituto
no lo provee NADIE salvo busybox79el trabajo real, y son 56 más de los 23 previstos

Los 23 applets «sin dueño» eran 79. No porque falte escribir más userland, sino porque nueve paquetes que el TSV daba por dueños no instalan el binario que se les atribuía.

Lo que aparece cuando se mira el artefacto en vez de la tabla

xz es el caso que más duele, y no es un olvido: es la receta haciendo lo que pide. recipes/xz.toml pasa --disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo --disable-scripts ⇒ el artefacto sellado tiene usr/bin vacío: es liblzma y nada más. Y xz figura en seis perfiles. O sea que la imagen declara xz y el único xz que hay adentro es el applet de busybox. Lo mismo, por la misma razón, con ncurses: su receta instala make install.libs install.includes ⇒ ni clear, ni reset, ni tput, ni infocmp.

Es un patrón, no dos accidentes: una receta de LIBRERÍA no es un paquete de HERRAMIENTAS, y build-state.json no distingue entre las dos — las dos salen sealed y con sus perfiles. Es [[catalogo-no-es-imagen]] un piso más abajo: ahí el paquete existía y no viajaba; acá viaja y no trae el binario. Ninguna métrica actual lo ve; el censo sí.

Los demás huecos nuevos, por qué cada uno:

dueñoapplets que NO proveecausa
iproute21812 son net-tools (ya marcados NO-drop-in) y 6 son nombres internos de busybox — ipaddr iplink ipneigh iproute iprule iptunnel son ip addr/ip link/… escritos como applet. No son comandos de nadie: se BORRAN del defconfig, no se reemplazan
shadow8addgroup adduser delgroup deluser mkpasswd cryptpw ya estaban marcados; add-shell y remove-shell son de debianutils y nadie los trae
xz6receta de librería (arriba)
kbd4loadfont loadkmap dumpkmap son los NOMBRES de busybox de setfont/loadkeys/dumpkeys, que kbd sí trae ⇒ es NO-drop-in de nombre, no hueco de función. vlock no lo trae nadie
iputils3trae ping arping tracepath clockdiff; traceroute/traceroute6 son otro paquete y ping6 es ping -6
ncurses2receta de librería (arriba)
util-linux2hd es el alias de hexdump, que sí trae ⇒ falso hueco. setpriv es hueco real: el configure lo desactivó
uutils2arch y hostname — ver abajo, los cubre el feature
vim1instala vim ex view rvim vimdiff xxd, no vi: falta el symlink, una línea
cronie1run-parts es de debianutils
libarchive1instala bsdcpio, no cpio
socat chrony dhcpcd6los NO-drop-in ya conocidos

Y tres correcciones al revés — el dueño estaba mal, pero el sustituto EXISTE

Los 31 «otro proveedor» son casi todos coreutils (GNU, sellado, sin perfil) cubriendo lo que uutils no traía. Los tres que cambian una conclusión del plan:

uutils no traía 25 de sus 90 — arreglado en la receta

El artefacto sellado b3:9005da83… instala 78 symlinks, no los 90 que este documento le atribuía, y el propio multicall lo dice sin ambigüedad:

$ …-uutils/usr/bin/coreutils chmod --version
coreutils: unknown program 'chmod'

Faltaban exactamente estos 25: arch chgrp chmod chown chroot groups hostid hostname id install kill logname mkfifo mknod nice nohup nproc stat stty sync timeout uname users who whoami. La causa es que recipes/uutils.toml construía con las default features (feat_common_core), y el Cargo.toml del tag pineado pone el resto detrás de feat_os_unix_musl — que cubre esos 25 y ninguno más. Arreglado con un flag en la receta.

⚠ Esto importaba más de lo que parece: USERLAND_COMPONENTS hidrata uutils DESPUÉS de busybox para que lo ensombrezca, y lo que no existe no ensombrece nada. O sea que en el producto de hoy chmod, chown, id, uname y stat los sigue dando busybox, con el userland Rust instalado encima y la métrica diciendo que están cubiertos.

⚠ Lo que el feature NO arregla: who, users, uptime y pinky compilan contra los stubs de utmpx de musl y contestan vacío. No es regresión (el who de busybox en musl está igual de ciego), pero tampoco es sustitución.

Qué cambia en el orden de ataque

  1. xz y ncurses necesitan su paquete de herramientas — variante de receta, no perilla: tocar --disable-xz re-hashea xz y arrastra a todos sus dependientes. Patrón elfutils-libdw.
  2. Los 6 ipaddr/iplink/… salen del defconfig en el próximo recorte: 277 → 271 sin escribir nada.
  3. vi en la receta de vim y cpio→bsdcpio en libarchive: un symlink cada uno.
  4. getty deja de ser escritura y pasa a ser migración de card a agetty.
  5. Falta un guardián nuevo, hermano del vigía de applets retirados: uno que falle cuando una receta declare un perfil y su artefacto no instale el binario homónimo. El censo es el instrumento; falta cablearlo a CI.

Lo que este censo NO mide

Lo hecho con el censo en la mano (2026-09-21, misma tanda)

El número que baja: 79 → 69 applets que sólo provee busybox, y ninguno de los diez costó escribir userland.

unidadestadosello
uutils con feat_os_unix_musl✅ 79 → 106 applets, 27 nuevos, 0 perdidosb3:614ff653…
recipes/xz-tools.toml — variante con las herramientas✅ 23 binarios, estático, round-trip probadob3:194c7d69…
vi en recipes/vim.toml✅ usr/bin/vi → vimb3:d303ec31…
cpio en recipes/libarchive.toml✅ usr/bin/cpio → bsdcpiob3:3f999ff8…
paso 2 del plan — declarar la munición en targets.toml✅ uutils findutils findutils-xargs diffutils gzip grep xz-tools en base—
guardián de regresiones (--guardian)✅ probado en los dos sentidos—

Los cuatro artefactos sellaron con el mismo hash en el store tirable y en /store —dos work_root distintos, bytes idénticos—, o sea reproducibilidad confirmada de paso, que no era el objetivo. Y el reparto del TSV mejoró solo: «el dueño que el TSV nombra sí lo provee» pasó de 167 a 194, porque los 25 que antes cubría el coreutils de GNU ahora los da el uutils al que el TSV se los atribuía.

⚠ xz-tools salió DINÁMICO en la primera corrida —interpreter /lib/ld-musl-x86_64.so.1— pese al link = "static" de la receta, y funcionaba perfectamente: comprimía y descomprimía. Es el relink de libtool, que ya documentan libarchive.toml y bluez.toml: hace falta LDFLAGS=-all-static en compile Y en install, y sin él el link de la receta se ignora en silencio. Lo delató mirar el file del binario, no una prueba que fallara — [[link-static-mentira-libtool]].

xz-tools es el tercer caso de la familia de zstd-cli, y eso ya estaba escrito en targets.toml: «la receta canónica construye sólo lib/ y su artefacto no tiene usr/bin; declararla no habría arreglado nada». Lo mismo pasó con zlib-shared, libffi-shared y musl-shared. Cuatro veces la misma forma: la receta publica la lib, la imagen declara el paquete, y el binario no está. El censo es el primer instrumento que lo ve sin que se rompa nada primero.

El guardián vigila pérdidas, no un número, y esa decisión es el punto:

✗ REGRESIÓN: 2 applets PERDIERON su proveedor y vuelven a depender de busybox:
    xz             lo proveía xz
    xzcat          lo proveía xz

Un umbral («no más de 79 sin dueño») hay que subirlo cada vez que se retira un applet, y a la tercera nadie lo sube con criterio — [[umbral-a-mano-envejece]]. Una pérdida, en cambio, es siempre una regresión. Probado en los dos sentidos, que es lo que hace falta para saber que un guardián sirve: rc=0 contra /store completo, rc=1 contra un store mutilado, nombrando applet y proveedor perdido.

Lo que NO se hizo, y por qué: ncurses-tools (para clear, reset, tput) parece el gemelo de xz-tools y no lo es. La receta canónica instala install.libs install.includes y tampoco instala la base de datos terminfo: una variante con los progs obliga a decidir si terminfo entra en la imagen (~7 MB) o si basta con los --with-fallbacks ya compilados dentro de la lib. Es una decisión de producto, no un enlace que faltaba — y se decide, no se hace de paso.

Y el barrido general, con su límite

Buscado el mismo patrón en TODO el corpus —recetas declaradas en algún perfil cuyo directorio de binarios existe y está VACÍO— salen dos, y las dos son la familia xz: xz (6 perfiles) y xz-shared (8 perfiles). O sea que el caso está acotado y xz-tools lo cierra.

⚠ Pero el barrido no habría encontrado ncurses, y conviene decir por qué: su receta no deja un usr/bin vacío, no crea el directorio, y eso es indistinguible de una librería legítima — zlib tampoco trae binarios y está bien así. Sin una declaración por receta de «qué binarios publico», no hay señal que separar del ruido. Lo que da la señal es el TSV de applets, porque es lo único del repo que escribe la EXPECTATIVA (este applet debería venir de este paquete) contra la cual medir. Es la misma forma que [[subcomando-sin-driver]]: el problema no se ve mirando el artefacto, se ve cruzándolo con lo que alguien prometió que tendría.