From ddc6e4845bf502a960047f07d75e4b4053354fe3 Mon Sep 17 00:00:00 2001 From: Sergio Date: Mon, 21 Sep 2026 18:57:14 +0000 Subject: [PATCH] =?UTF-8?q?sh:=20el=20banco=20DIFERENCIAL=20=E2=80=94=20co?= =?UTF-8?q?mpara=20el=20=C3=81RBOL=20producido,=20no=20s=C3=B3lo=20stdout,?= =?UTF-8?q?=20y=20el=20generador=20saca=20el=20bug=20de=20brush=20sin=20sa?= =?UTF-8?q?ber=20que=20existe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plan-botar-busybox.md | 125 ++++ .../01-brush-1394-comillas-invertidas.sh | 8 + .../sh-casos/02-shift-mas-alla-de-argc.sh | 5 + .../fixtures/sh-casos/03-card-arje-exec.sh | 10 + .../sh-casos/04-heredoc-comillado-y-guion.sh | 12 + .../fixtures/sh-casos/05-set-e-y-subshell.sh | 9 + .../fixtures/sh-casos/06-ifs-split-y-glob.sh | 13 + scripts/fixtures/sh-casos/07-printf-y-echo.sh | 6 + .../sh-casos/08-trap-exit-y-estado.sh | 5 + .../fixtures/sh-casos/09-local-en-funcion.sh | 8 + scripts/fixtures/sh-casos/10-escribe-arbol.sh | 12 + scripts/fixtures/sh-casos/LEEME.md | 15 + scripts/sh-banco-diferencial.py | 618 ++++++++++++++++++ 13 files changed, 846 insertions(+) create mode 100644 scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh create mode 100644 scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh create mode 100644 scripts/fixtures/sh-casos/03-card-arje-exec.sh create mode 100644 scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh create mode 100644 scripts/fixtures/sh-casos/05-set-e-y-subshell.sh create mode 100644 scripts/fixtures/sh-casos/06-ifs-split-y-glob.sh create mode 100644 scripts/fixtures/sh-casos/07-printf-y-echo.sh create mode 100644 scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh create mode 100644 scripts/fixtures/sh-casos/09-local-en-funcion.sh create mode 100644 scripts/fixtures/sh-casos/10-escribe-arbol.sh create mode 100644 scripts/fixtures/sh-casos/LEEME.md create mode 100755 scripts/sh-banco-diferencial.py diff --git a/docs/plan-botar-busybox.md b/docs/plan-botar-busybox.md index 327cfc59..bf15919d 100644 --- a/docs/plan-botar-busybox.md +++ b/docs/plan-botar-busybox.md @@ -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. diff --git a/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh b/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh new file mode 100644 index 00000000..0e88e200 --- /dev/null +++ b/scripts/fixtures/sh-casos/01-brush-1394-comillas-invertidas.sh @@ -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)]" diff --git a/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh b/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh new file mode 100644 index 00000000..23c98e99 --- /dev/null +++ b/scripts/fixtures/sh-casos/02-shift-mas-alla-de-argc.sh @@ -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=$?" diff --git a/scripts/fixtures/sh-casos/03-card-arje-exec.sh b/scripts/fixtures/sh-casos/03-card-arje-exec.sh new file mode 100644 index 00000000..efd2ec06 --- /dev/null +++ b/scripts/fixtures/sh-casos/03-card-arje-exec.sh @@ -0,0 +1,10 @@ +# La forma exacta del payload de las cards de arje (crates/takana-bootstrap/src/lib.rs:240): +# argv = ["sh", "-c", "; ; exec "] +# 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 diff --git a/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh b/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh new file mode 100644 index 00000000..6891da06 --- /dev/null +++ b/scripts/fixtures/sh-casos/04-heredoc-comillado-y-guion.sh @@ -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 < 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=$#" diff --git a/scripts/fixtures/sh-casos/07-printf-y-echo.sh b/scripts/fixtures/sh-casos/07-printf-y-echo.sh new file mode 100644 index 00000000..f4fd57fc --- /dev/null +++ b/scripts/fixtures/sh-casos/07-printf-y-echo.sh @@ -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 "" diff --git a/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh b/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh new file mode 100644 index 00000000..1d1e37da --- /dev/null +++ b/scripts/fixtures/sh-casos/08-trap-exit-y-estado.sh @@ -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=$?" diff --git a/scripts/fixtures/sh-casos/09-local-en-funcion.sh b/scripts/fixtures/sh-casos/09-local-en-funcion.sh new file mode 100644 index 00000000..778a1d05 --- /dev/null +++ b/scripts/fixtures/sh-casos/09-local-en-funcion.sh @@ -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 diff --git a/scripts/fixtures/sh-casos/10-escribe-arbol.sh b/scripts/fixtures/sh-casos/10-escribe-arbol.sh new file mode 100644 index 00000000..bbe244e1 --- /dev/null +++ b/scripts/fixtures/sh-casos/10-escribe-arbol.sh @@ -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 diff --git a/scripts/fixtures/sh-casos/LEEME.md b/scripts/fixtures/sh-casos/LEEME.md new file mode 100644 index 00000000..120a789b --- /dev/null +++ b/scripts/fixtures/sh-casos/LEEME.md @@ -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. diff --git a/scripts/sh-banco-diferencial.py b/scripts/sh-banco-diferencial.py new file mode 100755 index 00000000..92d0920c --- /dev/null +++ b/scripts/sh-banco-diferencial.py @@ -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_]+"), ""), + (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"), ""), +] + + +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())