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## ✅ RESUELTO — `simi`, y la noticia es mejor de lo que esta sección decía
129
130**El bloqueante ya no existe.** Lo que había escrito acá —que el candidato era `brush` y que había
131que medirlo— quedó superado: se escribió un `/bin/sh` PROPIO, **`simi`** (`recipes/simi.toml`,
132`crates/simi`, 4448 líneas de Rust), y está medido contra el corpus REAL.
133
134| | simi | ash (control) | bash | brush |
135|---|---|---|---|---|
136| divergencias duras, **1339 casos** | **0** | — | 8 | 35 |
137| avisos (stderr) | 0 | — | | |
138| ms/caso | 1,5 | 1,5 | | |
139| tamaño | **499 KB** estático, 0 NEEDED | 0,92 MB | | 6,9 MB |
140| latencia (hub) | 547 µs | 464 µs · 1,18× | | 3,5× |
141
142⚠ **Y lo importante de esos 1339: sólo 10 están escritos a mano — los otros 1329 son fases REALES
143de recetas del corpus.** No es «pasa un banco POSIX que yo inventé» (eso es
144`sh-banco-posix.sh`, y brush lo pasa 56/57 y aun así no construye una receta autotools): es «hace
145LO MISMO que el ash sobre el material que de verdad hay».
146
147Para llegar a 0 hubo que arreglar **una receta, no simi**: `wasi-libcxx` usaba `${t/unknown-/}`, un
148bash-ism que el ash tolera por cortesía y que POSIX no tiene. Era la ÚNICA divergencia de las 1339.
149simi implementa las diez expansiones POSIX (`${v}` `:-` `:=` `:?` `:+` `${#v}` `#` `##` `%` `%%`) y
150**ninguna de bash** (`${v/a/b}` `${v:1:2}` `${!v}` `${v^^}`) — verificadas una por una. Eso no es
151una carencia: es la línea que define qué es `/bin/sh` acá, y hace que un bash-ism en una receta
152FALLE en vez de colarse.
153
154### Lo que queda de `simi`, y es sólo declararla
155
156Está **sellada y vigente** (`b3:327b31b7…`, traída al store del hub el 2026-10-22) y **en ningún
157perfil, a propósito**: declararla la mete en la raíz de confianza del arranque (frente C) y eso es
158su propia unidad de trabajo. Dicho de otro modo: el paso que falta no es escribir ni medir, es
159DECIDIR.
160
161⚠ Dos cosas que su receta tenía caducadas y se corrigieron el 22-oct: citaba el hash
162`b3:46ff6fcf…` (superado) y «159 casos» (el banco creció a 1339). *Una medición con su hash al lado
163caduca cuando el hash avanza* — y ésta llevaba un mes diciendo un número viejo.
164
165## Orden de ataque
166
167Cada paso es una unidad de trabajo con su criterio de terminado. Los tres primeros **no tocan el
168frente C** y se pueden hacer hoy.
169
1701. ~~**Recortar el `defconfig`.**~~ **HECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.**
1712. **Declarar la munición que ya existe.** `uutils`, `findutils`, `findutils-xargs`, `diffutils`,
172 `brush`, `gzip`, `libarchive` a los perfiles de `targets.toml`. Terminado cuando `build-state.py`
173 los marca con perfil y la imagen los lleva.
1743. ~~**Medir `brush` como `/bin/sh`.**~~ **HECHO, 2026-09-21 — veredicto abajo: no sirve todavía
175 para el sandbox.** Queda pendiente medirlo como `/bin/sh` del PRODUCTO (cards + `product-boot-test.sh`),
176 que es un consumidor distinto y mucho más blando.
1774. **Las dos recetas de adopción baratas** (`uutils/procps`, `uutils/util-linux`), previa
178 verificación de madurez upstream. Cubren ~60 applets de los 148 «ya cubiertos por C».
1795. **`grep` y `sed` POSIX en Rust.** Es la escritura de mejor relación coste/beneficio y desbloquea
180 el `/toolchain` del frente A.
1816. **Migrar los 24 build-deps.** Cambiar `busybox` por el conjunto real en `[deps] build` de las 24
182 recetas. Terminado cuando `busybox` sale de los 7 perfiles.
1837. **La cola larga:** kmod, psmisc, gestor de /dev, getty, bc, dns. Después del shell, no antes.
1848. **ADR — `awk`**, y **ADR — el frente C** (sustituir busybox en `STAGE1_COMPONENTS` y
185 `ATTEST_PATHS`). No antes de que 1–7 estén cerrados.
186
187## TRIAJE DE LOS 47 QUE QUEDAN — medido 2026-10-22, y cambia el tamaño del problema
188
189El censo dice «SÓLO lo provee busybox: **47** ← el trabajo que queda» (eran 79 cuando se escribió
190este plan; el paso 2 y el recorte bajaron el resto). Pero ese 47 se lee como «47 implementaciones
191por escribir», y **medido uno por uno no lo es**: la mayoría son NOMBRES HEREDADOS cuya capacidad
192ya viaja con otro nombre. Lo que publica cada dueño declarado, medido en su artefacto:
193
194| dueño | publica de verdad | lo que busybox cubre con otro nombre |
195|---|---|---|
196| `iproute2` | `ip ss bridge tc nstat ifstat routel` | `arp brctl ifconfig ifdown ifenslave ifup nameif netstat route slattach tunctl vconfig` |
197| `shadow` | `useradd userdel groupadd groupdel chpasswd chage…` | `adduser deluser addgroup delgroup` |
198| `kbd` | `loadkeys dumpkeys setfont openvt chvt…` | `loadkmap dumpkmap loadfont` |
199| `dhcpcd` | `dhcpcd` | `udhcpc udhcpc6 dumpleases` |
200| `iputils` | `ping arping tracepath clockdiff` | `ping6 traceroute traceroute6` |
201| `util-linux` | `agetty hexdump` (+200) | `getty hd` |
202
203### A — RETIRAR del TSV: 26 applets, cero trabajo de implementación
204
205Son los nombres de **net-tools** y los wrappers estilo Debian. `ip`/`ss`/`bridge` reemplazan a los
206doce de iproute2 — no es opinión, es que net-tools está obsoleto hace una década y la distro ya
207eligió iproute2. Igual `useradd` sobre `adduser`, `loadkeys` sobre `loadkmap`, `ping -6` sobre
208`ping6`, `dhcpcd` sobre `udhcpc`.
209
210⇒ **No se escriben: se declaran retirados** y el número baja de 47 a 21 sin tocar una línea de
211código. Lo que sí hace falta es decirlo en el TSV, porque hoy figuran con `destino` y eso los hace
212parecer trabajo pendiente.
213
214### B — ALIAS: 2, y uno es dudoso
215
216- `getty` → **`agetty`** de util-linux, que SÍ está en el artefacto (`/sbin/agetty`, medido). Es
217 renombrar en las cards, no empaquetar.
218- `hd` → **`hexdump`** de util-linux (`/usr/bin/hexdump`, medido).
219- ⚠ `nc` → `socat` **NO es alias**: la sintaxis difiere por completo. O se escribe un envoltorio o
220 se retira; un enlace rompería a quien lo invoque.
221
222### C — HUECOS REALES: 19, y sólo tres son grandes
223
224| applet(s) | qué falta | tamaño |
225|---|---|---|
226| ~~`sh` `ash` `hush`~~ | ✅ **RESUELTO por `simi`** (ver arriba): 0 divergencias en 1339 casos, 499 KB, estático. No es `brush` — es código propio. Lo único pendiente es DECLARARLA en un perfil, que es decisión de frente C, no trabajo de implementación | — |
227| ~~`halt` `poweroff` `reboot`~~ | ❌ **NO son hueco: están INDULTADOS a propósito y el TSV lo dice.** Me equivoqué al meterlos acá: sí es cierto que los tres resuelven a busybox en la caja viva, pero eso **es la decisión**, no un descuido — *«nada más en el corpus apaga la máquina: arje-zero es PID1, no una CLI»*, y `kernel_ab.rs` documenta `reboot -f kexec`. Mismo caso: `setuidgid` (lo invoca la card de gitea) y `switch_root` (lo ejecuta el /init del instalador). ⇒ **5 indultados, no 3 huecos.** Lección: el TSV ya tenía el veredicto medido y yo lo re-derivé peor | — |
228| `nslookup` | **no hay NINGÚN cliente DNS de consulta**: `drill`, `dig`, `host`, `kdig` — ninguno existe en el store. Es hueco de capacidad, no de nombre | mediano |
229| `setpriv` | nuestro `util-linux` NO lo construye (medido: ausente del artefacto, y upstream lo trae) ⇒ probablemente una bandera del configure. ⚠ Pero `util-linux` tiene radio 79: tocarlo cascadea | chico, cascada caro |
230| `vlock` `mkpasswd` `cryptpw` `add-shell` `remove-shell` | utilidades sueltas de cuenta/consola que nadie más trae | chicos |
231| `killall5` `cttyhack` `setuidgid` `run-parts` `ntpd` `rdate` `dumpleases` | triaje individual: varios son de un init que no usamos (`killall5`, `cttyhack`) o duplican algo ya presente (`ntpd` ↔ `ntp-daemon` de ntpd-rs, `rdate` ↔ chrony) | chicos |
232
233### Lo que YA se reemplazó con software propio (tawasuyu) — para no re-descubrirlo
234
235Pregunta del usuario, medida en el TSV y en los artefactos:
236
237| pieza propia | qué applets se llevó |
238|---|---|
239| **`akllay`** | **`awk`** — publica `akllay` **y** `awk` en su artefacto, «sellada y EN IMAGEN». ⚠ Y hay un SEGUNDO awk propio: **`qillqa`** también publica `awk`; los proveedores medidos de ese applet son `akllay, gawk, qillqa` — tres, uno de ellos C. La nota del TSV dice «DECIDIR», y sigue sin decidir |
240| **`arje-zero`** | **15 applets retirados** el 2026-09-21, y es el que más se llevó: `init linuxrc run-init runlevel` + toda la familia de runit (`runsv runsvdir sv svc svlogd svok chpst envdir envuidgid softlimit start-stop-daemon`). No los reimplementó: los cubre **por función**, siendo PID 1 |
241| **`hammerd`** | **3**: `klogd logread syslogd` — el diario propio reemplaza al syslog de busybox |
242
243Y el número que ordena el resto: **114 applets marcados `BORRAR`** — decididos innecesarios con
244medición (el gestor de `/dev` no hace falta porque los seis kernels traen `CONFIG_DEVTMPFS_MOUNT=y`
245y `UEVENT_HELPER` apagado; `killall5` lo cubre arje-zero por ser PID 1 y apagar él). O sea que de
246los 401 originales, **la mayor parte del camino se hizo decidiendo, no escribiendo**.
247
248### Lo que este triaje NO decide
249
250No toca el TSV ni retira nada: **mide y reparte**. Retirar 26 nombres es una decisión de producto
251—«esta distro no provee `ifconfig`»— y merece quedar escrita como tal, no caer de un barrido.
252Y el orden no cambia: sin `sh` no se bota busybox en ningún frente, así que C-fila-1 sigue siendo
253el paso siguiente y todo lo demás es independiente de él.
254
255⚠ **Un dato que el censo no ve y vale para planificar:** la caja de producción corre el busybox
256**de ANTES del recorte** (1.230.976 bytes, 14-sep) porque su imagen es anterior al 21-sep. O sea
257que los applets ya retirados siguen vivos ahí hasta que se rehaga la imagen — medir «qué usa el
258producto» contra esa caja da un resultado más gordo que el real.
259
260## La métrica, y el guardián que falta
261
262`docs/state/busybox-applets.tsv` convierte esto en un número que baja: **401 → 272 → … → 0**.
263Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con `build-state.json`.
264
265**Falta un guardián**, y sin él el paso 1 se deshace solo: un script que falle si alguna receta,
266script o card invoca un applet que ya salió del `defconfig`. Hoy nada lo detectaría — el fallo
267aparecería en boot, en otra máquina, semanas después.
268
269## Lo que este documento NO midió (y hay que decirlo así)
270
271- **El reparto applet→destino es juicio, no medida.** Lo medido son los 401 symlinks, el estado
272 `sealed`/perfiles de cada receta y las 24 recetas con `busybox` en `[deps] build`. La asignación de
273 dueño la escribí yo y hay que revisarla receta a receta.
274- **No se verificó la equivalencia de banderas de ninguna pareja.** Los 24 marcados `NO-drop-in` son
275 los que sé que difieren; el resto dice `sin-verificar` y eso es literal, no una forma de decir «va
276 a andar».
277- **No se midió qué applets usa el producto de verdad.** El `defconfig` instala 401 porque nadie
278 eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1.
279- **`uutils/procps` y `uutils/util-linux` no se verificaron upstream.** Los ~60 applets que les
280 atribuyo son una hipótesis de trabajo.
281
282---
283
284# Veredicto del paso 3 — `brush` como `/bin/sh`, MEDIDO
285
286**Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, todo bajo `flock -o work/.farm-build.lock` ·
287**Contra qué:** busybox 1.36.1 `sh` (ash) como control, en la misma caja y la misma corrida.
288
289> **No sirve todavía como `/bin/sh` del SANDBOX DE BUILD.** Pasa el banco POSIX igual que busybox,
290> parsea todo el corpus, y aun así **0 de 3 recetas autotools construyen** (control: 3/3). Lo que
291> falla no es su parser: es que el `libtool` que su `configure` genera sale con un nivel de escapado
292> de menos y después no lo parsea ningún shell, ni él ni busybox.
293
294## Lo que se construyó
295
296`takana build recipes/brush.toml` en el worker selló **`b3:715237c78edd…`** — **el mismo hash que
297`build-state.json` traía del otro hub.** Dos máquinas distintas, bytes idénticos: reproducibilidad
298confirmada de paso, que no era el objetivo de la prueba.
299
300Binario: ELF x86-64 **estático** (sin loader), `brush 0.4.0`, **7,25 MB** — contra **1,23 MB** del
301busybox que trae los 401 applets. Botar busybox por brush, hoy, **engorda** la imagen.
302
303## Lo que PASA
304
305| prueba | brush | control busybox |
306|---|---|---|
307| **Banco POSIX** — 57 casos (`scripts/sh-banco-posix.sh`) | **56/57** | 56/57 |
308| **Las 1130 fases de shell del corpus** (`sh -n` sobre las fases de las 549 recetas que las tienen) | **0 rechazadas** | 0 rechazadas |
309| **`configure` de ffmpeg** — 8345 líneas, `sh -n` | rc=0 | rc=0 |
310| **Invocado como `sh`** (argv[0]) | ✅ | ✅ |
311| **El patrón de las cards de arje** (`sh -c '…; exec …'`) | ✅ | ✅ |
312
313La única divergencia del banco: **`shift` más allá de `$#` devuelve 2**, y POSIX/busybox/bash
314devuelven 1. Cosmético, pero un `set -e` que haga `shift` de más se comporta distinto.
315
316⚠ **Y el banco NO es prueba de suficiencia** — esto es lo que hay que recordar del ejercicio:
317**brush lo pasa entero y aun así no construye una sola receta autotools.** Un banco de casos
318sintéticos mide lo que a uno se le ocurre preguntar; el consumidor real mide lo que hay.
319
320## Lo que FALLA
321
322**Tres recetas autotools reales, con el único cambio de `/bin/sh` en una copia del lab:**
323
324```sh
325cp -al /opt/takana/.dev-fs/alpine $L/alpine # copia con hardlinks, el lab compartido intacto
326cp …-brush/usr/bin/brush $L/alpine/bin/brush
327ln -sfn /bin/brush $L/alpine/bin/sh # ← lo ÚNICO que cambia; sed/grep/awk siguen siendo busybox
328flock -o work/.farm-build.lock env TAKANA_ROOTFS=$L/alpine \
329 ./target/release/takana --store <store-tirable> build recipes/<r>.toml
330```
331
332| receta | control (busybox sh) | brush |
333|---|---|---|
334| `flex` | ✅ sellada | ❌ `make[1]: Error 2` |
335| `pkgconf` | ✅ sellada | ❌ `./libtool: syntax error at line 368 col 30` |
336| `oniguruma` | ✅ sellada | ❌ `make[1]: Error 2` |
337
338### La causa, hasta donde está medida
339
340**El `configure` corre entero y bien bajo brush.** Lo que sale mal es el `libtool` que ese
341`configure` GENERA. Regenerado el mismo fichero con cada shell y comparado, **25 líneas difieren, y
342todas en el mismo sentido: falta un nivel de escapado.**
343
344```
345 control (busybox) brush
346línea 62 ECHO="printf %s\\n" ECHO="printf %s\n"
347línea 132 file_magic_cmd="\$MAGIC_CMD" file_magic_cmd="$MAGIC_CMD"
348línea 176 …'s/^.*[ ]\\([ABCDGIRSTW]… …'s/^.*[ ]\([ABCDGIRSTW]…
349```
350
351Ese fichero ya no lo parsea **nadie** — ni brush ni busybox lo aceptan (`rc=2` los dos, misma línea
352368). Y el contraprueba cierra el caso: **el `libtool` del control lo parsea brush sin quejarse
353(`rc=0`)**. ⇒ El parser de brush está bien; lo que difiere es lo que su shell PRODUCE.
354
355### El mecanismo, encontrado (2026-09-21, después de escribir lo de arriba)
356
357Primero se escribió acá que la causa no estaba determinada, tras descartar las tres sospechas
358obvias —`echo` con barras invertidas, sustitución de comando preservando `\\`, y el
359`func_quote_for_eval` de libtool aplicado a mano—, que dan **idéntico** en ambos shells. La causa
360apareció bajando al `config.status` que genera el `libtool`. **Es de una línea:**
361
362```sh
363V=hola
364echo "a: [`echo \\$V`]" # POSIX: $V · brush: \hola
365echo "b: [`echo \\\\`]" # POSIX: \ · brush: \\
366echo "c: [`echo \$V`]" # POSIX: hola · brush: $V
367echo "d: [$(echo \\$V)]" # POSIX: \hola · brush: \hola ✅
368```
369
370**brush aplica a las comillas invertidas las reglas de `$( )`.** POSIX manda otra cosa: dentro de
371`` `…` `` la barra invertida conserva su significado literal **salvo** cuando precede a `$`,
372`` ` `` o `\`, donde se ELIMINA antes de parsear el texto del comando (POSIX.1-2024, 2.6.3). Que el
373caso **d** sí esté bien es lo que delata la causa.
374
375Por qué rompe libtool: el `config.status` generado trae este bucle, que decide si cada variable
376necesita escapado antes de escribirla en el `libtool`:
377
378```sh
379for var in SHELL ECHO PATH_SEPARATOR SED GREP …; do
380 case `eval \\$ECHO \\""\\$$var"\\"` in
381 *[\\\`\"\$]*) eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;;
382 *) eval "lt_$var=\\\"\$$var\\\"" ;;
383 esac
384done
385```
386
387Bajo brush ese `case` evalúa a **cadena vacía**, así que **todas** las variables caen en la rama sin
388escapar. De ahí las 25 líneas y de ahí que el `configure` termine bien: no falla nada, sólo sale mal
389escrito.
390
391**Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394).**
392
393## Y el otro dato, que no es un fallo pero decide
394
395**Latencia de arranque: 813 µs contra 91 µs de busybox — 8,9×** (300 invocaciones de `sh -c :`).
396Un `configure` de autotools levanta decenas de miles de shells: a ese factor, el sandbox entero se
397vuelve medible más lento. Es un argumento independiente del bug, y no se arregla arreglando el bug.
398
399## Qué queda de esto
400
4011. **brush NO entra al sandbox de build.** El frente A sigue con busybox hasta que esto se cierre.
4022. **El frente del producto sigue abierto**: las cards, la getty y la shell interactiva no generan
403 libtool. Ahí brush no tiene ninguna evidencia en contra — pero tampoco se probó, y el
404 `product-boot-test.sh` es la prueba que falta.
4053. **Reportado upstream: [reubeno/brush#1394](https://github.com/reubeno/brush/issues/1394)**, con
406 el reproductor de una línea, la tabla de los cuatro casos y el fragmento de `config.status`.
4074. **El paso 5 del plan (grep/sed POSIX en Rust) sube de prioridad**, porque no depende del shell.
4085. **No se tocó el lab compartido**: copia con hardlinks y stores tirables. El worker siguió
409 compilando su cola en paralelo, serializado por el `flock`.
410
411## ¿Parchar, o buscar otra opción?
412
413**Primero lo que cierra la pregunta barata: adelantar el pin NO sirve.** Nuestra receta apunta a
414`96a26d0c` (tag `brush-shell-v0.4.0`, mayo 2026), que sigue siendo el último release. Se construyó
415**`main` de hoy** (`737dd57e`, 2026-09-21 — cuatro meses y medio de trabajo encima) y **reproduce
416idéntico**, los cuatro casos. No hay nada que esperar de la rama.
417
418Lo que queda, en orden de coste:
419
420| opción | qué cuesta | qué deja |
421|---|---|---|
422| **Esperar a #1394** | cero | el upstream es activísimo (issue #1393 de anteayer) y el bug es acotado, pero no es nuestro calendario |
423| **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 |
424| **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 |
425| **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 |
426| **Dejar busybox donde está** | cero | es lo que dice el plan: el shell NO es por donde se empieza |
427
428**La recomendación es la última, y no por conservadurismo.** El plan ya ordenaba los pasos 1, 2 y 5
429—recortar el defconfig, declarar lo sellado, escribir `grep`/`sed` POSIX— **sin depender del shell**,
430y valen 129 applets y dos piezas de userland. Gastar el turno en pelear el shell es empezar por el
431único frente que está bloqueado por terceros.
432
433Lo que sí conviene hacer ya, porque es gratis: **suscribirse a #1394** y **medir brush como `/bin/sh`
434del PRODUCTO** (`product-boot-test.sh` + las cards), que es un consumidor que no genera libtool y
435donde brush no tiene ninguna evidencia en contra.
436
437
438---
439
440# Paso 1 — el recorte, HECHO y MEDIDO
441
442**Fecha:** 2026-09-21 · **Dónde:** `dev.gioser.net`, bajo `flock -o`, contra un store tirable y con
443un directorio de recetas por hardlinks — el `recipes/` del worker no se tocó.
444
445> **401 → 277 applets.** 124 retirados, 5 indultados, binario de **1.230.976 → 977.096 bytes**
446> (−20,6 %). Nuevo hash `b3:a24fdbf6…` (el vigente era `2a2b1280…`). Y **3 de las 24 recetas que
447> declaran busybox build-dep se reconstruyeron y sellaron con el recortado**: el sandbox sigue en pie.
448
449## Lo que el ejercicio corrigió del plan
450
451**Mi tabla decía que los 24 applets de `arje-zero`/`hammerd` eran gratis. Es falso para 5 de ellos.**
452Esas recetas instalan **un binario cada una** (`-p arje-zero`, `-p hammerd`): reemplazan la FUNCIÓN,
453no el COMANDO. No hay un `poweroff` ni un `setuidgid` detrás. Los cinco indultos, con su razón:
454
455| indultado | por qué se queda |
456|---|---|
457| `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 |
458| `switch_root` | el `/init` del initramfs del instalador lo ejecuta (`scripts/takana-live-install.sh:375`), **antes** de que arje exista |
459| `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 |
460
461Los tres primeros los encontró el vigía; los tres últimos, preguntarse qué instala arje-zero de
462verdad en vez de creerle a mi propia tabla.
463
464## El vigía — y por qué un vigía ingenuo no sirve
465
466`scripts/busybox-vigia.py` falla si alguna receta, script o card invoca un applet retirado. **Lee la
467lista de la propia receta** (`RETIRADOS='…'`) cruzada con la columna `simbolo_kconfig` del TSV, así
468que no hay dos listas que se desincronicen.
469
470Su primera versión daba **91 hits y ninguno real**. Las cuatro reglas que lo bajaron a 0, todas
471medidas contra falsos positivos concretos:
472
473| regla | qué mataba |
474|---|---|
475| quitar comentarios antes de buscar | `# busybox provee sendmail, no mail` (logrotate), `/// los dos fsync no son prolijidad` |
476| 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(…)` |
477| ignorar nombres que el propio script define como función | `$(ts)` en los seis scripts de `scripts/farm/` |
478| ignorar patrones de `case` y `command -v X` | `proc\|sys\|dev\|init) continue ;;` · `command -v dpkg` en el censo de la mudanza |
479
480Y `[` / `[[` no se vigilan: son palabras del shell, y todo script bash del hub los usa sin que eso
481sea una llamada a un applet.
482
483⚠ **El vigía mira ESTE repo.** No ve los `configure` de terceros que corren dentro del sandbox, que
484son quienes podrían llamar a `which` o `unzip`. Por eso la verificación de abajo no es opcional.
485
486## El mapa applet → símbolo NO se deriva por mayúsculas
487
488Sale de las líneas `//applet:` del fuente de busybox 1.36.1 y vive en la columna `simbolo_kconfig`
489del TSV. Poner el nombre en mayúsculas **falla en 6 casos medidos**: `[`→`TEST1`, `[[`→`TEST2`,
490`arch`→`BB_ARCH`, `nbd-client`→`NBDCLIENT`, `sh`→`SH_IS_ASH`, `sysctl`→`BB_SYSCTL`. Dos de esos seis
491estaban en la lista a retirar.
492
493## Verificación
494
495| qué | resultado |
496|---|---|
497| applets tras el recorte | **277** (401 − 124), exactamente los previstos |
498| los 5 indultados presentes | ✅ y también `sh` y `getty` |
499| los que debían irse | ✅ `httpd telnetd dpkg rpm init syslogd runsv tree which unzip` fuera |
500| **el sandbox sigue construyendo** | ✅ `zlib-ng`, `jq` y `logrotate` reconstruidas y selladas con el busybox recortado |
501
502⚠ **Son 3 de las 24, no las 24.** Las otras 21 —entre ellas `linux`, `llama-cpp`, `protobuf`,
503`qdrant`— no se probaron: son horas de cómputo en una caja que está con la campaña de KDE. Si alguna
504usa un applet retirado, se verá en su próximo build y el vigía **no** lo habría anticipado, porque el
505llamador estaría en un `configure` de terceros.
506
507## El coste, dicho antes de que sorprenda
508
509**Cambiar esta lista re-hashea busybox, y con él las 24 recetas que lo declaran build-dep**:
510`abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl
511libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant
512sof-firmware valkey wireless-regdb zlib-ng`. **`linux` está en la lista**: el kernel se reconstruye.
513La granja lo hará en su propio ciclo; no se disparó a mano.
514
515## El arranque — PROBADO en QEMU, contra una línea base
516
517**2026-09-21.** Se ensamblaron **dos** Stage 1 completos y se arrancaron los dos en QEMU, para que
518cualquier diferencia se pueda atribuir al recorte y no al azar:
519
520| | rootfs sellado | applets en `/bin /sbin /usr/bin /usr/sbin` | qemu |
521|---|---|---|---|
522| **base** (busybox sin recortar) | `b3:1a3823b4…` | 400 | `rc=0` |
523| **recortado** | `b3:3aa8cd2d…` | **278** | `rc=0` |
524
525Los dos arrancan, los dos dan shell y los dos se apagan limpio. **Diff de las dos consolas: 35
526líneas, y ninguna es funcional** — frecuencia del TSC, direcciones del RAMDISK, BogoMIPS, hora del
527RTC. La única diferencia real es la que se buscaba:
528
529```
530~ # ls /bin /sbin /usr/bin /usr/sbin | wc -l
531410 ← base
532288 ← recortado
533```
534
535Criterios del runbook `stage1-vm-boot.md` §6, verificados en el recortado:
536
537- **PID 1** — `arje-zero` incarna y corre.
538- **Seed** — la card se carga: la prueba es que la `console-getty` llegó a incarnar.
539- **getty → shell** — `/bin/busybox getty -n -l /bin/sh 115200 console` da prompt `~ #`, y
540 `echo MARCA-SHELL-VIVA` responde. **`getty` y `sh` sobrevivieron al recorte, que era el riesgo.**
541- **`poweroff -f`** — `ACPI: PM: Preparing to enter system sleep state S5` / `reboot: Power down`.
542 Uno de los cinco indultados, probado **en vivo**: sin él la caja no se apaga.
543
544⚠ **Cómo se corrió, porque condiciona el resultado:** en `dev.gioser.net`, con **QEMU en TCG
545(emulación pura)** — el LXC no expone `/dev/kvm`, aunque el CPU sí trae los flags. Por eso el guion
546se le manda por consola serie con esperas generosas. Hubo que instalar `qemu-system-x86-core` (dnf)
547e ingerir de nuevo la semilla de Stage 0, que no estaba en el store del worker; sí salió con el
548mismo hash que el manifiesto ya anotaba (`3ce721ec…`).
549
550⚠ **Es Stage 1, no el producto.** Se eligió porque es donde vive busybox (`musl busybox hammerd
551arje-zero`) y porque necesitaba 2 artefactos en vez de 8. `product-boot-test.sh` —que además levanta
552sshd y netup— sigue sin correrse: le faltan `netup openssh uutils findutils-xargs diffutils
553ripgrep` en el store del worker.
554
555## Lo que queda del paso 1
556- **Las otras 21 dependientes**, cuando la granja llegue.
557- El siguiente corte natural son los `BORRAR` que no se tocaron por prudencia y los 272 que siguen:
558 el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (`grep`/`sed` POSIX).
559
560---
561
562# El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21
563
564**Instrumento:** `scripts/sh-banco-diferencial.py` · **casos de regresión:**
565`scripts/fixtures/sh-casos/` · **dónde:** el hub, contra los tres shells sellados que ya están en
566`/store` (`2a2b1280…-busybox`, `715237c7…-brush`, `87bf1a59…-bash`). No tocó la granja ni el lock.
567
568## Por qué, si ya existía `sh-banco-posix.sh`
569
570El veredicto del paso 3 dejó escrita la lección y no el instrumento:
571
572> **brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.**
573
574Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial
575pregunta otra cosa, y no hace falta saber la respuesta de antemano:
576
577> **¿este shell hace LO MISMO que el control, sobre el material que hay?**
578
579Tres decisiones de diseño, y las tres se ganaron con el bug de brush:
580
5811. **Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout.** El `libtool` mal escapado salía
582 con `rc=0` y stdout idéntico: **ningún banco de stdout puede verlo**. Cada caso corre en un
583 directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que
584 escribió. El caso `10-escribe-arbol.sh` diverge **sólo en el árbol** — es la demostración de que
585 esta columna hace falta.
5862. **El esperado es el control, no una aserción.** Un caso no declara resultado: declara material.
587 Eso permite tirarle al banco las 1130 fases del corpus y 26 `configure`/`libtool` reales sin
588 escribir una línea de expectativa.
5893. **Toda divergencia se CONFIRMA** volviendo a correr control y candidato. Un caso que imprime un
590 PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía
591 `$$`); un shell que diverge **de sí mismo** es un hallazgo distinto y peor. Sin esto el banco se
592 llena de ruido y deja de leerse, que es como muere un banco.
593
594Además: todos los shells se invocan por un symlink `sh` (argv[0] igual para todos, que es como se
595despliegan), con el **mismo userland busybox en el `PATH`** —las divergencias tienen que venir del
596shell, no de un `sed` distinto— y con `PATH` apuntando primero al shell bajo prueba, así un `sh`
597recursivo cae en ÉL y no en el del sistema.
598
599## Lo medido — 1273 casos, 5,5 s
600
601```
602scripts/sh-banco-diferencial.py --gen --phases \
603 --files '/work/sergio/work/sources/*/configure' \
604 --files '/work/sergio/work/sources/*/libtool' \
605 control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
606```
607
608| fuente | casos | modo | brush | bash |
609|---|---|---|---|---|
610| `cases` — regresiones conocidas | 10 | ejecutado | **4 divergen** | 0 |
611| `gen` — tortura de comillas | 107 | ejecutado | **31 divergen** | 8 |
612| `phases` — las fases de las 549 recetas | 1130 | `sh -n` | **0** | 0 |
613| `files` — `configure` y `libtool` reales | 26 | `sh -n` | **0** | 0 |
614
615**Los 1156 casos de parseo no divergen en ningún shell.** Confirma lo que el paso 3 ya decía —el
616parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol
617comparado. **Sirve sobre todo para saber dónde NO buscar.**
618
619## Lo que el generador encontró SOLO
620
621El generador barre el producto cartesiano `forma de sustitución × contexto × profundidad de barras
622invertidas × carácter objetivo` (107 casos). **No sabe que el bug #1394 existe, y lo escupe entero:**
623
624```
625✗ [brush] tortura/bt-desnudo-b1-var control: [hola] brush: [$V]
626✗ [brush] tortura/bt-desnudo-b2-var control: [$V] brush: [\hola]
627✗ [brush] tortura/bt-desnudo-b2-literal control: [x] brush: [\x]
628✗ [brush] libtool/case-de-config-status ← el bucle REAL de config.status, divergente
629```
630
631Ése es el punto del ejercicio: **el bug de brush apareció en el primer build real, después de
632sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco
633segundos, sin saber qué buscaba.**
634
635## Un hallazgo NUEVO, y no es del frente A
636
637El fixture `08-trap-exit-y-estado.sh` destapó algo que no estaba en el veredicto del paso 3:
638
639```sh
640( trap "echo trap-sub" EXIT; true ); echo despues
641# busybox → trap-sub / despues bash → trap-sub / despues brush → despues
642```
643
644**brush no ejecuta NUNCA el `trap … EXIT` de un subshell**, ni con `exit` explícito. Es independiente
645de #1394 y, a diferencia de aquél, **golpea el frente B**: un script de limpieza que confía en su
646trap no limpia, en silencio, con `rc=0`. La frase del veredicto —«en el producto brush no tiene
647ninguna evidencia en contra»— ya no se sostiene: ahora la tiene.
648
649⚠ **Y upstream YA LO SABÍA — pero no como issue, sino dentro de su propia suite de compatibilidad**,
650con el caso casi calcado al fixture y marcado `known_failure`:
651
652```yaml
653# brush-shell/tests/cases/compat/builtins/trap.yaml:226
654- name: "subshell can set its own EXIT trap"
655 known_failure: true # TODO(traps): EXIT trap in subshell
656```
657
658Y no era uno sino tres TODOs para el mismo síntoma: `:226` subshell `( )`, `:269` sustitución de
659comandos, `:323` sustitución de procesos. **Lección para el banco: un `known_failure` en la suite
660ajena es evidencia de primera y cuesta un `gh api` mirarla — antes de escribir «hallazgo nuevo»,
661buscar en las PRUEBAS del candidato, no sólo en sus issues.** Lo nuestro seguía aportando: que las
662tres variantes son probablemente un mismo bug (`on_exit()` sólo lo llaman los puntos de entrada de
663nivel superior; el subshell de `interp.rs:662-683` clona el shell y devuelve el exit code sin
664pasar por ahí), que también pega en pipeline y en segundo plano, y que es **silencioso**.
665
666**Reportado: [reubeno/brush#1396](https://github.com/reubeno/brush/issues/1396).**
667
668Y se confirmó la tercera, ya conocida: `shift` más allá de `$#` devuelve 2 (POSIX/busybox/bash: 1).
669
670## Las 8 de bash, que enseñan a leer el banco
671
672bash diverge en 8 casos del generador, **todos de la misma familia**: comilla o backtick sin cerrar
673dentro de `` `…` ``, donde busybox da `rc=2` y bash acepta. No es que bash esté mal — es que **el
674control es una referencia, no la verdad**. Cuando el candidato diverge, hay que ir a POSIX; el banco
675dice dónde mirar, no quién tiene razón.
676
677## Latencia, de paso
678
679300 invocaciones de `sh -c :` por shell, en el hub:
680
681| shell | µs/invocación | binario |
682|---|---|---|
683| busybox | 464 | 1,17 MB |
684| bash | 864 | 1,57 MB |
685| brush | 1724 | 6,92 MB |
686
687**brush/busybox = 3,7×** acá, contra el 8,9× medido en el worker: esta cifra incluye el `fork`+`exec`
688de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la
689misma y el argumento del veredicto no cambia.
690
691## Lo que este banco NO mide (y hay que decirlo así)
692
693- **No EJECUTA las fases del corpus** — sólo las parsea. Ejecutarlas necesita el sandbox y sus
694 fuentes, que es la extensión natural y la que cerraría el caso: correr un `configure` real bajo el
695 candidato y **comparar el árbol generado** contra el del control. La maquinaria de comparación ya
696 está; falta engancharla al sandbox.
697- **El generador sólo tortura comillas.** Faltan familias enteras: expansión de parámetros, `IFS` y
698 división en campos, aritmética, here-docs, señales y control de trabajos.
699- **Nada de interactivo** — ni edición de línea, ni historial, ni `job control`.
700- **El árbol no mira permisos finos ni mtimes**, sólo ruta + bit de ejecución + contenido.
701- **El banco no prueba suficiencia.** Es la misma advertencia de siempre, y ahora con dos
702 instrumentos que la ilustran en vez de uno.
703
704---
705
706# La superficie REAL de las cards, MEDIDA — 2026-09-21
707
708**Instrumento:** `scripts/sh-superficie-cards.py` (regenera el número; la tabla no se escribe a
709mano) · **salida:** `work/sh-superficie-cards.tsv`.
710
711La pregunta: **si mañana hubiera un `/bin/sh` propio, ¿qué tendría que saber hacer para que el
712PRODUCTO arranque?** No qué dice POSIX ni qué usa autotools — qué usan las cards que arje ejecuta.
713
714## Las cuatro poblaciones
715
716| población | dónde | cards | con shell |
717|---|---|---|---|
718| servicios de paquete | `recipes/*.toml` · `[[service]]` (emisor: `Service::card`) | 29 | **21** |
719| empotradas | `takana-bootstrap` · `STAGE1_SEED_CARD` + `SSHD_SERVICE_CARD` | 3 | **1** |
720| servidor de producción | `scripts/servidor/cards/*.json` | 1 | **1** |
721| la mudanza | `scripts/mudanza/formatos/arje.py:35` | plantilla | n/a |
722
723> **23 de 33 cards (69 %) ejecutan un fragmento de shell; 10 son `exec` directo.**
724
725La mudanza no se cuenta por card porque su contenido sale del censo de la máquina que se muda. Su
726superficie es la de la plantilla, y la plantilla entera es: **`cd VALOR && exec VALOR`**.
727
728⚠ **Hallazgo de paso, y hacía falta para medir bien: en una Card, `argv` va SIN `argv[0]`** — arje
729lo pone desde `exec`. Lo dice la card de `hammerd`, cuyo `argv` es `["--store","/store",…]` a
730secas. Por eso conviven dos idiomas que parecen incompatibles y no lo son:
731`exec=/bin/busybox argv=["sh","-c",…]` (recetas: despacho multicall) y `exec=/bin/sh
732argv=["-c",…]` (la mudanza). Leer el `argv` sin anteponer el `exec` hace que uno de los dos
733desaparezca de la cuenta.
734
735## Lo que las cards USAN
736
737```
738 secuencia ; 23 ███████████████████████
739 y/o && || 21 █████████████████████
740 redirección > 18 ██████████████████
741 grupo { } 18 ██████████████████
742 variable $x 4 ████
743 bucle while 2 ██
744 aritmética $(( )) 2 ██
745 tubería | 2 ██
746 sustitución $( ) 2 ██
747 case 1 █
748 bucle for 1 █
749 redirección 2> 1 █
750 glob * 1 █
751```
752
753**Y lo que NO aparece NI UNA VEZ:** `if`, funciones, `until`, `${…}` (ninguna expansión de
754parámetros: ni `:-`, ni `#`, ni `%`), posicionales `$1`/`$@`, `$?`, subshells `( )`, here-docs,
755`!`, segundo plano `&`, y **sustitución con comillas invertidas** — la construcción que tumbó a
756brush en el sandbox **no la usa ninguna card**.
757
758El idioma es uno solo y se repite 18 veces:
759
760```sh
761test -f /etc/x/x.conf || { echo 'x: falta … — es config del SITIO' >&2; exit 78; }; exec /usr/bin/x
762```
763
764**Builtins que el shell tiene que traer: 8.** `exit`×44 `echo`×31 `exec`×22 `test`×20 `cd`×8
765`[`×4 `command`×3 `continue`×1.
766
767**Órdenes externas que la imagen tiene que traer: 14.** `mkdir`×14 `grep`×11 `chown`×4 `id`×4
768`netup`×2 `ssh-keygen`×2 `chmod`×2 `mount`×2 `nft` `hostname` `basename` `blkid` `df` `awk`.
769
770## El cruce con el recorte — y una CORRECCIÓN
771
772```
773siguen en busybox: 11 · no son applets (otro paquete): 3 (netup, nft, ssh-keygen) · RETIRADAS: 0
774✓ ninguna card invoca un applet retirado por el recorte 401 → 277.
775```
776
777⚠ **Acá se escribió que esto «es el guardián que el plan pedía y nadie tenía». Es falso: el
778guardián EXISTE** — `scripts/busybox-vigia.py`, escrito con el paso 1, que lee los retirados de
779`recipes/busybox.toml`, los cruza con la columna `simbolo_kconfig` del TSV y **falla si alguna
780receta, script o card invoca uno**. Corrido hoy: `123 applets retirados de la receta · OK — nadie
781los invoca`, `rc=0`. La sección «Falta un guardián» de más arriba quedó vieja el mismo día.
782
783Lo que el cruce de este instrumento agrega es otra cosa, y conviene no confundirlas: el vigía
784contesta «¿alguien invoca algo que ya no está?» (una puerta, con `rc≠0`); esto contesta **«¿qué
785necesitan las cards para arrancar?»** — inventario, no puerta, y por eso incluye las que no son
786applets (`netup`, `nft`, `ssh-keygen`), que al vigía no le importan y a un perfil de imagen sí.
787
788## Y los 23 fragmentos, por el banco diferencial
789
790```
791scripts/sh-superficie-cards.py --dump-dir /tmp/frags
792scripts/sh-banco-diferencial.py --no-cases --files '/tmp/frags/*.sh' control=… brush=… bash=…
793 brush DIVERGE=0 bash DIVERGE=0
794```
795
796Las 23 parsean idéntico en los tres shells. Es `sh -n`, no ejecución —montan discos y levantan
797daemons—, así que dice que el lenguaje alcanza, no que el comportamiento coincida.
798
799## Dos bugs en una card, encontrados por el camino — y ARREGLADOS
800
801`scripts/servidor/cards/montar-trabajo.json` venía sin comillas en tres sitios (dos bugs, uno de
802ellos por duplicado), y los dos fallan en silencio. **Comprobados, no deducidos** — y **no fue un
803accidente de edición posterior: la card nació así en su único commit** (`3b305642`), o sea que las
804comillas se perdieron antes de entrar al árbol.
805
806**Bug 1 — el `awk` sin entrecomillar. Falla SIEMPRE:**
807
808```sh
809df -h / | awk NR==2{print } # awk: cmd. line:1: NR==2{print ← error de sintaxis, rc=1
810df -h / | awk 'NR==2{print $4}' # 13G ← lo que se quiso escribir
811```
812
813El `echo` final imprimía «`montar-trabajo: /vvv libres`», sin el número, en cada arranque.
814
815**Bug 2 — el patrón de `grep` sin entrecomillar, en las dos comprobaciones de montaje.** Es LATENTE:
816
817```sh
818grep -q /vvv /proc/mounts # matchea cualquier línea que CONTENGA /vvv (p.ej. /vvv/algo)
819grep -q " /vvv " /proc/mounts # con comillas: sólo el punto de montaje /vvv
820```
821
822Para que muerda hace falta otra línea de `/proc/mounts` que contenga la subcadena —`/vvv/algo`
823montado aparte, o un `/mnt/vvvv`—; con `/vvv` montado de verdad o ausente, acertaba. Igual en
824`grep -q /work/sergio`, donde un `/work/sergio2` bastaría.
825
826**Arreglado, y probado con la maquinaria de siempre:** el fragmento se corrió entero con
827`/proc/mounts` sustituido por un fichero de prueba y con `mkdir`/`chmod`/`chown`/`mount`/`blkid`
828stubbeados, así que nada mutó la máquina. Los dos escenarios, antes y después:
829
830| escenario | antes | después |
831|---|---|---|
832| `/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` |
833| `/vvv` montado de verdad | (acertaba) | no vuelve a montar, `170G libres` |
834
835El fragmento nuevo parsea en busybox, bash y brush.
836
837**Bug 3 — las variables desnudas, y ésta se comía un usuario entero.** Arreglado en la misma tanda
838junto con los dos `echo` de error, que iban sin comillas (inocuo hoy: ningún glob en el texto, y se
839comprobó que el mensaje y el `rc=78` salen idénticos antes y después). El que sí tenía diente era
840el bucle de `/home`:
841
842```sh
843# antes
844for h in /home/*; do u=$(basename $h); id -u $u >/dev/null 2>&1 || continue
845 mkdir -p /run/user/$(id -u $u); chown $u /run/user/$(id -u $u); …
846# después
847for h in /home/*; do u=$(basename "$h"); uid=$(id -u "$u" 2>/dev/null) || continue
848 mkdir -p "/run/user/$uid"; chown "$u" "/run/user/$uid"; …
849```
850
851Con un directorio llamado `juan perez` en `/home`, el `$u` desnudo se partía en dos campos y
852`id -u juan perez` fallaba ⇒ **el `continue` saltaba a ese usuario en silencio y se quedaba sin su
853`/run/user/<uid>`**. Medido con el mismo banco de stubs, sobre un `/home` de prueba con
854`juan perez`, `root` y `nadie`:
855
856```
857viejo usuarios que reciben chown: root
858nuevo usuarios que reciben chown: juan perez|root
859```
860
861`nadie` no recibe en ninguno de los dos, que es lo correcto: no está en `passwd`.
862
863De paso, `id -u` se llamaba **cuatro veces por usuario** (una para el test y tres para las órdenes);
864ahora una sola, izada a `uid`. Eso no era el bug pero era la forma limpia de entrecomillarlo, y se
865ve en la propia medición: las órdenes externas pasaron de `id×4` a `id×1`.
866
867## Qué dice esto sobre el `sh` propio
868
869El consumidor del PRODUCTO es minúsculo y está acotado: **8 builtins, 13 construcciones, ninguna
870expansión de parámetros, ningún `if`, ninguna función**. Eso no es «escribir un shell»: eso es un
871intérprete de listas `&&`/`||` con `test`, `echo`, `exec` y redirección — semanas, no meses. **El
872sandbox de build sigue siendo otro planeta**, y es el que conviene no mezclar.
873
874## Lo que esta medición NO dice
875
876- **Detección por patrón sobre un escáner que respeta comillas, no un parseo.** La primera versión
877 contaba 11 backticks que eran prosa dentro de mensajes de error (`` `minga init` ``) y una docena
878 de «órdenes externas» que eran palabras sueltas de esa prosa. Está arreglado —y el cuerpo de cada
879 sustitución se analiza como shell aparte, que es como aparecieron el `df` y el `awk`— pero sigue
880 siendo aproximación: `command -v journalctl` cuenta `command`, no `journalctl`.
881- **No mide la shell INTERACTIVA de `getty`**, que es otro consumidor y no ejecuta fragmentos.
882- **No mide `scripts/`**, que es su propia unidad de trabajo y es mucho más grande.
883- **No mide las cards REALES de la mudanza**: dependen de un censo que no está en el árbol.
884- **No ejecuta los fragmentos**, sólo los parsea.
885
886---
887
888# La superficie de `scripts/`, MEDIDA — 2026-09-21
889
890**Instrumento:** `scripts/sh-superficie-scripts.py` · **escáner compartido:**
891`scripts/lib/sh_analisis.py` · **salida:** `work/sh-superficie-scripts.tsv`.
892
893⚠ **Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error.** Las cards las ejecuta
894el producto ⇒ su superficie es el contrato de un `/bin/sh` propio hoy. `scripts/` lo ejecutan el
895hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento.
896Lo que sí importa hoy acá es otra cosa:
897
898> **¿algún guion se declara POSIX y no lo es?** Porque ése se rompe HOY: en la imagen `/bin/sh` es
899> el ash de busybox, no bash.
900
901## Las cuatro poblaciones, y quién las parsea
902
903De 118 ficheros descartados por no ser shell (`.html`, `.c`, `.mp4`, `.toml`…):
904
905| población | n | líneas | parsea ash | parsea bash |
906|---|---|---|---|---|
907| **POSIX** (`#!/bin/sh`, `#!/usr/bin/env sh`) | 81 | 10 167 | **81/81** | 81/81 |
908| **bash** (`#!/bin/bash`, `#!/usr/bin/env bash`) | 76 | 10 269 | **48/76** | 76/76 |
909| **EMPOTRADOS** (un `/init` escrito por heredoc) | 21 | 714 | **21/21** | 21/21 |
910| **`source`-only** (`.sh` sin shebang) | 6 | 138 | 6/6 | 6/6 |
911
912> **Cero guiones se declaran POSIX y fallan bajo ash.** Ni los 81 sueltos ni los 21 empotrados.
913
914La clasificación es por la **primera línea**, no por «contiene un shebang»: un `grep -rl '^#!/bin/sh'`
915da 86 porque cuenta los scripts bash que EMPOTRAN un `#!/bin/sh` en un heredoc. Con ese grep,
916`mirada-usb.sh` y `selfhost-verify.sh` aparecían como «POSIX roto bajo ash» — y son bash legítimos.
917
918**Los 21 empotrados son la población que importa para el producto**, y hoy nadie los parsea: son los
919`/init` de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros
920(`takana-live-install.sh` ×3, `metal-desktop-image.sh` ×5, `install-image-efi.sh` ×2, `disk-image.sh`,
921`metal-iso.sh`, `metal-usb-sdboot.sh`, `mirada-usb.sh`, `selfhost-verify.sh`, `cc-por-zig.sh`,
922`jaula-herramientas.sh`, `atuq-en-cosmic.sh`, `dunst-headless.sh`, `metal-desktop-image-dual.sh`).
923**Los 21 los parsea el ash.**
924
925## Por qué los 28 bash son bash: arrays, y casi nada más
926
927| bashismo | guiones |
928|---|---|
929| **`array=( )`** | **31** |
930| `declare`/`typeset` | 7 |
931| `+=` en asignación | 6 |
932| `[[ ]]` | 5 |
933| `<<<` | 4 |
934| `<( )` | 4 |
935| `${arr[i]}` | 3 |
936| `(( ))` | 3 |
937| `mapfile` | 1 |
938
939No aparece ninguna vez: `${v//x/y}`, `${v^^}`, `function nombre`, `PIPESTATUS`/`BASH_*`, `echo -e`,
940`read -a/-d`, `source` en vez de `.`.
941
942**De los 28 que el ash no parsea, 26 lo son por arrays y nada más.** Ése es el presupuesto completo
943de un `sh` propio que quisiera correr la herramienta del proyecto: **arrays indexados**. Los otros
944dos suman `[[ ]]`, `<( )` y `mapfile`.
945
946⚠ **Parsear no es correr.** 48 de los 76 `#!bash` parsean tal cual bajo ash y eso NO significa que
947funcionen: `local`, `echo -e` o un `trap` de subshell parsean en los dos y se portan distinto (lo de
948brush#1396 es justo eso). La tabla mide sintaxis.
949
950## La superficie del lenguaje
951
952Los 81 POSIX usan **todo** lo que las cards no usan: `if` 63/81, funciones 30/81, `${…}` 74/81,
953posicionales 66/81, here-docs 22/81, `case` 26/81, `until` 3/81. **Comparado con las cards —8
954builtins, ningún `if`, ninguna función— es otro lenguaje.** Builtins en todo `scripts/`: 27
955(`echo`×1996, `[`×1270, `exit`×473, `export`×239, `printf`×236, `trap`×40, `local`×19, `ulimit`×3…).
956
957Órdenes externas: **257 por nombre desnudo** (las resuelve el `PATH`) y **35 por ruta absoluta** (no
958pasan por `PATH` y por lo tanto **no pueden ser un applet**). Cruzadas con el recorte:
959**0 applets retirados** — que es lo que dice `busybox-vigia.py`, y ahora los dos instrumentos
960coinciden.
961
962## Los siete bugs del INSTRUMENTO, que son la parte que hay que contar
963
964Cada número de arriba salió mal primero. Vale más anotar esto que la tabla:
965
9661. **Comentarios.** El escáner no los quitaba, y en este repo los comentarios son prosa llena de
967 `` `backticks` ``: medido en `scripts/farm/cosecha-cron.sh`, **89 backticks y los 89 dentro de
968 comentarios**. Daba «sustitución `` `…` `` 75/81» cuando la cifra real es 9/81.
9692. **`shlex` sobre el fichero entero.** Se desincroniza —no es un parser de shell— y una comilla
970 suelta desplaza el estado de TODO lo que sigue: `takana-live-install.sh` daba la orden `init`
971 porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Reescrito sobre el texto
972 que el escáner ya deja sin comillas.
9733. **Cuerpos de heredoc.** No son shell del script que los contiene: `scripts/desplegar-strip.sh`
974 empotra un programa de **Python**, y su `for p in glob.glob(…)` aparecía como la orden `p`.
975 Sacarlos bajó las órdenes externas de 680 a 257 — o sea que **el 62 % del inventario era ruido**.
9764. **Patrones de `case`.** `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden
977 invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía
978 razón. Ahora hay estado de `case`.
9795. **Cuerpos de `$(( ))`.** `i=$((i+1))` daba la orden `i+1`.
9806. **Descriptores de fichero.** `>&2` daba la orden `2`.
9817. **Nombre desnudo vs ruta absoluta.** `exec switch_root /newroot /sbin/init` marcaba el applet
982 `init`; pero una ruta absoluta no se resuelve por `PATH` y no puede ser un applet — en el
983 producto ese `/sbin/init` es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso
984 **mide algo útil**: 35 órdenes van por ruta absoluta y no dependen del `PATH`.
985
986**Ninguno de los siete se veía en la salida.** Todos daban números plausibles. Es la misma lección
987del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento
988en vez de al shell.
989
990## Una librería, no dos copias
991
992El escáner vive en `scripts/lib/sh_analisis.py` y lo importan los dos instrumentos. Duplicarlo era
993repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un
994campo distinto mal. Comprobado que la extracción no cambió nada: la salida de
995`sh-superficie-cards.py` es byte-idéntica antes y después (y su md5 se repite entre corridas — el
996orden de los empates se volvió determinista a propósito, para que la salida sea diffable).
997
998Y los arreglos 3–7 **corrigieron también la medición de las cards**: apareció `sleep`, que el
999tokenizador viejo se comía, y desapareció `i+1`.
1000
1001## Lo que esto NO mide
1002
1003- **`|` dentro de un patrón de `case`** se cuenta como tubería. Inflaba poco y no se arregló: hace
1004 falta gramática, no regex.
1005- **`sh -n` no es ejecución.** Nada de esto dice que los 48 `#!bash` que el ash parsea funcionen
1006 bajo ash.
1007- **No se midió qué guion corre DÓNDE.** Que `takana-install.sh` sea bash no obliga a nada porque
1008 corre en el hub; el que corre dentro de la imagen es `takana-live-install.sh`, y ése es POSIX.
1009 La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son
1010 por construcción.
1011
1012
1013---
1014
1015# `simi` — el `/bin/sh` propio, ESCRITO y MEDIDO (2026-09-21)
1016
1017**Crate:** `crates/simi/` (4448 líneas, `libc` y nada más) · **receta:** `recipes/simi.toml`,
1018**sellada** en `b3:46ff6fcf…` · **nombre:** *simi* es «boca, lengua» en quechua — es cómo se
1019le habla a la máquina. `rimay` estaba tomado (la familia de embeddings de tawasuyu).
1020
1021## El veredicto, primero
1022
1023Banco diferencial contra el ash de busybox 1.36.1, **159 casos** — 10 fixtures de regresión, 107 de
1024tortura de comillas, los 23 fragmentos de las cards y los 19 `/init` empotrados:
1025
1026| shell | divergencias duras | binario | latencia |
1027|---|---|---|---|
1028| **simi** (sellada, estática) | **0** | **0,48 MB** | **1,04×** el ash |
1029| bash 5.3.0 | 8 | 1,57 MB | 1,3× |
1030| brush 0.4.0 | 35 | 6,92 MB | 4,1× |
1031
1032Incluye el **bucle real de `config.status`** que genera cualquier `configure` de autotools — el que
1033tumbó a brush. Y 47 pruebas propias del crate en verde.
1034
1035## SELLADA, y medida contra el artefacto (2026-09-21)
1036
1037`takana build recipes/simi.toml` en el worker, bajo `flock -o work/.farm-build.lock`:
1038
1039```
1040sealed path=./store/46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834-simi
1041b3:46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834 rc=0
1042```
1043
1044El artefacto trae exactamente dos ficheros: `usr/bin/simi` y su `.hammer/recipe.toml`.
1045
1046| qué | medido sobre el ARTEFACTO |
1047|---|---|
1048| tipo | ELF x86-64, **estático**, stripped |
1049| tamaño | **498 736 bytes** (0,48 MB) |
1050| `scripts/static-audit.sh simi` | «estáticos de verdad: 1 · MIENTEN: 0» |
1051| banco diferencial, 159 casos vs el ash | **0 divergencias** (bash: 8 · brush: 35) |
1052| latencia de arranque | **468 µs** contra **449 µs** del ash — **1,04×** (brush: 1569 µs, 3,5×) |
1053| `scripts/verificar-repro.sh simi` | **`✓ simi REPRODUCE`** — bit a bit, apartando el artefacto y reconstruyendo |
1054
1055⚠ **La latencia del estático musl no es la del build nativo**: el build del hub (glibc, dinámico)
1056daba 865 µs, o sea 1,4× el ash. El estático baja a 1,04×. **Medir el binario que viaja, no el que se
1057compila a mano** — la diferencia acá fue de un 35 % y habría quedado escrita como si fuera del shell.
1058
1059⚠ Todavía NO está en ningún perfil de `docs/state/targets.toml`. Declararla es meterla en la raíz de
1060confianza del arranque, y eso es el paso 3 de abajo.
1061
1062⚠ **Y el artefacto vive SÓLO en el store del worker.** El worker sella con `PROMOTE=0` y la cosecha
1063baja el **manifiesto**, no el store: el hub no lo tiene. Promoverlo es otro paso.
1064
1065### Dos trampas operativas que costaron dos corridas
1066
1067**1. `--store <tirable>` NO sirve para verificar reproducibilidad, y falla con un error que no dice
1068nada.** Un store vacío da `Error: io: No such file or directory (os error 2)` a secas, sin decir qué
1069falta, porque las DEPS del build no están ahí. El método correcto ya estaba escrito y es
1070`scripts/verificar-repro.sh`: **aparta** el artefacto dentro del MISMO store (un rename, que sobrevive
1071a un kill) y reconstruye, así las deps siguen en su sitio. Dos corridas perdidas por no leer el
1072instrumento que ya existía antes de inventar uno.
1073
1074**2. La cosecha BORRA una receta sembrada a mano.** `cosecha-cron.sh` siembra hub→worker con
1075`rsync --delete`, y el hub del que siembra es **gioser**, no el clon donde se trabaja. Entre el build
1076y la verificación, el worker perdió `recipes/simi.toml` —el artefacto sellado sobrevivió, la receta
1077no— y el verificador informó `REPRODUCEN: 0 · sin artefacto: 0`, o sea **nada**, porque su primer
1078`[ -f "$f" ]` falla y salta en silencio. Hasta que gioser haga `pull` del commit, cualquier corrida en
1079el worker necesita reponer la receta primero. Comprobado de paso que el hash NO cambia al editar los
1080comentarios de la receta: `hash_inputs` es una lista explícita de campos.
1081
1082## El alcance salió de la medición, no de POSIX
1083
1084| consumidor | qué necesita | estado |
1085|---|---|---|
1086| las 23 cards de arje con shell | 8 builtins, 13 construcciones, ningún `if`, ninguna función | ✅ |
1087| los 21 `/init` empotrados | + `if`, funciones, `case`, aquí-documentos, `${…}`, posicionales, `&` | ✅ |
1088| `takana-live-install.sh` (corre en la imagen viva) | POSIX entero | ✅ parsea; sin correr |
1089| la shell interactiva de `getty` | edición de línea, historial, control de trabajos | ❌ **no está** |
1090
1091Lo que **no** trae, dicho en vez de fingido: control de trabajos, edición de línea, historial,
1092`alias`, `getopts`, `times`, `[[ ]]`, arrays. El shell de `getty` es otro consumidor y sigue
1093pendiente.
1094
1095## Los siete bugs — que es la parte que enseña
1096
1097Ninguno se veía en la salida. Todos daban resultados plausibles, y **los seis primeros los destapó
1098el banco diferencial**, no el juicio:
1099
11001. **El lexer fundía lo entrecomillado con lo desnudo.** `x="a b"` quedaba como UN seg
1101 entrecomillado, el parser ya no veía la asignación —el prefijo antes del `=` tiene que ser
1102 desnudo— y **la ejecutaba como una orden**. El error decía «no encontrado», que manda a buscar al
1103 lugar equivocado. Ahora el comillado es por carácter.
11042. **La marca de escape interna chocaba con las barras reales.** La primera versión usaba `\` como
1105 marca y la quitaba al final, así que `$(echo \\$V)` perdía la barra que la sustitución había
1106 PRODUCIDO (ash: `\hola`, simi: `hola`). **La quita de comillas es sobre las comillas de la
1107 palabra, nunca sobre el resultado de una expansión** — y eso no se representa con un escape
1108 dentro del texto: hace falta una máscara de protección por carácter.
11093. **`set -e` no se suspendía dentro de una función llamada desde una condición.** Ahora es un
1110 contador DINÁMICO, y por eso entra solo donde tiene que entrar.
11114. **Los delimitadores de `$( )` y `${ }` no respetaban la barra invertida**: `$(echo \)` cerraba la
1112 sustitución cuando el paréntesis estaba escapado y no debía cerrar nada.
11135. **Las clases `[…]` no entendían `\`** — y `*[\\\`\"\$]*` es literalmente el patrón del
1114 `config.status` de libtool.
11156. **Dentro de comillas dobles, el cuerpo de un backtick sigue entre comillas**, así que la barra
1116 también se elimina ante `"`. Sin eso, dos casos de la tortura divergían en los dos sentidos.
11177. **Y uno que el banco NO vio:** el `trap EXIT` **heredado** se disparaba en el subshell. POSIX
1118 §2.12 manda resetearlo al entrar — es la mitad complementaria de brush#1396, y al revés. Lo
1119 encontró la prueba de regresión del crate, porque ningún caso del banco lo probaba. **Los dos
1120 oráculos hacen falta**, y el caso quedó agregado al fixture 08.
1121
1122Las dos invariantes que el fichero de ejecución sostiene a propósito, las dos porque se midieron
1123rotas en el candidato ajeno: **el `trap EXIT` de un subshell corre** (brush#1396) y **`exec`
1124reemplaza el proceso** (si el shell forkea, arje supervisa al shell y no al daemon).
1125
1126## Lo que queda, en orden
1127
11281. ~~**Construir la receta en el lab** y sellarla.~~ **HECHO** — `b3:46ff6fcf…`, ver arriba.
11292. **ADR** — escribir un shell propio y meterlo en `ATTEST_PATHS` es una decisión de frontera, y el
1130 nombre `simi` también. Este documento la registra; el ADR la decide.
11313. **Cablear `/bin/sh`**: `USERLAND_COMPONENTS` + el retiro del homónimo (`lib.rs:676`). Es frente C
1132 y toca la raíz de confianza del arranque.
11334. **Medir EJECUTANDO**, no sólo parseando: los `/init` en un arranque de QEMU
1134 (`product-boot-test.sh`) y `takana-live-install.sh` en la imagen viva.
11355. **La shell interactiva de `getty`**, que es el consumidor que falta entero.
11366. **El sandbox de build sigue con busybox**, y con razón: eso es otro planeta y no está prometido.
1137
1138
1139---
1140
1141# El censo de proveedores — el reparto applet→dueño, MEDIDO (2026-09-21)
1142
1143**Instrumento:** `scripts/busybox-censo-proveedores.py` · **columnas nuevas del TSV:**
1144`proveedor_medido`, `medido_el` · **qué mide:** índice inverso de
1145`<store>/<hash>-<paquete>/{bin,sbin,usr/bin,usr/sbin,libexec}` sobre los 1407 artefactos del store
1146del hub, cruzado con los 277 applets vivos.
1147
1148## Por qué había que medirlo
1149
1150Este documento decía, arriba y en letra clara: «**el reparto applet→destino es juicio, no medida**.
1151La asignación de dueño la escribí yo y hay que revisarla receta a receta». Revisada:
1152
1153| | n | qué significa |
1154|---|---|---|
1155| el dueño que el TSV nombra **sí lo provee** | 167 | el reparto era correcto |
1156| **lo provee OTRO paquete** | 31 | el dueño estaba mal escrito, pero hay sustituto |
1157| **no lo provee NADIE salvo busybox** | **79** | el trabajo real, y son 56 más de los 23 previstos |
1158
1159> **Los 23 applets «sin dueño» eran 79.** No porque falte escribir más userland, sino porque
1160> **nueve paquetes que el TSV daba por dueños no instalan el binario que se les atribuía.**
1161
1162## Lo que aparece cuando se mira el artefacto en vez de la tabla
1163
1164**`xz` es el caso que más duele, y no es un olvido: es la receta haciendo lo que pide.**
1165`recipes/xz.toml` pasa `--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo
1166--disable-scripts` ⇒ el artefacto sellado tiene **`usr/bin` vacío**: es liblzma y nada más. Y `xz`
1167figura **en seis perfiles**. O sea que la imagen declara `xz` y el único `xz` que hay adentro es el
1168applet de busybox. Lo mismo, por la misma razón, con **`ncurses`**: su receta instala
1169`make install.libs install.includes` ⇒ ni `clear`, ni `reset`, ni `tput`, ni `infocmp`.
1170
1171**Es un patrón, no dos accidentes: una receta de LIBRERÍA no es un paquete de HERRAMIENTAS**, y
1172`build-state.json` no distingue entre las dos — las dos salen `sealed` y con sus perfiles. Es
1173[[catalogo-no-es-imagen]] un piso más abajo: ahí el paquete existía y no viajaba; acá **viaja y no
1174trae el binario**. Ninguna métrica actual lo ve; el censo sí.
1175
1176Los demás huecos nuevos, por qué cada uno:
1177
1178| dueño | applets que NO provee | causa |
1179|---|---|---|
1180| `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 |
1181| `shadow` | 8 | `addgroup adduser delgroup deluser mkpasswd cryptpw` ya estaban marcados; `add-shell` y `remove-shell` son de debianutils y nadie los trae |
1182| `xz` | 6 | receta de librería (arriba) |
1183| `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 |
1184| `iputils` | 3 | trae `ping arping tracepath clockdiff`; `traceroute`/`traceroute6` son otro paquete y `ping6` es `ping -6` |
1185| `ncurses` | 2 | receta de librería (arriba) |
1186| `util-linux` | 2 | `hd` es el alias de `hexdump`, que sí trae ⇒ falso hueco. **`setpriv` es hueco real**: el `configure` lo desactivó |
1187| `uutils` | 2 | `arch` y `hostname` — ver abajo, los cubre el feature |
1188| `vim` | 1 | instala `vim ex view rvim vimdiff xxd`, **no `vi`**: falta el symlink, una línea |
1189| `cronie` | 1 | `run-parts` es de debianutils |
1190| `libarchive` | 1 | instala `bsdcpio`, no `cpio` |
1191| `socat` `chrony` `dhcpcd` | 6 | los NO-drop-in ya conocidos |
1192
1193## Y tres correcciones al revés — el dueño estaba mal, pero el sustituto EXISTE
1194
1195Los 31 «otro proveedor» son casi todos `coreutils` (GNU, sellado, sin perfil) cubriendo lo que
1196`uutils` no traía. Los tres que cambian una conclusión del plan:
1197
1198- **`switch_root` lo trae `util-linux`** — es uno de los cinco indultos del paso 1, y tiene
1199 sustituto real. (El `/init` del initramfs sigue necesitando el de busybox porque ahí no hay
1200 util-linux, pero eso es el initramfs, no la imagen.)
1201- **`sulogin` lo trae `util-linux`**, no shadow. **`partprobe` lo trae `parted`**, no util-linux.
1202- **`getty` tiene sustituto: `agetty`, de `util-linux`, que está en los 7 perfiles.** El TSV lo
1203 daba como `getty-ESCRIBIR`. No es escritura: es cambiar el `exec` de la card
1204 (`takana-bootstrap/src/lib.rs:240`) y medirlo. Lo que sigue faltando entero es la **shell
1205 interactiva**, que es otro consumidor — `simi` no trae edición de línea ni historial.
1206
1207## `uutils` no traía 25 de sus 90 — arreglado en la receta
1208
1209El artefacto sellado `b3:9005da83…` instala **78 symlinks**, no los 90 que este documento le
1210atribuía, y el propio multicall lo dice sin ambigüedad:
1211
1212```
1213$ …-uutils/usr/bin/coreutils chmod --version
1214coreutils: unknown program 'chmod'
1215```
1216
1217Faltaban exactamente estos 25: `arch chgrp chmod chown chroot groups hostid hostname id install
1218kill logname mkfifo mknod nice nohup nproc stat stty sync timeout uname users who whoami`. La causa
1219es que `recipes/uutils.toml` construía con las **default features** (`feat_common_core`), y el
1220`Cargo.toml` del tag pineado pone el resto detrás de `feat_os_unix_musl` — que cubre **esos 25 y
1221ninguno más**. Arreglado con un flag en la receta.
1222
1223⚠ **Esto importaba más de lo que parece:** `USERLAND_COMPONENTS` hidrata `uutils` DESPUÉS de
1224busybox para que lo ensombrezca, y **lo que no existe no ensombrece nada**. O sea que en el producto
1225de hoy `chmod`, `chown`, `id`, `uname` y `stat` los sigue dando busybox, con el userland Rust
1226instalado encima y la métrica diciendo que están cubiertos.
1227
1228⚠ **Lo que el feature NO arregla:** `who`, `users`, `uptime` y `pinky` compilan contra los stubs de
1229utmpx de musl y **contestan vacío**. No es regresión (el `who` de busybox en musl está igual de
1230ciego), pero tampoco es sustitución.
1231
1232## Qué cambia en el orden de ataque
1233
12341. **`xz` y `ncurses` necesitan su paquete de herramientas** — variante de receta, no perilla: tocar
1235 `--disable-xz` re-hashea xz y arrastra a todos sus dependientes. Patrón `elfutils-libdw`.
12362. **Los 6 `ipaddr/iplink/…` salen del defconfig** en el próximo recorte: 277 → 271 sin escribir nada.
12373. **`vi` en la receta de vim** y **`cpio`→`bsdcpio` en libarchive**: un symlink cada uno.
12384. **`getty` deja de ser escritura** y pasa a ser migración de card a `agetty`.
12395. **Falta un guardián nuevo**, hermano del vigía de applets retirados: uno que falle cuando una
1240 receta declare un perfil y su artefacto **no instale el binario homónimo**. El censo es el
1241 instrumento; falta cablearlo a CI.
1242
1243## Lo que este censo NO mide
1244
1245- **Equivalencia de nombre, no de banderas.** `nc` de socat cuenta como proveedor aunque su sintaxis
1246 difiera. La columna `equivalencia` del TSV sigue haciendo falta y sigue diciendo `sin-verificar`
1247 en 230 filas.
1248- **El store del HUB.** Un artefacto sellado sólo en el worker no aparece; `build-state.json` dice
1249 `sealed` igual. Los 79 hay que releerlos como «no está en el store del hub».
1250- **Qué applets EJECUTA el producto de verdad**, que sigue sin medirse y sigue siendo lo que
1251 abarataría todo lo demás.
1252
1253## Lo hecho con el censo en la mano (2026-09-21, misma tanda)
1254
1255> **El número que baja: 79 → 69 applets que sólo provee busybox**, y ninguno de los diez
1256> costó escribir userland.
1257
1258| unidad | estado | sello |
1259|---|---|---|
1260| `uutils` con `feat_os_unix_musl` | ✅ **79 → 106 applets**, 27 nuevos, 0 perdidos | `b3:614ff653…` |
1261| `recipes/xz-tools.toml` — variante con las herramientas | ✅ 23 binarios, estático, round-trip probado | `b3:194c7d69…` |
1262| `vi` en `recipes/vim.toml` | ✅ `usr/bin/vi → vim` | `b3:d303ec31…` |
1263| `cpio` en `recipes/libarchive.toml` | ✅ `usr/bin/cpio → bsdcpio` | `b3:3f999ff8…` |
1264| **paso 2 del plan** — declarar la munición en `targets.toml` | ✅ `uutils findutils findutils-xargs diffutils gzip grep xz-tools` en `base` | — |
1265| guardián de regresiones (`--guardian`) | ✅ probado en los dos sentidos | — |
1266
1267Los cuatro artefactos sellaron con **el mismo hash en el store tirable y en `/store`** —dos
1268`work_root` distintos, bytes idénticos—, o sea reproducibilidad confirmada de paso, que no era el
1269objetivo. Y el reparto del TSV mejoró solo: «el dueño que el TSV nombra sí lo provee» pasó de
1270**167 a 194**, porque los 25 que antes cubría el `coreutils` de GNU ahora los da el `uutils` al que
1271el TSV se los atribuía.
1272
1273⚠ **`xz-tools` salió DINÁMICO en la primera corrida** —`interpreter /lib/ld-musl-x86_64.so.1`—
1274pese al `link = "static"` de la receta, y funcionaba perfectamente: comprimía y descomprimía. Es el
1275relink de libtool, que ya documentan `libarchive.toml` y `bluez.toml`: **hace falta
1276`LDFLAGS=-all-static` en `compile` Y en `install`**, y sin él el `link` de la receta se ignora en
1277silencio. Lo delató mirar el `file` del binario, no una prueba que fallara — [[link-static-mentira-libtool]].
1278
1279**`xz-tools` es el tercer caso de la familia de `zstd-cli`**, y eso ya estaba escrito en
1280`targets.toml`: «la receta canónica construye sólo `lib/` y su artefacto no tiene `usr/bin`;
1281declararla no habría arreglado nada». Lo mismo pasó con `zlib-shared`, `libffi-shared` y
1282`musl-shared`. **Cuatro veces la misma forma**: la receta publica la lib, la imagen declara el
1283paquete, y el binario no está. El censo es el primer instrumento que lo ve sin que se rompa nada
1284primero.
1285
1286**El guardián vigila pérdidas, no un número**, y esa decisión es el punto:
1287
1288```
1289✗ REGRESIÓN: 2 applets PERDIERON su proveedor y vuelven a depender de busybox:
1290 xz lo proveía xz
1291 xzcat lo proveía xz
1292```
1293
1294Un umbral («no más de 79 sin dueño») hay que subirlo cada vez que se retira un applet, y a la
1295tercera nadie lo sube con criterio — [[umbral-a-mano-envejece]]. Una pérdida, en cambio, es siempre
1296una regresión. Probado en los dos sentidos, que es lo que hace falta para saber que un guardián
1297sirve: `rc=0` contra `/store` completo, `rc=1` contra un store mutilado, nombrando applet y
1298proveedor perdido.
1299
1300**Lo que NO se hizo, y por qué:** `ncurses-tools` (para `clear`, `reset`, `tput`) parece el gemelo
1301de `xz-tools` y no lo es. La receta canónica instala `install.libs install.includes` y **tampoco
1302instala la base de datos terminfo**: una variante con los `progs` obliga a decidir si terminfo entra
1303en la imagen (~7 MB) o si basta con los `--with-fallbacks` ya compilados dentro de la lib. Es una
1304decisión de producto, no un enlace que faltaba — y se decide, no se hace de paso.
1305
1306### Y el barrido general, con su límite
1307
1308Buscado el mismo patrón en TODO el corpus —recetas declaradas en algún perfil cuyo directorio de
1309binarios existe y está VACÍO— salen **dos, y las dos son la familia xz**: `xz` (6 perfiles) y
1310`xz-shared` (8 perfiles). O sea que el caso está acotado y `xz-tools` lo cierra.
1311
1312⚠ **Pero el barrido no habría encontrado `ncurses`**, y conviene decir por qué: su receta no deja un
1313`usr/bin` vacío, **no crea el directorio**, y eso es indistinguible de una librería legítima — `zlib`
1314tampoco trae binarios y está bien así. Sin una declaración por receta de «qué binarios publico», no
1315hay señal que separar del ruido. **Lo que da la señal es el TSV de applets**, porque es lo único del
1316repo que escribe la EXPECTATIVA (este applet debería venir de este paquete) contra la cual medir.
1317Es la misma forma que [[subcomando-sin-driver]]: el problema no se ve mirando el artefacto, se ve
1318cruzándolo con lo que alguien prometió que tendría.
1319
1320
1321## Segunda tanda: las dos piezas que el plan marcaba TRAER (2026-09-21)
1322
1323> **64 applets** sólo-busybox, desde 79. Cinco más, y otra vez sin escribir userland.
1324
1325| receta | applets | sello |
1326|---|---|---|
1327| `recipes/psmisc.toml` (23.7) | `killall pstree fuser` (+`prtstat pslog`) | `b3:b361bd04…` |
1328| `recipes/bc.toml` (gavinhoward/bc 7.0.3) | `bc dc` | `b3:050efb84…` |
1329
1330**`bc` es gavinhoward/bc y no el de GNU**, y la razón es de cadena: C99 sin dependencias, `bc` **y**
1331`dc` del mismo árbol, y es el bc de sistema de Alpine y FreeBSD. El de GNU arrastra `ed` y `flex`
1332como build-deps y su `dc` va en otro paquete.
1333
1334⚠ **`killall5` NO lo trae psmisc** — es de sysvinit. Acá lo cubre `arje-zero` por FUNCIÓN (es PID 1
1335y apaga él), así que el applet se retira con los indultos en vez de reemplazarse. Son 3 de los 4 que
1336la tabla prometía, y decirlo así evita que vuelva a prometer lo que no hay.
1337
1338### Los dos pisones de psmisc, que enseñan más que el resultado
1339
1340**1 · `-lncurses` no existe en este corpus, y hay dos formas de no arreglarlo.** El `configure` de
1341psmisc busca `tgetent` por NOMBRE de librería (`-ltinfo`, `-lncurses`, `-ltermcap`) y la nuestra es
1342`--enable-widec`, o sea `libncursesw.a`. Falló primero con `LIBS=-lncursesw` —el `AC_CHECK_LIB` no
1343pregunta si el símbolo resuelve, **compila un programa de prueba con `-lncurses`**— y falló después
1344con `ac_cv_lib_ncurses_tgetent=yes` + `make TERMCAP_LIB=-lncursesw`, porque
1345`src_pstree_LDADD = @TERMCAP_LIB@` se sustituye **en tiempo de configure** y el Makefile queda con
1346`-lncurses` literal, no con una referencia a la variable. Lo que funciona es darle el nombre que
1347busca: un enlace `libncurses.a → libncursesw.a` en el scratch y un `-L` a ese directorio.
1348
1349**2 · Y ese `-L` se comió el `-static`.** Primera versión: `LDFLAGS="-L$PWD/.compat-ncurses"`. El
1350harness inyecta `LDFLAGS=-static` para las recetas `link = "static"`
1351(`takana-build/src/lib.rs:285`), así que reemplazarlo lo pierde. **Los seis binarios sellaron
1352DINÁMICOS y nada falló**: `killall --version` contestaba bien, y sólo `pstree` —el único que enlaza
1353ncurses— moría en ejecución con `Error relocating /lib/libncursesw.so.6`, tomando la librería del
1354anfitrión. Un `file` sobre los binarios lo dice en un segundo; ninguna prueba del build lo iba a
1355decir. Es [[cgo-sin-baseline-sigill]] otra vez: **redefinir una variable que el harness ya puebla
1356pierde en silencio lo que el harness había puesto**, y el síntoma aparece lejos.
1357
1358## Quién usa lo que queda — `busybox-vigia.py --uso` (2026-09-21)
1359
1360Esto es **lo que el documento dejó sin medir tres veces**: «no se midió qué applets usa el producto
1361de verdad; eso abarataría el paso 1». Ahora se mide. El modo nuevo lee del TSV los applets con
1362`proveedor_medido = SOLO-BUSYBOX` y los busca en el repo con las reglas anti-ruido del vigía.
1363
1364De los **64**, sólo **12 tienen un llamador dentro del producto**:
1365
1366| applet | quién lo llama |
1367|---|---|
1368| `sh` | 28 sitios — el `/bin/sh` de todo; lo cierra `simi` |
1369| `ifconfig` `route` `udhcpc` `netstat` | `metal-iso.sh`, `cosmic/metal-desktop-image.sh`, `test-foco-egress.sh`, `mudanza/censar.py` — **todos con `ip`/`ss` como primera opción y el applet como respaldo** (`ip link set … \|\| ifconfig …`) |
1370| `insmod` | `iso-image.sh`, `install-image.sh` — ⚠ **es el `insmod` de GRUB**, no el de busybox: `insmod part_gpt` dentro de un `grub.cfg`. Falso positivo del grep, no migración |
1371| `setuidgid` | `gitea`, `pacha-secretos`, `tejido` — tres cards |
1372| `getty` | la card de la consola (`ssh-e2e-test.sh`, `takana-bootstrap`) |
1373| `adduser` | `servidor/alta-usuario.sh` |
1374| `nc` | `test-foco-egress.sh` |
1375| `nslookup` | `drive-netup.py` |
1376| `poweroff` | `farm/harkaq-campana-vol.sh` |
1377
1378**Los otros 52 no los invoca nadie en el repo.** Y ahí está la trampa, escrita en la salida del
1379propio comando para que no se lea como una lista de borrado:
1380
1381> **«Candidato» no es «retirable».** Un grep ve a quien invoca POR NOMBRE. Hay dos clases de
1382> consumidor que no aparecen nunca: **el kernel** —llama a `/sbin/modprobe` por
1383> `call_usermodehelper` y al gestor de `/dev` por hotplug, así que `modprobe lsmod depmod rmmod
1384> modinfo mdev uevent makedevs` salen «libres» y no lo son— y **el usuario de la máquina**, que
1385> espera encontrar `clear`, `reset`, `vi` o `traceroute` al entrar por ssh.
1386
1387### Y los dos falsos negativos que tuvo el modo, que son la parte que enseña
1388
1389La primera versión decía **10 invocados** y estaba mal en dos casos, los dos peligrosos porque
1390llevan a retirar un applet que el producto usa:
1391
13921. **`setuidgid`** — se invoca como `exec /usr/bin/setuidgid pacha`, con **ruta absoluta**. El
1393 patrón de comando pide el nombre pegado al `exec`, y ahí hay un `/usr/bin/` en medio.
13942. **`getty`** — vive como `"argv": ["getty", "-n", …]`, **elemento de un array JSON**. La regla que
1395 filtra cadenas de Rust/TOML por «parece una línea de comandos» —la que en su día bajó el ruido de
1396 91 hits a 0— descarta una cadena que es sólo un nombre.
1397
1398Las dos reglas que quitan ruido son las mismas que crean ceguera, y la diferencia es hacia dónde se
1399equivoca cada una. En el modo original —«¿alguien llama a algo que ya retiré?»— un falso positivo
1400cuesta un minuto y un falso negativo cuesta un boot roto. En este modo pasa igual, así que ahora
1401busca **tres formas**: nombre desnudo, ruta absoluta y primer elemento de `argv`.
1402
1403---
1404
1405# El frente `kmod` no existe: los kernels de takana no tienen módulos (2026-09-22)
1406
1407**Cómo salió:** al ir a escribir el `kmod` en Rust que este plan pide —la pieza más grande que
1408quedaba, 6 applets— lo primero fue mirar contra qué módulos probarlo. **No hay ninguno.**
1409
1410| medida | resultado |
1411|---|---|
1412| `.ko` en los artefactos sellados de los 4 kernels | **0** — sólo `boot/bzImage` y el `.config` |
1413| recetas del corpus que hacen `modules_install` | **0** |
1414| drivers `=m` en los `.config` sellados | **0** (contra 1809 `=y` en `linux-metal`) |
1415| `CONFIG_MODULES` en los tres kernels del store | **`# CONFIG_MODULES is not set`** |
1416| quién enciende `CONFIG_MODULES` en el repo | **nadie** |
1417
1418Los cuatro kernels son monolíticos por construcción: `compile = "make … bzImage"` y
1419`install = "cp arch/x86/boot/bzImage /out/boot/"`. Sin `CONFIG_MODULES`, el kernel **ni siquiera
1420implementa `init_module`/`delete_module`** — un `insmod` escrito en Rust recibiría `ENOSYS`.
1421
1422> **Los 6 applets de kmod no son trabajo de escritura: son 6 applets a RETIRAR.** Y con ellos se van
1423> otros 3: `CONFIG_DEVTMPFS_MOUNT=y` hace que el kernel cree y monte `/dev` con sus nodos, no hay
1424> `CONFIG_UEVENT_HELPER` (o sea que el kernel no llama a ningún helper de hotplug), y `libudev-zero`
1425> ya viaja en los 8 perfiles. `mdev`, `makedevs` y `uevent` tampoco tienen consumidor.
1426
1427## Lo que esto le hace a la cuenta del plan
1428
1429De los **23 applets** que este documento llamó «el trabajo real», queda muy poco:
1430
1431| pieza | decía | es |
1432|---|---|---|
1433| kmod (6) | escribir en Rust | **retirar** — el kernel no tiene módulos |
1434| gestor de /dev (3) | escribir o decidir | **retirar** — devtmpfs + libudev-zero |
1435| psmisc (4) | traer | ✅ hecho (3; `killall5` es de sysvinit, lo cubre arje-zero por función) |
1436| bc (2) | traer | ✅ hecho |
1437| grep (3) · sed (1) | escribir en Rust | GNU declarado como puente ✅; el Rust nativo sigue siendo deseable, no urgente |
1438| getty (2) | escribir | **migrar**: `agetty` de util-linux está en los 7 perfiles |
1439| awk (1) | decidir | sigue siendo un ADR |
1440| nslookup (1) | traer | **decidir**: ninguno de los candidatos (ldns→`drill`, knot→`kdig`, doggo) publica `nslookup`; el único llamador es `scripts/drive-netup.py` y migrarlo es más barato que traer BIND |
1441
1442## Y la corrección que me toca a mí
1443
1444Hace dos horas escribí en la salida de `busybox-vigia.py --uso`, como advertencia:
1445
1446> «el KERNEL — llama a `/sbin/modprobe` por `call_usermodehelper` … `modprobe lsmod depmod rmmod
1447> modinfo mdev uevent makedevs` salen «libres» acá y no lo son: es el frente kmod, que es escritura
1448> pendiente, no un borrado.»
1449
1450**Es plausible, es la forma correcta de razonar, y es falsa para este producto.** La escribí sin
1451medirla, en el mismo commit en el que presumía de haber arreglado dos falsos negativos. Corregida en
1452el script. Es [[la-etiqueta-no-es-el-hecho]] exacta: la advertencia sonaba a experiencia y era una
1453suposición, y lo que la desarmó fue ir a buscar contra qué probar el código que iba a escribir.
1454
1455## Qué hacer con los 9 — y por qué NO ahora
1456
1457Retirarlos es editar `RETIRADOS` en `recipes/busybox.toml`, y eso **re-hashea busybox**. Las 24
1458recetas que lo declaran build-dep se invalidan, y **15 de esas 24 siguen HOY en deuda por el recorte
1459del 2026-09-21** (`linux`, `llama-cpp`, `protobuf`, `qdrant`, `valkey`, `zlib-ng`, los firmware…).
1460Recortar otra vez ahora duplica ese trabajo para la granja.
1461
1462**El siguiente recorte junta todo lo que ya está decidido y se hace UNA vez:** los 6 de kmod, los 3
1463de /dev, y los 6 alias internos de iproute2 (`ipaddr iplink ipneigh iproute iprule iptunnel`, que no
1464son comandos de nadie) — **15 applets, 277 → 262**, sin escribir una línea y sin reemplazar nada.
1465
1466## El paso 2, verificado en una hidratación real (2026-09-22)
1467
1468El criterio de terminado del paso 2 era «cuando `build-state.py` los marca con perfil **y la imagen
1469los lleva**». Lo segundo no se comprueba declarando: se hidrata y se mira. Hecho, con el orden real
1470del ensamblado —busybox primero, el userland encima, que es lo que hace `USERLAND_COMPONENTS`:
1471
1472```
1473grep → grep find → find diff → diffutils xz → xz (xz-tools)
1474sed → sed xargs → xargs cmp → diffutils bc → bc dc → bc
1475gzip → gzip zcat → zcat vi → vim cpio → bsdcpio
1476killall/pstree/fuser → psmisc chmod/uname/id/stat/ls/cat → coreutils (uutils)
1477```
1478
1479**Ningún binario de los declarados lo sigue ganando busybox.** Y la prueba que faltaba: hidratar la
1480`xz` canónica ENCIMA de `xz-tools` —el escenario que motivó quitarle `lib/` e `include/` a la
1481variante— deja `liblzma.a` de la canónica, `usr/bin/xz` de la variante, y `xz --version` sigue
1482contestando. **Conviven**, que es lo que la receta afirmaba y ahora está medido en vez de razonado.
1483
1484---
1485
1486# `akllay` — el `awk` propio, ESCRITO y MEDIDO (2026-09-22)
1487
1488**Crate:** `crates/akllay/` (3531 líneas, `libc` y nada más) · **receta:** `recipes/akllay.toml`
1489(sellada, `b3:400e8114…`) · **nombre:** *akllay* es «escoger, seleccionar» en quechua — lo que hace
1490awk con los registros.
1491
1492⚠ **Se llamó `qillqa` durante un commit y ese nombre estaba TOMADO**, lo que es su propia lección:
1493en `tawasuyu/02_ruway/qillqa` hay un producto entero —registro público a prueba de manipulación, 16
1494crates, SDD propio— cuyo `qillqa-cli` declara un binario llamado exactamente `qillqa`. Y takana
1495EMPAQUETA recetas de tawasuyu (`boveda`, `tejido`, `pacha`, `shuma-pregunta`…), así que los dos
1496binarios habrían acabado en el mismo `/usr/bin` de la misma imagen: colisión silenciosa, gana el
1497último que hidrate. **La práctica de comprobarlo ya estaba escrita en el otro repo** —
1498`01_yachay/qhali/SDD.md`: «Nombre: `qhali`. Verificado sin colisión:»— y no la seguí: elegí por
1499significado. Verificado ahora en los dos repos y contra los 2193 nombres de binario del store:
1500`akllay` libre, y `simi` también lo estaba (sus apariciones en tawasuyu son «runa simi», el nombre
1501del idioma). **Comprobar el nombre contra tawasuyu es parte de nombrar, no un trámite posterior.** El comando instalado es `awk` — eso es
1502contrato. El nombre propio lo registra este documento y lo decide un ADR, igual que `simi`.
1503
1504## El veredicto, primero
1505
1506> **Construye el corpus y reproduce bit a bit.** Tres recetas autotools reales con akllay como
1507> `/usr/bin/awk` del lab: **3 de 3 sellaron, y las tres con el hash IDÉNTICO** al vigente construido
1508> con el `awk` de busybox. Es exactamente la prueba que brush falló 0 de 3.
1509
1510| | akllay | busybox awk | gawk 5.3.2 |
1511|---|---|---|---|
1512| banco diferencial (109 casos, dos oráculos) | **104 iguales, 0 divergencias** | oráculo | oráculo |
1513| `pkgconf` · `flex` · `oniguruma` con él de `awk` | **3/3, hash idéntico** | 3/3 (control) | — |
1514| binario | **0,64 MB** | 0,98 MB (401→277 applets) | 0,83 MB |
1515| latencia de arranque | **1,04×** | 1,00× | 1,10× |
1516
1517Los hashes, para que se pueda repetir: `pkgconf b3:cfb6a37e…`, `flex b3:6b25e351…`,
1518`oniguruma b3:751a5270…` — los tres son los que ya estaban en `build-state.json`.
1519
1520⚠ **Son tres recetas, no las 943.** Lo que prueban es que el camino que tumbó a brush —el
1521`config.status` que genera el `libtool`, donde awk hace el trabajo pesado— aquí pasa. No prueban
1522que no haya un cuarto caso.
1523
1524## El banco es DIFERENCIAL y contra DOS oráculos, y eso decidió cosas
1525
1526`scripts/awk-banco-diferencial.py` corre los mismos 109 casos en akllay, en el `awk` de busybox y en
1527`gawk --posix`. El veredicto no es «igual a X» sino «igual a LOS DOS», y **los oráculos discrepan
1528entre sí en 5 casos** — que es justo para lo que sirve tener dos:
1529
1530| caso | busybox | gawk | POSIX manda | akllay |
1531|---|---|---|---|---|
1532| `2^3^2` | 64 | **512** | derecha ⇒ 512 | 512 ✓ |
1533| `index(s,"")` | 0 | **1** | 1 | 1 ✓ |
1534| `gsub(/a*/,"-")` sobre `xax` | 4 · `-x--x-` | **3 · `-x-x-`** | 3 | **era 4, corregido** |
1535| `printf "%*d"` | no lo soporta | **sí** | sí | sí ✓ |
1536| `length(array)` | **2** | rechaza (`--posix`) | no lo define | 2 |
1537
1538El tercero era nuestro: **una coincidencia vacía pegada al final de la anterior no sustituye**. Sin
1539esa regla, `gsub` mete guiones de más. Se ve sólo cuando los dos oráculos no coinciden, y por eso un
1540banco contra un único awk lo habría dado por bueno.
1541
1542## Las cinco cosas que el banco destapó — ninguna reventaba
1543
1544Todas daban un resultado plausible, ninguna imprimía un error. Quedan como pruebas de regresión en
1545`crates/akllay/tests/divergencias_medidas.rs`:
1546
15471. **Leer `NF` no forzaba el partido en campos** ⇒ `{print NF}` daba **0** hasta que alguien tocara
1548 un campo. El partido es perezoso a propósito —es lo que hace barato asignar a `$0`— y el precio
1549 es acordarse de forzarlo en cada lectura que dependa de los campos.
15502. **`length` sin paréntesis** se leía como una VARIABLE llamada `length`, que vale vacío. Es el
1551 único builtin al que POSIX permite omitirlos, y el brazo que lo trataba estaba DESPUÉS del brazo
1552 general de identificador: inalcanzable.
15533. **Los arrays se pasan por referencia**, y eso se decide mirando si la función usa el parámetro
1554 como array. `a["k"]=1` tiene el nombre en el **lugar** de la asignación y sólo se miraba el valor.
15554. **`substr` con inicio ≤ 0** recorta a 1 **sin ajustar el largo**, y los no enteros se truncan. La
1556 lectura estricta de POSIX daría otra cosa; ni busybox ni gawk la hacen, y lo que hay que ejecutar
1557 son los guiones escritos contra ellos.
15585. La regla del vacío en `gsub`, arriba.
1559
1560## Por qué el motor de expresiones regulares es propio
1561
1562**POSIX es leftmost-LONGEST; la familia Perl —y el crate `regex` en su modo por defecto— es
1563leftmost-first.** Para `/a|ab/` sobre `"ab"`, POSIX devuelve `ab` y Perl devuelve `a`. En un awk eso
1564no es académico: decide qué borra un `sub()`, dónde parte un `split()` y cuánto valen `RSTART` y
1565`RLENGTH`. Adoptar un motor ajeno es adoptar su semántica.
1566
1567Construcción de Thompson con conjunto de hilos: sin retroceso, así que `(a*)*b` contra sesenta `a`
1568termina igual de rápido que cualquier otra cosa, y quedarse con el final más lejano alcanzado da el
1569«más largo» de POSIX sin trabajo extra.
1570
1571## Lo que queda
1572
15731. **Declararlo en `targets.toml`** y decidir si sustituye al applet en el defconfig. Va con el
1574 recorte de los 15, no suelto.
15752. **ADR** — el nombre y la adopción de un awk propio, junto con el de `simi`.
15763. **Medir más recetas**: las tres son el camino de autotools, que es el duro, pero el corpus tiene
1577 943.
15784. **Lo que NO trae, dicho en vez de fingido:** las extensiones de gawk (`gensub`, `asort`,
1579 `PROCINFO`, `RT`, arrays multidimensionales de verdad, `--re-interval` es siempre on), `getline`
1580 desde coprocesos, y las localizaciones. Es awk POSIX, no gawk.
1581
1582---
1583
1584# Recorte 2 — los 15, HECHO y MEDIDO (2026-09-22)
1585
1586> **277 → 262 applets.** 15 retirados, 0 indultos nuevos, binario de **977.096 → 961.632 bytes**.
1587> Nuevo hash `b3:4bd934f6…` (el vigente era `a24fdbf6…`). Y el censo de proveedores baja de **62 a
1588> 47**: los 15 eran, todos, applets que sólo daba busybox.
1589
1590| grupo | applets | por qué salen |
1591|---|---|---|
1592| **kmod** | `insmod rmmod lsmod modprobe depmod modinfo` + `MODPROBE_SMALL` | el kernel no tiene módulos: `# CONFIG_MODULES is not set` en los cuatro, cero `=m`, ningún `modules_install` |
1593| **gestor de /dev** | `mdev makedevs uevent` | `DEVTMPFS_MOUNT=y` monta `/dev` con sus nodos, no hay `UEVENT_HELPER`, y `libudev-zero` viaja en los 8 perfiles |
1594| **alias de iproute2** | `ipaddr iplink ipneigh iproute iprule iptunnel` | no son comandos de nadie: son `ip addr`, `ip link`… escritos como applet. El `ip` de verdad no se toca |
1595
1596## Verificación
1597
1598| qué | resultado |
1599|---|---|
1600| applets tras el recorte | **262**, exactamente los previstos |
1601| los 15 fuera | ✅ ninguno queda |
1602| indultados presentes | ✅ `setuidgid switch_root poweroff halt reboot adduser`, y también `sh`, `ash`, `getty`, `ip` |
1603| **el sandbox sigue construyendo** | ✅ `zlib-ng`, `jq` y `logrotate` reconstruidas y selladas con el recortado |
1604| vigía | ✅ 138 applets retirados, 0 invocaciones |
1605| reproducible | ✅ mismo hash en el store tirable y en `/store` |
1606
1607## Las dos cosas que el recorte enseñó, que no estaban en el plan
1608
1609**1 · Cinco applets no se podían vigilar, y nadie lo sabía.** `insmod rmmod lsmod modprobe depmod`
1610tenían **`?`** en la columna `simbolo_kconfig` del TSV: sus líneas `//applet:` del fuente llevan un
1611`IF_NOT_MODPROBE_SMALL(...)` anidado que el extractor no entendía. El TSV avisó con `?` en vez de
1612inventar —que es lo correcto— pero **sin símbolo no hay cruce, así que el vigía no los miraba**: se
1613habrían retirado cinco applets sin guardián, que es exactamente el fallo que el vigía existe para
1614evitar. Rellenados desde el fuente ANTES de retirarlos.
1615
1616**2 · `insmod` es también una directiva de GRUB.** El vigía dio 6 hits en `iso-image.sh`,
1617`install-image.sh` y `takana-live-install.sh`, y los 6 son `insmod part_gpt` / `iso9660` / `ext2`
1618dentro de un `cat > …/grub.cfg <<CFG`: los lee GRUB en el arranque, cuando el applet ni existe. Es
1619la **quinta regla anti-ruido** del vigía, y se blanquea la REGIÓN del heredoc y no una lista de
1620módulos de GRUB — una lista envejece, y el día que alguien escriba `insmod gzio` volvería el falso
1621positivo.
1622
1623## Lo que cuesta, dicho claro
1624
1625Tocar `RETIRADOS` re-hashea busybox y con él **las 24 recetas que lo declaran build-dep**. Tres se
1626reconstruyeron aquí como prueba; las otras 21 las paga la granja. **No es deuda nueva de este
1627recorte**: 15 de esas 24 ya estaban en deuda por el recorte del 21/09, y esto las junta en una sola
1628reconstrucción en vez de dos.
1629
1630⚠ **Y la columna del TSV ya no lleva fecha.** Era `recorte_2026_09_21`; con el segundo recorte se
1631vio que no escala. Ahora son `recorte` (sigue/RETIRADO) y `retirado_el` (la fecha), que es lo que
1632debió ser desde el principio: una columna por tanda convierte el fichero en un registro de
1633tandas en vez de un estado.
1634
1635## La deuda del recorte, PAGADA — 21 recetas, y la que no se pudo (2026-09-22)
1636
1637Las 24 recetas que declaran `busybox` build-dep quedaron invalidadas por el recorte. Tres se
1638reconstruyeron en el hub como prueba; **las 21 restantes las molió la granja en 44 minutos**
1639(`dev.gioser.net`, 12:05 → 12:49), bajo el mismo `flock -o` que usa su loop.
1640
1641**Verificado por cruce de hashes, no por código de salida:** para cada receta se calculó en el HUB
1642el hash que espera y se comprobó que ese artefacto exacto está en el store del WORKER. **20 de 21.**
1643
1644> ⚠ **Y el código de salida no servía**, que es la parte que enseña: el guión de la tanda hacía
1645> `takana build … | tail -3` y leía `rc=$?`, **que es el código de `tail`, no el del build**. Las 21
1646> salieron «rc=0» y una había fallado. Un `rc` detrás de una tubería mide el último eslabón.
1647
1648### La que no se pudo, y por qué no es deuda del recorte
1649
1650`ia-modelo-embeddings` no construye **en ninguna de las dos máquinas**, y no por el recorte: su
1651`[source]` apunta a `https://no-hay-upstream.invalid/…` a propósito. Es una de las **tres recetas
1652cuya fuente sólo vive en el mirror** (ADR 0013; las otras son `ia-modelo-chat` y
1653`firefox-pgo-profile`), y el mirror es un Storage Box por **sftp**. Fallan **dos cosas a la vez**:
1654
16551. **El `curl` del corpus no habla sftp.** `takana` lo invoca por nombre y el PATH del lab pone
1656 primero `/work/sergio/bin/curl` (8.20.0 musl, sin libssh2) ⇒ `curl: (1) Protocol "sftp" disabled`.
1657 El de la imagen sí lo trae. **Mismo patrón que el `git` sin `remote-https`.**
16582. **La clave no está**: `~/.ssh/github5` no existe en el hub y `id_ed25519` no está autorizada en
1659 el Storage Box (`(67) Authentication failure`).
1660
1661Arreglar una sola no destraba nada, y cada una falla con un error que no menciona a la otra. Anotado
1662en `scripts/fuentes/mirror-env.sh`, que es donde alguien lo va a buscar.
1663
1664### El estado que queda
1665
1666```
1667sealed 946 · debt 1 · ajeno 3
1668base 85/85 · cli 153/153 · servidor 186/186 · mirada 41/41 · metal-tigerlake 7/7
1669grafo: CIERRA | topo-sort: OK
1670```
1671
1672Todas las imágenes completas. La única deuda del corpus es esa receta, y está esperando una
1673credencial, no un compilador.
1674
1675⚠ **El manifiesto de sellados del worker no existía en este hub** (`work/farm-sellados.txt`), así
1676que el grafo reportaba `never` sobre artefactos que sí existen. Se trajo a mano —es lo mismo que
1677hace la cosecha, sin tocar el lock— y con él aparecen los 26 sellados que no están en el disco de
1678esta máquina pero sí en el del worker.
1679
1680---
1681
1682# Paso 6 — busybox sale de las build-deps, y con eso de las IMÁGENES (2026-09-22)
1683
1684> **`busybox → perfiles: NINGUNO`.** Era el paso que de verdad lo saca del producto.
1685
1686`busybox` **nunca estuvo declarado en `targets.toml`** (cero menciones): llegaba a los 7 perfiles
1687por la **clausura**, porque 24 recetas lo pedían en `[deps] build`. Quitarlo de ahí lo saca de todas
1688las imágenes sin escribir una línea de userland.
1689
1690## La dep era redundante, y se midió antes de tocar las 24
1691
1692El sandbox monta el **rootfs Alpine completo** (`.dev-fs/alpine`) como base read-only
1693(`takana-build/src/sandbox.rs:4`), y ese rootfs **ya trae su propio busybox**: el shell y los
1694coreutils del build salen de ahí. La dep sólo añadía el busybox de takana encima.
1695
1696| comprobación | resultado |
1697|---|---|
1698| `zlib-ng` sin la dep | ✅ sella igual (`b3:f2439671`) |
1699| recetas que MENCIONAN busybox en sus fases | **ninguna** de las 23 |
1700| las 4 que lo nombran (`e2fsprogs kbd vim networkmanager`) | lo hacen en **comentarios** |
1701
1702## El tropiezo del cambio mecánico
1703
1704Dos recetas se resistieron: `jq` y `linux`. El regex con `count=1` pegó en una línea de
1705**comentario** que también dice `build = [` —las dos documentan sus deps en prosa— y, como ahí no
1706había busybox, la sustitución no cambiaba nada y el fichero no se escribía. El efecto fue dejar
1707intacta la dep real de más abajo. Arregladas a mano, y verificado con `git diff` que **ningún
1708comentario quedó alterado** en las otras 22: un cambio masivo sobre ficheros con prosa necesita esa
1709comprobación, no basta con que el resultado parezca correcto.
1710
1711## Lo que queda de busybox
1712
1713Ya no está en ninguna imagen. Lo que sigue es el **frente C**, que es ADR y toca la evidencia de
1714reproducibilidad:
1715
1716- `STAGE1_COMPONENTS` (`takana-bootstrap/src/lib.rs:172`) — es 1 de los 4 del self-host
1717- `ATTEST_PATHS` (`:465`) — `bin/busybox` está atestado
1718- dos cards con `exec "/bin/busybox"`: la consola (`:240`) y sshd (`:429`)
1719
1720Y los **47 applets** del censo, que ya no viajan en ninguna imagen pero siguen siendo el inventario
1721de lo que el producto tendría que proveer por otra vía el día que el frente C se cierre.
1722
1723## `getty → agetty` NO es migrar un `exec` — corrección (2026-09-22)
1724
1725Este documento decía, y yo lo repetí: «`getty` deja de ser escritura y pasa a ser **migrar el
1726`exec` de la card**, porque `agetty` de util-linux ya está en los 7 perfiles». **Es falso, y el
1727cambio hecho a ciegas deja el arranque SIN CONSOLA.**
1728
1729Lo que falta no es sintaxis. Eso sí se verificó y está bien: `agetty` acepta el argv del applet tal
1730cual —documenta las dos formas, `<line> [<baud>]` y `<baud>,... <line>`, y admite `-n`
1731(`--skip-login`) y `-l` (`--login-program`) con la misma semántica—, comprobado contra el binario
1732sellado y no contra la documentación.
1733
1734**Lo que falta es el binario.** La card `console-getty` vive en `STAGE1_SEED_CARD`, o sea en la
1735seed del **núcleo**, y el rootfs del núcleo tiene exactamente **cuatro** componentes:
1736
1737```rust
1738const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"];
1739const USERLAND_COMPONENTS: &[&str] = &["uutils", "findutils", "findutils-xargs", "diffutils", "ripgrep"];
1740const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"];
1741```
1742
1743`util-linux` **no está en ninguna de las tres**. Comprobado en los artefactos sellados, que es donde
1744se ve y no en `targets.toml`:
1745
1746| | `sbin/agetty` | `sbin/getty` |
1747|---|---|---|
1748| `stage1-rootfs` (`1a3823b4…`) | **no existe** | ✅ (symlink a busybox) |
1749| `product-rootfs` (`86398943…`) | **no existe** | ✅ |
1750
1751Que `util-linux` esté en los 7 perfiles de `targets.toml` **no lo pone en el rootfs del producto**:
1752el producto se ensambla de esas tres listas, no del catálogo. Es la distinción
1753[[catalogo-no-es-imagen]] otra vez, y esta vez me la tragué entera: la habría dejado apuntando a un
1754binario inexistente, con `supervision: Restart` reintentando para siempre y el arranque sin consola
1755— que es justo el fallo que sólo se ve en metal, tarde.
1756
1757### Entonces, qué es de verdad
1758
1759Una decisión de arquitectura, no una línea:
1760
17611. **Meter `util-linux` en `STAGE1_COMPONENTS`** — el núcleo pasa de 4 a 5 componentes, cambia el
1762 `of_tree` del self-host y toca la evidencia de reproducibilidad. **Es ADR.**
17632. **Mover la card de consola a la capa de producto** y añadir `util-linux` a
1764 `USERLAND_COMPONENTS` — no toca el núcleo, pero entonces **el stage1 se queda sin consola** (o
1765 conserva la suya de busybox y hay dos cards distintas, una por capa).
1766
1767Las dos son defendibles y ninguna es «migrar un exec». Lo que queda medido y aprovechable es que el
1768argv no cambia: cuando se decida, el `exec` es lo único que se toca.
1769
1770⚠ **Y la validación de un getty no se hace con pruebas unitarias.** Se intentó ejercitar `agetty`
1771contra un pty real —con `setsid`, y también como root— y **no ejecuta el programa de login**: su
1772contrato necesita ser el tty de control de una sesión de arranque. Los 42 tests del crate pasaban
1773con la card cambiada y el producto habría arrancado mudo. La prueba es `scripts/product-boot-test.sh`
1774en QEMU, que esta jaula no tiene.
1775
1776## La consola con `agetty`, ARRANCADA en QEMU (2026-09-22)
1777
1778`util-linux` entró a `STAGE1_COMPONENTS` (el núcleo pasó de 4 a 5) y con eso la card `console-getty`
1779ya puede ejecutar un binario que existe en ese rootfs. **Probado arrancando**, que es lo único que
1780vale para un getty:
1781
1782| qué | dónde | resultado |
1783|---|---|---|
1784| `stage1-rootfs` | hub **y** worker | `b3:b9eb52a3` — **el mismo hash en las dos máquinas** |
1785| `product-rootfs` | worker | `b3:57e101e8` |
1786| `takana attest` sobre el atestado | hub | ✅ 7 binarios casan su BLAKE3, `/sbin/agetty` entre ellos |
1787| **arranque en QEMU** (TCG, sin KVM) | worker | ✅ `*** PRODUCT SSH OK ***` |
1788| **`agetty` vivo en el guest** | worker | ✅ `/sbin/agetty agetty -n -l /bin/sh 115200 console` |
1789| **¿bucle de reinicio?** | worker | ❌ no: mismo pid en tres muestras separadas 3 s |
1790
1791Lo último es lo que de verdad se buscaba. Un `exec` que apunta a un binario inexistente **no rompe
1792el arranque**: arje lo reintenta por `supervision: Restart`, el sistema sigue, sshd responde y el
1793boot test pasa igual. Por eso no alcanzaba con «bootea»: había que ir a ver **si el proceso está
1794vivo y si es el mismo**.
1795
1796⚠ **Lo que NO se probó:** que al pulsar Enter en la consola serie aparezca una shell. Eso pide
1797hablarle al puerto serie del guest (`-serial unix:…` + un cliente), y el andamiaje costaba más que
1798lo que añadía sobre «el proceso correcto está vivo y estable». Queda dicho en vez de supuesto.
1799
1800### Y el error de método, que es la parte que enseña
1801
1802Para ensamblar el producto en el worker **le quité el `flock`**, razonando que `bootstrap product`
1803«sólo hidrata artefactos ya sellados». **Es falso: construye las recetas que le faltan**, y se lo
1804pilló compilando `diffutils`. Eso es exactamente la carrera del ADR 0012 — si el loop de la granja
1805hubiera estado con una receta que compartiera dependencia, el árbol de fuentes quedaba roto para
1806siempre. No pasó porque el loop estaba con `ffmpeg`: **suerte, no diseño**.
1807
1808La vía correcta, que es la que se usó después: **copiar al worker los artefactos que le faltaban**
1809(`ripgrep`, `netup`, `openssh`, `arje-packager`) en vez de construirlos allá. El store es
1810direccionado por contenido, así que copiar un artefacto sellado es legítimo y no compite por nada.
1811Con todo presente, el ensamblado bajo `flock` fue instantáneo.
1812
1813---
1814
1815# Paso 7 — el censo dejaba de decir la verdad, y el vigía no miraba el producto (2026-09-22)
1816
1817Dos instrumentos de este plan habían envejecido. No es teoría: entre los dos hacían que la campaña
1818**pidiera trabajo ya hecho** y que el guardián **no viera el arranque del producto**.
1819
1820## 7.1 · El `destino` del TSV era juicio, y el juicio se quedó viejo
1821
1822La cabecera de este documento cifró **23 applets de trabajo real**. Medido hoy contra los artefactos
1823y el cierre de imagen, con `scripts/busybox-censo-proveedores.py --auditar-destinos`:
1824
1825> **293 celdas del TSV estaban mal.** 22 en `destino` —o sea trabajo mal contado— y 271 en
1826> `estado_del_dueño`.
1827
1828La regla nueva no opina: **si un paquete que VIAJA EN LA IMAGEN provee el applet, ése es el
1829destino.** Sale del índice de proveedores que el censo ya medía, cruzado con
1830`yupana.membresia_imagen()`. Lo que destapó:
1831
1832| applet(s) | decía | es |
1833|---|---|---|
1834| `awk` | `awk-DECISION` | **`akllay`** — el awk propio en Rust, sellado, en los 7 perfiles, publicando `/usr/bin/awk` |
1835| `bc` `dc` | `bc-TRAER` | `bc` (sellada y en los 7 perfiles desde `4357b29c`) |
1836| `killall` `pstree` `fuser` | `psmisc-TRAER` | `psmisc` (ídem) |
1837| `grep` `egrep` `fgrep` | `grep-posix` | `grep` — el GNU viaja y cubre los tres HOY |
1838| `sed` | `sed-rs` | `sed` |
1839| `xz` `xzcat` `lzma` … (5) | `xz` | **`xz-tools`** — `xz` es la lib, y su `usr/bin` está vacío ([[censo-proveedores-applets]]) |
1840| `clear` `reset` | `ncurses` | `ncurses-tools` |
1841| `logger` | `hammerd` | `util-linux` — confirma la corrección del paso 1: hammerd reemplaza la FUNCIÓN, no el comando |
1842| `switch_root` | `arje-zero` | `util-linux` — mismo caso |
1843| `sulogin` | `shadow` | `util-linux` |
1844| `partprobe` | `util-linux` | `parted` |
1845
1846⚠ **`BORRAR` no se reescribe nunca.** Es una DECISIÓN («este applet no se reemplaza»), y ninguna
1847medición la contradice: que `cups` traiga un `lpr` no significa que haya que conservar el applet.
1848Medir es para las filas que declaran trabajo pendiente, no para deshacer lo decidido.
1849
1850## 7.2 · `kmod` y el gestor de `/dev`: 9 applets que eran BORRAR, no ESCRIBIR
1851
1852Medido en los `.config` que **los artefactos publican** (`/boot/config-*`), no en las recetas:
1853
1854| | MODULES | drivers `=m` | DEVTMPFS_MOUNT | UEVENT_HELPER |
1855|---|---|---|---|---|
1856| `linux-generic`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`, `linux-gioser` | **off** | **0** | `=y` | off |
1857| `linux` (QEMU / self-host) | **`=y`** | **13** | `=y` | off |
1858
1859Y la medición que cierra el caso: **ningún artefacto del store publica `lib/modules`** — ni siquiera
1860`linux`. No hay módulo que cargar en ninguna imagen.
1861
1862⇒ `insmod rmmod lsmod modprobe depmod modinfo` y `mdev makedevs uevent` pasan a **BORRAR** con su
1863motivo escrito en el TSV. Eran 9 de los 23.
1864
1865⚠ **Y el texto que ya estaba en el vigía se pasaba de ancho**: decía «los cuatro kernels de takana
1866traen `# CONFIG_MODULES is not set`, CERO drivers `=m`». Son **seis** kernels sellados, cinco
1867cumplen eso y el sexto trae 13 módulos. La conclusión no cambia; la afirmación sí, y una afirmación
1868de más en un guardián es lo que enseña a no creerle.
1869
1870## 7.3 · `killall5` prometía un paquete que nunca lo iba a traer
1871
1872Decía `psmisc-TRAER`. **`psmisc` no trae `killall5`** —es de sysvinit— y su propia receta lo dice en
1873la línea 9: acá lo cubre `arje-zero` **por función**, porque es PID 1 y apaga él. Pasa a `BORRAR`.
1874
1875## 7.4 · El vigía no veía las cadenas crudas de Rust — o sea, no veía el producto
1876
1877`busybox-vigia.py` extraía las cadenas de un `.rs` con `"([^"\\\n]|\\.)*"`, que **excluye el salto de
1878línea**. Y `takana-bootstrap` guarda los guiones del PRODUCTO en literales crudos multilínea
1879(`const REBUILD_DRIVER: &str = r#"#!/bin/sh …"#`). Consecuencia medida: con `insmod` retirado, el
1880vigía decía **«OK — nadie los invoca»** mientras `crates/takana-bootstrap/src/lib.rs:1388` hacía
1881`insmod /toolchain/lib/overlay.ko`.
1882
1883Arreglado (`cadenas_rust`), y con dos cosas más que hacían falta para que sirva:
1884
1885- **El número de línea era el del texto extraído, no el del fichero** (`lib.rs:125` cuando el
1886 `insmod` está en la 1388). Ahora hay un mapa de líneas reales. Un guardián que manda al sitio
1887 equivocado gasta la confianza igual que uno que no encuentra nada.
1888- **Sexta regla anti-ruido**: una invocación cuyo ARGUMENTO cuelga de `/toolchain/` no es el applet.
1889 Ese `insmod` lo resuelve el `PATH=/toolchain/usr/bin:…` que el propio guión pone tres líneas antes
1890 — es el `kmod` de Alpine, y su comentario lo dice. Se reconoce por el argumento y no por el
1891 fichero, así que vale para cualquier llamada futura del mismo tipo y no tapa un `insmod` normal.
1892 ⚠ No se "arregla" poniéndole la ruta absoluta al guión: `REBUILD_DRIVER` se hornea en el rootfs del
1893 builder y tocarlo mueve la evidencia de reproducibilidad del self-host (`of_tree`, ADR 0008).
1894
1895Probado con una rotura a propósito: un `modprobe overlay` dentro de un `r#"…"#` ⇒ `rc=1` y la línea
1896correcta. Sin el arreglo, ese mismo caso pasaba en verde.
1897
1898## El estado del censo, después
1899
1900```
1901401 applets · 262 vivos · 139 retirados
1902destinos que TODAVÍA declaran trabajo: 3
1903 getty-ESCRIBIR 2 cttyhack getty ← es ADR (ver el §getty→agetty), no escritura
1904 dns-TRAER 1 nslookup ← traer una receta
1905lo provee OTRO paquete (el `destino` está mal): 0
1906```
1907
1908**De los 23 applets de «trabajo real» quedan 3**, y dos de ellos son una decisión de arquitectura ya
1909escrita. No se cerró la brecha escribiendo userland: se cerró **midiendo** lo que ya existía, que es
1910la misma lección con la que empezó este documento.
1911
1912## Re-verificar el self-host tras meter `util-linux`: hasta dónde se llegó (2026-09-24)
1913
1914El `of_tree` del stage1 cambió al pasar el núcleo de 4 a 5 componentes, así que el baseline había que
1915re-anclarlo. **Eso está hecho y medido**:
1916
1917```
1918of_tree(stage1) b3:9adefb82… → b3:d152f060… (anclado en scripts/selfhost-verify.sh)
1919```
1920
1921⚠ **Pero eso es el cruce entre MÁQUINAS, no el auto-alojamiento.** Que la VM reconstruya ese mismo
1922árbol con el toolchain de adentro **sigue sin verificarse**, y conviene decir exactamente dónde se
1923atascó porque el camino destapó tres cosas, dos de ellas arregladas.
1924
1925### 1 · El script moría en silencio en cualquier máquina sin `/lib/modules` — ARREGLADO
1926
1927`set -euo pipefail` + `find` sobre un directorio inexistente: el `find` falla, con `pipefail` el pipe
1928falla, la asignación falla y `set -e` aborta **antes** del `echo "NO encontrado"` que el propio bucle
1929tenía preparado. El `2>/dev/null` remata el silencio. Pasa en el worker porque es un **LXC**: comparte
1930el kernel del host y no trae árbol de módulos. El log terminaba en la línea del `say`, sin una
1931palabra, y el proceso llevaba **dos días muerto** mientras yo lo daba por vivo — porque
1932`pgrep -f selfhost-verify` **se encuentra a sí mismo**.
1933
1934### 2 · El initramfs pesaba 15 G y no cabía en la RAM de la VM — ARREGLADO
1935
1936```
1937work/builder.cpio.gz: 15G ← contra MEM=8192 ⇒ DRIVER done sent=False elapsed=2s
1938```
1939
1940Un initramfs se carga entero en RAM. Los 15 G no eran el toolchain (1,2 G) ni el store (396 M): era
1941el `--work-cache`, que lleva `repos/` y `tarballs/` — en una máquina que ya construyó el corpus, eso
1942son **8,7 G de mirrors git + 6,3 G de tarballs**. Ahora `WORK_CACHE` es variable y el caché mínimo
1943del núcleo son **174 M**: `takana.git`, `arje-zero.git` y los tres tarballs de musl/busybox/util-linux.
1944El mensaje del script —«? incompleto (timeout/boot/OOM)»— no ayudaba: el dato que lo delataba estaba
1945impreso cuatro líneas más arriba.
1946
1947### 3 · El muro actual: `bwrap: pivot_root: Invalid argument` — ABIERTO
1948
1949Con las dos cosas arregladas, **la VM arranca** (hay prompt y el driver corre) y falla el PRIMER
1950build de dentro, con **0 sellados**:
1951
1952```
1953bwrap: pivot_root: Invalid argument
1954Error: core: build phase falló (exit 1): ./configure …
1955```
1956
1957No es falta de capacidades del kernel — se comprobó en el `.config` sellado: `USER_NS`,
1958`NAMESPACES`, `OVERLAY_FS`, `TMPFS`, `DEVTMPFS` y `BLK_DEV_INITRD` están **todos en `y`**. Y no es el
1959`ENODEV` de overlay-como-módulo que el runbook documenta (§ «overlay es módulo»), que es otro error.
1960
1961**La diferencia con la corrida que sí funcionó es el KERNEL.** El runbook valida este flujo con
1962`/boot/vmlinuz-linux` —el de una laptop Arch— y ahí musl y busybox se reconstruyeron y sellaron
1963dentro de la VM. Acá se le pasó **nuestro** `linux` 6.16.12, porque el worker es un contenedor y no
1964tiene kernel propio que ofrecer.
1965
1966⇒ Lo que falta para cerrar la re-verificación es **una máquina con RAM suficiente y un kernel de
1967distro**: el worker tiene los 16 G pero es un LXC sin kernel propio; el hub y gioser tienen kernel
1968pero ~7 G, y la VM sola pide 8. Mientras tanto, lo que está verificado es el cruce entre máquinas
1969(el mismo `of_tree` calculado en hub y worker), no el rebuild de adentro.
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:
| frente | qué es busybox ahí | dificultad |
| A · Sandbox de build | [deps] build = [… "busybox" …] en 24 recetas — es el sh+coreutils que el sandbox del lab trae (recipes/jq.toml:5-7, recipes/zlib-ng.toml:11). Más el /toolchain mixto de Alpine con el swap de docs/11-bootstrap.md:234-240 | media — no es runtime, no toca el producto |
| B · Runtime del producto | los applets de /bin y /sbin de las imágenes. USERLAND_COMPONENTS (crates/takana-bootstrap/src/lib.rs:406) ya los ensombrece con los binarios Rust y retira el homónimo de los otros dirs (lib.rs:676) | baja — la máquina ya está construida, falta munición |
| C · Stage 1 / self-host / atestación | STAGE1_COMPONENTS (lib.rs:172, el 4/4), ATTEST_PATHS como raíz-de-confianza (lib.rs:465), y el exec de las cards de getty y sshd (lib.rs:429, :240) | alta — es el ADR 0008, y toca la evidencia de reproducibilidad of_tree=9adefb82… |
⚠ La membresía de perfil de busybox es del frente A, no del B. busybox figura en cli,
servidor, los cuatro escritorios y metal-tigerlake, pero no está declarado como raíz en
docs/state/targets.toml (los únicos hits ahí son comentarios). Llega por la clausura, porque 24
recetas lo declaran build-dep: abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng. Sacarlo de
las imágenes empieza por ahí y no requiere escribir ni una línea de userland.
El reparto de los 401
| estado del dueño | n | qué significa |
| sellada + en perfil | 148 | el reemplazo ya viaja en la imagen: el applet es redundante hoy |
| sellada, sin perfil | 125 | construido, sellado, y no viaja: falta declararlo |
| sin dueño | 128 | de los cuales 105 son BORRAR y 23 son trabajo real |
148 · Ya cubiertos y en la imagen — el applet sobra
util-linux 48 · iproute2 19 · procps-ng 12 · shadow 14 · kbd 12 · xz 6 · iputils 5 ·
e2fsprogs 5 · bzip2 3 · cronie 3 · dhcpcd 3 · ncurses 2 · dosfstools 2 · chrony 2 ·
vim 2 · less lsof pciutils usbutils mandoc socat patch binutils tar wget 1 c/u.
⚠ 24 de estos 148 NO son drop-in, y están marcados en el TSV. El caso grande es la red:
iproute2 da ip/ss, no ifconfig/route/arp/netstat — eso es net-tools, que no está en
el corpus. Igual con addgroup/adduser (shadow da groupadd/useradd), udhcpc (dhcpcd es otro
cliente con otro contrato de hooks) y nc (socat tiene otra sintaxis). Cada uno de esos 24 es una
migración de llamadores, no un borrado. Mirar quién los invoca en scripts/ y en las cards antes.
125 · Sellados y sin viajar — la munición que ya está fabricada
| dueño | n | qué cubre |
uutils | 90 | todo el núcleo POSIX: ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown … |
arje-zero | 20 | init linuxrc runlevel halt poweroff reboot run-init switch_root + los 12 de runit (runsv sv svc svlogd chpst envdir setuidgid softlimit start-stop-daemon …) |
hammerd | 4 | syslogd klogd logger logread |
brush | 3 | sh ash hush |
gzip | 3 | gzip gunzip zcat |
diffutils | 2 | diff cmp |
findutils / findutils-xargs / libarchive | 3 | find · xargs · cpio |
⚠ CORRECCIÓN (2026-09-21, al ejecutar el paso 1): los 24 de arje-zero y hammerd NO son
gratis, y esta tabla decía mal. Esas dos recetas reemplazan la función, no el comando:
cada una instala UN binario (-p arje-zero, -p hammerd), así que no existe un poweroff, un
logger ni un setuidgid que las respalde. «Cubierto» significaba «nadie necesita correr esto»,
y para 5 de los 24 era falso. Ver los indultos del paso 1.
128 · Sin dueño
105 se borran (no se reemplazan, salen del defconfig): dpkg dpkg-deb rpm rpm2cpio httpd ftpd ftpget ftpput tftp tftpd telnet telnetd inetd dnsd sendmail popmaildir lpd lpq lpr ubi* nand* nbd-client i2c* hdparm fdformat setserial powertop bootchartd fbsplash conspy ether-wake udhcpd udpsvd tcpsvd watchdog whois tree which unzip … (lista completa en el TSV).
23 son el trabajo real:
| pieza | applets | qué es |
| kmod | insmod rmmod lsmod modprobe depmod modinfo (6) | no hay receta kmod en el corpus. En Rust: finit_module + parseo de modules.dep. Bien acotado |
| psmisc | killall killall5 pstree fuser (4) | procps-ng NO los trae. Traer receta psmisc (C) o escribir |
| grep POSIX | grep egrep fgrep (3) | grep GNU está sellado (sin perfil) como puente. El Rust nativo se escribe sobre grep-matcher/grep-regex/grep-searcher |
| gestor de /dev | mdev makedevs uevent (3) | decisión: lo absorbe arje, o entra una receta eudev/mdevd |
| bc | bc dc (2) | traer receta |
| getty | getty cttyhack (2) | hoy la card de getty ejecuta /bin/busybox (lib.rs:429) |
| awk | awk (1) | decisión abierta, ver abajo |
| sed | sed (1) | sed GNU sellado y en los 7 perfiles; el Rust nativo está por escribir |
| dns | nslookup (1) | traer receta bind-tools/doggo |
Las tres clases de reemplazo — y cuándo forkear (nunca)
La pregunta era si hay que forkear los que «existen pero no son exactos». No, y hay tres clases
distintas que conviene no mezclar:
1 · Adopción exacta. uutils/coreutils, uutils/findutils, uutils/diffutils: reimplementación
madura, MIT, receta Cargo con commit pinneado (ADR 0006), multicall. El patrón está probado y
documentado en la cabecera de recipes/uutils.toml. Lo que falta de esta clase es
uutils/procps y uutils/util-linux — receta calcada, coste bajo. ⚠ Verificar su estado
upstream antes de prometer los 60 applets que cubrirían: util-linux en Rust era embrionario.
2 · «Existe pero no es exacto» — y forkear es la trampa. ripgrep no es grep POSIX, sd no es
sed, fd no es find (por eso la receta buena es uutils/findutils y no fd), procs no es
ps, bottom no es top, helix no es vi. Todas están selladas y en los 7 perfiles, y ninguna
sirve de reemplazo: cambian las banderas. Forkear ripgrep para que hable POSIX es peor que
escribir — heredás su arquitectura de recursión+ignore y peleás contra ella para siempre. Lo correcto
es usarlas como librerías: grep-matcher/grep-regex/grep-searcher están publicadas
justamente para eso, y un grep POSIX encima son unos cientos de líneas.
3 · awk es el caso feo, y hay que decidirlo, no resolverlo de paso. recipes/goawk.toml existe
y está sellada, pero goawk es Go, no Rust — no cumple la premisa de soberanía de cadena de la
Etapa C. frawk es Rust pero es otro dialecto (JIT, semántica propia). gawk es C con extensiones
GNU que los configure del corpus sí usan. Las tres opciones son legítimas y ninguna es gratis.
El bloqueante, y la buena noticia
Sin sh POSIX no se bota busybox en ningún frente: es el exec de las cards, el /bin/sh del
producto y el shell del sandbox. Lo esperado era que fuera un proyecto de meses.
✅ RESUELTO — simi, y la noticia es mejor de lo que esta sección decía
El bloqueante ya no existe. Lo que había escrito acá —que el candidato era brush y que había
que medirlo— quedó superado: se escribió un /bin/sh PROPIO, simi (recipes/simi.toml,
crates/simi, 4448 líneas de Rust), y está medido contra el corpus REAL.
| simi | ash (control) | bash | brush |
| divergencias duras, 1339 casos | 0 | — | 8 | 35 |
| avisos (stderr) | 0 | — | | |
| ms/caso | 1,5 | 1,5 | | |
| tamaño | 499 KB estático, 0 NEEDED | 0,92 MB | | 6,9 MB |
| latencia (hub) | 547 µs | 464 µs · 1,18× | | 3,5× |
⚠ Y lo importante de esos 1339: sólo 10 están escritos a mano — los otros 1329 son fases REALES
de recetas del corpus. No es «pasa un banco POSIX que yo inventé» (eso es
sh-banco-posix.sh, y brush lo pasa 56/57 y aun así no construye una receta autotools): es «hace
LO MISMO que el ash sobre el material que de verdad hay».
Para llegar a 0 hubo que arreglar una receta, no simi: wasi-libcxx usaba ${t/unknown-/}, un
bash-ism que el ash tolera por cortesía y que POSIX no tiene. Era la ÚNICA divergencia de las 1339.
simi implementa las diez expansiones POSIX (${v} :- := :? :+ ${#v} # ## % %%) y
ninguna de bash (${v/a/b} ${v:1:2} ${!v} ${v^^}) — verificadas una por una. Eso no es
una carencia: es la línea que define qué es /bin/sh acá, y hace que un bash-ism en una receta
FALLE en vez de colarse.
Lo que queda de simi, y es sólo declararla
Está sellada y vigente (b3:327b31b7…, traída al store del hub el 2026-10-22) y en ningún
perfil, a propósito: declararla la mete en la raíz de confianza del arranque (frente C) y eso es
su propia unidad de trabajo. Dicho de otro modo: el paso que falta no es escribir ni medir, es
DECIDIR.
⚠ Dos cosas que su receta tenía caducadas y se corrigieron el 22-oct: citaba el hash
b3:46ff6fcf… (superado) y «159 casos» (el banco creció a 1339). Una medición con su hash al lado
caduca cuando el hash avanza — y ésta llevaba un mes diciendo un número viejo.
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.
Recortar el defconfig. HECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.
- 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.
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.
- 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».
grep y sed POSIX en Rust. Es la escritura de mejor relación coste/beneficio y desbloquea
el /toolchain del frente A.
- 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.
- La cola larga: kmod, psmisc, gestor de /dev, getty, bc, dns. Después del shell, no antes.
- ADR —
awk, y ADR — el frente C (sustituir busybox en STAGE1_COMPONENTS y
ATTEST_PATHS). No antes de que 1–7 estén cerrados.
TRIAJE DE LOS 47 QUE QUEDAN — medido 2026-10-22, y cambia el tamaño del problema
El censo dice «SÓLO lo provee busybox: 47 ← el trabajo que queda» (eran 79 cuando se escribió
este plan; el paso 2 y el recorte bajaron el resto). Pero ese 47 se lee como «47 implementaciones
por escribir», y medido uno por uno no lo es: la mayoría son NOMBRES HEREDADOS cuya capacidad
ya viaja con otro nombre. Lo que publica cada dueño declarado, medido en su artefacto:
| dueño | publica de verdad | lo que busybox cubre con otro nombre |
iproute2 | ip ss bridge tc nstat ifstat routel | arp brctl ifconfig ifdown ifenslave ifup nameif netstat route slattach tunctl vconfig |
shadow | useradd userdel groupadd groupdel chpasswd chage… | adduser deluser addgroup delgroup |
kbd | loadkeys dumpkeys setfont openvt chvt… | loadkmap dumpkmap loadfont |
dhcpcd | dhcpcd | udhcpc udhcpc6 dumpleases |
iputils | ping arping tracepath clockdiff | ping6 traceroute traceroute6 |
util-linux | agetty hexdump (+200) | getty hd |
A — RETIRAR del TSV: 26 applets, cero trabajo de implementación
Son los nombres de net-tools y los wrappers estilo Debian. ip/ss/bridge reemplazan a los
doce de iproute2 — no es opinión, es que net-tools está obsoleto hace una década y la distro ya
eligió iproute2. Igual useradd sobre adduser, loadkeys sobre loadkmap, ping -6 sobre
ping6, dhcpcd sobre udhcpc.
⇒ No se escriben: se declaran retirados y el número baja de 47 a 21 sin tocar una línea de
código. Lo que sí hace falta es decirlo en el TSV, porque hoy figuran con destino y eso los hace
parecer trabajo pendiente.
B — ALIAS: 2, y uno es dudoso
getty → agetty de util-linux, que SÍ está en el artefacto (/sbin/agetty, medido). Es
renombrar en las cards, no empaquetar.
hd → hexdump de util-linux (/usr/bin/hexdump, medido).
- ⚠
nc → socat NO es alias: la sintaxis difiere por completo. O se escribe un envoltorio o
se retira; un enlace rompería a quien lo invoque.
C — HUECOS REALES: 19, y sólo tres son grandes
| applet(s) | qué falta | tamaño |
sh ash hush | ✅ RESUELTO por simi (ver arriba): 0 divergencias en 1339 casos, 499 KB, estático. No es brush — es código propio. Lo único pendiente es DECLARARLA en un perfil, que es decisión de frente C, no trabajo de implementación | — |
halt poweroff reboot | ❌ NO son hueco: están INDULTADOS a propósito y el TSV lo dice. Me equivoqué al meterlos acá: sí es cierto que los tres resuelven a busybox en la caja viva, pero eso es la decisión, no un descuido — «nada más en el corpus apaga la máquina: arje-zero es PID1, no una CLI», y kernel_ab.rs documenta reboot -f kexec. Mismo caso: setuidgid (lo invoca la card de gitea) y switch_root (lo ejecuta el /init del instalador). ⇒ 5 indultados, no 3 huecos. Lección: el TSV ya tenía el veredicto medido y yo lo re-derivé peor | — |
nslookup | no hay NINGÚN cliente DNS de consulta: drill, dig, host, kdig — ninguno existe en el store. Es hueco de capacidad, no de nombre | mediano |
setpriv | nuestro util-linux NO lo construye (medido: ausente del artefacto, y upstream lo trae) ⇒ probablemente una bandera del configure. ⚠ Pero util-linux tiene radio 79: tocarlo cascadea | chico, cascada caro |
vlock mkpasswd cryptpw add-shell remove-shell | utilidades sueltas de cuenta/consola que nadie más trae | chicos |
killall5 cttyhack setuidgid run-parts ntpd rdate dumpleases | triaje individual: varios son de un init que no usamos (killall5, cttyhack) o duplican algo ya presente (ntpd ↔ ntp-daemon de ntpd-rs, rdate ↔ chrony) | chicos |
Lo que YA se reemplazó con software propio (tawasuyu) — para no re-descubrirlo
Pregunta del usuario, medida en el TSV y en los artefactos:
| pieza propia | qué applets se llevó |
akllay | awk — publica akllay y awk en su artefacto, «sellada y EN IMAGEN». ⚠ Y hay un SEGUNDO awk propio: qillqa también publica awk; los proveedores medidos de ese applet son akllay, gawk, qillqa — tres, uno de ellos C. La nota del TSV dice «DECIDIR», y sigue sin decidir |
arje-zero | 15 applets retirados el 2026-09-21, y es el que más se llevó: init linuxrc run-init runlevel + toda la familia de runit (runsv runsvdir sv svc svlogd svok chpst envdir envuidgid softlimit start-stop-daemon). No los reimplementó: los cubre por función, siendo PID 1 |
hammerd | 3: klogd logread syslogd — el diario propio reemplaza al syslog de busybox |
Y el número que ordena el resto: 114 applets marcados BORRAR — decididos innecesarios con
medición (el gestor de /dev no hace falta porque los seis kernels traen CONFIG_DEVTMPFS_MOUNT=y
y UEVENT_HELPER apagado; killall5 lo cubre arje-zero por ser PID 1 y apagar él). O sea que de
los 401 originales, la mayor parte del camino se hizo decidiendo, no escribiendo.
Lo que este triaje NO decide
No toca el TSV ni retira nada: mide y reparte. Retirar 26 nombres es una decisión de producto
—«esta distro no provee ifconfig»— y merece quedar escrita como tal, no caer de un barrido.
Y el orden no cambia: sin sh no se bota busybox en ningún frente, así que C-fila-1 sigue siendo
el paso siguiente y todo lo demás es independiente de él.
⚠ Un dato que el censo no ve y vale para planificar: la caja de producción corre el busybox
de ANTES del recorte (1.230.976 bytes, 14-sep) porque su imagen es anterior al 21-sep. O sea
que los applets ya retirados siguen vivos ahí hasta que se rehaga la imagen — medir «qué usa el
producto» contra esa caja da un resultado más gordo que el real.
La métrica, y el guardián que falta
docs/state/busybox-applets.tsv convierte esto en un número que baja: 401 → 272 → … → 0.
Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con build-state.json.
Falta un guardián, y sin él el paso 1 se deshace solo: un script que falle si alguna receta,
script o card invoca un applet que ya salió del defconfig. Hoy nada lo detectaría — el fallo
aparecería en boot, en otra máquina, semanas después.
Lo que este documento NO midió (y hay que decirlo así)
- El reparto applet→destino es juicio, no medida. Lo medido son los 401 symlinks, el estado
sealed/perfiles de cada receta y las 24 recetas con busybox en [deps] build. La asignación de
dueño la escribí yo y hay que revisarla receta a receta.
- No se verificó la equivalencia de banderas de ninguna pareja. Los 24 marcados
NO-drop-in son
los que sé que difieren; el resto dice sin-verificar y eso es literal, no una forma de decir «va
a andar».
- No se midió qué applets usa el producto de verdad. El
defconfig instala 401 porque nadie
eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1.
uutils/procps y uutils/util-linux no se verificaron upstream. Los ~60 applets que les
atribuyo son una hipótesis de trabajo.
Veredicto del paso 3 — brush como /bin/sh, MEDIDO
Fecha: 2026-09-21 · Dónde: dev.gioser.net, todo bajo flock -o work/.farm-build.lock ·
Contra qué: busybox 1.36.1 sh (ash) como control, en la misma caja y la misma corrida.
No sirve todavía como /bin/sh del SANDBOX DE BUILD. Pasa el banco POSIX igual que busybox,
parsea todo el corpus, y aun así 0 de 3 recetas autotools construyen (control: 3/3). Lo que
falla no es su parser: es que el libtool que su configure genera sale con un nivel de escapado
de menos y después no lo parsea ningún shell, ni él ni busybox.
Lo que se construyó
takana build recipes/brush.toml en el worker selló b3:715237c78edd… — el mismo hash que
build-state.json traía del otro hub. Dos máquinas distintas, bytes idénticos: reproducibilidad
confirmada de paso, que no era el objetivo de la prueba.
Binario: ELF x86-64 estático (sin loader), brush 0.4.0, 7,25 MB — contra 1,23 MB del
busybox que trae los 401 applets. Botar busybox por brush, hoy, engorda la imagen.
Lo que PASA
| prueba | brush | control busybox |
Banco POSIX — 57 casos (scripts/sh-banco-posix.sh) | 56/57 | 56/57 |
Las 1130 fases de shell del corpus (sh -n sobre las fases de las 549 recetas que las tienen) | 0 rechazadas | 0 rechazadas |
configure de ffmpeg — 8345 líneas, sh -n | rc=0 | rc=0 |
Invocado como sh (argv[0]) | ✅ | ✅ |
El patrón de las cards de arje (sh -c '…; exec …') | ✅ | ✅ |
La única divergencia del banco: shift más allá de $# devuelve 2, y POSIX/busybox/bash
devuelven 1. Cosmético, pero un set -e que haga shift de más se comporta distinto.
⚠ Y el banco NO es prueba de suficiencia — esto es lo que hay que recordar del ejercicio:
brush lo pasa entero y aun así no construye una sola receta autotools. Un banco de casos
sintéticos mide lo que a uno se le ocurre preguntar; el consumidor real mide lo que hay.
Lo que FALLA
Tres recetas autotools reales, con el único cambio de /bin/sh en una copia del lab:
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
| receta | control (busybox sh) | brush |
flex | ✅ sellada | ❌ make[1]: Error 2 |
pkgconf | ✅ sellada | ❌ ./libtool: syntax error at line 368 col 30 |
oniguruma | ✅ sellada | ❌ make[1]: Error 2 |
La causa, hasta donde está medida
El configure corre entero y bien bajo brush. Lo que sale mal es el libtool que ese
configure GENERA. Regenerado el mismo fichero con cada shell y comparado, 25 líneas difieren, y
todas en el mismo sentido: falta un nivel de escapado.
control (busybox) brush
línea 62 ECHO="printf %s\\n" ECHO="printf %s\n"
línea 132 file_magic_cmd="\$MAGIC_CMD" file_magic_cmd="$MAGIC_CMD"
línea 176 …'s/^.*[ ]\\([ABCDGIRSTW]… …'s/^.*[ ]\([ABCDGIRSTW]…
Ese fichero ya no lo parsea nadie — ni brush ni busybox lo aceptan (rc=2 los dos, misma línea
368). Y el contraprueba cierra el caso: el libtool del control lo parsea brush sin quejarse
(rc=0). ⇒ El parser de brush está bien; lo que difiere es lo que su shell PRODUCE.
El mecanismo, encontrado (2026-09-21, después de escribir lo de arriba)
Primero se escribió acá que la causa no estaba determinada, tras descartar las tres sospechas
obvias —echo con barras invertidas, sustitución de comando preservando \\, y el
func_quote_for_eval de libtool aplicado a mano—, que dan idéntico en ambos shells. La causa
apareció bajando al config.status que genera el libtool. Es de una línea:
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
- brush NO entra al sandbox de build. El frente A sigue con busybox hasta que esto se cierre.
- 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.
- Reportado upstream: reubeno/brush#1394, con
el reproductor de una línea, la tabla de los cuatro casos y el fragmento de
config.status.
- El paso 5 del plan (grep/sed POSIX en Rust) sube de prioridad, porque no depende del shell.
- No se tocó el lab compartido: copia con hardlinks y stores tirables. El worker siguió
compilando su cola en paralelo, serializado por el
flock.
¿Parchar, o buscar otra opción?
Primero lo que cierra la pregunta barata: adelantar el pin NO sirve. Nuestra receta apunta a
96a26d0c (tag brush-shell-v0.4.0, mayo 2026), que sigue siendo el último release. Se construyó
main de hoy (737dd57e, 2026-09-21 — cuatro meses y medio de trabajo encima) y reproduce
idéntico, los cuatro casos. No hay nada que esperar de la rama.
Lo que queda, en orden de coste:
| opción | qué cuesta | qué deja |
| Esperar a #1394 | cero | el upstream es activísimo (issue #1393 de anteayer) y el bug es acotado, pero no es nuestro calendario |
| Parchar brush en la receta | un .patch en el árbol, como ya hacen 13 recetas | ⚠ es un parche al parser de un shell, que es el componente más cargado de todos. Y el bug apareció en el PRIMER build real: no es prueba de que sea el único |
| dash | receta nueva, C, ~200 KB. Es el sh de referencia POSIX | resuelve el sandbox YA — y no avanza nada la soberanía en Rust |
bash como /bin/sh del producto | cero: ya está sellada Y en los 7 perfiles | tapa el frente B sin escribir nada. No sirve para el sandbox sin medirlo antes |
| Dejar busybox donde está | cero | es lo que dice el plan: el shell NO es por donde se empieza |
La recomendación es la última, y no por conservadurismo. El plan ya ordenaba los pasos 1, 2 y 5
—recortar el defconfig, declarar lo sellado, escribir grep/sed POSIX— sin depender del shell,
y valen 129 applets y dos piezas de userland. Gastar el turno en pelear el shell es empezar por el
único frente que está bloqueado por terceros.
Lo que sí conviene hacer ya, porque es gratis: suscribirse a #1394 y medir brush como /bin/sh
del PRODUCTO (product-boot-test.sh + las cards), que es un consumidor que no genera libtool y
donde brush no tiene ninguna evidencia en contra.
Paso 1 — el recorte, HECHO y MEDIDO
Fecha: 2026-09-21 · Dónde: dev.gioser.net, bajo flock -o, contra un store tirable y con
un directorio de recetas por hardlinks — el recipes/ del worker no se tocó.
401 → 277 applets. 124 retirados, 5 indultados, binario de 1.230.976 → 977.096 bytes
(−20,6 %). Nuevo hash b3:a24fdbf6… (el vigente era 2a2b1280…). Y 3 de las 24 recetas que
declaran busybox build-dep se reconstruyeron y sellaron con el recortado: el sandbox sigue en pie.
Lo que el ejercicio corrigió del plan
Mi tabla decía que los 24 applets de arje-zero/hammerd eran gratis. Es falso para 5 de ellos.
Esas recetas instalan un binario cada una (-p arje-zero, -p hammerd): reemplazan la FUNCIÓN,
no el COMANDO. No hay un poweroff ni un setuidgid detrás. Los cinco indultos, con su razón:
| indultado | por qué se queda |
setuidgid | la card de gitea hace setuidgid gitea (recipes/gitea.toml:89, «applet de busybox, comprobado presente»). Sin él el servicio arranca, muere y reintenta para siempre |
switch_root | el /init del initramfs del instalador lo ejecuta (scripts/takana-live-install.sh:375), antes de que arje exista |
poweroff · halt · reboot | nada más en el corpus apaga la máquina. arje-zero es PID 1, no una CLI. Quitarlos deja un producto que no se puede apagar desde la shell |
Los tres primeros los encontró el vigía; los tres últimos, preguntarse qué instala arje-zero de
verdad en vez de creerle a mi propia tabla.
El vigía — y por qué un vigía ingenuo no sirve
scripts/busybox-vigia.py falla si alguna receta, script o card invoca un applet retirado. Lee la
lista de la propia receta (RETIRADOS='…') cruzada con la columna simbolo_kconfig del TSV, así
que no hay dos listas que se desincronicen.
Su primera versión daba 91 hits y ninguno real. Las cuatro reglas que lo bajaron a 0, todas
medidas contra falsos positivos concretos:
| regla | qué mataba |
| quitar comentarios antes de buscar | # busybox provee sendmail, no mail (logrotate), /// los dos fsync no son prolijidad |
| en Rust/Python, mirar sólo cadenas que parezcan una línea de comandos | "init" como nombre de capacidad, tree como variable, rx = re.compile(…) |
| ignorar nombres que el propio script define como función | $(ts) en los seis scripts de scripts/farm/ |
ignorar patrones de case y command -v X | proc|sys|dev|init) continue ;; · command -v dpkg en el censo de la mudanza |
Y [ / [[ no se vigilan: son palabras del shell, y todo script bash del hub los usa sin que eso
sea una llamada a un applet.
⚠ El vigía mira ESTE repo. No ve los configure de terceros que corren dentro del sandbox, que
son quienes podrían llamar a which o unzip. Por eso la verificación de abajo no es opcional.
El mapa applet → símbolo NO se deriva por mayúsculas
Sale de las líneas //applet: del fuente de busybox 1.36.1 y vive en la columna simbolo_kconfig
del TSV. Poner el nombre en mayúsculas falla en 6 casos medidos: [→TEST1, [[→TEST2,
arch→BB_ARCH, nbd-client→NBDCLIENT, sh→SH_IS_ASH, sysctl→BB_SYSCTL. Dos de esos seis
estaban en la lista a retirar.
Verificación
| qué | resultado |
| applets tras el recorte | 277 (401 − 124), exactamente los previstos |
| los 5 indultados presentes | ✅ y también sh y getty |
| los que debían irse | ✅ httpd telnetd dpkg rpm init syslogd runsv tree which unzip fuera |
| el sandbox sigue construyendo | ✅ zlib-ng, jq y logrotate reconstruidas y selladas con el busybox recortado |
⚠ Son 3 de las 24, no las 24. Las otras 21 —entre ellas linux, llama-cpp, protobuf,
qdrant— no se probaron: son horas de cómputo en una caja que está con la campaña de KDE. Si alguna
usa un applet retirado, se verá en su próximo build y el vigía no lo habría anticipado, porque el
llamador estaría en un configure de terceros.
El coste, dicho antes de que sorprenda
Cambiar esta lista re-hashea busybox, y con él las 24 recetas que lo declaran build-dep:
abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng. linux está en la lista: el kernel se reconstruye.
La granja lo hará en su propio ciclo; no se disparó a mano.
El arranque — PROBADO en QEMU, contra una línea base
2026-09-21. Se ensamblaron dos Stage 1 completos y se arrancaron los dos en QEMU, para que
cualquier diferencia se pueda atribuir al recorte y no al azar:
| rootfs sellado | applets en /bin /sbin /usr/bin /usr/sbin | qemu |
| base (busybox sin recortar) | b3:1a3823b4… | 400 | rc=0 |
| recortado | b3:3aa8cd2d… | 278 | rc=0 |
Los dos arrancan, los dos dan shell y los dos se apagan limpio. Diff de las dos consolas: 35
líneas, y ninguna es funcional — frecuencia del TSC, direcciones del RAMDISK, BogoMIPS, hora del
RTC. La única diferencia real es la que se buscaba:
~ # ls /bin /sbin /usr/bin /usr/sbin | wc -l
410 ← base
288 ← recortado
Criterios del runbook stage1-vm-boot.md §6, verificados en el recortado:
- PID 1 —
arje-zero incarna y corre.
- Seed — la card se carga: la prueba es que la
console-getty llegó a incarnar.
- getty → shell —
/bin/busybox getty -n -l /bin/sh 115200 console da prompt ~ #, y
echo MARCA-SHELL-VIVA responde. getty y sh sobrevivieron al recorte, que era el riesgo.
poweroff -f — ACPI: PM: Preparing to enter system sleep state S5 / reboot: Power down.
Uno de los cinco indultados, probado en vivo: sin él la caja no se apaga.
⚠ Cómo se corrió, porque condiciona el resultado: en dev.gioser.net, con QEMU en TCG
(emulación pura) — el LXC no expone /dev/kvm, aunque el CPU sí trae los flags. Por eso el guion
se le manda por consola serie con esperas generosas. Hubo que instalar qemu-system-x86-core (dnf)
e ingerir de nuevo la semilla de Stage 0, que no estaba en el store del worker; sí salió con el
mismo hash que el manifiesto ya anotaba (3ce721ec…).
⚠ Es Stage 1, no el producto. Se eligió porque es donde vive busybox (musl busybox hammerd arje-zero) y porque necesitaba 2 artefactos en vez de 8. product-boot-test.sh —que además levanta
sshd y netup— sigue sin correrse: le faltan netup openssh uutils findutils-xargs diffutils ripgrep en el store del worker.
Lo que queda del paso 1
- Las otras 21 dependientes, cuando la granja llegue.
- El siguiente corte natural son los
BORRAR que no se tocaron por prudencia y los 272 que siguen:
el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (grep/sed POSIX).
El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21
Instrumento: scripts/sh-banco-diferencial.py · casos de regresión:
scripts/fixtures/sh-casos/ · dónde: el hub, contra los tres shells sellados que ya están en
/store (2a2b1280…-busybox, 715237c7…-brush, 87bf1a59…-bash). No tocó la granja ni el lock.
Por qué, si ya existía sh-banco-posix.sh
El veredicto del paso 3 dejó escrita la lección y no el instrumento:
brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.
Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial
pregunta otra cosa, y no hace falta saber la respuesta de antemano:
¿este shell hace LO MISMO que el control, sobre el material que hay?
Tres decisiones de diseño, y las tres se ganaron con el bug de brush:
- 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.
- 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.
- Toda divergencia se CONFIRMA volviendo a correr control y candidato. Un caso que imprime un
PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía
$$); un shell que diverge de sí mismo es un hallazgo distinto y peor. Sin esto el banco se
llena de ruido y deja de leerse, que es como muere un banco.
Además: todos los shells se invocan por un symlink sh (argv[0] igual para todos, que es como se
despliegan), con el mismo userland busybox en el PATH —las divergencias tienen que venir del
shell, no de un sed distinto— y con PATH apuntando primero al shell bajo prueba, así un sh
recursivo cae en ÉL y no en el del sistema.
Lo medido — 1273 casos, 5,5 s
scripts/sh-banco-diferencial.py --gen --phases \
--files '/work/sergio/work/sources/*/configure' \
--files '/work/sergio/work/sources/*/libtool' \
control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
| fuente | casos | modo | brush | bash |
cases — regresiones conocidas | 10 | ejecutado | 4 divergen | 0 |
gen — tortura de comillas | 107 | ejecutado | 31 divergen | 8 |
phases — las fases de las 549 recetas | 1130 | sh -n | 0 | 0 |
files — configure y libtool reales | 26 | sh -n | 0 | 0 |
Los 1156 casos de parseo no divergen en ningún shell. Confirma lo que el paso 3 ya decía —el
parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol
comparado. Sirve sobre todo para saber dónde NO buscar.
Lo que el generador encontró SOLO
El generador barre el producto cartesiano forma de sustitución × contexto × profundidad de barras invertidas × carácter objetivo (107 casos). No sabe que el bug #1394 existe, y lo escupe entero:
✗ [brush] tortura/bt-desnudo-b1-var control: [hola] brush: [$V]
✗ [brush] tortura/bt-desnudo-b2-var control: [$V] brush: [\hola]
✗ [brush] tortura/bt-desnudo-b2-literal control: [x] brush: [\x]
✗ [brush] libtool/case-de-config-status ← el bucle REAL de config.status, divergente
Ése es el punto del ejercicio: el bug de brush apareció en el primer build real, después de
sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco
segundos, sin saber qué buscaba.
Un hallazgo NUEVO, y no es del frente A
El fixture 08-trap-exit-y-estado.sh destapó algo que no estaba en el veredicto del paso 3:
( 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ón | binario |
| busybox | 464 | 1,17 MB |
| bash | 864 | 1,57 MB |
| brush | 1724 | 6,92 MB |
brush/busybox = 3,7× acá, contra el 8,9× medido en el worker: esta cifra incluye el fork+exec
de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la
misma y el argumento del veredicto no cambia.
Lo que este banco NO mide (y hay que decirlo así)
- No EJECUTA las fases del corpus — sólo las parsea. Ejecutarlas necesita el sandbox y sus
fuentes, que es la extensión natural y la que cerraría el caso: correr un
configure real bajo el
candidato y comparar el árbol generado contra el del control. La maquinaria de comparación ya
está; falta engancharla al sandbox.
- El generador sólo tortura comillas. Faltan familias enteras: expansión de parámetros,
IFS y
división en campos, aritmética, here-docs, señales y control de trabajos.
- Nada de interactivo — ni edición de línea, ni historial, ni
job control.
- El árbol no mira permisos finos ni mtimes, sólo ruta + bit de ejecución + contenido.
- El banco no prueba suficiencia. Es la misma advertencia de siempre, y ahora con dos
instrumentos que la ilustran en vez de uno.
La superficie REAL de las cards, MEDIDA — 2026-09-21
Instrumento: scripts/sh-superficie-cards.py (regenera el número; la tabla no se escribe a
mano) · salida: work/sh-superficie-cards.tsv.
La pregunta: si mañana hubiera un /bin/sh propio, ¿qué tendría que saber hacer para que el
PRODUCTO arranque? No qué dice POSIX ni qué usa autotools — qué usan las cards que arje ejecuta.
Las cuatro poblaciones
| población | dónde | cards | con shell |
| servicios de paquete | recipes/*.toml · [[service]] (emisor: Service::card) | 29 | 21 |
| empotradas | takana-bootstrap · STAGE1_SEED_CARD + SSHD_SERVICE_CARD | 3 | 1 |
| servidor de producción | scripts/servidor/cards/*.json | 1 | 1 |
| la mudanza | scripts/mudanza/formatos/arje.py:35 | plantilla | n/a |
23 de 33 cards (69 %) ejecutan un fragmento de shell; 10 son exec directo.
La mudanza no se cuenta por card porque su contenido sale del censo de la máquina que se muda. Su
superficie es la de la plantilla, y la plantilla entera es: cd VALOR && exec VALOR.
⚠ Hallazgo de paso, y hacía falta para medir bien: en una Card, argv va SIN argv[0] — arje
lo pone desde exec. Lo dice la card de hammerd, cuyo argv es ["--store","/store",…] a
secas. Por eso conviven dos idiomas que parecen incompatibles y no lo son:
exec=/bin/busybox argv=["sh","-c",…] (recetas: despacho multicall) y exec=/bin/sh argv=["-c",…] (la mudanza). Leer el argv sin anteponer el exec hace que uno de los dos
desaparezca de la cuenta.
Lo que las cards USAN
secuencia ; 23 ███████████████████████
y/o && || 21 █████████████████████
redirección > 18 ██████████████████
grupo { } 18 ██████████████████
variable $x 4 ████
bucle while 2 ██
aritmética $(( )) 2 ██
tubería | 2 ██
sustitución $( ) 2 ██
case 1 █
bucle for 1 █
redirección 2> 1 █
glob * 1 █
Y lo que NO aparece NI UNA VEZ: if, funciones, until, ${…} (ninguna expansión de
parámetros: ni :-, ni #, ni %), posicionales $1/$@, $?, subshells ( ), here-docs,
!, segundo plano &, y sustitución con comillas invertidas — la construcción que tumbó a
brush en el sandbox no la usa ninguna card.
El idioma es uno solo y se repite 18 veces:
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:
| escenario | antes | después |
/proc/mounts sólo trae /vvv/sergio (el falso positivo) | no montaba /vvv y el echo salía sin número | mount /dev/…-trabajo /vvv + 170G libres |
/vvv montado de verdad | (acertaba) | no vuelve a montar, 170G libres |
El fragmento nuevo parsea en busybox, bash y brush.
Bug 3 — las variables desnudas, y ésta se comía un usuario entero. Arreglado en la misma tanda
junto con los dos echo de error, que iban sin comillas (inocuo hoy: ningún glob en el texto, y se
comprobó que el mensaje y el rc=78 salen idénticos antes y después). El que sí tenía diente era
el bucle de /home:
# 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
- Detección por patrón sobre un escáner que respeta comillas, no un parseo. La primera versión
contaba 11 backticks que eran prosa dentro de mensajes de error (
`minga init`) y una docena
de «órdenes externas» que eran palabras sueltas de esa prosa. Está arreglado —y el cuerpo de cada
sustitución se analiza como shell aparte, que es como aparecieron el df y el awk— pero sigue
siendo aproximación: command -v journalctl cuenta command, no journalctl.
- No mide la shell INTERACTIVA de
getty, que es otro consumidor y no ejecuta fragmentos.
- No mide
scripts/, que es su propia unidad de trabajo y es mucho más grande.
- No mide las cards REALES de la mudanza: dependen de un censo que no está en el árbol.
- No ejecuta los fragmentos, sólo los parsea.
La superficie de scripts/, MEDIDA — 2026-09-21
Instrumento: scripts/sh-superficie-scripts.py · escáner compartido:
scripts/lib/sh_analisis.py · salida: work/sh-superficie-scripts.tsv.
⚠ Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error. Las cards las ejecuta
el producto ⇒ su superficie es el contrato de un /bin/sh propio hoy. scripts/ lo ejecutan el
hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento.
Lo que sí importa hoy acá es otra cosa:
¿algún guion se declara POSIX y no lo es? Porque ése se rompe HOY: en la imagen /bin/sh es
el ash de busybox, no bash.
Las cuatro poblaciones, y quién las parsea
De 118 ficheros descartados por no ser shell (.html, .c, .mp4, .toml…):
| población | n | líneas | parsea ash | parsea bash |
POSIX (#!/bin/sh, #!/usr/bin/env sh) | 81 | 10 167 | 81/81 | 81/81 |
bash (#!/bin/bash, #!/usr/bin/env bash) | 76 | 10 269 | 48/76 | 76/76 |
EMPOTRADOS (un /init escrito por heredoc) | 21 | 714 | 21/21 | 21/21 |
source-only (.sh sin shebang) | 6 | 138 | 6/6 | 6/6 |
Cero guiones se declaran POSIX y fallan bajo ash. Ni los 81 sueltos ni los 21 empotrados.
La clasificación es por la primera línea, no por «contiene un shebang»: un grep -rl '^#!/bin/sh'
da 86 porque cuenta los scripts bash que EMPOTRAN un #!/bin/sh en un heredoc. Con ese grep,
mirada-usb.sh y selfhost-verify.sh aparecían como «POSIX roto bajo ash» — y son bash legítimos.
Los 21 empotrados son la población que importa para el producto, y hoy nadie los parsea: son los
/init de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros
(takana-live-install.sh ×3, metal-desktop-image.sh ×5, install-image-efi.sh ×2, disk-image.sh,
metal-iso.sh, metal-usb-sdboot.sh, mirada-usb.sh, selfhost-verify.sh, cc-por-zig.sh,
jaula-herramientas.sh, atuq-en-cosmic.sh, dunst-headless.sh, metal-desktop-image-dual.sh).
Los 21 los parsea el ash.
Por qué los 28 bash son bash: arrays, y casi nada más
| bashismo | guiones |
array=( ) | 31 |
declare/typeset | 7 |
+= en asignación | 6 |
[[ ]] | 5 |
<<< | 4 |
<( ) | 4 |
${arr[i]} | 3 |
(( )) | 3 |
mapfile | 1 |
No aparece ninguna vez: ${v//x/y}, ${v^^}, function nombre, PIPESTATUS/BASH_*, echo -e,
read -a/-d, source en vez de ..
De los 28 que el ash no parsea, 26 lo son por arrays y nada más. Ése es el presupuesto completo
de un sh propio que quisiera correr la herramienta del proyecto: arrays indexados. Los otros
dos suman [[ ]], <( ) y mapfile.
⚠ Parsear no es correr. 48 de los 76 #!bash parsean tal cual bajo ash y eso NO significa que
funcionen: local, echo -e o un trap de subshell parsean en los dos y se portan distinto (lo de
brush#1396 es justo eso). La tabla mide sintaxis.
La superficie del lenguaje
Los 81 POSIX usan todo lo que las cards no usan: if 63/81, funciones 30/81, ${…} 74/81,
posicionales 66/81, here-docs 22/81, case 26/81, until 3/81. Comparado con las cards —8
builtins, ningún if, ninguna función— es otro lenguaje. Builtins en todo scripts/: 27
(echo×1996, [×1270, exit×473, export×239, printf×236, trap×40, local×19, ulimit×3…).
Órdenes externas: 257 por nombre desnudo (las resuelve el PATH) y 35 por ruta absoluta (no
pasan por PATH y por lo tanto no pueden ser un applet). Cruzadas con el recorte:
0 applets retirados — que es lo que dice busybox-vigia.py, y ahora los dos instrumentos
coinciden.
Los siete bugs del INSTRUMENTO, que son la parte que hay que contar
Cada número de arriba salió mal primero. Vale más anotar esto que la tabla:
- 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.
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.
- 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.
- 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.
- Cuerpos de
$(( )). i=$((i+1)) daba la orden i+1.
- Descriptores de fichero.
>&2 daba la orden 2.
- Nombre desnudo vs ruta absoluta.
exec switch_root /newroot /sbin/init marcaba el applet
init; pero una ruta absoluta no se resuelve por PATH y no puede ser un applet — en el
producto ese /sbin/init es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso
mide algo útil: 35 órdenes van por ruta absoluta y no dependen del PATH.
Ninguno de los siete se veía en la salida. Todos daban números plausibles. Es la misma lección
del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento
en vez de al shell.
Una librería, no dos copias
El escáner vive en scripts/lib/sh_analisis.py y lo importan los dos instrumentos. Duplicarlo era
repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un
campo distinto mal. Comprobado que la extracción no cambió nada: la salida de
sh-superficie-cards.py es byte-idéntica antes y después (y su md5 se repite entre corridas — el
orden de los empates se volvió determinista a propósito, para que la salida sea diffable).
Y los arreglos 3–7 corrigieron también la medición de las cards: apareció sleep, que el
tokenizador viejo se comía, y desapareció i+1.
Lo que esto NO mide
| dentro de un patrón de case se cuenta como tubería. Inflaba poco y no se arregló: hace
falta gramática, no regex.
sh -n no es ejecución. Nada de esto dice que los 48 #!bash que el ash parsea funcionen
bajo ash.
- No se midió qué guion corre DÓNDE. Que
takana-install.sh sea bash no obliga a nada porque
corre en el hub; el que corre dentro de la imagen es takana-live-install.sh, y ése es POSIX.
La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son
por construcción.
simi — el /bin/sh propio, ESCRITO y MEDIDO (2026-09-21)
Crate: crates/simi/ (4448 líneas, libc y nada más) · receta: recipes/simi.toml,
sellada en b3:46ff6fcf… · nombre: simi es «boca, lengua» en quechua — es cómo se
le habla a la máquina. rimay estaba tomado (la familia de embeddings de tawasuyu).
El veredicto, primero
Banco diferencial contra el ash de busybox 1.36.1, 159 casos — 10 fixtures de regresión, 107 de
tortura de comillas, los 23 fragmentos de las cards y los 19 /init empotrados:
| shell | divergencias duras | binario | latencia |
| simi (sellada, estática) | 0 | 0,48 MB | 1,04× el ash |
| bash 5.3.0 | 8 | 1,57 MB | 1,3× |
| brush 0.4.0 | 35 | 6,92 MB | 4,1× |
Incluye el bucle real de config.status que genera cualquier configure de autotools — el que
tumbó a brush. Y 47 pruebas propias del crate en verde.
SELLADA, y medida contra el artefacto (2026-09-21)
takana build recipes/simi.toml en el worker, bajo flock -o work/.farm-build.lock:
sealed path=./store/46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834-simi
b3:46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834 rc=0
El artefacto trae exactamente dos ficheros: usr/bin/simi y su .hammer/recipe.toml.
| qué | medido sobre el ARTEFACTO |
| tipo | ELF x86-64, estático, stripped |
| tamaño | 498 736 bytes (0,48 MB) |
scripts/static-audit.sh simi | «estáticos de verdad: 1 · MIENTEN: 0» |
| banco diferencial, 159 casos vs el ash | 0 divergencias (bash: 8 · brush: 35) |
| latencia de arranque | 468 µs contra 449 µs del ash — 1,04× (brush: 1569 µs, 3,5×) |
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
| consumidor | qué necesita | estado |
| las 23 cards de arje con shell | 8 builtins, 13 construcciones, ningún if, ninguna función | ✅ |
los 21 /init empotrados | + if, funciones, case, aquí-documentos, ${…}, posicionales, & | ✅ |
takana-live-install.sh (corre en la imagen viva) | POSIX entero | ✅ parsea; sin correr |
la shell interactiva de getty | edición de línea, historial, control de trabajos | ❌ no está |
Lo que no trae, dicho en vez de fingido: control de trabajos, edición de línea, historial,
alias, getopts, times, [[ ]], arrays. El shell de getty es otro consumidor y sigue
pendiente.
Los siete bugs — que es la parte que enseña
Ninguno se veía en la salida. Todos daban resultados plausibles, y los seis primeros los destapó
el banco diferencial, no el juicio:
- 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.
- 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.
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.
- 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.
- Las clases
[…] no entendían \ — y *[\\\"$]*es literalmente el patrón delconfig.status` de libtool.
- 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.
- 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
Construir la receta en el lab y sellarla. HECHO — b3:46ff6fcf…, ver arriba.
- 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.
- 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.
- 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.
- La shell interactiva de
getty, que es el consumidor que falta entero.
- 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:
| n | qué significa |
| el dueño que el TSV nombra sí lo provee | 167 | el reparto era correcto |
| lo provee OTRO paquete | 31 | el dueño estaba mal escrito, pero hay sustituto |
| no lo provee NADIE salvo busybox | 79 | el 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ño | applets que NO provee | causa |
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 |
shadow | 8 | addgroup adduser delgroup deluser mkpasswd cryptpw ya estaban marcados; add-shell y remove-shell son de debianutils y nadie los trae |
xz | 6 | receta de librería (arriba) |
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 |
iputils | 3 | trae ping arping tracepath clockdiff; traceroute/traceroute6 son otro paquete y ping6 es ping -6 |
ncurses | 2 | receta de librería (arriba) |
util-linux | 2 | hd es el alias de hexdump, que sí trae ⇒ falso hueco. setpriv es hueco real: el configure lo desactivó |
uutils | 2 | arch y hostname — ver abajo, los cubre el feature |
vim | 1 | instala vim ex view rvim vimdiff xxd, no vi: falta el symlink, una línea |
cronie | 1 | run-parts es de debianutils |
libarchive | 1 | instala bsdcpio, no cpio |
socat chrony dhcpcd | 6 | los 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:
switch_root lo trae util-linux — es uno de los cinco indultos del paso 1, y tiene
sustituto real. (El /init del initramfs sigue necesitando el de busybox porque ahí no hay
util-linux, pero eso es el initramfs, no la imagen.)
sulogin lo trae util-linux, no shadow. partprobe lo trae parted, no util-linux.
getty tiene sustituto: agetty, de util-linux, que está en los 7 perfiles. El TSV lo
daba como getty-ESCRIBIR. No es escritura: es cambiar el exec de la card
(takana-bootstrap/src/lib.rs:240) y medirlo. Lo que sigue faltando entero es la shell
interactiva, que es otro consumidor — simi no trae edición de línea ni historial.
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
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.
- Los 6
ipaddr/iplink/… salen del defconfig en el próximo recorte: 277 → 271 sin escribir nada.
vi en la receta de vim y cpio→bsdcpio en libarchive: un symlink cada uno.
getty deja de ser escritura y pasa a ser migración de card a agetty.
- 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
- Equivalencia de nombre, no de banderas.
nc de socat cuenta como proveedor aunque su sintaxis
difiera. La columna equivalencia del TSV sigue haciendo falta y sigue diciendo sin-verificar
en 230 filas.
- El store del HUB. Un artefacto sellado sólo en el worker no aparece;
build-state.json dice
sealed igual. Los 79 hay que releerlos como «no está en el store del hub».
- Qué applets EJECUTA el producto de verdad, que sigue sin medirse y sigue siendo lo que
abarataría todo lo demás.
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.
| unidad | estado | sello |
uutils con feat_os_unix_musl | ✅ 79 → 106 applets, 27 nuevos, 0 perdidos | b3:614ff653… |
recipes/xz-tools.toml — variante con las herramientas | ✅ 23 binarios, estático, round-trip probado | b3:194c7d69… |
vi en recipes/vim.toml | ✅ usr/bin/vi → vim | b3:d303ec31… |
cpio en recipes/libarchive.toml | ✅ usr/bin/cpio → bsdcpio | b3: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.
Segunda tanda: las dos piezas que el plan marcaba TRAER (2026-09-21)
64 applets sólo-busybox, desde 79. Cinco más, y otra vez sin escribir userland.
| receta | applets | sello |
recipes/psmisc.toml (23.7) | killall pstree fuser (+prtstat pslog) | b3:b361bd04… |
recipes/bc.toml (gavinhoward/bc 7.0.3) | bc dc | b3:050efb84… |
bc es gavinhoward/bc y no el de GNU, y la razón es de cadena: C99 sin dependencias, bc y
dc del mismo árbol, y es el bc de sistema de Alpine y FreeBSD. El de GNU arrastra ed y flex
como build-deps y su dc va en otro paquete.
⚠ killall5 NO lo trae psmisc — es de sysvinit. Acá lo cubre arje-zero por FUNCIÓN (es PID 1
y apaga él), así que el applet se retira con los indultos en vez de reemplazarse. Son 3 de los 4 que
la tabla prometía, y decirlo así evita que vuelva a prometer lo que no hay.
Los dos pisones de psmisc, que enseñan más que el resultado
1 · -lncurses no existe en este corpus, y hay dos formas de no arreglarlo. El configure de
psmisc busca tgetent por NOMBRE de librería (-ltinfo, -lncurses, -ltermcap) y la nuestra es
--enable-widec, o sea libncursesw.a. Falló primero con LIBS=-lncursesw —el AC_CHECK_LIB no
pregunta si el símbolo resuelve, compila un programa de prueba con -lncurses— y falló después
con ac_cv_lib_ncurses_tgetent=yes + make TERMCAP_LIB=-lncursesw, porque
src_pstree_LDADD = @TERMCAP_LIB@ se sustituye en tiempo de configure y el Makefile queda con
-lncurses literal, no con una referencia a la variable. Lo que funciona es darle el nombre que
busca: un enlace libncurses.a → libncursesw.a en el scratch y un -L a ese directorio.
2 · Y ese -L se comió el -static. Primera versión: LDFLAGS="-L$PWD/.compat-ncurses". El
harness inyecta LDFLAGS=-static para las recetas link = "static"
(takana-build/src/lib.rs:285), así que reemplazarlo lo pierde. Los seis binarios sellaron
DINÁMICOS y nada falló: killall --version contestaba bien, y sólo pstree —el único que enlaza
ncurses— moría en ejecución con Error relocating /lib/libncursesw.so.6, tomando la librería del
anfitrión. Un file sobre los binarios lo dice en un segundo; ninguna prueba del build lo iba a
decir. Es [[cgo-sin-baseline-sigill]] otra vez: redefinir una variable que el harness ya puebla
pierde en silencio lo que el harness había puesto, y el síntoma aparece lejos.
Quién usa lo que queda — busybox-vigia.py --uso (2026-09-21)
Esto es lo que el documento dejó sin medir tres veces: «no se midió qué applets usa el producto
de verdad; eso abarataría el paso 1». Ahora se mide. El modo nuevo lee del TSV los applets con
proveedor_medido = SOLO-BUSYBOX y los busca en el repo con las reglas anti-ruido del vigía.
De los 64, sólo 12 tienen un llamador dentro del producto:
| applet | quién lo llama |
sh | 28 sitios — el /bin/sh de todo; lo cierra simi |
ifconfig route udhcpc netstat | metal-iso.sh, cosmic/metal-desktop-image.sh, test-foco-egress.sh, mudanza/censar.py — todos con ip/ss como primera opción y el applet como respaldo (ip link set … || ifconfig …) |
insmod | iso-image.sh, install-image.sh — ⚠ es el insmod de GRUB, no el de busybox: insmod part_gpt dentro de un grub.cfg. Falso positivo del grep, no migración |
setuidgid | gitea, pacha-secretos, tejido — tres cards |
getty | la card de la consola (ssh-e2e-test.sh, takana-bootstrap) |
adduser | servidor/alta-usuario.sh |
nc | test-foco-egress.sh |
nslookup | drive-netup.py |
poweroff | farm/harkaq-campana-vol.sh |
Los otros 52 no los invoca nadie en el repo. Y ahí está la trampa, escrita en la salida del
propio comando para que no se lea como una lista de borrado:
«Candidato» no es «retirable». Un grep ve a quien invoca POR NOMBRE. Hay dos clases de
consumidor que no aparecen nunca: el kernel —llama a /sbin/modprobe por
call_usermodehelper y al gestor de /dev por hotplug, así que modprobe lsmod depmod rmmod modinfo mdev uevent makedevs salen «libres» y no lo son— y el usuario de la máquina, que
espera encontrar clear, reset, vi o traceroute al entrar por ssh.
Y los dos falsos negativos que tuvo el modo, que son la parte que enseña
La primera versión decía 10 invocados y estaba mal en dos casos, los dos peligrosos porque
llevan a retirar un applet que el producto usa:
setuidgid — se invoca como exec /usr/bin/setuidgid pacha, con ruta absoluta. El
patrón de comando pide el nombre pegado al exec, y ahí hay un /usr/bin/ en medio.
getty — vive como "argv": ["getty", "-n", …], elemento de un array JSON. La regla que
filtra cadenas de Rust/TOML por «parece una línea de comandos» —la que en su día bajó el ruido de
91 hits a 0— descarta una cadena que es sólo un nombre.
Las dos reglas que quitan ruido son las mismas que crean ceguera, y la diferencia es hacia dónde se
equivoca cada una. En el modo original —«¿alguien llama a algo que ya retiré?»— un falso positivo
cuesta un minuto y un falso negativo cuesta un boot roto. En este modo pasa igual, así que ahora
busca tres formas: nombre desnudo, ruta absoluta y primer elemento de argv.
El frente kmod no existe: los kernels de takana no tienen módulos (2026-09-22)
Cómo salió: al ir a escribir el kmod en Rust que este plan pide —la pieza más grande que
quedaba, 6 applets— lo primero fue mirar contra qué módulos probarlo. No hay ninguno.
| medida | resultado |
.ko en los artefactos sellados de los 4 kernels | 0 — sólo boot/bzImage y el .config |
recetas del corpus que hacen modules_install | 0 |
drivers =m en los .config sellados | 0 (contra 1809 =y en linux-metal) |
CONFIG_MODULES en los tres kernels del store | # CONFIG_MODULES is not set |
quién enciende CONFIG_MODULES en el repo | nadie |
Los cuatro kernels son monolíticos por construcción: compile = "make … bzImage" y
install = "cp arch/x86/boot/bzImage /out/boot/". Sin CONFIG_MODULES, el kernel ni siquiera
implementa init_module/delete_module — un insmod escrito en Rust recibiría ENOSYS.
Los 6 applets de kmod no son trabajo de escritura: son 6 applets a RETIRAR. Y con ellos se van
otros 3: CONFIG_DEVTMPFS_MOUNT=y hace que el kernel cree y monte /dev con sus nodos, no hay
CONFIG_UEVENT_HELPER (o sea que el kernel no llama a ningún helper de hotplug), y libudev-zero
ya viaja en los 8 perfiles. mdev, makedevs y uevent tampoco tienen consumidor.
Lo que esto le hace a la cuenta del plan
De los 23 applets que este documento llamó «el trabajo real», queda muy poco:
| pieza | decía | es |
| kmod (6) | escribir en Rust | retirar — el kernel no tiene módulos |
| gestor de /dev (3) | escribir o decidir | retirar — devtmpfs + libudev-zero |
| psmisc (4) | traer | ✅ hecho (3; killall5 es de sysvinit, lo cubre arje-zero por función) |
| bc (2) | traer | ✅ hecho |
| grep (3) · sed (1) | escribir en Rust | GNU declarado como puente ✅; el Rust nativo sigue siendo deseable, no urgente |
| getty (2) | escribir | migrar: agetty de util-linux está en los 7 perfiles |
| awk (1) | decidir | sigue siendo un ADR |
| nslookup (1) | traer | decidir: ninguno de los candidatos (ldns→drill, knot→kdig, doggo) publica nslookup; el único llamador es scripts/drive-netup.py y migrarlo es más barato que traer BIND |
Y la corrección que me toca a mí
Hace dos horas escribí en la salida de busybox-vigia.py --uso, como advertencia:
«el KERNEL — llama a /sbin/modprobe por call_usermodehelper … modprobe lsmod depmod rmmod modinfo mdev uevent makedevs salen «libres» acá y no lo son: es el frente kmod, que es escritura
pendiente, no un borrado.»
Es plausible, es la forma correcta de razonar, y es falsa para este producto. La escribí sin
medirla, en el mismo commit en el que presumía de haber arreglado dos falsos negativos. Corregida en
el script. Es [[la-etiqueta-no-es-el-hecho]] exacta: la advertencia sonaba a experiencia y era una
suposición, y lo que la desarmó fue ir a buscar contra qué probar el código que iba a escribir.
Qué hacer con los 9 — y por qué NO ahora
Retirarlos es editar RETIRADOS en recipes/busybox.toml, y eso re-hashea busybox. Las 24
recetas que lo declaran build-dep se invalidan, y 15 de esas 24 siguen HOY en deuda por el recorte
del 2026-09-21 (linux, llama-cpp, protobuf, qdrant, valkey, zlib-ng, los firmware…).
Recortar otra vez ahora duplica ese trabajo para la granja.
El siguiente recorte junta todo lo que ya está decidido y se hace UNA vez: los 6 de kmod, los 3
de /dev, y los 6 alias internos de iproute2 (ipaddr iplink ipneigh iproute iprule iptunnel, que no
son comandos de nadie) — 15 applets, 277 → 262, sin escribir una línea y sin reemplazar nada.
El paso 2, verificado en una hidratación real (2026-09-22)
El criterio de terminado del paso 2 era «cuando build-state.py los marca con perfil y la imagen
los lleva». Lo segundo no se comprueba declarando: se hidrata y se mira. Hecho, con el orden real
del ensamblado —busybox primero, el userland encima, que es lo que hace USERLAND_COMPONENTS:
grep → grep find → find diff → diffutils xz → xz (xz-tools)
sed → sed xargs → xargs cmp → diffutils bc → bc dc → bc
gzip → gzip zcat → zcat vi → vim cpio → bsdcpio
killall/pstree/fuser → psmisc chmod/uname/id/stat/ls/cat → coreutils (uutils)
Ningún binario de los declarados lo sigue ganando busybox. Y la prueba que faltaba: hidratar la
xz canónica ENCIMA de xz-tools —el escenario que motivó quitarle lib/ e include/ a la
variante— deja liblzma.a de la canónica, usr/bin/xz de la variante, y xz --version sigue
contestando. Conviven, que es lo que la receta afirmaba y ahora está medido en vez de razonado.
akllay — el awk propio, ESCRITO y MEDIDO (2026-09-22)
Crate: crates/akllay/ (3531 líneas, libc y nada más) · receta: recipes/akllay.toml
(sellada, b3:400e8114…) · nombre: akllay es «escoger, seleccionar» en quechua — lo que hace
awk con los registros.
⚠ Se llamó qillqa durante un commit y ese nombre estaba TOMADO, lo que es su propia lección:
en tawasuyu/02_ruway/qillqa hay un producto entero —registro público a prueba de manipulación, 16
crates, SDD propio— cuyo qillqa-cli declara un binario llamado exactamente qillqa. Y takana
EMPAQUETA recetas de tawasuyu (boveda, tejido, pacha, shuma-pregunta…), así que los dos
binarios habrían acabado en el mismo /usr/bin de la misma imagen: colisión silenciosa, gana el
último que hidrate. La práctica de comprobarlo ya estaba escrita en el otro repo —
01_yachay/qhali/SDD.md: «Nombre: qhali. Verificado sin colisión:»— y no la seguí: elegí por
significado. Verificado ahora en los dos repos y contra los 2193 nombres de binario del store:
akllay libre, y simi también lo estaba (sus apariciones en tawasuyu son «runa simi», el nombre
del idioma). Comprobar el nombre contra tawasuyu es parte de nombrar, no un trámite posterior. El comando instalado es awk — eso es
contrato. El nombre propio lo registra este documento y lo decide un ADR, igual que simi.
El veredicto, primero
Construye el corpus y reproduce bit a bit. Tres recetas autotools reales con akllay como
/usr/bin/awk del lab: 3 de 3 sellaron, y las tres con el hash IDÉNTICO al vigente construido
con el awk de busybox. Es exactamente la prueba que brush falló 0 de 3.
| akllay | busybox awk | gawk 5.3.2 |
| banco diferencial (109 casos, dos oráculos) | 104 iguales, 0 divergencias | oráculo | oráculo |
pkgconf · flex · oniguruma con él de awk | 3/3, hash idéntico | 3/3 (control) | — |
| binario | 0,64 MB | 0,98 MB (401→277 applets) | 0,83 MB |
| latencia de arranque | 1,04× | 1,00× | 1,10× |
Los hashes, para que se pueda repetir: pkgconf b3:cfb6a37e…, flex b3:6b25e351…,
oniguruma b3:751a5270… — los tres son los que ya estaban en build-state.json.
⚠ Son tres recetas, no las 943. Lo que prueban es que el camino que tumbó a brush —el
config.status que genera el libtool, donde awk hace el trabajo pesado— aquí pasa. No prueban
que no haya un cuarto caso.
El banco es DIFERENCIAL y contra DOS oráculos, y eso decidió cosas
scripts/awk-banco-diferencial.py corre los mismos 109 casos en akllay, en el awk de busybox y en
gawk --posix. El veredicto no es «igual a X» sino «igual a LOS DOS», y los oráculos discrepan
entre sí en 5 casos — que es justo para lo que sirve tener dos:
| caso | busybox | gawk | POSIX manda | akllay |
2^3^2 | 64 | 512 | derecha ⇒ 512 | 512 ✓ |
index(s,"") | 0 | 1 | 1 | 1 ✓ |
gsub(/a*/,"-") sobre xax | 4 · -x--x- | 3 · -x-x- | 3 | era 4, corregido |
printf "%*d" | no lo soporta | sí | sí | sí ✓ |
length(array) | 2 | rechaza (--posix) | no lo define | 2 |
El tercero era nuestro: una coincidencia vacía pegada al final de la anterior no sustituye. Sin
esa regla, gsub mete guiones de más. Se ve sólo cuando los dos oráculos no coinciden, y por eso un
banco contra un único awk lo habría dado por bueno.
Las cinco cosas que el banco destapó — ninguna reventaba
Todas daban un resultado plausible, ninguna imprimía un error. Quedan como pruebas de regresión en
crates/akllay/tests/divergencias_medidas.rs:
- Leer
NF no forzaba el partido en campos ⇒ {print NF} daba 0 hasta que alguien tocara
un campo. El partido es perezoso a propósito —es lo que hace barato asignar a $0— y el precio
es acordarse de forzarlo en cada lectura que dependa de los campos.
length sin paréntesis se leía como una VARIABLE llamada length, que vale vacío. Es el
único builtin al que POSIX permite omitirlos, y el brazo que lo trataba estaba DESPUÉS del brazo
general de identificador: inalcanzable.
- Los arrays se pasan por referencia, y eso se decide mirando si la función usa el parámetro
como array.
a["k"]=1 tiene el nombre en el lugar de la asignación y sólo se miraba el valor.
substr con inicio ≤ 0 recorta a 1 sin ajustar el largo, y los no enteros se truncan. La
lectura estricta de POSIX daría otra cosa; ni busybox ni gawk la hacen, y lo que hay que ejecutar
son los guiones escritos contra ellos.
- La regla del vacío en
gsub, arriba.
Por qué el motor de expresiones regulares es propio
POSIX es leftmost-LONGEST; la familia Perl —y el crate regex en su modo por defecto— es
leftmost-first. Para /a|ab/ sobre "ab", POSIX devuelve ab y Perl devuelve a. En un awk eso
no es académico: decide qué borra un sub(), dónde parte un split() y cuánto valen RSTART y
RLENGTH. Adoptar un motor ajeno es adoptar su semántica.
Construcción de Thompson con conjunto de hilos: sin retroceso, así que (a*)*b contra sesenta a
termina igual de rápido que cualquier otra cosa, y quedarse con el final más lejano alcanzado da el
«más largo» de POSIX sin trabajo extra.
Lo que queda
- Declararlo en
targets.toml y decidir si sustituye al applet en el defconfig. Va con el
recorte de los 15, no suelto.
- ADR — el nombre y la adopción de un awk propio, junto con el de
simi.
- Medir más recetas: las tres son el camino de autotools, que es el duro, pero el corpus tiene
943.
- Lo que NO trae, dicho en vez de fingido: las extensiones de gawk (
gensub, asort,
PROCINFO, RT, arrays multidimensionales de verdad, --re-interval es siempre on), getline
desde coprocesos, y las localizaciones. Es awk POSIX, no gawk.
Recorte 2 — los 15, HECHO y MEDIDO (2026-09-22)
277 → 262 applets. 15 retirados, 0 indultos nuevos, binario de 977.096 → 961.632 bytes.
Nuevo hash b3:4bd934f6… (el vigente era a24fdbf6…). Y el censo de proveedores baja de 62 a
47: los 15 eran, todos, applets que sólo daba busybox.
| grupo | applets | por qué salen |
| kmod | insmod rmmod lsmod modprobe depmod modinfo + MODPROBE_SMALL | el kernel no tiene módulos: # CONFIG_MODULES is not set en los cuatro, cero =m, ningún modules_install |
| gestor de /dev | mdev makedevs uevent | DEVTMPFS_MOUNT=y monta /dev con sus nodos, no hay UEVENT_HELPER, y libudev-zero viaja en los 8 perfiles |
| alias de iproute2 | ipaddr iplink ipneigh iproute iprule iptunnel | no son comandos de nadie: son ip addr, ip link… escritos como applet. El ip de verdad no se toca |
Verificación
| qué | resultado |
| applets tras el recorte | 262, exactamente los previstos |
| los 15 fuera | ✅ ninguno queda |
| indultados presentes | ✅ setuidgid switch_root poweroff halt reboot adduser, y también sh, ash, getty, ip |
| el sandbox sigue construyendo | ✅ zlib-ng, jq y logrotate reconstruidas y selladas con el recortado |
| vigía | ✅ 138 applets retirados, 0 invocaciones |
| reproducible | ✅ mismo hash en el store tirable y en /store |
Las dos cosas que el recorte enseñó, que no estaban en el plan
1 · Cinco applets no se podían vigilar, y nadie lo sabía. insmod rmmod lsmod modprobe depmod
tenían ? en la columna simbolo_kconfig del TSV: sus líneas //applet: del fuente llevan un
IF_NOT_MODPROBE_SMALL(...) anidado que el extractor no entendía. El TSV avisó con ? en vez de
inventar —que es lo correcto— pero sin símbolo no hay cruce, así que el vigía no los miraba: se
habrían retirado cinco applets sin guardián, que es exactamente el fallo que el vigía existe para
evitar. Rellenados desde el fuente ANTES de retirarlos.
2 · insmod es también una directiva de GRUB. El vigía dio 6 hits en iso-image.sh,
install-image.sh y takana-live-install.sh, y los 6 son insmod part_gpt / iso9660 / ext2
dentro de un cat > …/grub.cfg <<CFG: los lee GRUB en el arranque, cuando el applet ni existe. Es
la quinta regla anti-ruido del vigía, y se blanquea la REGIÓN del heredoc y no una lista de
módulos de GRUB — una lista envejece, y el día que alguien escriba insmod gzio volvería el falso
positivo.
Lo que cuesta, dicho claro
Tocar RETIRADOS re-hashea busybox y con él las 24 recetas que lo declaran build-dep. Tres se
reconstruyeron aquí como prueba; las otras 21 las paga la granja. No es deuda nueva de este
recorte: 15 de esas 24 ya estaban en deuda por el recorte del 21/09, y esto las junta en una sola
reconstrucción en vez de dos.
⚠ Y la columna del TSV ya no lleva fecha. Era recorte_2026_09_21; con el segundo recorte se
vio que no escala. Ahora son recorte (sigue/RETIRADO) y retirado_el (la fecha), que es lo que
debió ser desde el principio: una columna por tanda convierte el fichero en un registro de
tandas en vez de un estado.
La deuda del recorte, PAGADA — 21 recetas, y la que no se pudo (2026-09-22)
Las 24 recetas que declaran busybox build-dep quedaron invalidadas por el recorte. Tres se
reconstruyeron en el hub como prueba; las 21 restantes las molió la granja en 44 minutos
(dev.gioser.net, 12:05 → 12:49), bajo el mismo flock -o que usa su loop.
Verificado por cruce de hashes, no por código de salida: para cada receta se calculó en el HUB
el hash que espera y se comprobó que ese artefacto exacto está en el store del WORKER. 20 de 21.
⚠ Y el código de salida no servía, que es la parte que enseña: el guión de la tanda hacía
takana build … | tail -3 y leía rc=$?, que es el código de tail, no el del build. Las 21
salieron «rc=0» y una había fallado. Un rc detrás de una tubería mide el último eslabón.
La que no se pudo, y por qué no es deuda del recorte
ia-modelo-embeddings no construye en ninguna de las dos máquinas, y no por el recorte: su
[source] apunta a https://no-hay-upstream.invalid/… a propósito. Es una de las tres recetas
cuya fuente sólo vive en el mirror (ADR 0013; las otras son ia-modelo-chat y
firefox-pgo-profile), y el mirror es un Storage Box por sftp. Fallan dos cosas a la vez:
- El
curl del corpus no habla sftp. takana lo invoca por nombre y el PATH del lab pone
primero /work/sergio/bin/curl (8.20.0 musl, sin libssh2) ⇒ curl: (1) Protocol "sftp" disabled.
El de la imagen sí lo trae. Mismo patrón que el git sin remote-https.
- La clave no está:
~/.ssh/github5 no existe en el hub y id_ed25519 no está autorizada en
el Storage Box ((67) Authentication failure).
Arreglar una sola no destraba nada, y cada una falla con un error que no menciona a la otra. Anotado
en scripts/fuentes/mirror-env.sh, que es donde alguien lo va a buscar.
El estado que queda
sealed 946 · debt 1 · ajeno 3
base 85/85 · cli 153/153 · servidor 186/186 · mirada 41/41 · metal-tigerlake 7/7
grafo: CIERRA | topo-sort: OK
Todas las imágenes completas. La única deuda del corpus es esa receta, y está esperando una
credencial, no un compilador.
⚠ El manifiesto de sellados del worker no existía en este hub (work/farm-sellados.txt), así
que el grafo reportaba never sobre artefactos que sí existen. Se trajo a mano —es lo mismo que
hace la cosecha, sin tocar el lock— y con él aparecen los 26 sellados que no están en el disco de
esta máquina pero sí en el del worker.
Paso 6 — busybox sale de las build-deps, y con eso de las IMÁGENES (2026-09-22)
busybox → perfiles: NINGUNO. Era el paso que de verdad lo saca del producto.
busybox nunca estuvo declarado en targets.toml (cero menciones): llegaba a los 7 perfiles
por la clausura, porque 24 recetas lo pedían en [deps] build. Quitarlo de ahí lo saca de todas
las imágenes sin escribir una línea de userland.
La dep era redundante, y se midió antes de tocar las 24
El sandbox monta el rootfs Alpine completo (.dev-fs/alpine) como base read-only
(takana-build/src/sandbox.rs:4), y ese rootfs ya trae su propio busybox: el shell y los
coreutils del build salen de ahí. La dep sólo añadía el busybox de takana encima.
| comprobación | resultado |
zlib-ng sin la dep | ✅ sella igual (b3:f2439671) |
| recetas que MENCIONAN busybox en sus fases | ninguna de las 23 |
las 4 que lo nombran (e2fsprogs kbd vim networkmanager) | lo hacen en comentarios |
El tropiezo del cambio mecánico
Dos recetas se resistieron: jq y linux. El regex con count=1 pegó en una línea de
comentario que también dice build = [ —las dos documentan sus deps en prosa— y, como ahí no
había busybox, la sustitución no cambiaba nada y el fichero no se escribía. El efecto fue dejar
intacta la dep real de más abajo. Arregladas a mano, y verificado con git diff que ningún
comentario quedó alterado en las otras 22: un cambio masivo sobre ficheros con prosa necesita esa
comprobación, no basta con que el resultado parezca correcto.
Lo que queda de busybox
Ya no está en ninguna imagen. Lo que sigue es el frente C, que es ADR y toca la evidencia de
reproducibilidad:
STAGE1_COMPONENTS (takana-bootstrap/src/lib.rs:172) — es 1 de los 4 del self-host
ATTEST_PATHS (:465) — bin/busybox está atestado
- dos cards con
exec "/bin/busybox": la consola (:240) y sshd (:429)
Y los 47 applets del censo, que ya no viajan en ninguna imagen pero siguen siendo el inventario
de lo que el producto tendría que proveer por otra vía el día que el frente C se cierre.
getty → agetty NO es migrar un exec — corrección (2026-09-22)
Este documento decía, y yo lo repetí: «getty deja de ser escritura y pasa a ser migrar el
exec de la card, porque agetty de util-linux ya está en los 7 perfiles». Es falso, y el
cambio hecho a ciegas deja el arranque SIN CONSOLA.
Lo que falta no es sintaxis. Eso sí se verificó y está bien: agetty acepta el argv del applet tal
cual —documenta las dos formas, <line> [<baud>] y <baud>,... <line>, y admite -n
(--skip-login) y -l (--login-program) con la misma semántica—, comprobado contra el binario
sellado y no contra la documentación.
Lo que falta es el binario. La card console-getty vive en STAGE1_SEED_CARD, o sea en la
seed del núcleo, y el rootfs del núcleo tiene exactamente cuatro componentes:
const STAGE1_COMPONENTS: &[&str] = &["musl", "busybox", "hammerd", "arje-zero"];
const USERLAND_COMPONENTS: &[&str] = &["uutils", "findutils", "findutils-xargs", "diffutils", "ripgrep"];
const SERVICE_COMPONENTS: &[&str] = &["netup", "openssh"];
util-linux no está en ninguna de las tres. Comprobado en los artefactos sellados, que es donde
se ve y no en targets.toml:
| sbin/agetty | sbin/getty |
stage1-rootfs (1a3823b4…) | no existe | ✅ (symlink a busybox) |
product-rootfs (86398943…) | no existe | ✅ |
Que util-linux esté en los 7 perfiles de targets.toml no lo pone en el rootfs del producto:
el producto se ensambla de esas tres listas, no del catálogo. Es la distinción
[[catalogo-no-es-imagen]] otra vez, y esta vez me la tragué entera: la habría dejado apuntando a un
binario inexistente, con supervision: Restart reintentando para siempre y el arranque sin consola
— que es justo el fallo que sólo se ve en metal, tarde.
Entonces, qué es de verdad
Una decisión de arquitectura, no una línea:
- Meter
util-linux en STAGE1_COMPONENTS — el núcleo pasa de 4 a 5 componentes, cambia el
of_tree del self-host y toca la evidencia de reproducibilidad. Es ADR.
- Mover la card de consola a la capa de producto y añadir
util-linux a
USERLAND_COMPONENTS — no toca el núcleo, pero entonces el stage1 se queda sin consola (o
conserva la suya de busybox y hay dos cards distintas, una por capa).
Las dos son defendibles y ninguna es «migrar un exec». Lo que queda medido y aprovechable es que el
argv no cambia: cuando se decida, el exec es lo único que se toca.
⚠ Y la validación de un getty no se hace con pruebas unitarias. Se intentó ejercitar agetty
contra un pty real —con setsid, y también como root— y no ejecuta el programa de login: su
contrato necesita ser el tty de control de una sesión de arranque. Los 42 tests del crate pasaban
con la card cambiada y el producto habría arrancado mudo. La prueba es scripts/product-boot-test.sh
en QEMU, que esta jaula no tiene.
La consola con agetty, ARRANCADA en QEMU (2026-09-22)
util-linux entró a STAGE1_COMPONENTS (el núcleo pasó de 4 a 5) y con eso la card console-getty
ya puede ejecutar un binario que existe en ese rootfs. Probado arrancando, que es lo único que
vale para un getty:
| qué | dónde | resultado |
stage1-rootfs | hub y worker | b3:b9eb52a3 — el mismo hash en las dos máquinas |
product-rootfs | worker | b3:57e101e8 |
takana attest sobre el atestado | hub | ✅ 7 binarios casan su BLAKE3, /sbin/agetty entre ellos |
| arranque en QEMU (TCG, sin KVM) | worker | ✅ *** PRODUCT SSH OK *** |
agetty vivo en el guest | worker | ✅ /sbin/agetty agetty -n -l /bin/sh 115200 console |
| ¿bucle de reinicio? | worker | ❌ no: mismo pid en tres muestras separadas 3 s |
Lo último es lo que de verdad se buscaba. Un exec que apunta a un binario inexistente no rompe
el arranque: arje lo reintenta por supervision: Restart, el sistema sigue, sshd responde y el
boot test pasa igual. Por eso no alcanzaba con «bootea»: había que ir a ver si el proceso está
vivo y si es el mismo.
⚠ Lo que NO se probó: que al pulsar Enter en la consola serie aparezca una shell. Eso pide
hablarle al puerto serie del guest (-serial unix:… + un cliente), y el andamiaje costaba más que
lo que añadía sobre «el proceso correcto está vivo y estable». Queda dicho en vez de supuesto.
Y el error de método, que es la parte que enseña
Para ensamblar el producto en el worker le quité el flock, razonando que bootstrap product
«sólo hidrata artefactos ya sellados». Es falso: construye las recetas que le faltan, y se lo
pilló compilando diffutils. Eso es exactamente la carrera del ADR 0012 — si el loop de la granja
hubiera estado con una receta que compartiera dependencia, el árbol de fuentes quedaba roto para
siempre. No pasó porque el loop estaba con ffmpeg: suerte, no diseño.
La vía correcta, que es la que se usó después: copiar al worker los artefactos que le faltaban
(ripgrep, netup, openssh, arje-packager) en vez de construirlos allá. El store es
direccionado por contenido, así que copiar un artefacto sellado es legítimo y no compite por nada.
Con todo presente, el ensamblado bajo flock fue instantáneo.
Paso 7 — el censo dejaba de decir la verdad, y el vigía no miraba el producto (2026-09-22)
Dos instrumentos de este plan habían envejecido. No es teoría: entre los dos hacían que la campaña
pidiera trabajo ya hecho y que el guardián no viera el arranque del producto.
7.1 · El destino del TSV era juicio, y el juicio se quedó viejo
La cabecera de este documento cifró 23 applets de trabajo real. Medido hoy contra los artefactos
y el cierre de imagen, con scripts/busybox-censo-proveedores.py --auditar-destinos:
293 celdas del TSV estaban mal. 22 en destino —o sea trabajo mal contado— y 271 en
estado_del_dueño.
La regla nueva no opina: si un paquete que VIAJA EN LA IMAGEN provee el applet, ése es el
destino. Sale del índice de proveedores que el censo ya medía, cruzado con
yupana.membresia_imagen(). Lo que destapó:
| applet(s) | decía | es |
awk | awk-DECISION | akllay — el awk propio en Rust, sellado, en los 7 perfiles, publicando /usr/bin/awk |
bc dc | bc-TRAER | bc (sellada y en los 7 perfiles desde 4357b29c) |
killall pstree fuser | psmisc-TRAER | psmisc (ídem) |
grep egrep fgrep | grep-posix | grep — el GNU viaja y cubre los tres HOY |
sed | sed-rs | sed |
xz xzcat lzma … (5) | xz | xz-tools — xz es la lib, y su usr/bin está vacío ([[censo-proveedores-applets]]) |
clear reset | ncurses | ncurses-tools |
logger | hammerd | util-linux — confirma la corrección del paso 1: hammerd reemplaza la FUNCIÓN, no el comando |
switch_root | arje-zero | util-linux — mismo caso |
sulogin | shadow | util-linux |
partprobe | util-linux | parted |
⚠ BORRAR no se reescribe nunca. Es una DECISIÓN («este applet no se reemplaza»), y ninguna
medición la contradice: que cups traiga un lpr no significa que haya que conservar el applet.
Medir es para las filas que declaran trabajo pendiente, no para deshacer lo decidido.
7.2 · kmod y el gestor de /dev: 9 applets que eran BORRAR, no ESCRIBIR
Medido en los .config que los artefactos publican (/boot/config-*), no en las recetas:
| MODULES | drivers =m | DEVTMPFS_MOUNT | UEVENT_HELPER |
linux-generic, linux-metal, linux-metal-dual, linux-metal-kexec, linux-gioser | off | 0 | =y | off |
linux (QEMU / self-host) | =y | 13 | =y | off |
Y la medición que cierra el caso: ningún artefacto del store publica lib/modules — ni siquiera
linux. No hay módulo que cargar en ninguna imagen.
⇒ insmod rmmod lsmod modprobe depmod modinfo y mdev makedevs uevent pasan a BORRAR con su
motivo escrito en el TSV. Eran 9 de los 23.
⚠ Y el texto que ya estaba en el vigía se pasaba de ancho: decía «los cuatro kernels de takana
traen # CONFIG_MODULES is not set, CERO drivers =m». Son seis kernels sellados, cinco
cumplen eso y el sexto trae 13 módulos. La conclusión no cambia; la afirmación sí, y una afirmación
de más en un guardián es lo que enseña a no creerle.
7.3 · killall5 prometía un paquete que nunca lo iba a traer
Decía psmisc-TRAER. psmisc no trae killall5 —es de sysvinit— y su propia receta lo dice en
la línea 9: acá lo cubre arje-zero por función, porque es PID 1 y apaga él. Pasa a BORRAR.
7.4 · El vigía no veía las cadenas crudas de Rust — o sea, no veía el producto
busybox-vigia.py extraía las cadenas de un .rs con "([^"\\\n]|\\.)*", que excluye el salto de
línea. Y takana-bootstrap guarda los guiones del PRODUCTO en literales crudos multilínea
(const REBUILD_DRIVER: &str = r#"#!/bin/sh …"#). Consecuencia medida: con insmod retirado, el
vigía decía «OK — nadie los invoca» mientras crates/takana-bootstrap/src/lib.rs:1388 hacía
insmod /toolchain/lib/overlay.ko.
Arreglado (cadenas_rust), y con dos cosas más que hacían falta para que sirva:
- El número de línea era el del texto extraído, no el del fichero (
lib.rs:125 cuando el
insmod está en la 1388). Ahora hay un mapa de líneas reales. Un guardián que manda al sitio
equivocado gasta la confianza igual que uno que no encuentra nada.
- Sexta regla anti-ruido: una invocación cuyo ARGUMENTO cuelga de
/toolchain/ no es el applet.
Ese insmod lo resuelve el PATH=/toolchain/usr/bin:… que el propio guión pone tres líneas antes
— es el kmod de Alpine, y su comentario lo dice. Se reconoce por el argumento y no por el
fichero, así que vale para cualquier llamada futura del mismo tipo y no tapa un insmod normal.
⚠ No se "arregla" poniéndole la ruta absoluta al guión: REBUILD_DRIVER se hornea en el rootfs del
builder y tocarlo mueve la evidencia de reproducibilidad del self-host (of_tree, ADR 0008).
Probado con una rotura a propósito: un modprobe overlay dentro de un r#"…"# ⇒ rc=1 y la línea
correcta. Sin el arreglo, ese mismo caso pasaba en verde.
El estado del censo, después
401 applets · 262 vivos · 139 retirados
destinos que TODAVÍA declaran trabajo: 3
getty-ESCRIBIR 2 cttyhack getty ← es ADR (ver el §getty→agetty), no escritura
dns-TRAER 1 nslookup ← traer una receta
lo provee OTRO paquete (el `destino` está mal): 0
De los 23 applets de «trabajo real» quedan 3, y dos de ellos son una decisión de arquitectura ya
escrita. No se cerró la brecha escribiendo userland: se cerró midiendo lo que ya existía, que es
la misma lección con la que empezó este documento.
Re-verificar el self-host tras meter util-linux: hasta dónde se llegó (2026-09-24)
El of_tree del stage1 cambió al pasar el núcleo de 4 a 5 componentes, así que el baseline había que
re-anclarlo. Eso está hecho y medido:
of_tree(stage1) b3:9adefb82… → b3:d152f060… (anclado en scripts/selfhost-verify.sh)
⚠ Pero eso es el cruce entre MÁQUINAS, no el auto-alojamiento. Que la VM reconstruya ese mismo
árbol con el toolchain de adentro sigue sin verificarse, y conviene decir exactamente dónde se
atascó porque el camino destapó tres cosas, dos de ellas arregladas.
1 · El script moría en silencio en cualquier máquina sin /lib/modules — ARREGLADO
set -euo pipefail + find sobre un directorio inexistente: el find falla, con pipefail el pipe
falla, la asignación falla y set -e aborta antes del echo "NO encontrado" que el propio bucle
tenía preparado. El 2>/dev/null remata el silencio. Pasa en el worker porque es un LXC: comparte
el kernel del host y no trae árbol de módulos. El log terminaba en la línea del say, sin una
palabra, y el proceso llevaba dos días muerto mientras yo lo daba por vivo — porque
pgrep -f selfhost-verify se encuentra a sí mismo.
2 · El initramfs pesaba 15 G y no cabía en la RAM de la VM — ARREGLADO
work/builder.cpio.gz: 15G ← contra MEM=8192 ⇒ DRIVER done sent=False elapsed=2s
Un initramfs se carga entero en RAM. Los 15 G no eran el toolchain (1,2 G) ni el store (396 M): era
el --work-cache, que lleva repos/ y tarballs/ — en una máquina que ya construyó el corpus, eso
son 8,7 G de mirrors git + 6,3 G de tarballs. Ahora WORK_CACHE es variable y el caché mínimo
del núcleo son 174 M: takana.git, arje-zero.git y los tres tarballs de musl/busybox/util-linux.
El mensaje del script —«? incompleto (timeout/boot/OOM)»— no ayudaba: el dato que lo delataba estaba
impreso cuatro líneas más arriba.
3 · El muro actual: bwrap: pivot_root: Invalid argument — ABIERTO
Con las dos cosas arregladas, la VM arranca (hay prompt y el driver corre) y falla el PRIMER
build de dentro, con 0 sellados:
bwrap: pivot_root: Invalid argument
Error: core: build phase falló (exit 1): ./configure …
No es falta de capacidades del kernel — se comprobó en el .config sellado: USER_NS,
NAMESPACES, OVERLAY_FS, TMPFS, DEVTMPFS y BLK_DEV_INITRD están todos en y. Y no es el
ENODEV de overlay-como-módulo que el runbook documenta (§ «overlay es módulo»), que es otro error.
La diferencia con la corrida que sí funcionó es el KERNEL. El runbook valida este flujo con
/boot/vmlinuz-linux —el de una laptop Arch— y ahí musl y busybox se reconstruyeron y sellaron
dentro de la VM. Acá se le pasó nuestro linux 6.16.12, porque el worker es un contenedor y no
tiene kernel propio que ofrecer.
⇒ Lo que falta para cerrar la re-verificación es una máquina con RAM suficiente y un kernel de
distro: el worker tiene los 16 G pero es un LXC sin kernel propio; el hub y gioser tienen kernel
pero ~7 G, y la VM sola pide 8. Mientras tanto, lo que está verificado es el cruce entre máquinas
(el mismo of_tree calculado en hub y worker), no el rebuild de adentro.