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

⎇ 9b5861d815cfb08418773920067400bd11f9aa66 scripts/sh-superficie-scripts.py Python · 346 líneas · 15.8 KB Historia Quién tocó qué crudo

scripts: la superficie medida — 0 guiones POSIX roto bajo ash, y los 28 bash lo son por ARRAYS a4105cba1 · Sergio ·

1#!/usr/bin/env python3
2"""Mide la superficie de shell de `scripts/` — SDD: docs/plan-botar-busybox.md.
3
4Hermano de `sh-superficie-cards.py`, con el escáner compartido en `scripts/lib/sh_analisis.py`.
5**Pero contesta una pregunta distinta, y conviene no mezclarlas:**
6
7  - las CARDS las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio HOY;
8  - `scripts/` lo ejecuta el HUB y el worker, bajo el bash del anfitrión ⇒ su superficie sólo
9    obliga el día del auto-alojamiento (Etapa C). Hoy lo que importa acá es otra cosa:
10
11        **¿algún guion se declara POSIX y no lo es?** Porque ése SÍ se rompe hoy —
12        en la imagen `/bin/sh` es el ash de busybox, no bash.
13
14Tres poblaciones, por la PRIMERA línea (no por «contiene un shebang»: dos scripts bash empotran un
15`#!/bin/sh` en un heredoc y un grep ingenuo los cuenta como POSIX — pasó):
16
17  1. **POSIX** — `#!/bin/sh`, `#!/usr/bin/env sh`, `#!/bin/ash`
18  2. **bash** — `#!/bin/bash`, `#!/usr/bin/env bash`
19  3. **EMPOTRADOS** — guiones `#!/bin/sh` que otro script ESCRIBE con un heredoc (un `/init`, un
20     envoltorio). Ésos viajan a la imagen y los corre el ash, así que son los que más importan y
21     los que nadie parsea.
22
23De cada uno: si lo parsea el ash, si lo parsea bash, qué construcciones usa y qué órdenes invoca.
24Para la población bash, además, **por qué** es bash: qué bashismo concreto trae.
25
26Uso:
27    scripts/sh-superficie-scripts.py               # resumen + TSV
28    scripts/sh-superficie-scripts.py --list        # el detalle por fichero
29    scripts/sh-superficie-scripts.py --dump-dir D  # los empotrados como ficheros, para el banco
30"""
31
32from __future__ import annotations
33
34import argparse
35import collections
36import glob as globmod
37import re
38import subprocess
39import sys
40from dataclasses import dataclass, field
41from pathlib import Path
42
43RAIZ = Path(__file__).resolve().parent.parent
44sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
45from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos,  # noqa: E402
46                         desnudas_y_absolutas, heredocs, piezas_shell,
47                         rasgos_y_ordenes)
48
49SHEBANG_POSIX = {"#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/ash", "#!/bin/busybox sh"}
50SHEBANG_BASH = {"#!/bin/bash", "#!/usr/bin/env bash", "#!/bin/bash -e"}
51
52# Lo que hace que un guion NO sea POSIX. Se busca sobre el texto de SINTAXIS (fuera de comillas),
53# así un `[[` dentro de un mensaje no cuenta.
54BASHISMOS = [
55    ("[[ ]]",              r"\[\["),
56    ("array=( )",          r"^\s*\w+=\(|\bdeclare\s+-[aA]\b|\blocal\s+-[aA]\b"),
57    ("${arr[i]}",          r"\$\{\w+\[[^]]*\]"),
58    ("(( ))",              r"(?<!\$)\(\("),
59    ("<<< (here-string)",  r"<<<"),
60    ("<( ) proceso",       r"<\("),
61    ("declare/typeset",    r"\bdeclare\b|\btypeset\b"),
62    ("mapfile/readarray",  r"\bmapfile\b|\breadarray\b"),
63    ("${v//x/y}",          r"\$\{\w+//"),
64    ("${v^^} ${v,,}",      r"\$\{\w+\^\^|\$\{\w+,,"),
65    ("+= en asignación",   r"^\s*\w+\+="),
66    ("function nombre",    r"\bfunction\s+\w+"),
67    ("PIPESTATUS/BASH_*",  r"\bPIPESTATUS\b|\bBASH_\w+"),
68    ("echo -e",            r"\becho\s+-e\b"),
69    ("read -a / -d",       r"\bread\s+-[ad]\b"),
70    ("source (no `.`)",    r"^\s*source\s"),
71]
72
73
74@dataclass
75class Guion:
76    ruta: str
77    poblacion: str            # posix | bash | empotrado | sin-shebang
78    shebang: str
79    codigo: str
80    padre: str | None = None  # para los empotrados: quién lo escribe
81    ash_ok: bool | None = None
82    bash_ok: bool | None = None
83    ash_error: str = ""
84    rasgos: set[str] = field(default_factory=set)
85    bashismos: set[str] = field(default_factory=set)
86    builtins: collections.Counter = field(default_factory=collections.Counter)
87    externas: collections.Counter = field(default_factory=collections.Counter)
88    lineas: int = 0
89
90
91# ───────────────────────────────────────────────── recolección
92
93# Ficheros de shell SIN extensión `.sh` y sin shebang que igual son shell. Se nombran uno por uno
94# a propósito: el balde automático traía `.html`, `.mp4` y `.c`, y un balde así no es una medición.
95# `perfil/profile` importa doble porque VIAJA A LA IMAGEN: lo lee toda shell de login.
96EXTRAS = {"scripts/servidor/perfil/profile"}
97
98FUERA = ("/fixtures/sh-casos/", "/__pycache__/", "/.git/")
99
100
101def es_shell(ruta: Path, primera: str) -> bool:
102    """Un fichero cuenta como shell si lo dice su extensión, su shebang, o la lista EXTRAS."""
103    if str(ruta.relative_to(RAIZ)) in EXTRAS:
104        return True
105    if ruta.suffix == ".sh":
106        return True
107    return primera in SHEBANG_POSIX or primera in SHEBANG_BASH
108
109
110def ficheros_shell() -> list[tuple[Path, str, str]]:
111    """`(ruta, texto, primera línea)` de cada fichero de shell, y cuántos se descartaron."""
112    salida, descartados = [], 0
113    for ruta in sorted(RAIZ.glob("scripts/**/*")):
114        if not ruta.is_file() or any(f in f"/{ruta.as_posix()}/" for f in FUERA):
115            continue
116        try:
117            texto = ruta.read_text(encoding="utf-8", errors="replace")
118        except OSError:
119            continue
120        primera = texto.splitlines()[0].strip() if texto.strip() else ""
121        if es_shell(ruta, primera):
122            salida.append((ruta, texto, primera))
123        else:
124            descartados += 1
125    ficheros_shell.descartados = descartados
126    return salida
127
128
129def empotrados(texto: str) -> list[tuple[str, str]]:
130    """Los cuerpos de heredoc que SON guiones de shell: `(delimitador, cuerpo)`.
131
132    Un heredoc de configuración —o de Python— no es un guion. La separación la hace
133    `sh_analisis.heredocs`; acá sólo se filtra por shebang.
134    """
135    return [(d, c) for d, c in heredocs(texto)[1]
136            if c.lstrip().startswith("#!") and ("sh" in c.lstrip().splitlines()[0])]
137
138
139def recolectar() -> list[Guion]:
140    guiones: list[Guion] = []
141    for ruta, texto, primera in ficheros_shell():
142        rel = str(ruta.relative_to(RAIZ))
143        if primera in SHEBANG_POSIX:
144            pob = "posix"
145        elif primera in SHEBANG_BASH:
146            pob = "bash"
147        elif primera.startswith("#!"):
148            pob = "otro-interprete"
149        else:
150            pob = "sin-shebang"   # se hace `source`, no se ejecuta
151        if pob != "otro-interprete":
152            guiones.append(Guion(ruta=rel, poblacion=pob, shebang=primera or "(ninguno)",
153                                 codigo=texto, lineas=texto.count("\n")))
154        for delim, cuerpo in empotrados(texto):
155            guiones.append(Guion(ruta=f"{rel}«{delim}»", poblacion="empotrado",
156                                 shebang=cuerpo.lstrip().splitlines()[0], codigo=cuerpo,
157                                 padre=rel, lineas=cuerpo.count("\n")))
158    return guiones
159
160
161# ───────────────────────────────────────────────── análisis
162
163def parsea(binario: list[str], codigo: str, tmp: Path) -> tuple[bool, str]:
164    tmp.write_text(codigo, encoding="utf-8")
165    p = subprocess.run(binario + ["-n", str(tmp)], capture_output=True, timeout=30)
166    err = p.stderr.decode("utf-8", "replace").strip().splitlines()
167    return p.returncode == 0, (err[0].split(": ", 1)[-1] if err else "")
168
169
170def analizar(g: Guion, ash: list[str], bash: list[str], tmp: Path) -> None:
171    # El ANÁLISIS va sobre el texto sin cuerpos de heredoc (ésos se miden como población
172    # `empotrado`, o no se miden si no son shell); el PARSEO va sobre el fichero entero, que es
173    # lo que el shell ve de verdad.
174    propio = g.codigo if g.poblacion == "empotrado" else heredocs(g.codigo)[0]
175    g.rasgos, g.builtins, g.externas = rasgos_y_ordenes(propio)
176    sintaxis = "\n".join(s for _, s, _ in piezas_shell(propio))
177    g.bashismos = {n for n, patron in BASHISMOS if re.search(patron, sintaxis, re.M)}
178    g.ash_ok, g.ash_error = parsea(ash, g.codigo, tmp)
179    g.bash_ok, _ = parsea(bash, g.codigo, tmp)
180
181
182def descubrir(patron: str, sufijo: str) -> list[str] | None:
183    for d in sorted(globmod.glob(patron)):
184        p = Path(d) / sufijo
185        if p.is_file():
186            return [str(p)]
187    return None
188
189
190# ───────────────────────────────────────────────── informe
191
192def main() -> int:
193    ap = argparse.ArgumentParser(description="Mide la superficie de shell de scripts/.")
194    ap.add_argument("--ash", metavar="RUTA", help="binario del shell POSIX de control (busybox)")
195    ap.add_argument("--bash", metavar="RUTA", help="binario de bash")
196    ap.add_argument("--list", action="store_true", help="detalle por fichero")
197    ap.add_argument("--dump-dir", metavar="DIR", help="volcar los guiones EMPOTRADOS como ficheros")
198    ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-scripts.tsv"))
199    args = ap.parse_args()
200
201    ash = [args.ash, "sh"] if args.ash else (
202        (descubrir("/store/*-busybox", "bin/busybox") or [None]) + ["sh"])
203    bash = [args.bash] if args.bash else descubrir("/store/*-bash", "bin/bash")
204    if not ash[0] or not bash:
205        print("✗ no encontré los shells. Pasá --ash y --bash "
206              "(p.ej. /store/…-busybox/bin/busybox y /store/…-bash/bin/bash)", file=sys.stderr)
207        return 2
208    print(f"Control POSIX: {' '.join(ash)}\nbash:          {bash[0]}\n")
209
210    guiones = recolectar()
211    print(f"Ficheros de scripts/ descartados por no ser shell (.html, .c, .mp4, …): "
212          f"{ficheros_shell.descartados}")
213    tmp = Path(RAIZ / "work" / ".sh-superficie-tmp.sh")
214    tmp.parent.mkdir(parents=True, exist_ok=True)
215    try:
216        for g in guiones:
217            analizar(g, ash, bash, tmp)
218    finally:
219        tmp.unlink(missing_ok=True)
220
221    por = collections.defaultdict(list)
222    for g in guiones:
223        por[g.poblacion].append(g)
224
225    # ── inventario y la matriz de parseo
226    print("── Poblaciones, y quién las parsea")
227    print(f"   {'población':<16} {'n':>4} {'líneas':>7}   parsea ash   parsea bash")
228    for pob in ("posix", "bash", "empotrado", "sin-shebang"):
229        gs = por.get(pob, [])
230        if not gs:
231            continue
232        print(f"   {pob:<16} {len(gs):>4} {sum(g.lineas for g in gs):>7}   "
233              f"{sum(1 for g in gs if g.ash_ok):>5}/{len(gs):<5}   "
234              f"{sum(1 for g in gs if g.bash_ok):>5}/{len(gs)}")
235
236    # ── lo que se rompe HOY: un guion que se declara POSIX y el ash no lo parsea
237    rotos = [g for g in guiones if g.poblacion in ("posix", "empotrado") and not g.ash_ok]
238    print(f"\n── Se declaran POSIX y el ash NO los parsea: {len(rotos)}")
239    if rotos:
240        for g in rotos:
241            print(f"   ⛔ {g.ruta}  ({g.shebang})")
242            print(f"        ash: {g.ash_error}")
243            print(f"        bash lo parsea: {'sí ⇒ es un bashismo' if g.bash_ok else 'no ⇒ error de sintaxis'}")
244            if g.bashismos:
245                print(f"        bashismos detectados: {', '.join(sorted(g.bashismos))}")
246    else:
247        print("   ✓ ninguno. Todo lo que dice `#!/bin/sh` lo parsea el ash de busybox.")
248
249    # ── los bash: ¿por qué son bash?
250    bashs = por.get("bash", [])
251    if bashs:
252        solo_bash = [g for g in bashs if not g.ash_ok]
253        print(f"\n── Los {len(bashs)} guiones `#!bash`: {len(bashs)-len(solo_bash)} parsean tal cual "
254              f"bajo ash, {len(solo_bash)} no")
255        print("   (parsear no es correr: un `echo -e` o un `local` parsean en los dos y se portan distinto)")
256        hist = collections.Counter()
257        for g in bashs:
258            hist.update(g.bashismos)
259        cat = [n for n, _ in BASHISMOS]
260        print("\n   Bashismos, por cuántos guiones los traen:")
261        for n, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
262            print(f"      {n:<20} {c:>3}  {'█' * c}")
263        sin = [n for n, _ in BASHISMOS if n not in hist]
264        if sin:
265            print(f"   no aparece: {', '.join(sin)}")
266
267    # ── la superficie del lenguaje, por población
268    for pob in ("posix", "bash", "empotrado"):
269        gs = por.get(pob, [])
270        if not gs:
271            continue
272        hist = collections.Counter()
273        for g in gs:
274            hist.update(g.rasgos)
275        cat = [r for r, _, _ in RASGOS]
276        print(f"\n── Construcciones · población {pob} ({len(gs)} guiones)")
277        for r, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
278            print(f"   {r:<20} {c:>4}/{len(gs)}")
279        faltan = [r for r in cat if r not in hist]
280        if faltan:
281            print(f"   no aparece: {', '.join(faltan)}")
282
283    # ── órdenes
284    bi, ex = collections.Counter(), collections.Counter()
285    for g in guiones:
286        bi.update(g.builtins)
287        ex.update(g.externas)
288    print(f"\n── Builtins usados en todo scripts/ ({len(bi)})")
289    print("   " + "  ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
290    desnudas, absolutas = desnudas_y_absolutas(ex)
291    top = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
292    print(f"\n── Órdenes externas por NOMBRE DESNUDO ({len(desnudas)} distintas, las resuelve el "
293          f"PATH) · las 30 más usadas")
294    print("   " + "  ".join(f"{k}×{v}" for k, v in top[:30]))
295    abs_top = sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))
296    print(f"\n── …y por RUTA ABSOLUTA ({len(absolutas)} distintas, no pasan por PATH) · las 15 más "
297          f"usadas")
298    print("   " + "  ".join(f"{k}×{v}" for k, v in abs_top[:15]))
299
300    applets = applets_conocidos()
301    if applets:
302        retiradas = [(k, v) for k, v in top if applets.get(k, ("", ""))[1] == "RETIRADO"]
303        print(f"\n── Cruce con el recorte ({RUTA_APPLETS.name})")
304        print(f"   nombres desnudos que son applet RETIRADO: {len(retiradas)}"
305              + (f" → {', '.join(k for k, _ in retiradas)}" if retiradas else ""))
306        print("   ⚠ sólo se cruzan los DESNUDOS: una ruta absoluta no se resuelve por PATH y por lo"
307              "\n     tanto no puede ser un applet. Cruzar las absolutas daba un falso positivo"
308              "\n     medido (`/sbin/init`, que en el producto es arje).")
309        print("   ⚠ la PUERTA es `scripts/busybox-vigia.py`, que ya falla si alguien invoca uno;"
310              "\n     esto es inventario, y su lista es más ancha (incluye no-applets).")
311
312    if args.list:
313        print("\n── Detalle")
314        for g in sorted(guiones, key=lambda x: (x.poblacion, x.ruta)):
315            marca = "" if g.ash_ok else "  ⛔ ash no parsea"
316            print(f"\n  [{g.poblacion}] {g.ruta}  ({g.lineas} líneas){marca}")
317            if g.bashismos:
318                print(f"      bashismos: {', '.join(sorted(g.bashismos))}")
319            print(f"      rasgos: {', '.join(sorted(g.rasgos))}")
320
321    if args.dump_dir:
322        d = Path(args.dump_dir)
323        d.mkdir(parents=True, exist_ok=True)
324        n = 0
325        for g in por.get("empotrado", []):
326            nombre = re.sub(r"[^\w.-]", "_", g.ruta) + ".sh"
327            (d / nombre).write_text(g.codigo, encoding="utf-8")
328            n += 1
329        print(f"\nEmpotrados escritos en {d} ({n} ficheros)")
330
331    tsv = Path(args.tsv)
332    tsv.parent.mkdir(parents=True, exist_ok=True)
333    with tsv.open("w", encoding="utf-8") as fh:
334        fh.write("poblacion\truta\tpadre\tshebang\tlineas\tash_ok\tbash_ok\tash_error\t"
335                 "bashismos\trasgos\tbuiltins\texternas\n")
336        for g in guiones:
337            fh.write(f"{g.poblacion}\t{g.ruta}\t{g.padre or ''}\t{g.shebang}\t{g.lineas}\t"
338                     f"{'si' if g.ash_ok else 'no'}\t{'si' if g.bash_ok else 'no'}\t{g.ash_error}\t"
339                     f"{'|'.join(sorted(g.bashismos))}\t{'|'.join(sorted(g.rasgos))}\t"
340                     f"{'|'.join(sorted(g.builtins))}\t{'|'.join(sorted(g.externas))}\n")
341    print(f"\nTSV: {tsv}")
342    return len(rotos)
343
344
345if __name__ == "__main__":
346    sys.exit(main())