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.
This commit is contained in:
Sergio
2026-09-21 18:58:09 +00:00
parent 0db85d51f8
commit ddc6e4845b
13 changed files with 846 additions and 0 deletions
+125
View File
@@ -454,3 +454,128 @@ ripgrep` en el store del worker.
- **Las otras 21 dependientes**, cuando la granja llegue.
- El siguiente corte natural son los `BORRAR` que no se tocaron por prudencia y los 272 que siguen:
el número baja a 0 por el paso 2 (declarar la munición sellada) y el 5 (`grep`/`sed` POSIX).
---
# El banco DIFERENCIAL — construido y CORRIDO, 2026-09-21
**Instrumento:** `scripts/sh-banco-diferencial.py` · **casos de regresión:**
`scripts/fixtures/sh-casos/` · **dónde:** el hub, contra los tres shells sellados que ya están en
`/store` (`2a2b1280…-busybox`, `715237c7…-brush`, `87bf1a59…-bash`). No tocó la granja ni el lock.
## Por qué, si ya existía `sh-banco-posix.sh`
El veredicto del paso 3 dejó escrita la lección y no el instrumento:
> **brush pasa 56/57 del banco POSIX y aun así no construye una sola receta autotools.**
Un banco de casos escritos a mano mide lo que a uno se le ocurrió preguntar. El banco diferencial
pregunta otra cosa, y no hace falta saber la respuesta de antemano:
> **¿este shell hace LO MISMO que el control, sobre el material que hay?**
Tres decisiones de diseño, y las tres se ganaron con el bug de brush:
1. **Se compara el ÁRBOL DE FICHEROS PRODUCIDO, no sólo stdout.** El `libtool` mal escapado salía
con `rc=0` y stdout idéntico: **ningún banco de stdout puede verlo**. Cada caso corre en un
directorio descartable y se le toma huella (ruta + bit de ejecución + sha256) a todo lo que
escribió. El caso `10-escribe-arbol.sh` diverge **sólo en el árbol** — es la demostración de que
esta columna hace falta.
2. **El esperado es el control, no una aserción.** Un caso no declara resultado: declara material.
Eso permite tirarle al banco las 1130 fases del corpus y 26 `configure`/`libtool` reales sin
escribir una línea de expectativa.
3. **Toda divergencia se CONFIRMA** volviendo a correr control y candidato. Un caso que imprime un
PID diverge siempre y no dice nada (pasó en el primer intento con la card de arje, que imprimía
`$$`); un shell que diverge **de sí mismo** es un hallazgo distinto y peor. Sin esto el banco se
llena de ruido y deja de leerse, que es como muere un banco.
Además: todos los shells se invocan por un symlink `sh` (argv[0] igual para todos, que es como se
despliegan), con el **mismo userland busybox en el `PATH`** —las divergencias tienen que venir del
shell, no de un `sed` distinto— y con `PATH` apuntando primero al shell bajo prueba, así un `sh`
recursivo cae en ÉL y no en el del sistema.
## Lo medido — 1273 casos, 5,5 s
```
scripts/sh-banco-diferencial.py --gen --phases \
--files '/work/sergio/work/sources/*/configure' \
--files '/work/sergio/work/sources/*/libtool' \
control=…-busybox/bin/busybox brush=…-brush/usr/bin/brush bash=…-bash/bin/bash
```
| fuente | casos | modo | brush | bash |
|---|---|---|---|---|
| `cases` — regresiones conocidas | 10 | ejecutado | **4 divergen** | 0 |
| `gen` — tortura de comillas | 107 | ejecutado | **31 divergen** | 8 |
| `phases` — las fases de las 549 recetas | 1130 | `sh -n` | **0** | 0 |
| `files``configure` y `libtool` reales | 26 | `sh -n` | **0** | 0 |
**Los 1156 casos de parseo no divergen en ningún shell.** Confirma lo que el paso 3 ya decía —el
parser de brush está bien— y lo dice ahora con bash de tercero en discordia y con el árbol
comparado. **Sirve sobre todo para saber dónde NO buscar.**
## Lo que el generador encontró SOLO
El generador barre el producto cartesiano `forma de sustitución × contexto × profundidad de barras
invertidas × carácter objetivo` (107 casos). **No sabe que el bug #1394 existe, y lo escupe entero:**
```
✗ [brush] tortura/bt-desnudo-b1-var control: [hola] brush: [$V]
✗ [brush] tortura/bt-desnudo-b2-var control: [$V] brush: [\hola]
✗ [brush] tortura/bt-desnudo-b2-literal control: [x] brush: [\x]
✗ [brush] libtool/case-de-config-status ← el bucle REAL de config.status, divergente
```
Ése es el punto del ejercicio: **el bug de brush apareció en el primer build real, después de
sellar la receta y de montar un lab entero. El generador lo habría dado el día cero, en cinco
segundos, sin saber qué buscaba.**
## Un hallazgo NUEVO, y no es del frente A
El fixture `08-trap-exit-y-estado.sh` destapó algo que no estaba en el veredicto del paso 3:
```sh
( trap "echo trap-sub" EXIT; true ); echo despues
# busybox → trap-sub / despues bash → trap-sub / despues brush → despues
```
**brush no ejecuta NUNCA el `trap … EXIT` de un subshell**, ni con `exit` explícito. Es independiente
de #1394 y, a diferencia de aquél, **golpea el frente B**: un script de limpieza que confía en su
trap no limpia, en silencio, con `rc=0`. La frase del veredicto —«en el producto brush no tiene
ninguna evidencia en contra»— ya no se sostiene: ahora la tiene. **Sin reportar upstream todavía.**
Y se confirmó la tercera, ya conocida: `shift` más allá de `$#` devuelve 2 (POSIX/busybox/bash: 1).
## Las 8 de bash, que enseñan a leer el banco
bash diverge en 8 casos del generador, **todos de la misma familia**: comilla o backtick sin cerrar
dentro de `` `` ``, donde busybox da `rc=2` y bash acepta. No es que bash esté mal — es que **el
control es una referencia, no la verdad**. Cuando el candidato diverge, hay que ir a POSIX; el banco
dice dónde mirar, no quién tiene razón.
## Latencia, de paso
300 invocaciones de `sh -c :` por shell, en el hub:
| shell | µs/invocación | binario |
|---|---|---|
| busybox | 464 | 1,17 MB |
| bash | 864 | 1,57 MB |
| brush | 1724 | 6,92 MB |
**brush/busybox = 3,7×** acá, contra el 8,9× medido en el worker: esta cifra incluye el `fork`+`exec`
de Python y comprime el factor. Sirve para comparar, no como número absoluto. La dirección es la
misma y el argumento del veredicto no cambia.
## Lo que este banco NO mide (y hay que decirlo así)
- **No EJECUTA las fases del corpus** — sólo las parsea. Ejecutarlas necesita el sandbox y sus
fuentes, que es la extensión natural y la que cerraría el caso: correr un `configure` real bajo el
candidato y **comparar el árbol generado** contra el del control. La maquinaria de comparación ya
está; falta engancharla al sandbox.
- **El generador sólo tortura comillas.** Faltan familias enteras: expansión de parámetros, `IFS` y
división en campos, aritmética, here-docs, señales y control de trabajos.
- **Nada de interactivo** — ni edición de línea, ni historial, ni `job control`.
- **El árbol no mira permisos finos ni mtimes**, sólo ruta + bit de ejecución + contenido.
- **El banco no prueba suficiencia.** Es la misma advertencia de siempre, y ahora con dos
instrumentos que la ilustran en vez de uno.
@@ -0,0 +1,8 @@
# POSIX.1-2024 §2.6.3: dentro de `…` la barra invertida es literal SALVO ante $, ` y \.
# Reproductor exacto de reubeno/brush#1394 (docs/plan-botar-busybox.md, veredicto del paso 3).
# Es el bug que hizo que 0 de 3 recetas autotools construyeran bajo brush.
V=hola
echo "a: [`echo \\$V`]"
echo "b: [`echo \\\\`]"
echo "c: [`echo \$V`]"
echo "d: [$(echo \\$V)]"
@@ -0,0 +1,5 @@
# La única divergencia que el banco POSIX de 57 casos encontró en brush: `shift` más allá de $#
# devuelve 2; POSIX/busybox/bash devuelven 1. Cosmético salvo bajo `set -e`.
set --
shift 2>/dev/null
echo "rc=$?"
@@ -0,0 +1,10 @@
# La forma exacta del payload de las cards de arje (crates/takana-bootstrap/src/lib.rs:240):
# argv = ["sh", "-c", "<cmd>; <cmd>; exec <daemon>"]
# El `exec` final tiene que REEMPLAZAR el proceso, no forkear: si el shell forkea, arje supervisa
# al shell y no al daemon, y el reinicio queda apuntando al proceso equivocado.
# El caso compara PIDs entre sí, nunca los imprime: un PID en stdout es ruido no determinista.
echo paso1 > paso1.txt
echo $$ > pid-antes.txt
echo paso2
exec sh -c 'if [ "$(cat pid-antes.txt)" = "$$" ]; then r=si; else r=no; fi; rm -f pid-antes.txt; echo "exec-reemplaza=$r"'
echo NO-DEBERIA-LLEGAR
@@ -0,0 +1,12 @@
# 40 heredocs en 29 recetas del corpus. El delimitador comillado NO expande; el `<<-` come tabs
# de indentación (tabs REALES, no espacios) y nada más.
V=expandido
cat <<EOF
uno: $V
EOF
cat <<"EOF"
dos: $V
EOF
cat <<-EOF
tres: $V
EOF
@@ -0,0 +1,9 @@
# `set -e` es la semántica históricamente más divergente entre shells. ADR 0020 dice que las fases
# de build corren SIN -e, pero las cards y los scripts del producto sí lo usan.
set -e
( false ) || echo "or-salva"
if false; then echo no; fi
echo "sigo-vivo"
f() { false; echo "en-funcion-tras-false"; }
f || echo "funcion-abortada"
echo fin
@@ -0,0 +1,13 @@
# Orden de expansiones: sustitución → división en campos por IFS → expansión de rutas → quita de
# comillas. Un shell que las ordene mal rompe cualquier `for f in $LISTA`.
: > a.txt; : > b.txt
IFS=,
set -- $(echo "uno,dos,tres")
echo "campos=$# todos=[$*]"
IFS=' '
for f in *.txt; do echo "glob=$f"; done
for f in noexiste*; do echo "sin-match=$f"; done
x="a b"
echo "comillado=[$x] desnudo=[$x]"
set -- $x
echo "partido=$#"
@@ -0,0 +1,6 @@
# `echo` con barras invertidas es territorio no portable (XSI lo expande, otros no) y el corpus lo
# usa. `printf` es el contrato real. Esto mide en qué se separan.
printf '%s|%03d|%s\n' uno 7 'a\tb'
printf '%b\n' 'a\tb'
echo 'a\tb'
echo -n "sin-salto"; echo ""
@@ -0,0 +1,5 @@
# `trap ... EXIT` + el estado de salida propagado: lo usan los scripts de la granja para limpiar.
trap 'echo "trap-exit rc=$?"' EXIT
( trap 'echo "trap-subshell"' EXIT; true )
false
echo "ultimo=$?"
@@ -0,0 +1,8 @@
# `local` NO es POSIX y aun así lo usan 20 recetas del corpus. Cualquier sh propio lo necesita, y
# los shells que lo traen no coinciden en si hereda valor ni en el rc de `local x=$(false)`.
x=fuera
f() { local x=dentro; echo "dentro=$x"; }
f
echo "fuera=$x"
g() { local y; echo "vacia=[$y]"; }
g
@@ -0,0 +1,12 @@
# Caso de ÁRBOL: rc y stdout idénticos, lo que cambia es el fichero escrito. Es la clase de bug
# que tumbó a brush (el libtool generado con un nivel de escapado de menos) y la que ningún banco
# de stdout puede ver.
V='a"b`c\d$e'
{
echo "directo=$V"
echo "bt=`echo \\$V`"
echo "dollar=$(echo \\$V)"
printf '%s\n' "$V"
} > generado.txt
mkdir -p sub && cp generado.txt sub/copia.txt
echo listo
+15
View File
@@ -0,0 +1,15 @@
# Casos de regresión del banco diferencial
Un fichero `.sh` por caso. Los corre `scripts/sh-banco-diferencial.py` bajo cada shell en un
directorio limpio y compara **rc + stdout + árbol de ficheros producido** contra el control.
Reglas:
- **Un caso por fichero**, con un comentario arriba que diga QUÉ mide y DE DÓNDE salió
(bug, receta, card). Un caso sin procedencia es un caso que nadie sabe por qué está.
- **Nada de rutas absolutas ni red.** El `cwd` es descartable y el `PATH` trae el userland de
busybox más el shell bajo prueba; `HOME`, `LC_ALL=C`, `TZ=UTC` fijos.
- **Si el caso escribe ficheros, mejor** — el árbol producido es lo que atrapa los bugs que no se
ven en stdout (el `libtool` mal escapado de brush salía con rc=0 y stdout idéntico).
- El caso **no declara resultado esperado**: el esperado es lo que haga el control. Esto es un
banco diferencial, no una suite de aserciones.
+618
View File
@@ -0,0 +1,618 @@
#!/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())