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.
325 lines
14 KiB
Python
Executable File
325 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Mide la superficie REAL de shell de las cards de arje — SDD: docs/plan-botar-busybox.md.
|
||
|
||
La pregunta que contesta: **si mañana hubiera un `/bin/sh` propio, ¿qué tendría que saber hacer
|
||
para que el PRODUCTO arranque?** No «qué dice POSIX», no «qué usa autotools»: qué usan las cards
|
||
que arje ejecuta de verdad, contado sobre el árbol.
|
||
|
||
Poblaciones (las cuatro que emiten cards en este repo):
|
||
|
||
1. `recipes/*.toml` · bloques `[[service]]` — el emisor de verdad (`Service::card`,
|
||
`crates/takana-core/src/service.rs`), uno por servicio de paquete.
|
||
2. `crates/takana-bootstrap/src/lib.rs` · las cards empotradas como constantes `r#"…"#`
|
||
(`STAGE1_SEED_CARD` con su génesis, `SSHD_SERVICE_CARD`).
|
||
3. `scripts/servidor/cards/*.json` · cards escritas a mano para el servidor de producción.
|
||
4. `scripts/mudanza/declarar.py` · el envoltorio `sh -c 'cd … && exec …'` que la mudanza le
|
||
pone a todo servicio con `cwd` (SDD 29). No se cuenta por card porque depende del censo:
|
||
se cuenta como PLANTILLA, y su superficie es la de la plantilla.
|
||
|
||
De cada card se saca el fragmento de shell (el `argv` que sigue a `sh -c`), y de cada fragmento:
|
||
|
||
- **qué construcciones del lenguaje usa** (bucles, `case`, sustitución, aritmética, redirecciones,
|
||
grupos, …) — detección por patrón sobre texto tokenizado, no por parseo: aproximada y dicha así;
|
||
- **qué órdenes invoca**, separadas en *builtins* (las tiene que traer el shell) y *externas*
|
||
(las tiene que traer la imagen, y son la lista que necesita el guardián de applets que el plan
|
||
dice que falta).
|
||
|
||
Uso:
|
||
scripts/sh-superficie-cards.py # resumen + TSV
|
||
scripts/sh-superficie-cards.py --list # además, cada fragmento entero
|
||
scripts/sh-superficie-cards.py --dump-dir DIR # escribe un .sh por fragmento, para dárselos
|
||
# a sh-banco-diferencial.py --files
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import collections
|
||
import json
|
||
import re
|
||
import shlex
|
||
import sys
|
||
import tomllib
|
||
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, rasgos_y_ordenes)
|
||
|
||
# Los shells que una card puede invocar, y el multicall que los trae.
|
||
SHELLS = {"sh", "ash", "bash", "busybox"}
|
||
|
||
|
||
@dataclass
|
||
class Fragmento:
|
||
origen: str # recipes | bootstrap | servidor | mudanza
|
||
fuente: str # fichero:cosa
|
||
label: str
|
||
exec_: str
|
||
argv: list[str]
|
||
codigo: str | None = None # el texto que corre el shell; None = no hay shell
|
||
plantilla: bool = False # el fragmento es una plantilla, no una card real
|
||
rasgos: set[str] = field(default_factory=set)
|
||
builtins: collections.Counter = field(default_factory=collections.Counter)
|
||
externas: collections.Counter = field(default_factory=collections.Counter)
|
||
|
||
|
||
# ───────────────────────────────────────────────── extracción
|
||
|
||
def frag_de_payload(origen: str, fuente: str, card: dict) -> Fragmento | None:
|
||
payload = card.get("payload")
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
nativo = payload.get("Native")
|
||
if not isinstance(nativo, dict):
|
||
return None
|
||
ejec = nativo.get("exec", "")
|
||
argv = [str(a) for a in nativo.get("argv", [])]
|
||
f = Fragmento(origen=origen, fuente=fuente, label=str(card.get("label", "?")),
|
||
exec_=str(ejec), argv=argv)
|
||
f.codigo = codigo_de_argv(str(ejec), argv)
|
||
return f
|
||
|
||
|
||
def codigo_de_argv(ejecutable: str, argv: list[str]) -> str | None:
|
||
"""El texto que el shell va a interpretar, o None si la card no invoca un shell.
|
||
|
||
⚠ En una Card, **`argv` va SIN `argv[0]`**: arje lo pone desde `exec`. Lo dice la card de
|
||
`hammerd`, cuyo `argv` es `["--store", "/store", …]` a secas. Por eso los dos idiomas que
|
||
conviven en el corpus sólo se leen igual si se antepone el `exec`:
|
||
|
||
exec=/bin/busybox argv=["sh","-c",cod] ← recetas y bootstrap (despacho multicall)
|
||
exec=/bin/sh argv=["-c",cod] ← la mudanza (formatos/arje.py:35)
|
||
|
||
`getty -n -l /bin/sh` NO cuenta: ahí el shell es interactivo y no ejecuta un fragmento.
|
||
"""
|
||
linea = [Path(ejecutable).name] + [str(a) for a in argv]
|
||
if linea[0] == "busybox" and len(linea) > 1:
|
||
linea = linea[1:]
|
||
if linea[0] in SHELLS and "-c" in linea:
|
||
i = linea.index("-c")
|
||
if i + 1 < len(linea):
|
||
return linea[i + 1]
|
||
return None
|
||
|
||
|
||
def de_recetas() -> list[Fragmento]:
|
||
frags = []
|
||
for ruta in sorted(RAIZ.glob("recipes/*.toml")):
|
||
try:
|
||
datos = tomllib.loads(ruta.read_text(encoding="utf-8", errors="replace"))
|
||
except Exception:
|
||
continue
|
||
servicios = datos.get("service")
|
||
if isinstance(servicios, dict):
|
||
servicios = [servicios]
|
||
if not isinstance(servicios, list):
|
||
continue
|
||
for s in servicios:
|
||
if not isinstance(s, dict):
|
||
continue
|
||
argv = [str(a) for a in s.get("argv", [])]
|
||
f = Fragmento(origen="recipes", fuente=ruta.name,
|
||
label=str(s.get("label", "?")), exec_=str(s.get("exec", "")), argv=argv)
|
||
f.codigo = codigo_de_argv(str(s.get("exec", "")), argv)
|
||
frags.append(f)
|
||
return frags
|
||
|
||
|
||
RE_CONST = re.compile(r'const\s+([A-Z_0-9]+)\s*:\s*&str\s*=\s*r#"(.*?)"#;', re.S)
|
||
|
||
|
||
def de_bootstrap() -> list[Fragmento]:
|
||
"""Las cards empotradas como constantes. Una seed trae varias cards en `genesis`."""
|
||
frags = []
|
||
for ruta in sorted(RAIZ.glob("crates/*/src/*.rs")):
|
||
texto = ruta.read_text(encoding="utf-8", errors="replace")
|
||
for nombre, cuerpo in RE_CONST.findall(texto):
|
||
try:
|
||
v = json.loads(cuerpo)
|
||
except Exception:
|
||
continue
|
||
pendientes = [v]
|
||
while pendientes:
|
||
card = pendientes.pop()
|
||
if not isinstance(card, dict):
|
||
continue
|
||
for hija in card.get("genesis", []) or []:
|
||
pendientes.append(hija)
|
||
f = frag_de_payload("bootstrap", f"{ruta.name}:{nombre}", card)
|
||
if f:
|
||
frags.append(f)
|
||
return frags
|
||
|
||
|
||
def de_servidor() -> list[Fragmento]:
|
||
frags = []
|
||
for ruta in sorted(RAIZ.glob("scripts/**/cards/*.json")):
|
||
try:
|
||
v = json.loads(ruta.read_text(encoding="utf-8", errors="replace"))
|
||
except Exception:
|
||
continue
|
||
f = frag_de_payload("servidor", str(ruta.relative_to(RAIZ)), v)
|
||
if f:
|
||
frags.append(f)
|
||
return frags
|
||
|
||
|
||
RE_ENVOLTORIO = re.compile(r'''["']-c["']\s*,\s*f["'](.*?)["']\s*\]''')
|
||
RE_HUECO = re.compile(r"\{[^{}]*\}")
|
||
|
||
|
||
def de_mudanza() -> list[Fragmento]:
|
||
"""El envoltorio `sh -c 'cd … && exec …'` que la mudanza le pone a todo servicio con `cwd`
|
||
(`scripts/mudanza/formatos/arje.py:35`, SDD 29, regla 2 de `declarar.py`).
|
||
|
||
Es una PLANTILLA: el contenido sale del censo de la máquina que se muda, así que se mide la
|
||
forma y no una card concreta. Los `{…}` de la f-string se sustituyen por un marcador para
|
||
poder analizarla como shell — y el marcador es deliberadamente inofensivo, porque lo que la
|
||
plantilla interpole es justo lo que no está medido."""
|
||
frags = []
|
||
for ruta in sorted(RAIZ.glob("scripts/mudanza/**/*.py")):
|
||
texto = ruta.read_text(encoding="utf-8", errors="replace")
|
||
for i, cod in enumerate(RE_ENVOLTORIO.findall(texto)):
|
||
if not cod.strip():
|
||
continue
|
||
concreto = RE_HUECO.sub("VALOR", cod)
|
||
frags.append(Fragmento(
|
||
origen="mudanza", fuente=str(ruta.relative_to(RAIZ)), label=f"plantilla-{i+1}",
|
||
exec_="/bin/sh", argv=["-c", concreto], codigo=concreto, plantilla=True))
|
||
return frags
|
||
|
||
|
||
# ───────────────────────────────────────────────── análisis
|
||
|
||
def analizar(f: Fragmento) -> None:
|
||
"""Rellena rasgos/builtins/externas del fragmento. El escáner vive en
|
||
`scripts/lib/sh_analisis.py`, compartido con `sh-superficie-scripts.py`."""
|
||
if not f.codigo:
|
||
return
|
||
f.rasgos, f.builtins, f.externas = rasgos_y_ordenes(f.codigo)
|
||
|
||
|
||
# ───────────────────────────────────────────────── informe
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="Mide la superficie de shell de las cards de arje.")
|
||
ap.add_argument("--list", action="store_true", help="volcar cada fragmento entero")
|
||
ap.add_argument("--dump-dir", metavar="DIR",
|
||
help="escribir un .sh por fragmento (para sh-banco-diferencial.py --files)")
|
||
ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-cards.tsv"))
|
||
args = ap.parse_args()
|
||
|
||
frags = de_recetas() + de_bootstrap() + de_servidor() + de_mudanza()
|
||
for f in frags:
|
||
analizar(f)
|
||
|
||
con_shell = [f for f in frags if f.codigo and not f.plantilla]
|
||
sin_shell = [f for f in frags if not f.codigo]
|
||
plantillas = [f for f in frags if f.plantilla]
|
||
|
||
print(f"Cards analizadas: {len(frags) - len(plantillas)} "
|
||
f"(+{len(plantillas)} plantilla(s) de la mudanza)")
|
||
por_origen = collections.Counter(f.origen for f in frags if not f.plantilla)
|
||
for o, n in sorted(por_origen.items()):
|
||
con = sum(1 for f in frags if f.origen == o and f.codigo and not f.plantilla)
|
||
print(f" {o:<10} {n:3d} cards · {con:3d} invocan un shell")
|
||
print(f"\n → **{len(con_shell)} de {len(frags)-len(plantillas)} cards "
|
||
f"({100*len(con_shell)//max(1,len(frags)-len(plantillas))} %) ejecutan shell**; "
|
||
f"{len(sin_shell)} son exec directo.")
|
||
|
||
# ── rasgos
|
||
print("\n── Construcciones usadas (sobre los fragmentos reales, sin plantillas)")
|
||
hist = collections.Counter()
|
||
for f in con_shell:
|
||
hist.update(f.rasgos)
|
||
ancho = max((len(r) for r in hist), default=10)
|
||
catalogo = [r for r, _, _ in RASGOS]
|
||
# Orden DETERMINISTA: por cuenta y, en el empate, por el orden del catálogo. Con `most_common`
|
||
# los empates salían en el orden de iteración de un `set`, y la salida no era diffable.
|
||
for rasgo, n in sorted(hist.items(), key=lambda kv: (-kv[1], catalogo.index(kv[0]))):
|
||
barra = "█" * n
|
||
print(f" {rasgo:<{ancho}} {n:3d} {barra}")
|
||
no_usados = [r for r, _, _ in RASGOS if r not in hist]
|
||
if no_usados:
|
||
print(f"\n NO aparece ninguna vez: {', '.join(no_usados)}")
|
||
|
||
# ── órdenes
|
||
bi, ex = collections.Counter(), collections.Counter()
|
||
for f in con_shell:
|
||
bi.update(f.builtins)
|
||
ex.update(f.externas)
|
||
print(f"\n── Builtins que el shell tiene que traer ({len(bi)})")
|
||
print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
|
||
print(f"\n── Órdenes EXTERNAS que la imagen tiene que traer ({len(ex)})")
|
||
desnudas, absolutas = desnudas_y_absolutas(ex)
|
||
orden_ex = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
|
||
print(" desnudas (las resuelve el PATH): "
|
||
+ " ".join(f"{k}×{v}" for k, v in orden_ex))
|
||
print(" por ruta absoluta (no pasan por PATH): "
|
||
+ " ".join(f"{k}×{v}" for k, v in sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))))
|
||
|
||
# ── el cruce con el recorte de busybox: acá vive el guardián que el plan dice que falta
|
||
applets = applets_conocidos()
|
||
if applets:
|
||
retirados, siguen, ajenas = [], [], []
|
||
for orden, n in orden_ex:
|
||
fila = applets.get(orden)
|
||
if fila is None:
|
||
ajenas.append(orden)
|
||
elif fila[1] == "RETIRADO":
|
||
retirados.append((orden, n, fila[0]))
|
||
else:
|
||
siguen.append(orden)
|
||
print(f"\n── Cruce con el recorte del defconfig ({RUTA_APPLETS.name})")
|
||
print(f" siguen en busybox: {len(siguen)} · no son applets (otro paquete): "
|
||
f"{len(ajenas)} · RETIRADAS por el recorte: {len(retirados)}")
|
||
if ajenas:
|
||
print(f" no-applets: {', '.join(sorted(ajenas))}")
|
||
if retirados:
|
||
print("\n ⛔ CARDS QUE INVOCAN UN APPLET QUE YA NO ESTÁ EN LA IMAGEN:")
|
||
for orden, n, destino in retirados:
|
||
quien = [f.label for f in con_shell if orden in f.externas]
|
||
print(f" {orden} (×{n}, dueño previsto: {destino}) ← {', '.join(quien)}")
|
||
else:
|
||
print(" ✓ ninguna card invoca un applet retirado por el recorte 401 → 277.")
|
||
|
||
if plantillas:
|
||
print("\n── Plantillas (la mudanza; la card real depende del censo)")
|
||
for f in plantillas:
|
||
print(f" {f.fuente}:{f.label} rasgos={sorted(f.rasgos)}")
|
||
print(f" {f.codigo}")
|
||
|
||
if args.list:
|
||
print("\n── Fragmentos")
|
||
for f in sorted(con_shell, key=lambda x: (x.origen, x.label)):
|
||
print(f"\n [{f.origen}] {f.label} ({f.fuente})")
|
||
print(f" exec={f.exec_}")
|
||
print(f" {f.codigo}")
|
||
|
||
if args.dump_dir:
|
||
d = Path(args.dump_dir)
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
for f in con_shell:
|
||
nombre = re.sub(r"[^\w.-]", "_", f"{f.origen}-{f.label}") + ".sh"
|
||
(d / nombre).write_text(f.codigo + "\n", encoding="utf-8")
|
||
print(f"\nFragmentos escritos en {d} ({len(con_shell)} ficheros)")
|
||
|
||
tsv = Path(args.tsv)
|
||
tsv.parent.mkdir(parents=True, exist_ok=True)
|
||
with tsv.open("w", encoding="utf-8") as fh:
|
||
fh.write("origen\tfuente\tlabel\tshell\tplantilla\trasgos\tbuiltins\texternas\tcodigo\n")
|
||
for f in frags:
|
||
fh.write(f"{f.origen}\t{f.fuente}\t{f.label}\t{'si' if f.codigo else 'no'}\t"
|
||
f"{'si' if f.plantilla else 'no'}\t{'|'.join(sorted(f.rasgos))}\t"
|
||
f"{'|'.join(sorted(f.builtins))}\t{'|'.join(sorted(f.externas))}\t"
|
||
f"{(f.codigo or '').replace(chr(9), ' ').replace(chr(10), ' ')}\n")
|
||
print(f"\nTSV: {tsv}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|