Files
takana/scripts/busybox-vigia.py
SergioandClaude Opus 5 b808012962 recorte 2 HECHO: 277 → 262 applets, y el sandbox sigue construyendo
busybox b3:4bd934f6 (era a24fdbf6), 961.632 bytes. Los 15 fuera, los indultados
dentro (setuidgid switch_root poweroff halt reboot adduser, más sh/ash/getty/ip),
vigía en verde con 0 invocaciones, y mismo hash en el store tirable y en /store.

La prueba que importaba: zlib-ng, jq y logrotate reconstruidas y selladas CON el
busybox recortado, con contenido real. El sandbox no echa de menos ninguno de los
15.

El censo baja de 62 a 47: los 15 eran todos applets que sólo proveía busybox, así
que el recorte se los lleva enteros del contador.

La columna del TSV deja de llevar fecha. Era `recorte_2026_09_21`, y al segundo
recorte se ve que no escala: ahora son `recorte` (sigue/RETIRADO) y `retirado_el`
(la fecha). Una columna por tanda convierte el fichero en un registro de tandas
en vez de un estado. Actualizados los dos scripts que la leen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:47:35 +00:00

302 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Vigía del recorte de busybox (docs/plan-botar-busybox.md, paso 1).
Falla si alguna receta, script o card del repo INVOCA un applet que la receta de busybox
ya no construye. Sin esto el recorte se deshace solo: nada falla al quitar un applet —
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.
Así no hay dos listas que se desincronicen.
"""
import re, sys, pathlib, json
RAIZ = pathlib.Path(__file__).resolve().parent.parent
EXT = {'.sh', '.rs', '.toml', '.py'}
SALTAR = ('target/', '.git/', 'store/', 'work/', 'docs/')
# Un applet en posición de COMANDO: inicio de línea, o tras | ; & ` $( exec sudo && ||
PRE = r'(?:^|[|;&`]|\$\(|\bexec\s+|\bsudo\s+|&&\s*|\|\|\s*)[ \t]*'
POST = r'(?=[ \t;|&)`"\']|$)'
def simbolos_retirados():
"""Los símbolos Kconfig que la receta apaga: la lista RETIRADOS de su fase configure,
más cualquier `# CONFIG_X is not set` suelto (el recorte de `tc`, por ejemplo)."""
txt = (RAIZ / 'recipes' / 'busybox.toml').read_text()
syms = set(re.findall(r'#\s*CONFIG_([A-Z0-9_]+)\s+is\s+not\s+set', txt))
for lista in re.findall(r"RETIRADOS='([^']*)'", txt):
syms |= set(lista.split())
return syms
def applets_retirados():
"""Applets cuyo símbolo Kconfig la receta apaga.
El mapa applet->símbolo NO se deriva por mayúsculas: sale de la columna
`simbolo_kconfig` del TSV, extraída de las líneas `//applet:` del fuente de busybox.
Derivarlo estaría MAL en 6 casos medidos: `[`->TEST1, `[[`->TEST2, arch->BB_ARCH,
nbd-client->NBDCLIENT, sh->SH_IS_ASH, sysctl->BB_SYSCTL."""
syms = simbolos_retirados()
if not syms:
return set()
tsv = RAIZ / 'docs' / 'state' / 'busybox-applets.tsv'
filas = [l.split('\t') for l in tsv.read_text().splitlines()[1:] if l.strip()]
return {f[0] for f in filas if len(f) > 5 and f[5] in syms}
# Palabras del shell, no applets invocables: `[` y `[[` aparecen en todo script bash del HUB
# (que corre con el bash del hub, no con busybox) y no son llamadas a un applet.
NO_VIGILAR = {'[', '[['}
# Una cadena de Rust/Python sólo se mira si PARECE una línea de comandos. Sin esto, los literales
# sueltos ("init" como nombre de capacidad, "tree" como variable) ahogan al vigía: 91 hits, 0 reales.
PARECE_COMANDO = re.compile(r'(?:\bsh\s+-c\b|/bin/|/usr/bin/|/sbin/|&&|\|\||\$\()')
def sin_comentarios(txt, suf):
"""Quita comentarios y, en los lenguajes que no son shell, deja sólo las cadenas que
parecen una línea de comandos. Es el 90 % del ruido."""
if suf in ('.sh', '.toml'):
return re.sub(r'(?m)(?<!\\)#.*$', '', txt)
if suf in ('.rs', '.py'):
if suf == '.rs':
txt = re.sub(r'(?m)//.*$', '', txt)
else:
txt = re.sub(r'(?m)(?<!\\)#.*$', '', txt)
cad = re.findall(r'"((?:[^"\\\n]|\\.)*)"', txt) + re.findall(r"'((?:[^'\\\n]|\\.)*)'", txt)
return '\n'.join(c for c in cad if PARECE_COMANDO.search(c))
return txt
def sin_grub_cfg(txt):
"""Blanquea las regiones de heredoc que escriben un `grub.cfg`.
⚠ Quinta regla anti-ruido, y la destapó el recorte de los 15: **`insmod` es también una
directiva de GRUB**. `insmod part_gpt`, `insmod iso9660` y `insmod ext2` dentro de un
`cat > .../grub.cfg <<CFG … CFG` los lee GRUB en el arranque, no el applet de busybox — que
para entonces ni existe. Sin esta regla, retirar el applet `insmod` daba 6 hits, los 6 falsos,
en `iso-image.sh`, `install-image.sh` y `takana-live-install.sh`.
Se blanquea por REGIÓN y no por nombre de módulo: una lista de módulos de GRUB envejece, y el
día que alguien escriba `insmod gzio` volvería el falso positivo.
"""
lineas = txt.splitlines(keepends=True)
salida = []
delim = None
for ln in lineas:
if delim is None:
m = re.search(r'>\s*"?[^"\s]*grub\.cfg"?\s*<<-?\s*[\'"]?([A-Za-z_][A-Za-z0-9_]*)', ln)
if m:
delim = m.group(1)
salida.append(ln)
continue
salida.append(ln)
else:
salida.append('\n') # la línea se blanquea pero el conteo no se desplaza
if ln.strip() == delim:
delim = None
return ''.join(salida)
def funciones_definidas(txt):
"""Nombres definidos como función de shell EN ESE fichero: `ts() {`, `function ts {`."""
return set(re.findall(r'(?m)^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_-]*)\s*\(\)\s*\{', 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')
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
if f.suffix == '.sh':
crudo = sin_grub_cfg(crudo)
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)(?<!\\)#.*$', '', crudo)
else:
crudo_sc = crudo
sc_lineas = crudo_sc.splitlines()
for m2 in list(rx_ruta.finditer(crudo_sc)) + list(rx_argv.finditer(crudo_sc)):
n = crudo_sc[:m2.start()].count('\n') + 1
hits.append((rel, n, m2.group(1), sc_lineas[n - 1].strip()[:100] if n <= len(sc_lineas) else ''))
for m in rx.finditer(txt):
ap = m.group(1)
if ap in propias:
continue
antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
if antes.endswith('|') and despues == ')':
continue
ini = txt.rfind('\n', 0, m.start(1)) + 1
fin = txt.find('\n', m.end(1))
if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 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 — puede llamar a un helper de userspace sin que ningún grep lo vea.')
print(' ⚠ Acá NO: los cuatro kernels de takana traen `# CONFIG_MODULES is not set`, CERO')
print(' drivers `=m` y ninguna receta hace `modules_install` (medido 2026-09-22). Así que')
print(' modprobe/lsmod/depmod/rmmod/modinfo NO tienen consumidor, y con DEVTMPFS_MOUNT=y y')
print(' sin UEVENT_HELPER tampoco lo tienen mdev/makedevs/uevent. La advertencia sigue')
print(' valiendo como forma —un helper del kernel no aparece en un grep—, pero en ESTE')
print(' producto esos nueve son retirables, no escritura pendiente.')
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)')
return 0
if not retirados:
print('busybox-vigia: la receta no retira ningún applet — nada que vigilar')
return 0
rx = re.compile(PRE + '(' + '|'.join(re.escape(a) for a in
sorted(retirados, key=len, reverse=True)) + ')' + POST, re.M)
fallos = []
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
try:
crudo = f.read_text(errors='ignore')
except OSError:
continue
if len(crudo) > 900_000:
continue
if f.suffix == '.sh':
crudo = sin_grub_cfg(crudo)
propias = funciones_definidas(crudo)
txt = sin_comentarios(crudo, f.suffix)
lineas = None
for m in rx.finditer(txt):
ap = m.group(1)
if ap in propias: # una función local del script, no el applet
continue
antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
# Patrón de `case`, no invocación: `proc|sys|dev|init) continue ;;`
if antes.endswith('|') and despues == ')':
continue
# Comprobación explícita de existencia: quien escribe `command -v X` ya tolera que falte.
ini = txt.rfind('\n', 0, m.start(1)) + 1
fin = txt.find('\n', m.end(1))
if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
continue
if lineas is None:
lineas = txt.splitlines()
n = txt[:m.start()].count('\n') + 1
fallos.append((rel, n, ap, lineas[n - 1].strip()[:100]))
print(f'busybox-vigia: {len(retirados)} applets retirados de la receta')
if not fallos:
print('busybox-vigia: OK — nadie los invoca')
return 0
print(f'busybox-vigia: FALLO — {len(fallos)} invocaciones de applets retirados\n')
for rel, n, ap, linea in fallos:
print(f' {rel}:{n} [{ap}] {linea}')
print('\nO se indulta el applet en la receta, o se migra el llamador.')
return 1
if __name__ == '__main__':
sys.exit(main())