Files
takana/scripts/sh-banco-diferencial.py
Sergio ddc6e4845b sh: el banco DIFERENCIAL — compara el ÁRBOL producido, no sólo stdout, y el generador saca el bug de brush sin saber que existe
El banco POSIX de 57 casos lo pasa brush 56/57 y aun así no construye una
receta autotools: mide lo que a uno se le ocurrió preguntar. Este pregunta si
el candidato hace LO MISMO que el control sobre el material que hay.

- compara (rc, stdout, árbol de ficheros producido); stderr es aviso, no
  divergencia — el texto de error diverge legítimamente
- el árbol es la columna que faltaba: el caso 10 diverge SÓLO ahí, que es la
  clase de bug del libtool mal escapado (rc=0, stdout idéntico)
- toda divergencia se confirma re-corriendo control y candidato: separa caso
  no determinista de shell inestable de divergencia real
- fuentes: fixtures de regresión, generador de tortura de comillas, las 1130
  fases del corpus y configure/libtool reales en sh -n

Medido, 1273 casos en 5,5 s: brush 35 duras, bash 8, y CERO en los 1156 casos
de parseo. El generador saca el #1394 entero sin conocerlo. Hallazgo nuevo:
brush no ejecuta nunca el trap EXIT de un subshell — y eso golpea el frente
del PRODUCTO, donde el veredicto del paso 3 decía que no había evidencia en
contra.
2026-09-21 18:58:09 +00:00

619 lines
25 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
"""Banco DIFERENCIAL para candidatos a /bin/sh — SDD: docs/plan-botar-busybox.md.
No pregunta «¿este shell hace lo que yo creo que POSIX manda?» (eso es
`scripts/sh-banco-posix.sh`, 57 casos escritos a mano: brush lo pasa 56/57 y aun
así no construye una sola receta autotools). Pregunta otra cosa:
¿este shell hace LO MISMO que el control, sobre el material que hay?
Cada caso se corre bajo N shells en un directorio limpio y se compara la tupla
completa contra el control:
(rc, stdout, ÁRBOL DE FICHEROS PRODUCIDO) ← divergencia DURA
(stderr normalizado) ← AVISO: el texto de error
diverge legítimamente
El árbol de ficheros es la parte que importa y la que faltaba: el bug que tumbó
a brush (POSIX 2.6.3, la barra invertida dentro de `…`) no se ve en el stdout de
ningún caso sintético — se ve en el `libtool` que el shell ESCRIBE, 25 líneas con
un nivel de escapado de menos. Un banco que sólo mira stdout lo deja pasar.
Fuentes de casos (combinables):
--cases DIR ficheros .sh sueltos (regresiones conocidas)
--gen generador de tortura de comillas: producto cartesiano de
forma de sustitución × contexto × profundidad de barras ×
carácter objetivo. Es el que encuentra el bug de brush SIN
saber que existe
--phases las fases [build.phases] de recipes/*.toml, en modo `sh -n`
--files GLOB ficheros reales (configure, libtool, config.status) en `sh -n`
--latency N N invocaciones de `sh -c :` por shell, sin comparar nada
Uso:
scripts/sh-banco-diferencial.py --gen \\
control=/store/2a2b…-busybox/bin/busybox \\
brush=/store/7152…-brush/usr/bin/brush
El PRIMER shell es el control salvo que se nombre uno `control=` o se pase
--control NOMBRE. Salida: tabla por shell + TSV. Estado de salida = número de
divergencias duras del peor shell (0 = todos coinciden con el control).
"""
from __future__ import annotations
import argparse
import concurrent.futures
import glob as globmod
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
# ─────────────────────────────────────────────────────────── modelo
@dataclass
class Shell:
nombre: str
binario: Path
dir_sh: Path | None = None # dir con un symlink `sh` -> binario (argv[0]=sh)
@property
def sh(self) -> Path:
return (self.dir_sh / "sh") if self.dir_sh else self.binario
@dataclass
class Caso:
nombre: str
origen: str # cases | gen | phases | files
codigo: str
modo: str = "run" # run | parse (parse = `sh -n`, no ejecuta)
@dataclass
class Resultado:
rc: int
out: str
err: str
arbol: str
ms: float
timeout: bool = False
@dataclass
class Divergencia:
caso: Caso
shell: str
clase: str = "dura" # dura | aviso | no-determinista | inestable
campos: list[str] = field(default_factory=list) # rc | stdout | arbol
avisos: list[str] = field(default_factory=list) # stderr
control: Resultado | None = None
obtenido: Resultado | None = None
# ─────────────────────────────────────────────────────────── casos
TORTURA_OBJETIVOS = [
("var", "$V"),
("backtick", "`"),
("barra", "\\"),
("comilla", '"'),
("literal", "x"),
]
def generar_tortura_comillas() -> list[Caso]:
"""Producto cartesiano de la tortura de comillas.
POSIX.1-2024 §2.6.3: dentro de `…` la barra invertida conserva su
significado literal SALVO cuando precede a `$`, `` ` `` o `\\`, donde se
ELIMINA antes de parsear el texto del comando. Dentro de $( ) la regla es
otra: el contenido se parsea como un script completo. brush aplica la de
$( ) a las dos — y eso es exactamente lo que este generador barre.
"""
casos: list[Caso] = []
for forma, abre, cierra in (("bt", "`", "`"), ("dollar", "$(", ")")):
for ctx in ("desnudo", "comillado"):
for prof in range(0, 5):
for etiqueta, objetivo in TORTURA_OBJETIVOS:
cuerpo = "\\" * prof + objetivo
interior = f"echo {cuerpo}"
sust = f"{abre}{interior}{cierra}"
linea = f"echo [{sust}]" if ctx == "desnudo" else f'echo "[{sust}]"'
casos.append(Caso(
nombre=f"tortura/{forma}-{ctx}-b{prof}-{etiqueta}",
origen="gen",
codigo=f"V=hola\n{linea}\n",
))
# Anidamientos: el otro sitio donde los shells se separan.
anidados = {
"anid/bt-en-dollar": 'V=hola\necho "[$(echo `echo \\\\$V`)]"\n',
"anid/dollar-en-bt": 'V=hola\necho "[`echo $(echo \\\\$V)`]"\n',
"anid/bt-en-bt": 'V=hola\necho "[`echo \\`echo $V\\``]"\n',
"anid/dollar-en-dollar": 'V=hola\necho "[$(echo $(echo $V))]"\n',
"anid/comilla-en-bt": 'V=hola\necho "[`echo "$V"`]"\n',
}
for nombre, codigo in anidados.items():
casos.append(Caso(nombre=nombre, origen="gen", codigo=codigo))
# El bucle REAL de config.status que rompió libtool (plan-botar-busybox.md).
# No es un caso sintético: es el material que decide.
casos.append(Caso(
nombre="libtool/case-de-config-status",
origen="gen",
codigo=r'''ECHO="printf %s\\n"
SED=sed
GREP=grep
sed_quote_subst='s/\([`"$\\]\)/\\\1/g'
for var in ECHO SED GREP; do
case `eval \\$ECHO \\""\\$$var"\\"` in
*[\\\`\"\$]*) eval "lt_$var=\\\"\`\$ECHO \"\$$var\" | \$SED \"\$sed_quote_subst\"\`\\\"" ;;
*) eval "lt_$var=\\\"\$$var\\\"" ;;
esac
done
echo "ECHO=[$lt_ECHO]"
echo "SED=[$lt_SED]"
echo "GREP=[$lt_GREP]"
''',
))
# Un caso que ESCRIBE un fichero: verifica que el árbol se compara de verdad.
casos.append(Caso(
nombre="libtool/escribe-fichero-escapado",
origen="gen",
codigo=r'''V='a"b`c\d$e'
{
echo "directo=$V"
echo "bt=`echo \\$V`"
echo "dollar=$(echo \\$V)"
printf '%s\n' "$V"
} > generado.txt
''',
))
return casos
def cargar_casos_dir(directorio: Path) -> list[Caso]:
casos = []
for f in sorted(directorio.rglob("*.sh")):
casos.append(Caso(
nombre=f"cases/{f.relative_to(directorio)}",
origen="cases",
codigo=f.read_text(encoding="utf-8", errors="replace"),
))
return casos
RE_FASES = re.compile(r"\[build\.phases\](.*?)(?=\n\[|\Z)", re.S)
RE_CLAVE = re.compile(r'^\s*(\w+)\s*=\s*(.*)$', re.S)
def cargar_fases(patron: str) -> list[Caso]:
"""Extrae las fases de shell de las recetas. Modo `parse`: no se ejecutan
(necesitarían el sandbox y sus fuentes), pero el parser sí se mide."""
import tomllib
casos = []
for ruta in sorted(globmod.glob(patron)):
try:
datos = tomllib.loads(Path(ruta).read_text(encoding="utf-8", errors="replace"))
except Exception:
continue
fases = datos.get("build", {}).get("phases")
if not isinstance(fases, dict):
continue
nombre_receta = Path(ruta).stem
for fase, cuerpo in fases.items():
if isinstance(cuerpo, list):
cuerpo = "\n".join(str(x) for x in cuerpo)
if not isinstance(cuerpo, str) or not cuerpo.strip():
continue
casos.append(Caso(
nombre=f"phases/{nombre_receta}:{fase}",
origen="phases",
codigo=cuerpo if cuerpo.endswith("\n") else cuerpo + "\n",
modo="parse",
))
return casos
def cargar_ficheros(patrones: list[str]) -> list[Caso]:
casos = []
for patron in patrones:
for ruta in sorted(globmod.glob(patron, recursive=True)):
p = Path(ruta)
if not p.is_file():
continue
casos.append(Caso(
nombre=f"files/{p.name}",
origen="files",
codigo=p.read_text(encoding="utf-8", errors="replace"),
modo="parse",
))
return casos
# ─────────────────────────────────────────────────────────── ejecución
def hash_arbol(raiz: Path) -> str:
"""Huella del árbol de ficheros producido: ruta + bit de ejecución +
sha256 del contenido. Esto es lo que atrapa al libtool mal escapado."""
filas = []
for dirpath, dirnames, filenames in os.walk(raiz):
dirnames.sort()
for nombre in sorted(filenames):
f = Path(dirpath) / nombre
rel = f.relative_to(raiz).as_posix()
try:
if f.is_symlink():
filas.append(f"l {rel} {os.readlink(f)}")
continue
datos = f.read_bytes()
except OSError as e:
filas.append(f"e {rel} {e.errno}")
continue
ejec = "x" if os.access(f, os.X_OK) else "-"
filas.append(f"f {rel} {ejec} {hashlib.sha256(datos).hexdigest()[:16]}")
if not filas:
return "(vacío)"
return hashlib.sha256("\n".join(filas).encode()).hexdigest()[:16] + f" [{len(filas)}]"
def entorno(shell: Shell, bindir: Path, home: Path) -> dict[str, str]:
# PATH: primero el shell bajo prueba (para que un `sh` recursivo caiga en
# ÉL), después SIEMPRE el mismo userland — las divergencias tienen que
# venir del shell, no de un sed distinto.
return {
"PATH": f"{shell.dir_sh}:{bindir}" if shell.dir_sh else str(bindir),
"HOME": str(home),
"LC_ALL": "C",
"LANG": "C",
"TZ": "UTC",
"SHELL": str(shell.sh),
"TERM": "dumb",
}
def ejecutar(shell: Shell, caso: Caso, bindir: Path, timeout: float) -> Resultado:
base = Path(tempfile.mkdtemp(prefix="shdif-"))
try:
prog = base / "prog.sh"
prog.write_text(caso.codigo, encoding="utf-8")
cwd = base / "cwd"
cwd.mkdir()
home = base / "home"
home.mkdir()
argv = [str(shell.sh)]
if caso.modo == "parse":
argv.append("-n")
argv.append(str(prog))
t0 = time.monotonic()
try:
p = subprocess.run(
argv, cwd=cwd, env=entorno(shell, bindir, home),
stdin=subprocess.DEVNULL, capture_output=True, timeout=timeout,
)
rc, out, err, to = p.returncode, p.stdout, p.stderr, False
except subprocess.TimeoutExpired as e:
rc, out, err, to = -9, e.stdout or b"", e.stderr or b"", True
ms = (time.monotonic() - t0) * 1000
return Resultado(
rc=rc,
out=out.decode("utf-8", "replace"),
err=err.decode("utf-8", "replace"),
arbol=hash_arbol(cwd),
ms=ms,
timeout=to,
)
finally:
shutil.rmtree(base, ignore_errors=True)
RE_RUIDO = [
(re.compile(r"/tmp/shdif-[A-Za-z0-9_]+"), "<TMP>"),
(re.compile(r"^[^\s:]*(?:sh|brush|bash|busybox)[^\s:]*:\s*", re.M), ""),
(re.compile(r"\bline \d+\b"), "line N"),
(re.compile(r"\blínea \d+\b"), "line N"),
(re.compile(r"prog\.sh"), "<PROG>"),
]
def normalizar_err(texto: str) -> str:
for rx, rep in RE_RUIDO:
texto = rx.sub(rep, texto)
return " ".join(texto.split())
def comparar(ctrl: Resultado, otro: Resultado) -> tuple[list[str], list[str]]:
duras, avisos = [], []
if ctrl.rc != otro.rc:
duras.append("rc")
if ctrl.out != otro.out:
duras.append("stdout")
if ctrl.arbol != otro.arbol:
duras.append("arbol")
if normalizar_err(ctrl.err) != normalizar_err(otro.err):
avisos.append("stderr")
return duras, avisos
# ─────────────────────────────────────────────────────────── informe
def recorta(texto: str, n: int = 240) -> str:
texto = texto.replace("\n", "\\n")
return texto if len(texto) <= n else texto[:n] + f"…(+{len(texto)-n})"
def medir_latencia(shells: list[Shell], n: int, bindir: Path) -> None:
print(f"\n── Latencia de arranque · {n} invocaciones de `sh -c :`")
base = Path(tempfile.mkdtemp(prefix="shdif-lat-"))
try:
for sh in shells:
env = entorno(sh, bindir, base)
subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
stdin=subprocess.DEVNULL, capture_output=True) # calentar
t0 = time.monotonic()
for _ in range(n):
subprocess.run([str(sh.sh), "-c", ":"], env=env, cwd=base,
stdin=subprocess.DEVNULL, capture_output=True)
total = time.monotonic() - t0
tam = sh.binario.stat().st_size / 1024 / 1024
print(f" {sh.nombre:<12} {total/n*1e6:8.0f} µs/invocación "
f"binario {tam:5.2f} MB")
finally:
shutil.rmtree(base, ignore_errors=True)
print(" ⚠ incluye el fork+exec de Python: sirve para COMPARAR, no como cifra absoluta.")
def main() -> int:
ap = argparse.ArgumentParser(
description="Banco diferencial de shells: compara rc, stdout y árbol producido "
"contra un shell de control.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="ejemplo:\n %(prog)s --gen control=/store/…-busybox/bin/busybox "
"brush=/store/…-brush/usr/bin/brush\n",
)
ap.add_argument("shells", nargs="+", metavar="NOMBRE=RUTA",
help="shells a comparar; el primero es el control salvo --control")
ap.add_argument("--control", metavar="NOMBRE", help="cuál de los shells es el control")
ap.add_argument("--cases", metavar="DIR", default=str(RAIZ / "scripts/fixtures/sh-casos"),
help="directorio de casos .sh (por defecto: scripts/fixtures/sh-casos)")
ap.add_argument("--no-cases", action="store_true", help="no cargar el directorio de casos")
ap.add_argument("--gen", action="store_true", help="añadir el generador de tortura de comillas")
ap.add_argument("--phases", action="store_true",
help="añadir las fases [build.phases] de recipes/*.toml en modo `sh -n`")
ap.add_argument("--files", metavar="GLOB", action="append", default=[],
help="añadir ficheros reales (configure, libtool…) en modo `sh -n`")
ap.add_argument("--latency", metavar="N", type=int, default=0,
help="medir latencia de arranque con N invocaciones y salir")
ap.add_argument("--timeout", metavar="SEG", type=float, default=10.0)
ap.add_argument("--jobs", metavar="N", type=int, default=4)
ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-diferencial.tsv"))
ap.add_argument("--verbose", action="store_true", help="volcar el diff de cada divergencia")
ap.add_argument("--max-show", metavar="N", type=int, default=12,
help="cuántas divergencias detallar por shell (0 = todas)")
args = ap.parse_args()
# ── shells
tmp_sh = Path(tempfile.mkdtemp(prefix="shdif-bin-"))
shells: list[Shell] = []
for spec in args.shells:
if "=" not in spec:
print(f"✗ shell mal declarado (falta NOMBRE=): {spec}", file=sys.stderr)
return 2
nombre, ruta = spec.split("=", 1)
binario = Path(ruta).resolve()
if not binario.is_file() or not os.access(binario, os.X_OK):
print(f"✗ no es un ejecutable: {binario}", file=sys.stderr)
return 2
# symlink `sh` -> binario, para que argv[0] sea `sh` en todos por igual
# (busybox despacha por argv[0]; brush también cambia de modo).
d = tmp_sh / nombre
d.mkdir(parents=True)
(d / "sh").symlink_to(binario)
shells.append(Shell(nombre=nombre, binario=binario, dir_sh=d))
nombre_control = args.control or ("control" if any(s.nombre == "control" for s in shells)
else shells[0].nombre)
try:
control = next(s for s in shells if s.nombre == nombre_control)
except StopIteration:
print(f"✗ el control «{nombre_control}» no está entre los shells", file=sys.stderr)
return 2
otros = [s for s in shells if s is not control]
if not otros:
print("✗ hace falta al menos un shell además del control", file=sys.stderr)
return 2
# ── userland común: el busybox del control si lo es, o el del sistema
bindir = tmp_sh / "_bin"
bindir.mkdir()
bb = None
for s in shells:
if "busybox" in s.binario.name or "busybox" in str(s.binario):
bb = s.binario
break
if bb:
subprocess.run([str(bb), "--install", "-s", str(bindir)],
capture_output=True)
# `sh` del userland NO: cada shell trae el suyo en su propio dir
(bindir / "sh").unlink(missing_ok=True)
(bindir / "ash").unlink(missing_ok=True)
if not any(bindir.iterdir()):
for herr in ("sed", "grep", "awk", "tr", "wc", "cat", "printf", "echo", "ls",
"rm", "mkdir", "cp", "mv", "sort", "head", "tail", "cut", "expr",
"test", "true", "false", "sleep", "env", "date", "basename", "dirname"):
r = shutil.which(herr)
if r:
(bindir / herr).symlink_to(r)
try:
if args.latency:
medir_latencia(shells, args.latency, bindir)
return 0
# ── casos
casos: list[Caso] = []
dir_casos = Path(args.cases)
if not args.no_cases and dir_casos.is_dir():
casos += cargar_casos_dir(dir_casos)
if args.gen:
casos += generar_tortura_comillas()
if args.phases:
casos += cargar_fases(str(RAIZ / "recipes/*.toml"))
if args.files:
casos += cargar_ficheros(args.files)
if not casos:
print("✗ ningún caso cargado — pasá --gen, --phases, --files o poblá "
f"{dir_casos}", file=sys.stderr)
return 2
por_origen: dict[str, int] = {}
for c in casos:
por_origen[c.origen] = por_origen.get(c.origen, 0) + 1
print(f"Banco diferencial · {len(casos)} casos "
f"({', '.join(f'{k}={v}' for k, v in sorted(por_origen.items()))})")
print(f"Control: {control.nombre}{control.binario}")
for s in otros:
print(f" vs {s.nombre}{s.binario}")
# ── corrida
#
# Toda divergencia dura se CONFIRMA volviendo a correr control y shell. Un caso que
# imprime un PID o una fecha diverge siempre y no dice nada; un shell que diverge de sí
# mismo es un hallazgo distinto (y peor). Sin esta confirmación el banco se llena de
# ruido y deja de leerse — que es como muere un banco.
def tarea(caso: Caso) -> tuple[Caso, dict[str, Resultado], list[Divergencia]]:
res = {s.nombre: ejecutar(s, caso, bindir, args.timeout) for s in shells}
ctrl = res[control.nombre]
hallazgos: list[Divergencia] = []
for s in otros:
duras, avisos = comparar(ctrl, res[s.nombre])
if duras:
ctrl_bis = ejecutar(control, caso, bindir, args.timeout)
otro_bis = ejecutar(s, caso, bindir, args.timeout)
if comparar(ctrl, ctrl_bis)[0]:
clase = "no-determinista"
elif comparar(res[s.nombre], otro_bis)[0]:
clase = "inestable"
else:
clase = "dura"
elif avisos:
clase = "aviso"
else:
continue
hallazgos.append(Divergencia(
caso=caso, shell=s.nombre, clase=clase, campos=duras, avisos=avisos,
control=ctrl, obtenido=res[s.nombre],
))
return caso, res, hallazgos
divergencias: list[Divergencia] = []
cuenta: dict[str, dict[str, int]] = {
s.nombre: {"dura": 0, "aviso": 0, "no-determinista": 0, "inestable": 0}
for s in otros
}
timeouts: dict[str, int] = {s.nombre: 0 for s in shells}
ms_por_shell: dict[str, float] = {s.nombre: 0.0 for s in shells}
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex:
for i, (caso, res, hallazgos) in enumerate(ex.map(tarea, casos), 1):
for s in shells:
ms_por_shell[s.nombre] += res[s.nombre].ms
if res[s.nombre].timeout:
timeouts[s.nombre] += 1
for d in hallazgos:
cuenta[d.shell][d.clase] += 1
divergencias.append(d)
if i % 200 == 0:
print(f" … {i}/{len(casos)}", file=sys.stderr)
# ── informe
print("\n── Resumen")
for s in shells:
if s is control:
print(f" {s.nombre:<12} (control) "
f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso"
+ (f" timeouts={timeouts[s.nombre]}" if timeouts[s.nombre] else ""))
else:
c = cuenta[s.nombre]
extra = ""
if c["no-determinista"]:
extra += f" casos-no-deterministas={c['no-determinista']}"
if c["inestable"]:
extra += f" INESTABLE={c['inestable']}"
if timeouts[s.nombre]:
extra += f" timeouts={timeouts[s.nombre]}"
print(f" {s.nombre:<12} DIVERGE={c['dura']:<5} avisos(stderr)={c['aviso']:<5} "
f"{ms_por_shell[s.nombre]/len(casos):6.1f} ms/caso" + extra)
for clase, titulo in (("no-determinista", "Casos NO DETERMINISTAS — el control no se "
"repite a sí mismo; el caso está mal escrito, no el shell"),
("inestable", "Shell INESTABLE — no se repite a sí mismo. Es un "
"hallazgo peor que una divergencia")):
cuales = [d for d in divergencias if d.clase == clase]
if cuales:
print(f"\n── {titulo} ({len(cuales)})")
for d in cuales:
print(f" ⚠ [{d.shell}] {d.caso.nombre}")
duras = [d for d in divergencias if d.clase == "dura"]
if duras:
print(f"\n── Divergencias DURAS ({len(duras)})")
for s in otros:
suyas = [d for d in duras if d.shell == s.nombre]
if not suyas:
continue
mostrar = suyas if args.max_show == 0 else suyas[:args.max_show]
for d in mostrar:
print(f"\n ✗ [{d.shell}] {d.caso.nombre} ({', '.join(d.campos)})")
if args.verbose or True:
print(f" código: {recorta(d.caso.codigo)}")
if "rc" in d.campos:
print(f" rc: control={d.control.rc} "
f"{d.shell}={d.obtenido.rc}")
if "stdout" in d.campos:
print(f" control: {recorta(d.control.out)}")
print(f" {d.shell:<9} {recorta(d.obtenido.out)}")
if "arbol" in d.campos:
print(f" árbol: control={d.control.arbol} "
f"{d.shell}={d.obtenido.arbol}")
if args.max_show and len(suyas) > args.max_show:
print(f"\n … y {len(suyas)-args.max_show} divergencias duras más de "
f"{s.nombre} (ver el TSV; --max-show 0 las muestra todas)")
# ── TSV
tsv = Path(args.tsv)
tsv.parent.mkdir(parents=True, exist_ok=True)
with tsv.open("w", encoding="utf-8") as f:
f.write("shell\tcaso\torigen\tmodo\tclase\tcampos\trc_control\trc_shell\t"
"arbol_control\tarbol_shell\n")
for d in divergencias:
f.write(f"{d.shell}\t{d.caso.nombre}\t{d.caso.origen}\t{d.caso.modo}\t{d.clase}\t"
f"{','.join(d.campos + ['stderr'] * bool(d.avisos))}\t"
f"{d.control.rc}\t{d.obtenido.rc}\t"
f"{d.control.arbol}\t{d.obtenido.arbol}\n")
print(f"\nTSV: {tsv}")
peor = max((c["dura"] for c in cuenta.values()), default=0)
if peor == 0:
print("\n✓ ningún shell diverge del control en rc, stdout ni árbol producido.")
print(" ⚠ y eso NO es prueba de suficiencia: mide el material que se le dio.")
return min(peor, 250)
finally:
shutil.rmtree(tmp_sh, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())