psmisc 23.7 (killall pstree fuser prtstat pslog) y gavinhoward/bc 7.0.3 (bc Y dc del mismo árbol). Ninguna estaba en el corpus, y lo de psmisc es lo que el plan daba por cubierto sin estarlo: procps-ng NO trae killall ni pstree ni fuser — procps-ng es ps/top/free/kill/pgrep/pkill/pidof/uptime/vmstat/w/watch. bc es el de gavinhoward y no el de GNU por la cadena: C99 sin dependencias, bc y dc en un árbol, y es el bc de sistema de Alpine y FreeBSD. El de GNU arrastra ed y flex, y su dc va en otro paquete. ⚠ killall5 NO lo trae psmisc: es de sysvinit. Acá lo cubre arje-zero por FUNCIÓN (es PID 1 y apaga él) ⇒ se retira con los indultos, no se reemplaza. Son 3 de los 4 que la tabla prometía, dicho así para que no vuelva a prometer lo que no hay. Dos pisones de psmisc, los dos escritos en la receta para no repetirlos: 1. `-lncurses` no existe acá (la nuestra es widec ⇒ libncursesw.a). No sirve LIBS=-lncursesw —el AC_CHECK_LIB compila un programa de prueba CON -lncurses, no pregunta si el símbolo resuelve— ni la variable de caché con `make TERMCAP_LIB=…`, porque src_pstree_LDADD = @TERMCAP_LIB@ se sustituye en tiempo de configure y el Makefile queda con -lncurses LITERAL. Lo que funciona es un enlace libncurses.a → libncursesw.a en el scratch y un -L. 2. Y ese -L se comió el -static. El harness inyecta LDFLAGS=-static para las recetas link="static" (takana-build/src/lib.rs:285); reemplazar LDFLAGS lo pierde. Los seis binarios sellaron DINÁMICOS sin que nada fallara: killall respondía bien y sólo pstree moría en ejecución con `Error relocating /lib/libncursesw.so.6`, tomando la librería del anfitrión. Un `file` lo dice en un segundo; ninguna prueba del build lo iba a decir. Los dos van a `base` en targets.toml, y el censo baja de 69 a 64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1255 lines
72 KiB
Markdown
1255 lines
72 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.
|
||
|
||
|
||
---
|
||
|
||
# `simi` — el `/bin/sh` propio, ESCRITO y MEDIDO (2026-09-21)
|
||
|
||
**Crate:** `crates/simi/` (4448 líneas, `libc` y nada más) · **receta:** `recipes/simi.toml`,
|
||
**sellada** en `b3:46ff6fcf…` · **nombre:** *simi* es «boca, lengua» en quechua — es cómo se
|
||
le habla a la máquina. `rimay` estaba tomado (la familia de embeddings de tawasuyu).
|
||
|
||
## El veredicto, primero
|
||
|
||
Banco diferencial contra el ash de busybox 1.36.1, **159 casos** — 10 fixtures de regresión, 107 de
|
||
tortura de comillas, los 23 fragmentos de las cards y los 19 `/init` empotrados:
|
||
|
||
| shell | divergencias duras | binario | latencia |
|
||
|---|---|---|---|
|
||
| **simi** (sellada, estática) | **0** | **0,48 MB** | **1,04×** el ash |
|
||
| bash 5.3.0 | 8 | 1,57 MB | 1,3× |
|
||
| brush 0.4.0 | 35 | 6,92 MB | 4,1× |
|
||
|
||
Incluye el **bucle real de `config.status`** que genera cualquier `configure` de autotools — el que
|
||
tumbó a brush. Y 47 pruebas propias del crate en verde.
|
||
|
||
## SELLADA, y medida contra el artefacto (2026-09-21)
|
||
|
||
`takana build recipes/simi.toml` en el worker, bajo `flock -o work/.farm-build.lock`:
|
||
|
||
```
|
||
sealed path=./store/46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834-simi
|
||
b3:46ff6fcf67bd23dece4d130c92855908520200f88ca85981317f81fcc56bb834 rc=0
|
||
```
|
||
|
||
El artefacto trae exactamente dos ficheros: `usr/bin/simi` y su `.hammer/recipe.toml`.
|
||
|
||
| qué | medido sobre el ARTEFACTO |
|
||
|---|---|
|
||
| tipo | ELF x86-64, **estático**, stripped |
|
||
| tamaño | **498 736 bytes** (0,48 MB) |
|
||
| `scripts/static-audit.sh simi` | «estáticos de verdad: 1 · MIENTEN: 0» |
|
||
| banco diferencial, 159 casos vs el ash | **0 divergencias** (bash: 8 · brush: 35) |
|
||
| latencia de arranque | **468 µs** contra **449 µs** del ash — **1,04×** (brush: 1569 µs, 3,5×) |
|
||
| `scripts/verificar-repro.sh simi` | **`✓ simi REPRODUCE`** — bit a bit, apartando el artefacto y reconstruyendo |
|
||
|
||
⚠ **La latencia del estático musl no es la del build nativo**: el build del hub (glibc, dinámico)
|
||
daba 865 µs, o sea 1,4× el ash. El estático baja a 1,04×. **Medir el binario que viaja, no el que se
|
||
compila a mano** — la diferencia acá fue de un 35 % y habría quedado escrita como si fuera del shell.
|
||
|
||
⚠ Todavía NO está en ningún perfil de `docs/state/targets.toml`. Declararla es meterla en la raíz de
|
||
confianza del arranque, y eso es el paso 3 de abajo.
|
||
|
||
⚠ **Y el artefacto vive SÓLO en el store del worker.** El worker sella con `PROMOTE=0` y la cosecha
|
||
baja el **manifiesto**, no el store: el hub no lo tiene. Promoverlo es otro paso.
|
||
|
||
### Dos trampas operativas que costaron dos corridas
|
||
|
||
**1. `--store <tirable>` NO sirve para verificar reproducibilidad, y falla con un error que no dice
|
||
nada.** Un store vacío da `Error: io: No such file or directory (os error 2)` a secas, sin decir qué
|
||
falta, porque las DEPS del build no están ahí. El método correcto ya estaba escrito y es
|
||
`scripts/verificar-repro.sh`: **aparta** el artefacto dentro del MISMO store (un rename, que sobrevive
|
||
a un kill) y reconstruye, así las deps siguen en su sitio. Dos corridas perdidas por no leer el
|
||
instrumento que ya existía antes de inventar uno.
|
||
|
||
**2. La cosecha BORRA una receta sembrada a mano.** `cosecha-cron.sh` siembra hub→worker con
|
||
`rsync --delete`, y el hub del que siembra es **gioser**, no el clon donde se trabaja. Entre el build
|
||
y la verificación, el worker perdió `recipes/simi.toml` —el artefacto sellado sobrevivió, la receta
|
||
no— y el verificador informó `REPRODUCEN: 0 · sin artefacto: 0`, o sea **nada**, porque su primer
|
||
`[ -f "$f" ]` falla y salta en silencio. Hasta que gioser haga `pull` del commit, cualquier corrida en
|
||
el worker necesita reponer la receta primero. Comprobado de paso que el hash NO cambia al editar los
|
||
comentarios de la receta: `hash_inputs` es una lista explícita de campos.
|
||
|
||
## El alcance salió de la medición, no de POSIX
|
||
|
||
| consumidor | qué necesita | estado |
|
||
|---|---|---|
|
||
| las 23 cards de arje con shell | 8 builtins, 13 construcciones, ningún `if`, ninguna función | ✅ |
|
||
| los 21 `/init` empotrados | + `if`, funciones, `case`, aquí-documentos, `${…}`, posicionales, `&` | ✅ |
|
||
| `takana-live-install.sh` (corre en la imagen viva) | POSIX entero | ✅ parsea; sin correr |
|
||
| la shell interactiva de `getty` | edición de línea, historial, control de trabajos | ❌ **no está** |
|
||
|
||
Lo que **no** trae, dicho en vez de fingido: control de trabajos, edición de línea, historial,
|
||
`alias`, `getopts`, `times`, `[[ ]]`, arrays. El shell de `getty` es otro consumidor y sigue
|
||
pendiente.
|
||
|
||
## Los siete bugs — que es la parte que enseña
|
||
|
||
Ninguno se veía en la salida. Todos daban resultados plausibles, y **los seis primeros los destapó
|
||
el banco diferencial**, no el juicio:
|
||
|
||
1. **El lexer fundía lo entrecomillado con lo desnudo.** `x="a b"` quedaba como UN seg
|
||
entrecomillado, el parser ya no veía la asignación —el prefijo antes del `=` tiene que ser
|
||
desnudo— y **la ejecutaba como una orden**. El error decía «no encontrado», que manda a buscar al
|
||
lugar equivocado. Ahora el comillado es por carácter.
|
||
2. **La marca de escape interna chocaba con las barras reales.** La primera versión usaba `\` como
|
||
marca y la quitaba al final, así que `$(echo \\$V)` perdía la barra que la sustitución había
|
||
PRODUCIDO (ash: `\hola`, simi: `hola`). **La quita de comillas es sobre las comillas de la
|
||
palabra, nunca sobre el resultado de una expansión** — y eso no se representa con un escape
|
||
dentro del texto: hace falta una máscara de protección por carácter.
|
||
3. **`set -e` no se suspendía dentro de una función llamada desde una condición.** Ahora es un
|
||
contador DINÁMICO, y por eso entra solo donde tiene que entrar.
|
||
4. **Los delimitadores de `$( )` y `${ }` no respetaban la barra invertida**: `$(echo \)` cerraba la
|
||
sustitución cuando el paréntesis estaba escapado y no debía cerrar nada.
|
||
5. **Las clases `[…]` no entendían `\`** — y `*[\\\`\"\$]*` es literalmente el patrón del
|
||
`config.status` de libtool.
|
||
6. **Dentro de comillas dobles, el cuerpo de un backtick sigue entre comillas**, así que la barra
|
||
también se elimina ante `"`. Sin eso, dos casos de la tortura divergían en los dos sentidos.
|
||
7. **Y uno que el banco NO vio:** el `trap EXIT` **heredado** se disparaba en el subshell. POSIX
|
||
§2.12 manda resetearlo al entrar — es la mitad complementaria de brush#1396, y al revés. Lo
|
||
encontró la prueba de regresión del crate, porque ningún caso del banco lo probaba. **Los dos
|
||
oráculos hacen falta**, y el caso quedó agregado al fixture 08.
|
||
|
||
Las dos invariantes que el fichero de ejecución sostiene a propósito, las dos porque se midieron
|
||
rotas en el candidato ajeno: **el `trap EXIT` de un subshell corre** (brush#1396) y **`exec`
|
||
reemplaza el proceso** (si el shell forkea, arje supervisa al shell y no al daemon).
|
||
|
||
## Lo que queda, en orden
|
||
|
||
1. ~~**Construir la receta en el lab** y sellarla.~~ **HECHO** — `b3:46ff6fcf…`, ver arriba.
|
||
2. **ADR** — escribir un shell propio y meterlo en `ATTEST_PATHS` es una decisión de frontera, y el
|
||
nombre `simi` también. Este documento la registra; el ADR la decide.
|
||
3. **Cablear `/bin/sh`**: `USERLAND_COMPONENTS` + el retiro del homónimo (`lib.rs:676`). Es frente C
|
||
y toca la raíz de confianza del arranque.
|
||
4. **Medir EJECUTANDO**, no sólo parseando: los `/init` en un arranque de QEMU
|
||
(`product-boot-test.sh`) y `takana-live-install.sh` en la imagen viva.
|
||
5. **La shell interactiva de `getty`**, que es el consumidor que falta entero.
|
||
6. **El sandbox de build sigue con busybox**, y con razón: eso es otro planeta y no está prometido.
|
||
|
||
|
||
---
|
||
|
||
# El censo de proveedores — el reparto applet→dueño, MEDIDO (2026-09-21)
|
||
|
||
**Instrumento:** `scripts/busybox-censo-proveedores.py` · **columnas nuevas del TSV:**
|
||
`proveedor_medido`, `medido_el` · **qué mide:** índice inverso de
|
||
`<store>/<hash>-<paquete>/{bin,sbin,usr/bin,usr/sbin,libexec}` sobre los 1407 artefactos del store
|
||
del hub, cruzado con los 277 applets vivos.
|
||
|
||
## Por qué había que medirlo
|
||
|
||
Este documento decía, arriba y en letra clara: «**el reparto applet→destino es juicio, no medida**.
|
||
La asignación de dueño la escribí yo y hay que revisarla receta a receta». Revisada:
|
||
|
||
| | n | qué significa |
|
||
|---|---|---|
|
||
| el dueño que el TSV nombra **sí lo provee** | 167 | el reparto era correcto |
|
||
| **lo provee OTRO paquete** | 31 | el dueño estaba mal escrito, pero hay sustituto |
|
||
| **no lo provee NADIE salvo busybox** | **79** | el trabajo real, y son 56 más de los 23 previstos |
|
||
|
||
> **Los 23 applets «sin dueño» eran 79.** No porque falte escribir más userland, sino porque
|
||
> **nueve paquetes que el TSV daba por dueños no instalan el binario que se les atribuía.**
|
||
|
||
## Lo que aparece cuando se mira el artefacto en vez de la tabla
|
||
|
||
**`xz` es el caso que más duele, y no es un olvido: es la receta haciendo lo que pide.**
|
||
`recipes/xz.toml` pasa `--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo
|
||
--disable-scripts` ⇒ el artefacto sellado tiene **`usr/bin` vacío**: es liblzma y nada más. Y `xz`
|
||
figura **en seis perfiles**. O sea que la imagen declara `xz` y el único `xz` que hay adentro es el
|
||
applet de busybox. Lo mismo, por la misma razón, con **`ncurses`**: su receta instala
|
||
`make install.libs install.includes` ⇒ ni `clear`, ni `reset`, ni `tput`, ni `infocmp`.
|
||
|
||
**Es un patrón, no dos accidentes: una receta de LIBRERÍA no es un paquete de HERRAMIENTAS**, y
|
||
`build-state.json` no distingue entre las dos — las dos salen `sealed` y con sus perfiles. Es
|
||
[[catalogo-no-es-imagen]] un piso más abajo: ahí el paquete existía y no viajaba; acá **viaja y no
|
||
trae el binario**. Ninguna métrica actual lo ve; el censo sí.
|
||
|
||
Los demás huecos nuevos, por qué cada uno:
|
||
|
||
| dueño | applets que NO provee | causa |
|
||
|---|---|---|
|
||
| `iproute2` | 18 | 12 son net-tools (ya marcados NO-drop-in) y **6 son nombres internos de busybox** — `ipaddr iplink ipneigh iproute iprule iptunnel` son `ip addr`/`ip link`/… escritos como applet. **No son comandos de nadie: se BORRAN del defconfig**, no se reemplazan |
|
||
| `shadow` | 8 | `addgroup adduser delgroup deluser mkpasswd cryptpw` ya estaban marcados; `add-shell` y `remove-shell` son de debianutils y nadie los trae |
|
||
| `xz` | 6 | receta de librería (arriba) |
|
||
| `kbd` | 4 | `loadfont loadkmap dumpkmap` son los NOMBRES de busybox de `setfont`/`loadkeys`/`dumpkeys`, que kbd **sí** trae ⇒ es NO-drop-in de nombre, no hueco de función. `vlock` no lo trae nadie |
|
||
| `iputils` | 3 | trae `ping arping tracepath clockdiff`; `traceroute`/`traceroute6` son otro paquete y `ping6` es `ping -6` |
|
||
| `ncurses` | 2 | receta de librería (arriba) |
|
||
| `util-linux` | 2 | `hd` es el alias de `hexdump`, que sí trae ⇒ falso hueco. **`setpriv` es hueco real**: el `configure` lo desactivó |
|
||
| `uutils` | 2 | `arch` y `hostname` — ver abajo, los cubre el feature |
|
||
| `vim` | 1 | instala `vim ex view rvim vimdiff xxd`, **no `vi`**: falta el symlink, una línea |
|
||
| `cronie` | 1 | `run-parts` es de debianutils |
|
||
| `libarchive` | 1 | instala `bsdcpio`, no `cpio` |
|
||
| `socat` `chrony` `dhcpcd` | 6 | los NO-drop-in ya conocidos |
|
||
|
||
## Y tres correcciones al revés — el dueño estaba mal, pero el sustituto EXISTE
|
||
|
||
Los 31 «otro proveedor» son casi todos `coreutils` (GNU, sellado, sin perfil) cubriendo lo que
|
||
`uutils` no traía. Los tres que cambian una conclusión del plan:
|
||
|
||
- **`switch_root` lo trae `util-linux`** — es uno de los cinco indultos del paso 1, y tiene
|
||
sustituto real. (El `/init` del initramfs sigue necesitando el de busybox porque ahí no hay
|
||
util-linux, pero eso es el initramfs, no la imagen.)
|
||
- **`sulogin` lo trae `util-linux`**, no shadow. **`partprobe` lo trae `parted`**, no util-linux.
|
||
- **`getty` tiene sustituto: `agetty`, de `util-linux`, que está en los 7 perfiles.** El TSV lo
|
||
daba como `getty-ESCRIBIR`. No es escritura: es cambiar el `exec` de la card
|
||
(`takana-bootstrap/src/lib.rs:240`) y medirlo. Lo que sigue faltando entero es la **shell
|
||
interactiva**, que es otro consumidor — `simi` no trae edición de línea ni historial.
|
||
|
||
## `uutils` no traía 25 de sus 90 — arreglado en la receta
|
||
|
||
El artefacto sellado `b3:9005da83…` instala **78 symlinks**, no los 90 que este documento le
|
||
atribuía, y el propio multicall lo dice sin ambigüedad:
|
||
|
||
```
|
||
$ …-uutils/usr/bin/coreutils chmod --version
|
||
coreutils: unknown program 'chmod'
|
||
```
|
||
|
||
Faltaban exactamente estos 25: `arch chgrp chmod chown chroot groups hostid hostname id install
|
||
kill logname mkfifo mknod nice nohup nproc stat stty sync timeout uname users who whoami`. La causa
|
||
es que `recipes/uutils.toml` construía con las **default features** (`feat_common_core`), y el
|
||
`Cargo.toml` del tag pineado pone el resto detrás de `feat_os_unix_musl` — que cubre **esos 25 y
|
||
ninguno más**. Arreglado con un flag en la receta.
|
||
|
||
⚠ **Esto importaba más de lo que parece:** `USERLAND_COMPONENTS` hidrata `uutils` DESPUÉS de
|
||
busybox para que lo ensombrezca, y **lo que no existe no ensombrece nada**. O sea que en el producto
|
||
de hoy `chmod`, `chown`, `id`, `uname` y `stat` los sigue dando busybox, con el userland Rust
|
||
instalado encima y la métrica diciendo que están cubiertos.
|
||
|
||
⚠ **Lo que el feature NO arregla:** `who`, `users`, `uptime` y `pinky` compilan contra los stubs de
|
||
utmpx de musl y **contestan vacío**. No es regresión (el `who` de busybox en musl está igual de
|
||
ciego), pero tampoco es sustitución.
|
||
|
||
## Qué cambia en el orden de ataque
|
||
|
||
1. **`xz` y `ncurses` necesitan su paquete de herramientas** — variante de receta, no perilla: tocar
|
||
`--disable-xz` re-hashea xz y arrastra a todos sus dependientes. Patrón `elfutils-libdw`.
|
||
2. **Los 6 `ipaddr/iplink/…` salen del defconfig** en el próximo recorte: 277 → 271 sin escribir nada.
|
||
3. **`vi` en la receta de vim** y **`cpio`→`bsdcpio` en libarchive**: un symlink cada uno.
|
||
4. **`getty` deja de ser escritura** y pasa a ser migración de card a `agetty`.
|
||
5. **Falta un guardián nuevo**, hermano del vigía de applets retirados: uno que falle cuando una
|
||
receta declare un perfil y su artefacto **no instale el binario homónimo**. El censo es el
|
||
instrumento; falta cablearlo a CI.
|
||
|
||
## Lo que este censo NO mide
|
||
|
||
- **Equivalencia de nombre, no de banderas.** `nc` de socat cuenta como proveedor aunque su sintaxis
|
||
difiera. La columna `equivalencia` del TSV sigue haciendo falta y sigue diciendo `sin-verificar`
|
||
en 230 filas.
|
||
- **El store del HUB.** Un artefacto sellado sólo en el worker no aparece; `build-state.json` dice
|
||
`sealed` igual. Los 79 hay que releerlos como «no está en el store del hub».
|
||
- **Qué applets EJECUTA el producto de verdad**, que sigue sin medirse y sigue siendo lo que
|
||
abarataría todo lo demás.
|
||
|
||
## Lo hecho con el censo en la mano (2026-09-21, misma tanda)
|
||
|
||
> **El número que baja: 79 → 69 applets que sólo provee busybox**, y ninguno de los diez
|
||
> costó escribir userland.
|
||
|
||
| unidad | estado | sello |
|
||
|---|---|---|
|
||
| `uutils` con `feat_os_unix_musl` | ✅ **79 → 106 applets**, 27 nuevos, 0 perdidos | `b3:614ff653…` |
|
||
| `recipes/xz-tools.toml` — variante con las herramientas | ✅ 23 binarios, estático, round-trip probado | `b3:194c7d69…` |
|
||
| `vi` en `recipes/vim.toml` | ✅ `usr/bin/vi → vim` | `b3:d303ec31…` |
|
||
| `cpio` en `recipes/libarchive.toml` | ✅ `usr/bin/cpio → bsdcpio` | `b3:3f999ff8…` |
|
||
| **paso 2 del plan** — declarar la munición en `targets.toml` | ✅ `uutils findutils findutils-xargs diffutils gzip grep xz-tools` en `base` | — |
|
||
| guardián de regresiones (`--guardian`) | ✅ probado en los dos sentidos | — |
|
||
|
||
Los cuatro artefactos sellaron con **el mismo hash en el store tirable y en `/store`** —dos
|
||
`work_root` distintos, bytes idénticos—, o sea reproducibilidad confirmada de paso, que no era el
|
||
objetivo. Y el reparto del TSV mejoró solo: «el dueño que el TSV nombra sí lo provee» pasó de
|
||
**167 a 194**, porque los 25 que antes cubría el `coreutils` de GNU ahora los da el `uutils` al que
|
||
el TSV se los atribuía.
|
||
|
||
⚠ **`xz-tools` salió DINÁMICO en la primera corrida** —`interpreter /lib/ld-musl-x86_64.so.1`—
|
||
pese al `link = "static"` de la receta, y funcionaba perfectamente: comprimía y descomprimía. Es el
|
||
relink de libtool, que ya documentan `libarchive.toml` y `bluez.toml`: **hace falta
|
||
`LDFLAGS=-all-static` en `compile` Y en `install`**, y sin él el `link` de la receta se ignora en
|
||
silencio. Lo delató mirar el `file` del binario, no una prueba que fallara — [[link-static-mentira-libtool]].
|
||
|
||
**`xz-tools` es el tercer caso de la familia de `zstd-cli`**, y eso ya estaba escrito en
|
||
`targets.toml`: «la receta canónica construye sólo `lib/` y su artefacto no tiene `usr/bin`;
|
||
declararla no habría arreglado nada». Lo mismo pasó con `zlib-shared`, `libffi-shared` y
|
||
`musl-shared`. **Cuatro veces la misma forma**: la receta publica la lib, la imagen declara el
|
||
paquete, y el binario no está. El censo es el primer instrumento que lo ve sin que se rompa nada
|
||
primero.
|
||
|
||
**El guardián vigila pérdidas, no un número**, y esa decisión es el punto:
|
||
|
||
```
|
||
✗ REGRESIÓN: 2 applets PERDIERON su proveedor y vuelven a depender de busybox:
|
||
xz lo proveía xz
|
||
xzcat lo proveía xz
|
||
```
|
||
|
||
Un umbral («no más de 79 sin dueño») hay que subirlo cada vez que se retira un applet, y a la
|
||
tercera nadie lo sube con criterio — [[umbral-a-mano-envejece]]. Una pérdida, en cambio, es siempre
|
||
una regresión. Probado en los dos sentidos, que es lo que hace falta para saber que un guardián
|
||
sirve: `rc=0` contra `/store` completo, `rc=1` contra un store mutilado, nombrando applet y
|
||
proveedor perdido.
|
||
|
||
**Lo que NO se hizo, y por qué:** `ncurses-tools` (para `clear`, `reset`, `tput`) parece el gemelo
|
||
de `xz-tools` y no lo es. La receta canónica instala `install.libs install.includes` y **tampoco
|
||
instala la base de datos terminfo**: una variante con los `progs` obliga a decidir si terminfo entra
|
||
en la imagen (~7 MB) o si basta con los `--with-fallbacks` ya compilados dentro de la lib. Es una
|
||
decisión de producto, no un enlace que faltaba — y se decide, no se hace de paso.
|
||
|
||
### Y el barrido general, con su límite
|
||
|
||
Buscado el mismo patrón en TODO el corpus —recetas declaradas en algún perfil cuyo directorio de
|
||
binarios existe y está VACÍO— salen **dos, y las dos son la familia xz**: `xz` (6 perfiles) y
|
||
`xz-shared` (8 perfiles). O sea que el caso está acotado y `xz-tools` lo cierra.
|
||
|
||
⚠ **Pero el barrido no habría encontrado `ncurses`**, y conviene decir por qué: su receta no deja un
|
||
`usr/bin` vacío, **no crea el directorio**, y eso es indistinguible de una librería legítima — `zlib`
|
||
tampoco trae binarios y está bien así. Sin una declaración por receta de «qué binarios publico», no
|
||
hay señal que separar del ruido. **Lo que da la señal es el TSV de applets**, porque es lo único del
|
||
repo que escribe la EXPECTATIVA (este applet debería venir de este paquete) contra la cual medir.
|
||
Es la misma forma que [[subcomando-sin-driver]]: el problema no se ve mirando el artefacto, se ve
|
||
cruzándolo con lo que alguien prometió que tendría.
|
||
|
||
|
||
## Segunda tanda: las dos piezas que el plan marcaba TRAER (2026-09-21)
|
||
|
||
> **64 applets** sólo-busybox, desde 79. Cinco más, y otra vez sin escribir userland.
|
||
|
||
| receta | applets | sello |
|
||
|---|---|---|
|
||
| `recipes/psmisc.toml` (23.7) | `killall pstree fuser` (+`prtstat pslog`) | `b3:b361bd04…` |
|
||
| `recipes/bc.toml` (gavinhoward/bc 7.0.3) | `bc dc` | `b3:050efb84…` |
|
||
|
||
**`bc` es gavinhoward/bc y no el de GNU**, y la razón es de cadena: C99 sin dependencias, `bc` **y**
|
||
`dc` del mismo árbol, y es el bc de sistema de Alpine y FreeBSD. El de GNU arrastra `ed` y `flex`
|
||
como build-deps y su `dc` va en otro paquete.
|
||
|
||
⚠ **`killall5` NO lo trae psmisc** — es de sysvinit. Acá lo cubre `arje-zero` por FUNCIÓN (es PID 1
|
||
y apaga él), así que el applet se retira con los indultos en vez de reemplazarse. Son 3 de los 4 que
|
||
la tabla prometía, y decirlo así evita que vuelva a prometer lo que no hay.
|
||
|
||
### Los dos pisones de psmisc, que enseñan más que el resultado
|
||
|
||
**1 · `-lncurses` no existe en este corpus, y hay dos formas de no arreglarlo.** El `configure` de
|
||
psmisc busca `tgetent` por NOMBRE de librería (`-ltinfo`, `-lncurses`, `-ltermcap`) y la nuestra es
|
||
`--enable-widec`, o sea `libncursesw.a`. Falló primero con `LIBS=-lncursesw` —el `AC_CHECK_LIB` no
|
||
pregunta si el símbolo resuelve, **compila un programa de prueba con `-lncurses`**— y falló después
|
||
con `ac_cv_lib_ncurses_tgetent=yes` + `make TERMCAP_LIB=-lncursesw`, porque
|
||
`src_pstree_LDADD = @TERMCAP_LIB@` se sustituye **en tiempo de configure** y el Makefile queda con
|
||
`-lncurses` literal, no con una referencia a la variable. Lo que funciona es darle el nombre que
|
||
busca: un enlace `libncurses.a → libncursesw.a` en el scratch y un `-L` a ese directorio.
|
||
|
||
**2 · Y ese `-L` se comió el `-static`.** Primera versión: `LDFLAGS="-L$PWD/.compat-ncurses"`. El
|
||
harness inyecta `LDFLAGS=-static` para las recetas `link = "static"`
|
||
(`takana-build/src/lib.rs:285`), así que reemplazarlo lo pierde. **Los seis binarios sellaron
|
||
DINÁMICOS y nada falló**: `killall --version` contestaba bien, y sólo `pstree` —el único que enlaza
|
||
ncurses— moría en ejecución con `Error relocating /lib/libncursesw.so.6`, tomando la librería del
|
||
anfitrión. Un `file` sobre los binarios lo dice en un segundo; ninguna prueba del build lo iba a
|
||
decir. Es [[cgo-sin-baseline-sigill]] otra vez: **redefinir una variable que el harness ya puebla
|
||
pierde en silencio lo que el harness había puesto**, y el síntoma aparece lejos.
|