#!/usr/bin/env python3 """Mide la superficie de shell de `scripts/` — SDD: docs/plan-botar-busybox.md. Hermano de `sh-superficie-cards.py`, con el escáner compartido en `scripts/lib/sh_analisis.py`. **Pero contesta una pregunta distinta, y conviene no mezclarlas:** - las CARDS las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio HOY; - `scripts/` lo ejecuta el HUB y el worker, bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento (Etapa C). Hoy lo que importa acá es otra cosa: **¿algún guion se declara POSIX y no lo es?** Porque ése SÍ se rompe hoy — en la imagen `/bin/sh` es el ash de busybox, no bash. Tres poblaciones, por la PRIMERA línea (no por «contiene un shebang»: dos scripts bash empotran un `#!/bin/sh` en un heredoc y un grep ingenuo los cuenta como POSIX — pasó): 1. **POSIX** — `#!/bin/sh`, `#!/usr/bin/env sh`, `#!/bin/ash` 2. **bash** — `#!/bin/bash`, `#!/usr/bin/env bash` 3. **EMPOTRADOS** — guiones `#!/bin/sh` que otro script ESCRIBE con un heredoc (un `/init`, un envoltorio). Ésos viajan a la imagen y los corre el ash, así que son los que más importan y los que nadie parsea. De cada uno: si lo parsea el ash, si lo parsea bash, qué construcciones usa y qué órdenes invoca. Para la población bash, además, **por qué** es bash: qué bashismo concreto trae. Uso: scripts/sh-superficie-scripts.py # resumen + TSV scripts/sh-superficie-scripts.py --list # el detalle por fichero scripts/sh-superficie-scripts.py --dump-dir D # los empotrados como ficheros, para el banco """ from __future__ import annotations import argparse import collections import glob as globmod import re import subprocess import sys from dataclasses import dataclass, field from pathlib import Path RAIZ = Path(__file__).resolve().parent.parent sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402 desnudas_y_absolutas, heredocs, piezas_shell, rasgos_y_ordenes) SHEBANG_POSIX = {"#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/ash", "#!/bin/busybox sh"} SHEBANG_BASH = {"#!/bin/bash", "#!/usr/bin/env bash", "#!/bin/bash -e"} # Lo que hace que un guion NO sea POSIX. Se busca sobre el texto de SINTAXIS (fuera de comillas), # así un `[[` dentro de un mensaje no cuenta. BASHISMOS = [ ("[[ ]]", r"\[\["), ("array=( )", r"^\s*\w+=\(|\bdeclare\s+-[aA]\b|\blocal\s+-[aA]\b"), ("${arr[i]}", r"\$\{\w+\[[^]]*\]"), ("(( ))", r"(? bool: """Un fichero cuenta como shell si lo dice su extensión, su shebang, o la lista EXTRAS.""" if str(ruta.relative_to(RAIZ)) in EXTRAS: return True if ruta.suffix == ".sh": return True return primera in SHEBANG_POSIX or primera in SHEBANG_BASH def ficheros_shell() -> list[tuple[Path, str, str]]: """`(ruta, texto, primera línea)` de cada fichero de shell, y cuántos se descartaron.""" salida, descartados = [], 0 for ruta in sorted(RAIZ.glob("scripts/**/*")): if not ruta.is_file() or any(f in f"/{ruta.as_posix()}/" for f in FUERA): continue try: texto = ruta.read_text(encoding="utf-8", errors="replace") except OSError: continue primera = texto.splitlines()[0].strip() if texto.strip() else "" if es_shell(ruta, primera): salida.append((ruta, texto, primera)) else: descartados += 1 ficheros_shell.descartados = descartados return salida def empotrados(texto: str) -> list[tuple[str, str]]: """Los cuerpos de heredoc que SON guiones de shell: `(delimitador, cuerpo)`. Un heredoc de configuración —o de Python— no es un guion. La separación la hace `sh_analisis.heredocs`; acá sólo se filtra por shebang. """ return [(d, c) for d, c in heredocs(texto)[1] if c.lstrip().startswith("#!") and ("sh" in c.lstrip().splitlines()[0])] def recolectar() -> list[Guion]: guiones: list[Guion] = [] for ruta, texto, primera in ficheros_shell(): rel = str(ruta.relative_to(RAIZ)) if primera in SHEBANG_POSIX: pob = "posix" elif primera in SHEBANG_BASH: pob = "bash" elif primera.startswith("#!"): pob = "otro-interprete" else: pob = "sin-shebang" # se hace `source`, no se ejecuta if pob != "otro-interprete": guiones.append(Guion(ruta=rel, poblacion=pob, shebang=primera or "(ninguno)", codigo=texto, lineas=texto.count("\n"))) for delim, cuerpo in empotrados(texto): guiones.append(Guion(ruta=f"{rel}«{delim}»", poblacion="empotrado", shebang=cuerpo.lstrip().splitlines()[0], codigo=cuerpo, padre=rel, lineas=cuerpo.count("\n"))) return guiones # ───────────────────────────────────────────────── análisis def parsea(binario: list[str], codigo: str, tmp: Path) -> tuple[bool, str]: tmp.write_text(codigo, encoding="utf-8") p = subprocess.run(binario + ["-n", str(tmp)], capture_output=True, timeout=30) err = p.stderr.decode("utf-8", "replace").strip().splitlines() return p.returncode == 0, (err[0].split(": ", 1)[-1] if err else "") def analizar(g: Guion, ash: list[str], bash: list[str], tmp: Path) -> None: # El ANÁLISIS va sobre el texto sin cuerpos de heredoc (ésos se miden como población # `empotrado`, o no se miden si no son shell); el PARSEO va sobre el fichero entero, que es # lo que el shell ve de verdad. propio = g.codigo if g.poblacion == "empotrado" else heredocs(g.codigo)[0] g.rasgos, g.builtins, g.externas = rasgos_y_ordenes(propio) sintaxis = "\n".join(s for _, s, _ in piezas_shell(propio)) g.bashismos = {n for n, patron in BASHISMOS if re.search(patron, sintaxis, re.M)} g.ash_ok, g.ash_error = parsea(ash, g.codigo, tmp) g.bash_ok, _ = parsea(bash, g.codigo, tmp) def descubrir(patron: str, sufijo: str) -> list[str] | None: for d in sorted(globmod.glob(patron)): p = Path(d) / sufijo if p.is_file(): return [str(p)] return None # ───────────────────────────────────────────────── informe def main() -> int: ap = argparse.ArgumentParser(description="Mide la superficie de shell de scripts/.") ap.add_argument("--ash", metavar="RUTA", help="binario del shell POSIX de control (busybox)") ap.add_argument("--bash", metavar="RUTA", help="binario de bash") ap.add_argument("--list", action="store_true", help="detalle por fichero") ap.add_argument("--dump-dir", metavar="DIR", help="volcar los guiones EMPOTRADOS como ficheros") ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-scripts.tsv")) args = ap.parse_args() ash = [args.ash, "sh"] if args.ash else ( (descubrir("/store/*-busybox", "bin/busybox") or [None]) + ["sh"]) bash = [args.bash] if args.bash else descubrir("/store/*-bash", "bin/bash") if not ash[0] or not bash: print("✗ no encontré los shells. Pasá --ash y --bash " "(p.ej. /store/…-busybox/bin/busybox y /store/…-bash/bin/bash)", file=sys.stderr) return 2 print(f"Control POSIX: {' '.join(ash)}\nbash: {bash[0]}\n") guiones = recolectar() print(f"Ficheros de scripts/ descartados por no ser shell (.html, .c, .mp4, …): " f"{ficheros_shell.descartados}") tmp = Path(RAIZ / "work" / ".sh-superficie-tmp.sh") tmp.parent.mkdir(parents=True, exist_ok=True) try: for g in guiones: analizar(g, ash, bash, tmp) finally: tmp.unlink(missing_ok=True) por = collections.defaultdict(list) for g in guiones: por[g.poblacion].append(g) # ── inventario y la matriz de parseo print("── Poblaciones, y quién las parsea") print(f" {'población':<16} {'n':>4} {'líneas':>7} parsea ash parsea bash") for pob in ("posix", "bash", "empotrado", "sin-shebang"): gs = por.get(pob, []) if not gs: continue print(f" {pob:<16} {len(gs):>4} {sum(g.lineas for g in gs):>7} " f"{sum(1 for g in gs if g.ash_ok):>5}/{len(gs):<5} " f"{sum(1 for g in gs if g.bash_ok):>5}/{len(gs)}") # ── lo que se rompe HOY: un guion que se declara POSIX y el ash no lo parsea rotos = [g for g in guiones if g.poblacion in ("posix", "empotrado") and not g.ash_ok] print(f"\n── Se declaran POSIX y el ash NO los parsea: {len(rotos)}") if rotos: for g in rotos: print(f" ⛔ {g.ruta} ({g.shebang})") print(f" ash: {g.ash_error}") print(f" bash lo parsea: {'sí ⇒ es un bashismo' if g.bash_ok else 'no ⇒ error de sintaxis'}") if g.bashismos: print(f" bashismos detectados: {', '.join(sorted(g.bashismos))}") else: print(" ✓ ninguno. Todo lo que dice `#!/bin/sh` lo parsea el ash de busybox.") # ── los bash: ¿por qué son bash? bashs = por.get("bash", []) if bashs: solo_bash = [g for g in bashs if not g.ash_ok] print(f"\n── Los {len(bashs)} guiones `#!bash`: {len(bashs)-len(solo_bash)} parsean tal cual " f"bajo ash, {len(solo_bash)} no") print(" (parsear no es correr: un `echo -e` o un `local` parsean en los dos y se portan distinto)") hist = collections.Counter() for g in bashs: hist.update(g.bashismos) cat = [n for n, _ in BASHISMOS] print("\n Bashismos, por cuántos guiones los traen:") for n, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))): print(f" {n:<20} {c:>3} {'█' * c}") sin = [n for n, _ in BASHISMOS if n not in hist] if sin: print(f" no aparece: {', '.join(sin)}") # ── la superficie del lenguaje, por población for pob in ("posix", "bash", "empotrado"): gs = por.get(pob, []) if not gs: continue hist = collections.Counter() for g in gs: hist.update(g.rasgos) cat = [r for r, _, _ in RASGOS] print(f"\n── Construcciones · población {pob} ({len(gs)} guiones)") for r, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))): print(f" {r:<20} {c:>4}/{len(gs)}") faltan = [r for r in cat if r not in hist] if faltan: print(f" no aparece: {', '.join(faltan)}") # ── órdenes bi, ex = collections.Counter(), collections.Counter() for g in guiones: bi.update(g.builtins) ex.update(g.externas) print(f"\n── Builtins usados en todo scripts/ ({len(bi)})") print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0])))) desnudas, absolutas = desnudas_y_absolutas(ex) top = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0])) print(f"\n── Órdenes externas por NOMBRE DESNUDO ({len(desnudas)} distintas, las resuelve el " f"PATH) · las 30 más usadas") print(" " + " ".join(f"{k}×{v}" for k, v in top[:30])) abs_top = sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0])) print(f"\n── …y por RUTA ABSOLUTA ({len(absolutas)} distintas, no pasan por PATH) · las 15 más " f"usadas") print(" " + " ".join(f"{k}×{v}" for k, v in abs_top[:15])) applets = applets_conocidos() if applets: retiradas = [(k, v) for k, v in top if applets.get(k, ("", ""))[1] == "RETIRADO"] print(f"\n── Cruce con el recorte ({RUTA_APPLETS.name})") print(f" nombres desnudos que son applet RETIRADO: {len(retiradas)}" + (f" → {', '.join(k for k, _ in retiradas)}" if retiradas else "")) print(" ⚠ sólo se cruzan los DESNUDOS: una ruta absoluta no se resuelve por PATH y por lo" "\n tanto no puede ser un applet. Cruzar las absolutas daba un falso positivo" "\n medido (`/sbin/init`, que en el producto es arje).") print(" ⚠ la PUERTA es `scripts/busybox-vigia.py`, que ya falla si alguien invoca uno;" "\n esto es inventario, y su lista es más ancha (incluye no-applets).") if args.list: print("\n── Detalle") for g in sorted(guiones, key=lambda x: (x.poblacion, x.ruta)): marca = "" if g.ash_ok else " ⛔ ash no parsea" print(f"\n [{g.poblacion}] {g.ruta} ({g.lineas} líneas){marca}") if g.bashismos: print(f" bashismos: {', '.join(sorted(g.bashismos))}") print(f" rasgos: {', '.join(sorted(g.rasgos))}") if args.dump_dir: d = Path(args.dump_dir) d.mkdir(parents=True, exist_ok=True) n = 0 for g in por.get("empotrado", []): nombre = re.sub(r"[^\w.-]", "_", g.ruta) + ".sh" (d / nombre).write_text(g.codigo, encoding="utf-8") n += 1 print(f"\nEmpotrados escritos en {d} ({n} ficheros)") tsv = Path(args.tsv) tsv.parent.mkdir(parents=True, exist_ok=True) with tsv.open("w", encoding="utf-8") as fh: fh.write("poblacion\truta\tpadre\tshebang\tlineas\tash_ok\tbash_ok\tash_error\t" "bashismos\trasgos\tbuiltins\texternas\n") for g in guiones: fh.write(f"{g.poblacion}\t{g.ruta}\t{g.padre or ''}\t{g.shebang}\t{g.lineas}\t" f"{'si' if g.ash_ok else 'no'}\t{'si' if g.bash_ok else 'no'}\t{g.ash_error}\t" f"{'|'.join(sorted(g.bashismos))}\t{'|'.join(sorted(g.rasgos))}\t" f"{'|'.join(sorted(g.builtins))}\t{'|'.join(sorted(g.externas))}\n") print(f"\nTSV: {tsv}") return len(rotos) if __name__ == "__main__": sys.exit(main())