Pregunta distinta a la de las cards, y el doc lo dice: scripts/ corre en el hub bajo el bash del anfitrión, así que su superficie sólo obliga el día del auto-alojamiento. Lo que importa hoy es si algún guion se declara POSIX y no lo es, porque ése se rompe en la imagen, donde /bin/sh es el ash de busybox. - 81 POSIX (10.167 líneas), 76 bash (10.269), 21 EMPOTRADOS por heredoc (los /init que viajan a la imagen, y que hoy nadie parsea), 6 que se hacen source - los 81 POSIX y los 21 empotrados los parsea el ash: CERO roto - 48 de los 76 bash parsean tal cual bajo ash; de los 28 que no, 26 lo son por arrays y nada más. Ése es el presupuesto entero de un sh propio para correr la herramienta del proyecto - 27 builtins, 257 órdenes por nombre desnudo y 35 por ruta absoluta; 0 applets retirados, o sea que este inventario y busybox-vigia.py ahora coinciden El escáner se extrajo a scripts/lib/sh_analisis.py para no tener dos copias que divergan (ADR 0019 un piso más abajo). Comprobado que la extracción no cambió la salida de sh-superficie-cards.py, byte a byte, y que el orden de los empates es determinista para que sea diffable. Van anotados los SIETE bugs del instrumento, que es la parte que enseña: los comentarios sin quitar (89 backticks de prosa en cosecha-cron.sh), shlex desincronizándose sobre el fichero entero, los cuerpos de heredoc (uno era Python y aportaba la orden 'p' — sacarlos bajó las externas de 680 a 257), los patrones de case dando 'init', los cuerpos de $(( )) dando 'i+1', los descriptores dando '2', y la ruta absoluta confundida con applet. Ninguno se veía en la salida: los siete daban números plausibles.
347 lines
16 KiB
Python
Executable File
347 lines
16 KiB
Python
Executable File
#!/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"(?<!\$)\(\("),
|
||
("<<< (here-string)", r"<<<"),
|
||
("<( ) proceso", r"<\("),
|
||
("declare/typeset", r"\bdeclare\b|\btypeset\b"),
|
||
("mapfile/readarray", r"\bmapfile\b|\breadarray\b"),
|
||
("${v//x/y}", r"\$\{\w+//"),
|
||
("${v^^} ${v,,}", r"\$\{\w+\^\^|\$\{\w+,,"),
|
||
("+= en asignación", r"^\s*\w+\+="),
|
||
("function nombre", r"\bfunction\s+\w+"),
|
||
("PIPESTATUS/BASH_*", r"\bPIPESTATUS\b|\bBASH_\w+"),
|
||
("echo -e", r"\becho\s+-e\b"),
|
||
("read -a / -d", r"\bread\s+-[ad]\b"),
|
||
("source (no `.`)", r"^\s*source\s"),
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class Guion:
|
||
ruta: str
|
||
poblacion: str # posix | bash | empotrado | sin-shebang
|
||
shebang: str
|
||
codigo: str
|
||
padre: str | None = None # para los empotrados: quién lo escribe
|
||
ash_ok: bool | None = None
|
||
bash_ok: bool | None = None
|
||
ash_error: str = ""
|
||
rasgos: set[str] = field(default_factory=set)
|
||
bashismos: set[str] = field(default_factory=set)
|
||
builtins: collections.Counter = field(default_factory=collections.Counter)
|
||
externas: collections.Counter = field(default_factory=collections.Counter)
|
||
lineas: int = 0
|
||
|
||
|
||
# ───────────────────────────────────────────────── recolección
|
||
|
||
# Ficheros de shell SIN extensión `.sh` y sin shebang que igual son shell. Se nombran uno por uno
|
||
# a propósito: el balde automático traía `.html`, `.mp4` y `.c`, y un balde así no es una medición.
|
||
# `perfil/profile` importa doble porque VIAJA A LA IMAGEN: lo lee toda shell de login.
|
||
EXTRAS = {"scripts/servidor/perfil/profile"}
|
||
|
||
FUERA = ("/fixtures/sh-casos/", "/__pycache__/", "/.git/")
|
||
|
||
|
||
def es_shell(ruta: Path, primera: str) -> 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())
|