#!/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)(? 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)') 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())