Files
takana/docs/plan-botar-busybox.md
T
Sergio e8524eab93 montar-trabajo: las variables desnudas se comían un usuario, y los echo ya van entre comillas
Tercer bug de la misma card, de la misma familia y el de peor diente: con un
directorio llamado «juan perez» en /home, el $u desnudo se partía en dos
campos, id -u juan perez fallaba, y el || continue saltaba a ese usuario EN
SILENCIO — se quedaba sin su /run/user/<uid>.

- u=$(basename "$h"), chown "$u", "/run/user/$uid"
- id -u izado a uid=$(id -u "$u" 2>/dev/null) || continue: se llamaba cuatro
  veces por usuario (una para el test y tres para las órdenes), ahora una. No
  era el bug, era la forma limpia de entrecomillarlo — y se ve en la medición,
  que pasó de id×4 a id×1
- los dos echo de error, ahora entre comillas. Inocuo hoy (ningún glob en el
  texto) y se comprobó que el mensaje y el rc=78 salen idénticos

Medido con el mismo banco de stubs sobre un /home de prueba con «juan perez»,
root y nadie: antes recibía chown sólo root, ahora root y «juan perez». «nadie»
no recibe en ninguno, que es lo correcto — no está en passwd. Parsea en
busybox, bash y brush.
2026-09-21 19:36:00 +00:00

43 KiB
Raw Blame History

Plan — botar busybox, MEDIDO

Fecha: 2026-09-21 · Instrumento: los 401 symlinks de applet del artefacto sellado 2a2b1280…-busybox (contados en el store del worker), cruzados con el estado y la membresía de perfil de cada receta en docs/state/build-state.json y con grep '[deps]' recipes/*.toml. Tabla applet-por-applet: docs/state/busybox-applets.tsv (401 filas, generada, no escrita a mano).

Por qué existe este documento

La pregunta fue «¿qué hay que hacer para botar busybox?», con la sospecha de que faltaba escribir mucho userland en Rust. El número dice lo contrario, y el hueco está en otro lado:

De los 401 applets, 148 ya tienen dueño sellado Y en perfil (el applet es redundante en la imagen), 125 tienen dueño sellado que NO viaja en ninguna imagen (trabajo hecho y no cobrado), y de los 128 sin dueño, 105 son basura que se borra. El trabajo de escritura real son 23 applets repartidos en 8 piezas.

Es la lección de foot y del §D de plan-barrido-perfiles-y-servicios.md otra vez: uutils, findutils, findutils-xargs, diffutils y brush están todos sealed con perfiles: []. Se construyeron, se sellaron, y ninguna imagen los lleva.

Son TRES frentes, no uno — y esto es lo que hace que «botar busybox» se lea como imposible

Mezclarlos es el error que cuesta el trabajo. Tienen dificultad, dueño y riesgo distintos:

frente qué es busybox ahí dificultad
A · Sandbox de build [deps] build = [… "busybox" …] en 24 recetas — es el sh+coreutils que el sandbox del lab trae (recipes/jq.toml:5-7, recipes/zlib-ng.toml:11). Más el /toolchain mixto de Alpine con el swap de docs/11-bootstrap.md:234-240 media — no es runtime, no toca el producto
B · Runtime del producto los applets de /bin y /sbin de las imágenes. USERLAND_COMPONENTS (crates/takana-bootstrap/src/lib.rs:406) ya los ensombrece con los binarios Rust y retira el homónimo de los otros dirs (lib.rs:676) baja — la máquina ya está construida, falta munición
C · Stage 1 / self-host / atestación STAGE1_COMPONENTS (lib.rs:172, el 4/4), ATTEST_PATHS como raíz-de-confianza (lib.rs:465), y el exec de las cards de getty y sshd (lib.rs:429, :240) alta — es el ADR 0008, y toca la evidencia de reproducibilidad of_tree=9adefb82…

La membresía de perfil de busybox es del frente A, no del B. busybox figura en cli, servidor, los cuatro escritorios y metal-tigerlake, pero no está declarado como raíz en docs/state/targets.toml (los únicos hits ahí son comentarios). Llega por la clausura, porque 24 recetas lo declaran build-dep: abseil-cpp chrony cronie crun dwarves firmware-tigerlake ia-modelo-embeddings intel-ucode jq libmnl libnftnl linux linux-firmware llama-cpp logrotate nftables opensmtpd popt protobuf qdrant sof-firmware valkey wireless-regdb zlib-ng. Sacarlo de las imágenes empieza por ahí y no requiere escribir ni una línea de userland.

El reparto de los 401

estado del dueño n qué significa
sellada + en perfil 148 el reemplazo ya viaja en la imagen: el applet es redundante hoy
sellada, sin perfil 125 construido, sellado, y no viaja: falta declararlo
sin dueño 128 de los cuales 105 son BORRAR y 23 son trabajo real

148 · Ya cubiertos y en la imagen — el applet sobra

util-linux 48 · iproute2 19 · procps-ng 12 · shadow 14 · kbd 12 · xz 6 · iputils 5 · e2fsprogs 5 · bzip2 3 · cronie 3 · dhcpcd 3 · ncurses 2 · dosfstools 2 · chrony 2 · vim 2 · less lsof pciutils usbutils mandoc socat patch binutils tar wget 1 c/u.

24 de estos 148 NO son drop-in, y están marcados en el TSV. El caso grande es la red: iproute2 da ip/ss, no ifconfig/route/arp/netstat — eso es net-tools, que no está en el corpus. Igual con addgroup/adduser (shadow da groupadd/useradd), udhcpc (dhcpcd es otro cliente con otro contrato de hooks) y nc (socat tiene otra sintaxis). Cada uno de esos 24 es una migración de llamadores, no un borrado. Mirar quién los invoca en scripts/ y en las cards antes.

125 · Sellados y sin viajar — la munición que ya está fabricada

dueño n qué cubre
uutils 90 todo el núcleo POSIX: ls cp mv rm cat head tail sort cut tr wc stat date dd chmod chown …
arje-zero 20 init linuxrc runlevel halt poweroff reboot run-init switch_root + los 12 de runit (runsv sv svc svlogd chpst envdir setuidgid softlimit start-stop-daemon …)
hammerd 4 syslogd klogd logger logread
brush 3 sh ash hush
gzip 3 gzip gunzip zcat
diffutils 2 diff cmp
findutils / findutils-xargs / libarchive 3 find · xargs · cpio

CORRECCIÓN (2026-09-21, al ejecutar el paso 1): los 24 de arje-zero y hammerd NO son gratis, y esta tabla decía mal. Esas dos recetas reemplazan la función, no el comando: cada una instala UN binario (-p arje-zero, -p hammerd), así que no existe un poweroff, un logger ni un setuidgid que las respalde. «Cubierto» significaba «nadie necesita correr esto», y para 5 de los 24 era falso. Ver los indultos del paso 1.

128 · Sin dueño

105 se borran (no se reemplazan, salen del defconfig): dpkg dpkg-deb rpm rpm2cpio httpd ftpd ftpget ftpput tftp tftpd telnet telnetd inetd dnsd sendmail popmaildir lpd lpq lpr ubi* nand* nbd-client i2c* hdparm fdformat setserial powertop bootchartd fbsplash conspy ether-wake udhcpd udpsvd tcpsvd watchdog whois tree which unzip … (lista completa en el TSV).

23 son el trabajo real:

pieza applets qué es
kmod insmod rmmod lsmod modprobe depmod modinfo (6) no hay receta kmod en el corpus. En Rust: finit_module + parseo de modules.dep. Bien acotado
psmisc killall killall5 pstree fuser (4) procps-ng NO los trae. Traer receta psmisc (C) o escribir
grep POSIX grep egrep fgrep (3) grep GNU está sellado (sin perfil) como puente. El Rust nativo se escribe sobre grep-matcher/grep-regex/grep-searcher
gestor de /dev mdev makedevs uevent (3) decisión: lo absorbe arje, o entra una receta eudev/mdevd
bc bc dc (2) traer receta
getty getty cttyhack (2) hoy la card de getty ejecuta /bin/busybox (lib.rs:429)
awk awk (1) decisión abierta, ver abajo
sed sed (1) sed GNU sellado y en los 7 perfiles; el Rust nativo está por escribir
dns nslookup (1) traer receta bind-tools/doggo

Las tres clases de reemplazo — y cuándo forkear (nunca)

La pregunta era si hay que forkear los que «existen pero no son exactos». No, y hay tres clases distintas que conviene no mezclar:

1 · Adopción exacta. uutils/coreutils, uutils/findutils, uutils/diffutils: reimplementación madura, MIT, receta Cargo con commit pinneado (ADR 0006), multicall. El patrón está probado y documentado en la cabecera de recipes/uutils.toml. Lo que falta de esta clase es uutils/procps y uutils/util-linux — receta calcada, coste bajo. ⚠ Verificar su estado upstream antes de prometer los 60 applets que cubrirían: util-linux en Rust era embrionario.

2 · «Existe pero no es exacto» — y forkear es la trampa. ripgrep no es grep POSIX, sd no es sed, fd no es find (por eso la receta buena es uutils/findutils y no fd), procs no es ps, bottom no es top, helix no es vi. Todas están selladas y en los 7 perfiles, y ninguna sirve de reemplazo: cambian las banderas. Forkear ripgrep para que hable POSIX es peor que escribir — heredás su arquitectura de recursión+ignore y peleás contra ella para siempre. Lo correcto es usarlas como librerías: grep-matcher/grep-regex/grep-searcher están publicadas justamente para eso, y un grep POSIX encima son unos cientos de líneas.

3 · awk es el caso feo, y hay que decidirlo, no resolverlo de paso. recipes/goawk.toml existe y está sellada, pero goawk es Go, no Rust — no cumple la premisa de soberanía de cadena de la Etapa C. frawk es Rust pero es otro dialecto (JIT, semántica propia). gawk es C con extensiones GNU que los configure del corpus sí usan. Las tres opciones son legítimas y ninguna es gratis.

El bloqueante, y la buena noticia

Sin sh POSIX no se bota busybox en ningún frente: es el exec de las cards, el /bin/sh del producto y el shell del sandbox. Lo esperado era que fuera un proyecto de meses.

recipes/brush.toml (0.4.0, reubeno/brush, MIT, Rust, compat bash) está sealed. Compila estático musl con zig cc y produce /usr/bin/brush.

Y está sin validar como sh. La receta es un import crudo de nixpkgs y lo dice en su primera línea: «PUNTO DE PARTIDA, no final». No está en ningún perfil, nadie lo ejecutó como /bin/sh, y el sandbox de build es el consumidor más duro que existe (configure de autotools). La unidad de trabajo #1 es medirlo, no adoptarlo.

Orden de ataque

Cada paso es una unidad de trabajo con su criterio de terminado. Los tres primeros no tocan el frente C y se pueden hacer hoy.

  1. Recortar el defconfig. HECHO, 2026-09-21 — 401 → 277 applets. Veredicto al final.
  2. Declarar la munición que ya existe. uutils, findutils, findutils-xargs, diffutils, brush, gzip, libarchive a los perfiles de targets.toml. Terminado cuando build-state.py los marca con perfil y la imagen los lleva.
  3. Medir brush como /bin/sh. HECHO, 2026-09-21 — veredicto abajo: no sirve todavía para el sandbox. Queda pendiente medirlo como /bin/sh del PRODUCTO (cards + product-boot-test.sh), que es un consumidor distinto y mucho más blando.
  4. Las dos recetas de adopción baratas (uutils/procps, uutils/util-linux), previa verificación de madurez upstream. Cubren ~60 applets de los 148 «ya cubiertos por C».
  5. grep y sed POSIX en Rust. Es la escritura de mejor relación coste/beneficio y desbloquea el /toolchain del frente A.
  6. Migrar los 24 build-deps. Cambiar busybox por el conjunto real en [deps] build de las 24 recetas. Terminado cuando busybox sale de los 7 perfiles.
  7. La cola larga: kmod, psmisc, gestor de /dev, getty, bc, dns. Después del shell, no antes.
  8. ADR — awk, y ADR — el frente C (sustituir busybox en STAGE1_COMPONENTS y ATTEST_PATHS). No antes de que 17 estén cerrados.

La métrica, y el guardián que falta

docs/state/busybox-applets.tsv convierte esto en un número que baja: 401 → 272 → … → 0. Regenerarlo es contar los symlinks del artefacto sellado y cruzarlos con build-state.json.

Falta un guardián, y sin él el paso 1 se deshace solo: un script que falle si alguna receta, script o card invoca un applet que ya salió del defconfig. Hoy nada lo detectaría — el fallo aparecería en boot, en otra máquina, semanas después.

Lo que este documento NO midió (y hay que decirlo así)

  • El reparto applet→destino es juicio, no medida. Lo medido son los 401 symlinks, el estado sealed/perfiles de cada receta y las 24 recetas con busybox en [deps] build. La asignación de dueño la escribí yo y hay que revisarla receta a receta.
  • No se verificó la equivalencia de banderas de ninguna pareja. Los 24 marcados NO-drop-in son los que sé que difieren; el resto dice sin-verificar y eso es literal, no una forma de decir «va a andar».
  • No se midió qué applets usa el producto de verdad. El defconfig instala 401 porque nadie eligió; cuáles se ejecutan en una imagen viva no está medido. Eso abarataría el paso 1.
  • uutils/procps y uutils/util-linux no se verificaron upstream. Los ~60 applets que les atribuyo son una hipótesis de trabajo.

Veredicto del paso 3 — brush como /bin/sh, MEDIDO

Fecha: 2026-09-21 · Dónde: dev.gioser.net, todo bajo flock -o work/.farm-build.lock · Contra qué: busybox 1.36.1 sh (ash) como control, en la misma caja y la misma corrida.

No sirve todavía como /bin/sh del SANDBOX DE BUILD. Pasa el banco POSIX igual que busybox, parsea todo el corpus, y aun así 0 de 3 recetas autotools construyen (control: 3/3). Lo que falla no es su parser: es que el libtool que su configure genera sale con un nivel de escapado de menos y después no lo parsea ningún shell, ni él ni busybox.

Lo que se construyó

takana build recipes/brush.toml en el worker selló b3:715237c78edd…el mismo hash que build-state.json traía del otro hub. Dos máquinas distintas, bytes idénticos: reproducibilidad confirmada de paso, que no era el objetivo de la prueba.

Binario: ELF x86-64 estático (sin loader), brush 0.4.0, 7,25 MB — contra 1,23 MB del busybox que trae los 401 applets. Botar busybox por brush, hoy, engorda la imagen.

Lo que PASA

prueba brush control busybox
Banco POSIX — 57 casos (scripts/sh-banco-posix.sh) 56/57 56/57
Las 1130 fases de shell del corpus (sh -n sobre las fases de las 549 recetas que las tienen) 0 rechazadas 0 rechazadas
configure de ffmpeg — 8345 líneas, sh -n rc=0 rc=0
Invocado como sh (argv[0])
El patrón de las cards de arje (sh -c '…; exec …')

La única divergencia del banco: shift más allá de $# devuelve 2, y POSIX/busybox/bash devuelven 1. Cosmético, pero un set -e que haga shift de más se comporta distinto.

Y el banco NO es prueba de suficiencia — esto es lo que hay que recordar del ejercicio: brush lo pasa entero y aun así no construye una sola receta autotools. Un banco de casos sintéticos mide lo que a uno se le ocurre preguntar; el consumidor real mide lo que hay.

Lo que FALLA

Tres recetas autotools reales, con el único cambio de /bin/sh en una copia del lab:

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

  1. brush NO entra al sandbox de build. El frente A sigue con busybox hasta que esto se cierre.
  2. El frente del producto sigue abierto: las cards, la getty y la shell interactiva no generan libtool. Ahí brush no tiene ninguna evidencia en contra — pero tampoco se probó, y el product-boot-test.sh es la prueba que falta.
  3. Reportado upstream: reubeno/brush#1394, con el reproductor de una línea, la tabla de los cuatro casos y el fragmento de config.status.
  4. El paso 5 del plan (grep/sed POSIX en Rust) sube de prioridad, porque no depende del shell.
  5. No se tocó el lab compartido: copia con hardlinks y stores tirables. El worker siguió compilando su cola en paralelo, serializado por el flock.

¿Parchar, o buscar otra opción?

Primero lo que cierra la pregunta barata: adelantar el pin NO sirve. Nuestra receta apunta a 96a26d0c (tag brush-shell-v0.4.0, mayo 2026), que sigue siendo el último release. Se construyó main de hoy (737dd57e, 2026-09-21 — cuatro meses y medio de trabajo encima) y reproduce idéntico, los cuatro casos. No hay nada que esperar de la rama.

Lo que queda, en orden de coste:

opció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, archBB_ARCH, nbd-clientNBDCLIENT, shSH_IS_ASH, sysctlBB_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 1arje-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 -fACPI: PM: Preparing to enter system sleep state S5 / reboot: Power down. Uno de los cinco indultados, probado en vivo: sin él la caja no se apaga.

Cómo se corrió, porque condiciona el resultado: en dev.gioser.net, con QEMU en TCG (emulación pura) — el LXC no expone /dev/kvm, aunque el CPU sí trae los flags. Por eso el guion se le manda por consola serie con esperas generosas. Hubo que instalar qemu-system-x86-core (dnf) e ingerir de nuevo la semilla de Stage 0, que no estaba en el store del worker; sí salió con el mismo hash que el manifiesto ya anotaba (3ce721ec…).

Es Stage 1, no el producto. Se eligió porque es donde vive busybox (musl busybox hammerd arje-zero) y porque necesitaba 2 artefactos en vez de 8. product-boot-test.sh —que además levanta sshd y netup— sigue sin correrse: le faltan netup openssh uutils findutils-xargs diffutils ripgrep en el store del worker.

Lo que queda del paso 1

  • Las otras 21 dependientes, cuando la granja llegue.
  • El siguiente corte natural son los BORRAR que no se tocaron por prudencia y los 272 que siguen: el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (grep/sed POSIX).

El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21

Instrumento: scripts/sh-banco-diferencial.py · casos de regresión: scripts/fixtures/sh-casos/ · dónde: el hub, contra los tres shells sellados que ya están en /store (2a2b1280…-busybox, 715237c7…-brush, 87bf1a59…-bash). No tocó la granja ni el lock.

Por qué, si ya existía sh-banco-posix.sh

El veredicto del paso 3 dejó escrita la lección y no el instrumento:

brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.

Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial pregunta otra cosa, y no hace falta saber la respuesta de antemano:

¿este shell hace LO MISMO que el control, sobre el material que hay?

Tres decisiones de diseño, y las tres se ganaron con el bug de brush:

  1. Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout. El libtool mal escapado salía con rc=0 y stdout idéntico: ningún banco de stdout puede verlo. Cada caso corre en un directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que escribió. El caso 10-escribe-arbol.sh diverge sólo en el árbol — es la demostración de que esta columna hace falta.
  2. El esperado es el control, no una aserción. Un caso no declara resultado: declara material. Eso permite tirarle al banco las 1130 fases del corpus y 26 configure/libtool reales sin escribir una línea de expectativa.
  3. Toda divergencia se CONFIRMA volviendo a correr control y candidato. Un caso que imprime un PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía $$); un shell que diverge de sí mismo es un hallazgo distinto y peor. Sin esto el banco se llena de ruido y deja de leerse, que es como muere un banco.

Además: todos los shells se invocan por un symlink sh (argv[0] igual para todos, que es como se despliegan), con el mismo userland busybox en el PATH —las divergencias tienen que venir del shell, no de un sed distinto— y con PATH apuntando primero al shell bajo prueba, así un sh recursivo cae en ÉL y no en el del sistema.

Lo medido — 1273 casos, 5,5 s

scripts/sh-banco-diferencial.py --gen --phases \
    --files '/work/sergio/work/sources/*/configure' \
    --files '/work/sergio/work/sources/*/libtool' \
    control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
fuente casos modo brush bash
cases — regresiones conocidas 10 ejecutado 4 divergen 0
gen — tortura de comillas 107 ejecutado 31 divergen 8
phases — las fases de las 549 recetas 1130 sh -n 0 0
filesconfigure 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 guardián que faltaba, ahora corre

Cruzar esas 14 con docs/state/busybox-applets.tsv es exactamente el guardián que este documento pedía y nadie tenía:

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.

El recorte no rompió ninguna card. Y ahora es un número que se vuelve a sacar en un segundo, en vez de un fallo que aparecería en el arranque de otra máquina semanas despué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.