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.
This commit is contained in:
Sergio
2026-09-21 19:54:12 +00:00
parent a66346fb05
commit a4105cba11
4 changed files with 867 additions and 214 deletions
+346
View File
@@ -0,0 +1,346 @@
#!/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())