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