busybox recortado: 401 -> 277 applets, con vigía y cinco indultos medidos
Paso 1 de docs/plan-botar-busybox.md. 124 applets fuera del defconfig: los 105 que no se reemplazan (dpkg, rpm, httpd, telnetd, ubi*, nand*, i2c*) y 19 cuya función ya hace otro componente (init/runit -> arje-zero, syslogd/klogd/logread -> hammerd). Binario de 1.230.976 a 977.096 bytes. CORRIGE UN ERROR DEL PLAN: arje-zero y hammerd reemplazan la FUNCIÓN, no el COMANDO -- son un binario cada uno. Por eso 5 indultos: setuidgid (la card de gitea lo ejecuta), switch_root (el /init del instalador, antes de que arje exista) y poweroff/halt/reboot (nada más en el corpus apaga la máquina). scripts/busybox-vigia.py falla si alguien invoca un applet retirado; lee la lista de la propia receta para que no haya dos que se desincronicen. Su primera versión daba 91 hits y ninguno real: las cuatro reglas que lo bajaron a 0 están documentadas. El mapa applet->símbolo sale de las líneas //applet: del fuente, no de poner el nombre en mayúsculas: eso falla en 6 casos medidos, dos de ellos en la lista. Verificado: 277 applets exactos, los 5 indultados presentes, y zlib-ng, jq y logrotate reconstruidas y selladas con el busybox recortado -- el sandbox sigue en pie. Son 3 de las 24 dependientes, no las 24, y el boot de la imagen no se probó. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/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
|
||||
|
||||
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 main():
|
||||
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())
|
||||
Reference in New Issue
Block a user