Es lo que el plan dejó sin medir tres veces («no se midió qué applets usa el
producto de verdad; eso abarataría el paso 1»). El modo lee del TSV los applets
con proveedor_medido = SOLO-BUSYBOX y los busca en el repo con las reglas
anti-ruido que el vigía ya tenía.
12 invocados: sh (28 sitios, lo cierra simi), ifconfig/route/udhcpc/netstat
(todos con ip/ss como primera opción y el applet como respaldo), setuidgid (3
cards: gitea, pacha-secretos, tejido), getty (la card de la consola), adduser,
nc, nslookup, poweroff. Y uno que es falso positivo del grep y conviene tener
escrito: los `insmod` de iso-image.sh/install-image.sh son de GRUB —`insmod
part_gpt` dentro de un grub.cfg—, no del busybox.
Los otros 52 no los llama nadie EN EL REPO, y la salida lo dice sin dejar que se
lea como una lista de borrado: un grep ve a quien invoca por nombre, y hay dos
consumidores 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 clear/reset/vi/traceroute al entrar por ssh.
La primera versión del modo decía 10 y estaba mal en dos casos, los dos del lado
peligroso — dan permiso para retirar algo que se usa:
· setuidgid se invoca como `exec /usr/bin/setuidgid pacha`, con RUTA ABSOLUTA,
y el patrón de comando pide el nombre pegado al exec.
· getty vive como `"argv": ["getty", …]`, elemento de un array JSON, y la regla
que filtra cadenas por «parece una línea de comandos» —la que bajó el ruido
de 91 hits a 0— descarta un nombre suelto.
Las reglas que quitan ruido son las que crean ceguera; lo que decide es hacia
dónde se equivoca cada una. Ahora busca tres formas: nombre desnudo, ruta
absoluta y primer elemento de argv. El vigía sigue verde en su modo original
(123 applets retirados, nadie los invoca) y se salta su propio fichero, que
nombra applets en la documentación y se denunciaba a sí mismo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
12 KiB
Python
Executable File
264 lines
12 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 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_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)(?<!\\)#.*$', '', 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 — 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)')
|
|
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
|
|
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())
|