Files
takana/scripts/sh-superficie-scripts.py
Sergio a4105cba11 scripts: la superficie medida — 0 guiones POSIX roto bajo ash, y los 28 bash lo son por ARRAYS
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.
2026-09-21 19:54:12 +00:00

347 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())