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…
recorte 2: los 15 applets, el símbolo que el TSV no sabía y el insmod de GRUB
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())