tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ 40e9f4a12b5efc8007a2e0a3e8476cc5ac5cd1c8 scripts/busybox-vigia.py Python · 301 líneas · 14.1 KB Historia Quién tocó qué crudo

recorte 2: los 15 applets, el símbolo que el TSV no sabía y el insmod de GRUB 80cfadd24 · Sergio ·

1#!/usr/bin/env python3
2"""Vigía del recorte de busybox (docs/plan-botar-busybox.md, paso 1).
3
4Falla si alguna receta, script o card del repo INVOCA un applet que la receta de busybox
5ya no construye. Sin esto el recorte se deshace solo: nada falla al quitar un applet —
6el fallo aparece en boot, en otra máquina, semanas después.
7
8    scripts/busybox-vigia.py            # exit 1 si hay invocaciones de applets retirados
9    scripts/busybox-vigia.py --list     # imprime los applets retirados y sale 0
10    scripts/busybox-vigia.py --uso      # quién invoca los applets que SÓLO provee busybox
11
12`--uso` responde la pregunta que el plan dejó abierta tres veces: **de lo que todavía sólo da
13busybox, ¿qué usa el producto de verdad?** Lee los applets con `proveedor_medido = SOLO-BUSYBOX`
14del TSV y los busca en el repo con las mismas cuatro reglas anti-ruido del vigía. Lo que no invoca
15nadie es candidato a salir del defconfig sin escribir ni migrar nada; lo que sí, es una migración
16con nombre y número de línea.
17
18⚠ Mira ESTE repo, que es donde vive el producto (cards, guiones de imagen, instalador). NO ve los
19`configure` de terceros que corren dentro del sandbox de build: para ésos el consumidor es el
20frente A, y el criterio es otro.
21
22La lista de retirados se LEE de recipes/busybox.toml (los `# CONFIG_X is not set` que el
23recorte inyecta), cruzada con el mapa applet->símbolo de docs/state/busybox-applets.tsv.
24Así no hay dos listas que se desincronicen.
25"""
26import re, sys, pathlib, json
27
28RAIZ = pathlib.Path(__file__).resolve().parent.parent
29EXT = {'.sh', '.rs', '.toml', '.py'}
30SALTAR = ('target/', '.git/', 'store/', 'work/', 'docs/')
31
32# Un applet en posición de COMANDO: inicio de línea, o tras | ; & ` $( exec sudo && ||
33PRE = r'(?:^|[|;&`]|\$\(|\bexec\s+|\bsudo\s+|&&\s*|\|\|\s*)[ \t]*'
34POST = r'(?=[ \t;|&)`"\']|$)'
35
36
37def simbolos_retirados():
38    """Los símbolos Kconfig que la receta apaga: la lista RETIRADOS de su fase configure,
39    más cualquier `# CONFIG_X is not set` suelto (el recorte de `tc`, por ejemplo)."""
40    txt = (RAIZ / 'recipes' / 'busybox.toml').read_text()
41    syms = set(re.findall(r'#\s*CONFIG_([A-Z0-9_]+)\s+is\s+not\s+set', txt))
42    for lista in re.findall(r"RETIRADOS='([^']*)'", txt):
43        syms |= set(lista.split())
44    return syms
45
46
47def applets_retirados():
48    """Applets cuyo símbolo Kconfig la receta apaga.
49
50    El mapa applet->símbolo NO se deriva por mayúsculas: sale de la columna
51    `simbolo_kconfig` del TSV, extraída de las líneas `//applet:` del fuente de busybox.
52    Derivarlo estaría MAL en 6 casos medidos: `[`->TEST1, `[[`->TEST2, arch->BB_ARCH,
53    nbd-client->NBDCLIENT, sh->SH_IS_ASH, sysctl->BB_SYSCTL."""
54    syms = simbolos_retirados()
55    if not syms:
56        return set()
57    tsv = RAIZ / 'docs' / 'state' / 'busybox-applets.tsv'
58    filas = [l.split('\t') for l in tsv.read_text().splitlines()[1:] if l.strip()]
59    return {f[0] for f in filas if len(f) > 5 and f[5] in syms}
60
61
62# Palabras del shell, no applets invocables: `[` y `[[` aparecen en todo script bash del HUB
63# (que corre con el bash del hub, no con busybox) y no son llamadas a un applet.
64NO_VIGILAR = {'[', '[['}
65
66
67# Una cadena de Rust/Python sólo se mira si PARECE una línea de comandos. Sin esto, los literales
68# sueltos ("init" como nombre de capacidad, "tree" como variable) ahogan al vigía: 91 hits, 0 reales.
69PARECE_COMANDO = re.compile(r'(?:\bsh\s+-c\b|/bin/|/usr/bin/|/sbin/|&&|\|\||\$\()')
70
71
72def sin_comentarios(txt, suf):
73    """Quita comentarios y, en los lenguajes que no son shell, deja sólo las cadenas que
74    parecen una línea de comandos. Es el 90 % del ruido."""
75    if suf in ('.sh', '.toml'):
76        return re.sub(r'(?m)(?<!\\)#.*$', '', txt)
77    if suf in ('.rs', '.py'):
78        if suf == '.rs':
79            txt = re.sub(r'(?m)//.*$', '', txt)
80        else:
81            txt = re.sub(r'(?m)(?<!\\)#.*$', '', txt)
82        cad = re.findall(r'"((?:[^"\\\n]|\\.)*)"', txt) + re.findall(r"'((?:[^'\\\n]|\\.)*)'", txt)
83        return '\n'.join(c for c in cad if PARECE_COMANDO.search(c))
84    return txt
85
86
87def sin_grub_cfg(txt):
88    """Blanquea las regiones de heredoc que escriben un `grub.cfg`.
89
90    ⚠ Quinta regla anti-ruido, y la destapó el recorte de los 15: **`insmod` es también una
91    directiva de GRUB**. `insmod part_gpt`, `insmod iso9660` y `insmod ext2` dentro de un
92    `cat > .../grub.cfg <<CFG … CFG` los lee GRUB en el arranque, no el applet de busybox — que
93    para entonces ni existe. Sin esta regla, retirar el applet `insmod` daba 6 hits, los 6 falsos,
94    en `iso-image.sh`, `install-image.sh` y `takana-live-install.sh`.
95
96    Se blanquea por REGIÓN y no por nombre de módulo: una lista de módulos de GRUB envejece, y el
97    día que alguien escriba `insmod gzio` volvería el falso positivo.
98    """
99    lineas = txt.splitlines(keepends=True)
100    salida = []
101    delim = None
102    for ln in lineas:
103        if delim is None:
104            m = re.search(r'>\s*"?[^"\s]*grub\.cfg"?\s*<<-?\s*[\'"]?([A-Za-z_][A-Za-z0-9_]*)', ln)
105            if m:
106                delim = m.group(1)
107                salida.append(ln)
108                continue
109            salida.append(ln)
110        else:
111            salida.append('\n')            # la línea se blanquea pero el conteo no se desplaza
112            if ln.strip() == delim:
113                delim = None
114    return ''.join(salida)
115
116
117def funciones_definidas(txt):
118    """Nombres definidos como función de shell EN ESE fichero: `ts() {`, `function ts {`."""
119    return set(re.findall(r'(?m)^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_-]*)\s*\(\)\s*\{', txt)) | \
120           set(re.findall(r'(?m)^\s*function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*\{', txt))
121
122
123def solo_busybox():
124    """Applets vivos cuyo `proveedor_medido` (del censo) dice que no los provee nadie más."""
125    tsv = (RAIZ / 'docs' / 'state' / 'busybox-applets.tsv').read_text().splitlines()
126    cab = tsv[0].split('\t')
127    i_ap, i_prov = cab.index('applet'), cab.index('proveedor_medido')
128    i_rec = cab.index('recorte_2026_09_21')
129    out = set()
130    for ln in tsv[1:]:
131        c = ln.split('\t')
132        if len(c) > max(i_ap, i_prov, i_rec) and c[i_rec] != 'RETIRADO' and c[i_prov] == 'SOLO-BUSYBOX':
133            out.add(c[i_ap])
134    return out
135
136
137def escanear(applets):
138    """[(fichero, línea, applet, texto)] de cada invocación en el repo.
139
140    Busca TRES formas, y las tres hacen falta — con sólo la primera este modo dio dos falsos
141    negativos MEDIDOS, y un falso negativo acá significa retirar un applet que el producto usa:
142      1. nombre desnudo en posición de comando   `killall foo`
143      2. RUTA ABSOLUTA                           `exec /usr/bin/setuidgid pacha` — 3 recetas
144      3. primer elemento de un `argv` JSON       `"argv": ["getty", "-n", ...]` — la card de la
145                                                 consola, takana-bootstrap/src/lib.rs:241
146    La 2 se escapaba porque el patrón de comando pide el nombre pegado al `exec` y ahí hay un
147    `/usr/bin/` en medio; la 3 porque una cadena JSON suelta no parece una línea de comandos.
148    """
149    alt = '|'.join(re.escape(a) for a in sorted(applets, key=len, reverse=True))
150    rx = re.compile(PRE + '(' + alt + ')' + POST, re.M)
151    rx_ruta = re.compile(r'(?:/usr/bin/|/bin/|/usr/sbin/|/sbin/)(' + alt + r')\b')
152    rx_argv = re.compile(r'(?:argv|exec)["\s:=]+\[?\s*"(' + alt + r')"')
153    hits = []
154    for f in RAIZ.rglob('*'):
155        if not f.is_file() or f.suffix not in EXT:
156            continue
157        rel = str(f.relative_to(RAIZ))
158        if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
159            continue
160        # El propio vigía nombra applets en su documentación y en sus regex: se salta a sí mismo,
161        # o se denuncia en cada corrida y el ruido lo vuelve inservible.
162        if f.resolve() == pathlib.Path(__file__).resolve():
163            continue
164        try:
165            crudo = f.read_text(errors='ignore')
166        except OSError:
167            continue
168        if len(crudo) > 900_000:
169            continue
170        if f.suffix == '.sh':
171            crudo = sin_grub_cfg(crudo)
172        propias = funciones_definidas(crudo)
173        txt = sin_comentarios(crudo, f.suffix)
174        lineas = None
175        # Las formas 2 y 3 se buscan sobre el texto sin el filtro de «parece una línea de comandos»:
176        # una ruta absoluta y un `argv` ya son invocaciones por sí mismas.
177        if f.suffix == '.rs':
178            crudo_sc = re.sub(r'(?m)//.*$', '', crudo)
179        elif f.suffix in ('.sh', '.toml', '.py'):
180            crudo_sc = re.sub(r'(?m)(?<!\\)#.*$', '', crudo)
181        else:
182            crudo_sc = crudo
183        sc_lineas = crudo_sc.splitlines()
184        for m2 in list(rx_ruta.finditer(crudo_sc)) + list(rx_argv.finditer(crudo_sc)):
185            n = crudo_sc[:m2.start()].count('\n') + 1
186            hits.append((rel, n, m2.group(1), sc_lineas[n - 1].strip()[:100] if n <= len(sc_lineas) else ''))
187        for m in rx.finditer(txt):
188            ap = m.group(1)
189            if ap in propias:
190                continue
191            antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
192            if antes.endswith('|') and despues == ')':
193                continue
194            ini = txt.rfind('\n', 0, m.start(1)) + 1
195            fin = txt.find('\n', m.end(1))
196            if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
197                continue
198            if lineas is None:
199                lineas = txt.splitlines()
200            n = txt[:m.start()].count('\n') + 1
201            hits.append((rel, n, ap, lineas[n - 1].strip()[:100]))
202    return hits
203
204
205def modo_uso():
206    applets = solo_busybox() - NO_VIGILAR
207    if not applets:
208        print('busybox-vigia --uso: el censo no marca ningún applet como SOLO-BUSYBOX')
209        return 0
210    hits = escanear(applets)
211    usados = {}
212    for rel, n, ap, linea in hits:
213        usados.setdefault(ap, []).append((rel, n, linea))
214    libres = sorted(applets - set(usados))
215    print(f'busybox-vigia --uso: {len(applets)} applets que hoy SÓLO provee busybox\n')
216    print(f'── {len(usados)} INVOCADOS en el repo — cada uno es una migración, no un borrado ──')
217    for ap in sorted(usados):
218        print(f'  {ap}')
219        for rel, n, linea in usados[ap][:4]:
220            print(f'      {rel}:{n}  {linea}')
221        if len(usados[ap]) > 4:
222            print(f'      … y {len(usados[ap]) - 4} más')
223    print(f'\n── {len(libres)} SIN NINGUNA invocación — candidatos al defconfig ──')
224    print('  ' + ' '.join(libres))
225    print()
226    print('\u26a0 \u00abCandidato\u00bb NO es \u00abretirable\u00bb. Un grep ve a quien INVOCA por nombre, y hay dos clases de')
227    print('   consumidor que no aparecen NUNCA y que rompen el producto si se les quita el applet:')
228    print('     \u00b7 el KERNEL — puede llamar a un helper de userspace sin que ningún grep lo vea.')
229    print('       ⚠ Acá NO: los cuatro kernels de takana traen `# CONFIG_MODULES is not set`, CERO')
230    print('       drivers `=m` y ninguna receta hace `modules_install` (medido 2026-09-22). Así que')
231    print('       modprobe/lsmod/depmod/rmmod/modinfo NO tienen consumidor, y con DEVTMPFS_MOUNT=y y')
232    print('       sin UEVENT_HELPER tampoco lo tienen mdev/makedevs/uevent. La advertencia sigue')
233    print('       valiendo como forma —un helper del kernel no aparece en un grep—, pero en ESTE')
234    print('       producto esos nueve son retirables, no escritura pendiente.')
235    print('     \u00b7 el USUARIO de la máquina — clear, reset, vi, traceroute no los invoca ningún guión del')
236    print('       repo y son la CLI que alguien espera encontrar al entrar por ssh.')
237    print('   Lo que esta lista sí dice es cuáles no tienen consumidor DENTRO del producto: es el primer')
238    print('   filtro, no el último.')
239    return 0
240
241
242def main():
243    if '--uso' in sys.argv:
244        return modo_uso()
245    retirados = applets_retirados() - NO_VIGILAR
246    if '--list' in sys.argv:
247        print('\n'.join(sorted(retirados)) or '(la receta no retira ningún applet)')
248        return 0
249    if not retirados:
250        print('busybox-vigia: la receta no retira ningún applet — nada que vigilar')
251        return 0
252    rx = re.compile(PRE + '(' + '|'.join(re.escape(a) for a in
253                    sorted(retirados, key=len, reverse=True)) + ')' + POST, re.M)
254    fallos = []
255    for f in RAIZ.rglob('*'):
256        if not f.is_file() or f.suffix not in EXT:
257            continue
258        rel = str(f.relative_to(RAIZ))
259        if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
260            continue
261        try:
262            crudo = f.read_text(errors='ignore')
263        except OSError:
264            continue
265        if len(crudo) > 900_000:
266            continue
267        if f.suffix == '.sh':
268            crudo = sin_grub_cfg(crudo)
269        propias = funciones_definidas(crudo)
270        txt = sin_comentarios(crudo, f.suffix)
271        lineas = None
272        for m in rx.finditer(txt):
273            ap = m.group(1)
274            if ap in propias:       # una función local del script, no el applet
275                continue
276            antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
277            # Patrón de `case`, no invocación: `proc|sys|dev|init) continue ;;`
278            if antes.endswith('|') and despues == ')':
279                continue
280            # Comprobación explícita de existencia: quien escribe `command -v X` ya tolera que falte.
281            ini = txt.rfind('\n', 0, m.start(1)) + 1
282            fin = txt.find('\n', m.end(1))
283            if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
284                continue
285            if lineas is None:
286                lineas = txt.splitlines()
287            n = txt[:m.start()].count('\n') + 1
288            fallos.append((rel, n, ap, lineas[n - 1].strip()[:100]))
289    print(f'busybox-vigia: {len(retirados)} applets retirados de la receta')
290    if not fallos:
291        print('busybox-vigia: OK — nadie los invoca')
292        return 0
293    print(f'busybox-vigia: FALLO — {len(fallos)} invocaciones de applets retirados\n')
294    for rel, n, ap, linea in fallos:
295        print(f'  {rel}:{n}  [{ap}]  {linea}')
296    print('\nO se indulta el applet en la receta, o se migra el llamador.')
297    return 1
298
299
300if __name__ == '__main__':
301    sys.exit(main())