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