gioser

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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ ae6f0b45d94eb275e2464d898b35ee9a64595f43 scripts/busybox-vigia.py Python · 267 lines · 12.7 KB History Who touched what raw

el frente kmod no existe: los kernels de takana no tienen módulos 6b33d1a43 · 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 funciones_definidas(txt):
88    """Nombres definidos como función de shell EN ESE fichero: `ts() {`, `function ts {`."""
89    return set(re.findall(r'(?m)^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_-]*)\s*\(\)\s*\{', txt)) | \
90           set(re.findall(r'(?m)^\s*function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*\{', txt))
91
92
93def solo_busybox():
94    """Applets vivos cuyo `proveedor_medido` (del censo) dice que no los provee nadie más."""
95    tsv = (RAIZ / 'docs' / 'state' / 'busybox-applets.tsv').read_text().splitlines()
96    cab = tsv[0].split('\t')
97    i_ap, i_prov = cab.index('applet'), cab.index('proveedor_medido')
98    i_rec = cab.index('recorte_2026_09_21')
99    out = set()
100    for ln in tsv[1:]:
101        c = ln.split('\t')
102        if len(c) > max(i_ap, i_prov, i_rec) and c[i_rec] != 'RETIRADO' and c[i_prov] == 'SOLO-BUSYBOX':
103            out.add(c[i_ap])
104    return out
105
106
107def escanear(applets):
108    """[(fichero, línea, applet, texto)] de cada invocación en el repo.
109
110    Busca TRES formas, y las tres hacen falta — con sólo la primera este modo dio dos falsos
111    negativos MEDIDOS, y un falso negativo acá significa retirar un applet que el producto usa:
112      1. nombre desnudo en posición de comando   `killall foo`
113      2. RUTA ABSOLUTA                           `exec /usr/bin/setuidgid pacha` — 3 recetas
114      3. primer elemento de un `argv` JSON       `"argv": ["getty", "-n", ...]` — la card de la
115                                                 consola, takana-bootstrap/src/lib.rs:241
116    La 2 se escapaba porque el patrón de comando pide el nombre pegado al `exec` y ahí hay un
117    `/usr/bin/` en medio; la 3 porque una cadena JSON suelta no parece una línea de comandos.
118    """
119    alt = '|'.join(re.escape(a) for a in sorted(applets, key=len, reverse=True))
120    rx = re.compile(PRE + '(' + alt + ')' + POST, re.M)
121    rx_ruta = re.compile(r'(?:/usr/bin/|/bin/|/usr/sbin/|/sbin/)(' + alt + r')\b')
122    rx_argv = re.compile(r'(?:argv|exec)["\s:=]+\[?\s*"(' + alt + r')"')
123    hits = []
124    for f in RAIZ.rglob('*'):
125        if not f.is_file() or f.suffix not in EXT:
126            continue
127        rel = str(f.relative_to(RAIZ))
128        if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
129            continue
130        # El propio vigía nombra applets en su documentación y en sus regex: se salta a sí mismo,
131        # o se denuncia en cada corrida y el ruido lo vuelve inservible.
132        if f.resolve() == pathlib.Path(__file__).resolve():
133            continue
134        try:
135            crudo = f.read_text(errors='ignore')
136        except OSError:
137            continue
138        if len(crudo) > 900_000:
139            continue
140        propias = funciones_definidas(crudo)
141        txt = sin_comentarios(crudo, f.suffix)
142        lineas = None
143        # Las formas 2 y 3 se buscan sobre el texto sin el filtro de «parece una línea de comandos»:
144        # una ruta absoluta y un `argv` ya son invocaciones por sí mismas.
145        if f.suffix == '.rs':
146            crudo_sc = re.sub(r'(?m)//.*$', '', crudo)
147        elif f.suffix in ('.sh', '.toml', '.py'):
148            crudo_sc = re.sub(r'(?m)(?<!\\)#.*$', '', crudo)
149        else:
150            crudo_sc = crudo
151        sc_lineas = crudo_sc.splitlines()
152        for m2 in list(rx_ruta.finditer(crudo_sc)) + list(rx_argv.finditer(crudo_sc)):
153            n = crudo_sc[:m2.start()].count('\n') + 1
154            hits.append((rel, n, m2.group(1), sc_lineas[n - 1].strip()[:100] if n <= len(sc_lineas) else ''))
155        for m in rx.finditer(txt):
156            ap = m.group(1)
157            if ap in propias:
158                continue
159            antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
160            if antes.endswith('|') and despues == ')':
161                continue
162            ini = txt.rfind('\n', 0, m.start(1)) + 1
163            fin = txt.find('\n', m.end(1))
164            if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
165                continue
166            if lineas is None:
167                lineas = txt.splitlines()
168            n = txt[:m.start()].count('\n') + 1
169            hits.append((rel, n, ap, lineas[n - 1].strip()[:100]))
170    return hits
171
172
173def modo_uso():
174    applets = solo_busybox() - NO_VIGILAR
175    if not applets:
176        print('busybox-vigia --uso: el censo no marca ningún applet como SOLO-BUSYBOX')
177        return 0
178    hits = escanear(applets)
179    usados = {}
180    for rel, n, ap, linea in hits:
181        usados.setdefault(ap, []).append((rel, n, linea))
182    libres = sorted(applets - set(usados))
183    print(f'busybox-vigia --uso: {len(applets)} applets que hoy SÓLO provee busybox\n')
184    print(f'── {len(usados)} INVOCADOS en el repo — cada uno es una migración, no un borrado ──')
185    for ap in sorted(usados):
186        print(f'  {ap}')
187        for rel, n, linea in usados[ap][:4]:
188            print(f'      {rel}:{n}  {linea}')
189        if len(usados[ap]) > 4:
190            print(f'      … y {len(usados[ap]) - 4} más')
191    print(f'\n── {len(libres)} SIN NINGUNA invocación — candidatos al defconfig ──')
192    print('  ' + ' '.join(libres))
193    print()
194    print('\u26a0 \u00abCandidato\u00bb NO es \u00abretirable\u00bb. Un grep ve a quien INVOCA por nombre, y hay dos clases de')
195    print('   consumidor que no aparecen NUNCA y que rompen el producto si se les quita el applet:')
196    print('     \u00b7 el KERNEL — puede llamar a un helper de userspace sin que ningún grep lo vea.')
197    print('       ⚠ Acá NO: los cuatro kernels de takana traen `# CONFIG_MODULES is not set`, CERO')
198    print('       drivers `=m` y ninguna receta hace `modules_install` (medido 2026-09-22). Así que')
199    print('       modprobe/lsmod/depmod/rmmod/modinfo NO tienen consumidor, y con DEVTMPFS_MOUNT=y y')
200    print('       sin UEVENT_HELPER tampoco lo tienen mdev/makedevs/uevent. La advertencia sigue')
201    print('       valiendo como forma —un helper del kernel no aparece en un grep—, pero en ESTE')
202    print('       producto esos nueve son retirables, no escritura pendiente.')
203    print('     \u00b7 el USUARIO de la máquina — clear, reset, vi, traceroute no los invoca ningún guión del')
204    print('       repo y son la CLI que alguien espera encontrar al entrar por ssh.')
205    print('   Lo que esta lista sí dice es cuáles no tienen consumidor DENTRO del producto: es el primer')
206    print('   filtro, no el último.')
207    return 0
208
209
210def main():
211    if '--uso' in sys.argv:
212        return modo_uso()
213    retirados = applets_retirados() - NO_VIGILAR
214    if '--list' in sys.argv:
215        print('\n'.join(sorted(retirados)) or '(la receta no retira ningún applet)')
216        return 0
217    if not retirados:
218        print('busybox-vigia: la receta no retira ningún applet — nada que vigilar')
219        return 0
220    rx = re.compile(PRE + '(' + '|'.join(re.escape(a) for a in
221                    sorted(retirados, key=len, reverse=True)) + ')' + POST, re.M)
222    fallos = []
223    for f in RAIZ.rglob('*'):
224        if not f.is_file() or f.suffix not in EXT:
225            continue
226        rel = str(f.relative_to(RAIZ))
227        if any(rel.startswith(s) or ('/' + s) in rel for s in SALTAR):
228            continue
229        try:
230            crudo = f.read_text(errors='ignore')
231        except OSError:
232            continue
233        if len(crudo) > 900_000:
234            continue
235        propias = funciones_definidas(crudo)
236        txt = sin_comentarios(crudo, f.suffix)
237        lineas = None
238        for m in rx.finditer(txt):
239            ap = m.group(1)
240            if ap in propias:       # una función local del script, no el applet
241                continue
242            antes, despues = txt[:m.start(1)].rstrip(), txt[m.end(1):m.end(1) + 1]
243            # Patrón de `case`, no invocación: `proc|sys|dev|init) continue ;;`
244            if antes.endswith('|') and despues == ')':
245                continue
246            # Comprobación explícita de existencia: quien escribe `command -v X` ya tolera que falte.
247            ini = txt.rfind('\n', 0, m.start(1)) + 1
248            fin = txt.find('\n', m.end(1))
249            if re.search(r'command\s+-v\s+' + re.escape(ap) + r'\b', txt[ini:fin if fin > 0 else len(txt)]):
250                continue
251            if lineas is None:
252                lineas = txt.splitlines()
253            n = txt[:m.start()].count('\n') + 1
254            fallos.append((rel, n, ap, lineas[n - 1].strip()[:100]))
255    print(f'busybox-vigia: {len(retirados)} applets retirados de la receta')
256    if not fallos:
257        print('busybox-vigia: OK — nadie los invoca')
258        return 0
259    print(f'busybox-vigia: FALLO — {len(fallos)} invocaciones de applets retirados\n')
260    for rel, n, ap, linea in fallos:
261        print(f'  {rel}:{n}  [{ap}]  {linea}')
262    print('\nO se indulta el applet en la receta, o se migra el llamador.')
263    return 1
264
265
266if __name__ == '__main__':
267    sys.exit(main())