Files
takana/scripts/sh-superficie-cards.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

325 lines
14 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 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())