diff --git a/docs/plan-botar-busybox.md b/docs/plan-botar-busybox.md index de2f8d3c..57234d36 100644 --- a/docs/plan-botar-busybox.md +++ b/docs/plan-botar-busybox.md @@ -1252,3 +1252,48 @@ ncurses— moría en ejecución con `Error relocating /lib/libncursesw.so.6`, to anfitrión. Un `file` sobre los binarios lo dice en un segundo; ninguna prueba del build lo iba a decir. Es [[cgo-sin-baseline-sigill]] otra vez: **redefinir una variable que el harness ya puebla pierde en silencio lo que el harness había puesto**, y el síntoma aparece lejos. + +## Quién usa lo que queda — `busybox-vigia.py --uso` (2026-09-21) + +Esto es **lo que el documento dejó sin medir tres veces**: «no se midió qué applets usa el producto +de verdad; eso abarataría el paso 1». Ahora se mide. El modo nuevo lee del TSV los applets con +`proveedor_medido = SOLO-BUSYBOX` y los busca en el repo con las reglas anti-ruido del vigía. + +De los **64**, sólo **12 tienen un llamador dentro del producto**: + +| applet | quién lo llama | +|---|---| +| `sh` | 28 sitios — el `/bin/sh` de todo; lo cierra `simi` | +| `ifconfig` `route` `udhcpc` `netstat` | `metal-iso.sh`, `cosmic/metal-desktop-image.sh`, `test-foco-egress.sh`, `mudanza/censar.py` — **todos con `ip`/`ss` como primera opción y el applet como respaldo** (`ip link set … \|\| ifconfig …`) | +| `insmod` | `iso-image.sh`, `install-image.sh` — ⚠ **es el `insmod` de GRUB**, no el de busybox: `insmod part_gpt` dentro de un `grub.cfg`. Falso positivo del grep, no migración | +| `setuidgid` | `gitea`, `pacha-secretos`, `tejido` — tres cards | +| `getty` | la card de la consola (`ssh-e2e-test.sh`, `takana-bootstrap`) | +| `adduser` | `servidor/alta-usuario.sh` | +| `nc` | `test-foco-egress.sh` | +| `nslookup` | `drive-netup.py` | +| `poweroff` | `farm/harkaq-campana-vol.sh` | + +**Los otros 52 no los invoca nadie en el repo.** Y ahí está la trampa, escrita en la salida del +propio comando para que no se lea como una lista de borrado: + +> **«Candidato» no es «retirable».** Un grep ve a quien invoca POR NOMBRE. Hay dos clases de +> consumidor que no aparecen nunca: **el kernel** —llama a `/sbin/modprobe` por +> `call_usermodehelper` y al gestor de `/dev` por hotplug, así que `modprobe lsmod depmod rmmod +> modinfo mdev uevent makedevs` salen «libres» y no lo son— y **el usuario de la máquina**, que +> espera encontrar `clear`, `reset`, `vi` o `traceroute` al entrar por ssh. + +### Y los dos falsos negativos que tuvo el modo, que son la parte que enseña + +La primera versión decía **10 invocados** y estaba mal en dos casos, los dos peligrosos porque +llevan a retirar un applet que el producto usa: + +1. **`setuidgid`** — se invoca como `exec /usr/bin/setuidgid pacha`, con **ruta absoluta**. El + patrón de comando pide el nombre pegado al `exec`, y ahí hay un `/usr/bin/` en medio. +2. **`getty`** — vive como `"argv": ["getty", "-n", …]`, **elemento de un array JSON**. La regla que + filtra cadenas de Rust/TOML por «parece una línea de comandos» —la que en su día bajó el ruido de + 91 hits a 0— descarta una cadena que es sólo un nombre. + +Las dos reglas que quitan ruido son las mismas que crean ceguera, y la diferencia es hacia dónde se +equivoca cada una. En el modo original —«¿alguien llama a algo que ya retiré?»— un falso positivo +cuesta un minuto y un falso negativo cuesta un boot roto. En este modo pasa igual, así que ahora +busca **tres formas**: nombre desnudo, ruta absoluta y primer elemento de `argv`. diff --git a/scripts/busybox-vigia.py b/scripts/busybox-vigia.py index f978322e..4291dff5 100755 --- a/scripts/busybox-vigia.py +++ b/scripts/busybox-vigia.py @@ -7,6 +7,17 @@ el fallo aparece en boot, en otra máquina, semanas después. scripts/busybox-vigia.py # exit 1 si hay invocaciones de applets retirados scripts/busybox-vigia.py --list # imprime los applets retirados y sale 0 + scripts/busybox-vigia.py --uso # quién invoca los applets que SÓLO provee busybox + +`--uso` responde la pregunta que el plan dejó abierta tres veces: **de lo que todavía sólo da +busybox, ¿qué usa el producto de verdad?** Lee los applets con `proveedor_medido = SOLO-BUSYBOX` +del TSV y los busca en el repo con las mismas cuatro reglas anti-ruido del vigía. Lo que no invoca +nadie es candidato a salir del defconfig sin escribir ni migrar nada; lo que sí, es una migración +con nombre y número de línea. + +⚠ Mira ESTE repo, que es donde vive el producto (cards, guiones de imagen, instalador). NO ve los +`configure` de terceros que corren dentro del sandbox de build: para ésos el consumidor es el +frente A, y el criterio es otro. La lista de retirados se LEE de recipes/busybox.toml (los `# CONFIG_X is not set` que el recorte inyecta), cruzada con el mapa applet->símbolo de docs/state/busybox-applets.tsv. @@ -79,7 +90,122 @@ def funciones_definidas(txt): set(re.findall(r'(?m)^\s*function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*\{', txt)) +def solo_busybox(): + """Applets vivos cuyo `proveedor_medido` (del censo) dice que no los provee nadie más.""" + tsv = (RAIZ / 'docs' / 'state' / 'busybox-applets.tsv').read_text().splitlines() + cab = tsv[0].split('\t') + i_ap, i_prov = cab.index('applet'), cab.index('proveedor_medido') + i_rec = cab.index('recorte_2026_09_21') + out = set() + for ln in tsv[1:]: + c = ln.split('\t') + if len(c) > max(i_ap, i_prov, i_rec) and c[i_rec] != 'RETIRADO' and c[i_prov] == 'SOLO-BUSYBOX': + out.add(c[i_ap]) + return out + + +def escanear(applets): + """[(fichero, línea, applet, texto)] de cada invocación en el repo. + + Busca TRES formas, y las tres hacen falta — con sólo la primera este modo dio dos falsos + negativos MEDIDOS, y un falso negativo acá significa retirar un applet que el producto usa: + 1. nombre desnudo en posición de comando `killall foo` + 2. RUTA ABSOLUTA `exec /usr/bin/setuidgid pacha` — 3 recetas + 3. primer elemento de un `argv` JSON `"argv": ["getty", "-n", ...]` — la card de la + consola, takana-bootstrap/src/lib.rs:241 + La 2 se escapaba porque el patrón de comando pide el nombre pegado al `exec` y ahí hay un + `/usr/bin/` en medio; la 3 porque una cadena JSON suelta no parece una línea de comandos. + """ + alt = '|'.join(re.escape(a) for a in sorted(applets, key=len, reverse=True)) + rx = re.compile(PRE + '(' + alt + ')' + POST, re.M) + rx_ruta = re.compile(r'(?:/usr/bin/|/bin/|/usr/sbin/|/sbin/)(' + alt + r')\b') + rx_argv = re.compile(r'(?:argv|exec)["\s:=]+\[?\s*"(' + alt + r')"') + hits = [] + for f in RAIZ.rglob('*'): + if not f.is_file() or f.suffix not in EXT: + continue + rel = str(f.relative_to(RAIZ)) + if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR): + continue + # El propio vigía nombra applets en su documentación y en sus regex: se salta a sí mismo, + # o se denuncia en cada corrida y el ruido lo vuelve inservible. + if f.resolve() == pathlib.Path(__file__).resolve(): + continue + try: + crudo = f.read_text(errors='ignore') + except OSError: + continue + if len(crudo) > 900_000: + continue + propias = funciones_definidas(crudo) + txt = sin_comentarios(crudo, f.suffix) + lineas = None + # Las formas 2 y 3 se buscan sobre el texto sin el filtro de «parece una línea de comandos»: + # una ruta absoluta y un `argv` ya son invocaciones por sí mismas. + if f.suffix == '.rs': + crudo_sc = re.sub(r'(?m)//.*$', '', crudo) + elif f.suffix in ('.sh', '.toml', '.py'): + crudo_sc = re.sub(r'(?m)(? 0 else len(txt)]): + continue + if lineas is None: + lineas = txt.splitlines() + n = txt[:m.start()].count('\n') + 1 + hits.append((rel, n, ap, lineas[n - 1].strip()[:100])) + return hits + + +def modo_uso(): + applets = solo_busybox() - NO_VIGILAR + if not applets: + print('busybox-vigia --uso: el censo no marca ningún applet como SOLO-BUSYBOX') + return 0 + hits = escanear(applets) + usados = {} + for rel, n, ap, linea in hits: + usados.setdefault(ap, []).append((rel, n, linea)) + libres = sorted(applets - set(usados)) + print(f'busybox-vigia --uso: {len(applets)} applets que hoy SÓLO provee busybox\n') + print(f'── {len(usados)} INVOCADOS en el repo — cada uno es una migración, no un borrado ──') + for ap in sorted(usados): + print(f' {ap}') + for rel, n, linea in usados[ap][:4]: + print(f' {rel}:{n} {linea}') + if len(usados[ap]) > 4: + print(f' … y {len(usados[ap]) - 4} más') + print(f'\n── {len(libres)} SIN NINGUNA invocación — candidatos al defconfig ──') + print(' ' + ' '.join(libres)) + print() + print('\u26a0 \u00abCandidato\u00bb NO es \u00abretirable\u00bb. Un grep ve a quien INVOCA por nombre, y hay dos clases de') + print(' consumidor que no aparecen NUNCA y que rompen el producto si se les quita el applet:') + print(' \u00b7 el KERNEL — llama a /sbin/modprobe por call_usermodehelper, y al gestor de /dev por') + print(' hotplug. modprobe lsmod depmod rmmod modinfo mdev uevent makedevs salen \u00ablibres\u00bb acá') + print(' y no lo son: es el frente kmod, que es escritura pendiente, no un borrado.') + print(' \u00b7 el USUARIO de la máquina — clear, reset, vi, traceroute no los invoca ningún guión del') + print(' repo y son la CLI que alguien espera encontrar al entrar por ssh.') + print(' Lo que esta lista sí dice es cuáles no tienen consumidor DENTRO del producto: es el primer') + print(' filtro, no el último.') + return 0 + + def main(): + if '--uso' in sys.argv: + return modo_uso() retirados = applets_retirados() - NO_VIGILAR if '--list' in sys.argv: print('\n'.join(sorted(retirados)) or '(la receta no retira ningún applet)')