Es lo que el plan dejó sin medir tres veces («no se midió qué applets usa el
producto de verdad; eso abarataría el paso 1»). El modo lee del TSV los applets
con proveedor_medido = SOLO-BUSYBOX y los busca en el repo con las reglas
anti-ruido que el vigía ya tenía.
12 invocados: sh (28 sitios, lo cierra simi), ifconfig/route/udhcpc/netstat
(todos con ip/ss como primera opción y el applet como respaldo), setuidgid (3
cards: gitea, pacha-secretos, tejido), getty (la card de la consola), adduser,
nc, nslookup, poweroff. Y uno que es falso positivo del grep y conviene tener
escrito: los `insmod` de iso-image.sh/install-image.sh son de GRUB —`insmod
part_gpt` dentro de un grub.cfg—, no del busybox.
Los otros 52 no los llama nadie EN EL REPO, y la salida lo dice sin dejar que se
lea como una lista de borrado: un grep ve a quien invoca por nombre, y hay dos
consumidores 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 clear/reset/vi/traceroute al entrar por ssh.
La primera versión del modo decía 10 y estaba mal en dos casos, los dos del lado
peligroso — dan permiso para retirar algo que se usa:
· setuidgid se invoca como `exec /usr/bin/setuidgid pacha`, con RUTA ABSOLUTA,
y el patrón de comando pide el nombre pegado al exec.
· getty vive como `"argv": ["getty", …]`, elemento de un array JSON, y la regla
que filtra cadenas por «parece una línea de comandos» —la que bajó el ruido
de 91 hits a 0— descarta un nombre suelto.
Las reglas que quitan ruido son las que crean ceguera; lo que decide es hacia
dónde se equivoca cada una. Ahora busca tres formas: nombre desnudo, ruta
absoluta y primer elemento de argv. El vigía sigue verde en su modo original
(123 applets retirados, nadie los invoca) y se salta su propio fichero, que
nombra applets en la documentación y se denunciaba a sí mismo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 KiB
Plan — botar busybox, MEDIDO
Fecha: 2026-09-21 · Instrumento: los 401 symlinks de applet del artefacto sellado
2a2b1280…-busybox (contados en el store del worker), cruzados con el estado y la membresía de
perfil de cada receta en docs/state/build-state.json y con grep '[deps]' recipes/*.toml.
Tabla applet-por-applet: docs/state/busybox-applets.tsv (401 filas, generada, no escrita a mano).
Por qué existe este documento
La pregunta fue «¿qué hay que hacer para botar busybox?», con la sospecha de que faltaba escribir mucho userland en Rust. El número dice lo contrario, y el hueco está en otro lado:
De los 401 applets, 148 ya tienen dueño sellado Y en perfil (el applet es redundante en la imagen), 125 tienen dueño sellado que NO viaja en ninguna imagen (trabajo hecho y no cobrado), y de los 128 sin dueño, 105 son basura que se borra. El trabajo de escritura real son 23 applets repartidos en 8 piezas.
Es la lección de foot y del §D de plan-barrido-perfiles-y-servicios.md otra vez: uutils,
findutils, findutils-xargs, diffutils y brush están todos sealed con perfiles: [].
Se construyeron, se sellaron, y ninguna imagen los lleva.
Son TRES frentes, no uno — y esto es lo que hace que «botar busybox» se lea como imposible
Mezclarlos es el error que cuesta el trabajo. Tienen dificultad, dueño y riesgo distintos:
| frente | qué es busybox ahí | dificultad |
|---|---|---|
| A · Sandbox de build | [deps] build = [… "busybox" …] en 24 recetas — es el sh+coreutils que el sandbox del lab trae (recipes/jq.toml:5-7, recipes/zlib-ng.toml:11). Más el /toolchain mixto de Alpine con el swap de docs/11-bootstrap.md:234-240 |
media — no es runtime, no toca el producto |
| B · Runtime del producto | los applets de /bin y /sbin de las imágenes. USERLAND_COMPONENTS (crates/takana-bootstrap/src/lib.rs:406) ya los ensombrece con los binarios Rust y retira el homónimo de los otros dirs (lib.rs:676) |
baja — la máquina ya está construida, falta munición |
| C · Stage 1 / self-host / atestación | STAGE1_COMPONENTS (lib.rs:172, el 4/4), ATTEST_PATHS como raíz-de-confianza (lib.rs:465), y el exec de las cards de getty y sshd (lib.rs:429, :240) |
alta — es el ADR 0008, y toca la evidencia de reproducibilidad of_tree=9adefb82… |
⚠ La membresía de perfil de busybox es del frente A, no del B. busybox figura en cli,
servidor, los cuatro escritorios y metal-tigerlake, pero no está declarado como raíz en
docs/state/targets.toml (los únicos hits ahí son comentarios). Llega por la clausura, porque 24
recetas lo declaran build-dep: abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng. Sacarlo de
las imágenes empieza por ahí y no requiere escribir ni una línea de userland.
El reparto de los 401
| estado del dueño | n | qué significa |
|---|---|---|
| sellada + en perfil | 148 | el reemplazo ya viaja en la imagen: el applet es redundante hoy |
| sellada, sin perfil | 125 | construido, sellado, y no viaja: falta declararlo |
| sin dueño | 128 | de los cuales 105 son BORRAR y 23 son trabajo real |
148 · Ya cubiertos y en la imagen — el applet sobra
util-linux 48 · iproute2 19 · procps-ng 12 · shadow 14 · kbd 12 · xz 6 · iputils 5 ·
e2fsprogs 5 · bzip2 3 · cronie 3 · dhcpcd 3 · ncurses 2 · dosfstools 2 · chrony 2 ·
vim 2 · less lsof pciutils usbutils mandoc socat patch binutils tar wget 1 c/u.
⚠ 24 de estos 148 NO son drop-in, y están marcados en el TSV. El caso grande es la red:
iproute2 da ip/ss, no ifconfig/route/arp/netstat — eso es net-tools, que no está en
el corpus. Igual con addgroup/adduser (shadow da groupadd/useradd), udhcpc (dhcpcd es otro
cliente con otro contrato de hooks) y nc (socat tiene otra sintaxis). Cada uno de esos 24 es una
migración de llamadores, no un borrado. Mirar quién los invoca en scripts/ y en las cards antes.
125 · Sellados y sin viajar — la munición que ya está fabricada
| dueño | n | qué cubre |
|---|---|---|
uutils |
90 | todo el núcleo POSIX: ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown … |
arje-zero |
20 | init linuxrc runlevel halt poweroff reboot run-init switch_root + los 12 de runit (runsv sv svc svlogd chpst envdir setuidgid softlimit start-stop-daemon …) |
hammerd |
4 | syslogd klogd logger logread |
brush |
3 | sh ash hush |
gzip |
3 | gzip gunzip zcat |
diffutils |
2 | diff cmp |
findutils / findutils-xargs / libarchive |
3 | find · xargs · cpio |
⚠ CORRECCIÓN (2026-09-21, al ejecutar el paso 1): los 24 de arje-zero y hammerd NO son
gratis, y esta tabla decía mal. Esas dos recetas reemplazan la función, no el comando:
cada una instala UN binario (-p arje-zero, -p hammerd), así que no existe un poweroff, un
logger ni un setuidgid que las respalde. «Cubierto» significaba «nadie necesita correr esto»,
y para 5 de los 24 era falso. Ver los indultos del paso 1.
128 · Sin dueño
105 se borran (no se reemplazan, salen del defconfig): dpkg dpkg-deb rpm rpm2cpio httpd ftpd ftpget ftpput tftp tftpd telnet telnetd inetd dnsd sendmail popmaildir lpd lpq lpr ubi* nand* nbd-client i2c* hdparm fdformat setserial powertop bootchartd fbsplash conspy ether-wake udhcpd udpsvd tcpsvd watchdog whois tree which unzip … (lista completa en el TSV).
23 son el trabajo real:
| pieza | applets | qué es |
|---|---|---|
| kmod | insmod rmmod lsmod modprobe depmod modinfo (6) |
no hay receta kmod en el corpus. En Rust: finit_module + parseo de modules.dep. Bien acotado |
| psmisc | killall killall5 pstree fuser (4) |
procps-ng NO los trae. Traer receta psmisc (C) o escribir |
| grep POSIX | grep egrep fgrep (3) |
grep GNU está sellado (sin perfil) como puente. El Rust nativo se escribe sobre grep-matcher/grep-regex/grep-searcher |
| gestor de /dev | mdev makedevs uevent (3) |
decisión: lo absorbe arje, o entra una receta eudev/mdevd |
| bc | bc dc (2) |
traer receta |
| getty | getty cttyhack (2) |
hoy la card de getty ejecuta /bin/busybox (lib.rs:429) |
| awk | awk (1) |
decisión abierta, ver abajo |
| sed | sed (1) |
sed GNU sellado y en los 7 perfiles; el Rust nativo está por escribir |
| dns | nslookup (1) |
traer receta bind-tools/doggo |
Las tres clases de reemplazo — y cuándo forkear (nunca)
La pregunta era si hay que forkear los que «existen pero no son exactos». No, y hay tres clases distintas que conviene no mezclar:
1 · Adopción exacta. uutils/coreutils, uutils/findutils, uutils/diffutils: reimplementación
madura, MIT, receta Cargo con commit pinneado (ADR 0006), multicall. El patrón está probado y
documentado en la cabecera de recipes/uutils.toml. Lo que falta de esta clase es
uutils/procps y uutils/util-linux — receta calcada, coste bajo. ⚠ Verificar su estado
upstream antes de prometer los 60 applets que cubrirían: util-linux en Rust era embrionario.
2 · «Existe pero no es exacto» — y forkear es la trampa. ripgrep no es grep POSIX, sd no es
sed, fd no es find (por eso la receta buena es uutils/findutils y no fd), procs no es
ps, bottom no es top, helix no es vi. Todas están selladas y en los 7 perfiles, y ninguna
sirve de reemplazo: cambian las banderas. Forkear ripgrep para que hable POSIX es peor que
escribir — heredás su arquitectura de recursión+ignore y peleás contra ella para siempre. Lo correcto
es usarlas como librerías: grep-matcher/grep-regex/grep-searcher están publicadas
justamente para eso, y un grep POSIX encima son unos cientos de líneas.
3 · awk es el caso feo, y hay que decidirlo, no resolverlo de paso. recipes/goawk.toml existe
y está sellada, pero goawk es Go, no Rust — no cumple la premisa de soberanía de cadena de la
Etapa C. frawk es Rust pero es otro dialecto (JIT, semántica propia). gawk es C con extensiones
GNU que los configure del corpus sí usan. Las tres opciones son legítimas y ninguna es gratis.
El bloqueante, y la buena noticia
Sin sh POSIX no se bota busybox en ningún frente: es el exec de las cards, el /bin/sh del
producto y el shell del sandbox. Lo esperado era que fuera un proyecto de meses.
recipes/brush.toml (0.4.0, reubeno/brush, MIT, Rust, compat bash) está sealed. Compila
estático musl con zig cc y produce /usr/bin/brush.
⚠ Y está sin validar como sh. La receta es un import crudo de nixpkgs y lo dice en su primera
línea: «PUNTO DE PARTIDA, no final». No está en ningún perfil, nadie lo ejecutó como /bin/sh, y el
sandbox de build es el consumidor más duro que existe (configure de autotools). La unidad de
trabajo #1 es medirlo, no adoptarlo.
Orden de ataque
Cada paso es una unidad de trabajo con su criterio de terminado. Los tres primeros no tocan el frente C y se pueden hacer hoy.
Recortar elHECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.defconfig.- Declarar la munición que ya existe.
uutils,findutils,findutils-xargs,diffutils,brush,gzip,libarchivea los perfiles detargets.toml. Terminado cuandobuild-state.pylos marca con perfil y la imagen los lleva. MedirHECHO, 2026-09-21 — veredicto abajo: no sirve todavía para el sandbox. Queda pendiente medirlo comobrushcomo/bin/sh./bin/shdel 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». grepysedPOSIX en Rust. Es la escritura de mejor relación coste/beneficio y desbloquea el/toolchaindel frente A.- Migrar los 24 build-deps. Cambiar
busyboxpor el conjunto real en[deps] buildde las 24 recetas. Terminado cuandobusyboxsale 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 enSTAGE1_COMPONENTSyATTEST_PATHS). No antes de que 1–7 estén cerrados.
La métrica, y el guardián que falta
docs/state/busybox-applets.tsv convierte esto en un número que baja: 401 → 272 → … → 0.
Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con build-state.json.
Falta un guardián, y sin él el paso 1 se deshace solo: un script que falle si alguna receta,
script o card invoca un applet que ya salió del defconfig. Hoy nada lo detectaría — el fallo
aparecería en boot, en otra máquina, semanas después.
Lo que este documento NO midió (y hay que decirlo así)
- El reparto applet→destino es juicio, no medida. Lo medido son los 401 symlinks, el estado
sealed/perfiles de cada receta y las 24 recetas conbusyboxen[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-inson los que sé que difieren; el resto dicesin-verificary eso es literal, no una forma de decir «va a andar». - No se midió qué applets usa el producto de verdad. El
defconfiginstala 401 porque nadie eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1. uutils/procpsyuutils/util-linuxno 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/shdel 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 ellibtoolque suconfiguregenera 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.shes 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 era2a2b1280…). 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-zeroincarna y corre. - Seed — la card se carga: la prueba es que la
console-gettyllegó a incarnar. - getty → shell —
/bin/busybox getty -n -l /bin/sh 115200 consoleda prompt~ #, yecho MARCA-SHELL-VIVAresponde.gettyyshsobrevivieron 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
BORRARque 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/sedPOSIX).
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
libtoolmal escapado salía conrc=0y 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 caso10-escribe-arbol.shdiverge 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/libtoolreales 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
configurereal 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,
IFSy 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
execdirecto.
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 eldfy elawk— pero sigue siendo aproximación:command -v journalctlcuentacommand, nojournalctl. - 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/shes 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 enscripts/farm/cosecha-cron.sh, 89 backticks y los 89 dentro de comentarios. Daba «sustitución`…`75/81» cuando la cifra real es 9/81. shlexsobre 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.shdaba la ordeninitporque el token venía de dentro de unecho "…(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.shempotra un programa de Python, y sufor p in glob.glob(…)aparecía como la ordenp. 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 ;;dabainitcomo orden invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía razón. Ahora hay estado decase. - Cuerpos de
$(( )).i=$((i+1))daba la ordeni+1. - Descriptores de fichero.
>&2daba la orden2. - Nombre desnudo vs ruta absoluta.
exec switch_root /newroot /sbin/initmarcaba el appletinit; pero una ruta absoluta no se resuelve porPATHy no puede ser un applet — en el producto ese/sbin/inites arje (ADR 0007). Ahora se cuentan por separado, y de paso eso mide algo útil: 35 órdenes van por ruta absoluta y no dependen delPATH.
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 decasese cuenta como tubería. Inflaba poco y no se arregló: hace falta gramática, no regex.sh -nno es ejecución. Nada de esto dice que los 48#!bashque el ash parsea funcionen bajo ash.- No se midió qué guion corre DÓNDE. Que
takana-install.shsea bash no obliga a nada porque corre en el hub; el que corre dentro de la imagen estakana-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 -eno 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 EXITheredado 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_PATHSes una decisión de frontera, y el nombresimitambié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
/initen un arranque de QEMU (product-boot-test.sh) ytakana-live-install.shen 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_rootlo traeutil-linux— es uno de los cinco indultos del paso 1, y tiene sustituto real. (El/initdel initramfs sigue necesitando el de busybox porque ahí no hay util-linux, pero eso es el initramfs, no la imagen.)suloginlo traeutil-linux, no shadow.partprobelo traeparted, no util-linux.gettytiene sustituto:agetty, deutil-linux, que está en los 7 perfiles. El TSV lo daba comogetty-ESCRIBIR. No es escritura: es cambiar elexecde la card (takana-bootstrap/src/lib.rs:240) y medirlo. Lo que sigue faltando entero es la shell interactiva, que es otro consumidor —simino 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
xzyncursesnecesitan su paquete de herramientas — variante de receta, no perilla: tocar--disable-xzre-hashea xz y arrastra a todos sus dependientes. Patrónelfutils-libdw.- Los 6
ipaddr/iplink/…salen del defconfig en el próximo recorte: 277 → 271 sin escribir nada. vien la receta de vim ycpio→bsdcpioen libarchive: un symlink cada uno.gettydeja de ser escritura y pasa a ser migración de card aagetty.- 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.
ncde socat cuenta como proveedor aunque su sintaxis difiera. La columnaequivalenciadel TSV sigue haciendo falta y sigue diciendosin-verificaren 230 filas. - El store del HUB. Un artefacto sellado sólo en el worker no aparece;
build-state.jsondicesealedigual. 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/modprobeporcall_usermodehelpery al gestor de/devpor hotplug, así quemodprobe lsmod depmod rmmod modinfo mdev uevent makedevssalen «libres» y no lo son— y el usuario de la máquina, que espera encontrarclear,reset,viotracerouteal 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 comoexec /usr/bin/setuidgid pacha, con ruta absoluta. El patrón de comando pide el nombre pegado alexec, 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.