Files
takana/crates/simi/tests/divergencias_medidas.rs
T
Sergio 6809ef4be2 simi: el /bin/sh propio del producto — 4448 líneas, sólo libc, y CERO divergencias contra el ash
«simi» es boca/lengua en quechua. Es el sh que el frente C necesitaba: busybox
está en la raíz de confianza del arranque (STAGE1_COMPONENTS, ATTEST_PATHS) y en
el exec de cada card, y el candidato ajeno que se midió —brush 0.4.0— pesa 6,9 MB,
arranca 4x más lento que el ash y trae dos divergencias medidas (#1394, #1396).

Alcance MEDIDO, no supuesto: las 23 cards con shell (8 builtins, 13
construcciones), los 21 /init empotrados de las imágenes (+ if, funciones, case,
aquí-documentos) y el POSIX de takana-live-install.sh.

Resultado del banco diferencial, 159 casos contra el ash de busybox 1.36.1
(10 fixtures de regresión, 107 de tortura de comillas, 23 fragmentos de cards,
19 /init empotrados):

    simi   DIVERGE=0        bash  DIVERGE=8        brush  DIVERGE=35

Incluye el bucle REAL de config.status que genera cualquier configure de
autotools — el que tumbó a brush. 499 KB release, 1,4x la latencia del ash
(brush: 4,1x). 47 pruebas propias en verde.

Seis bugs que el banco destapó, y ninguno se veía sin un control al lado:

1. El lexer fundía lo entrecomillado con lo desnudo, así que x="a b" se ejecutaba
   como ORDEN y el error decía «no encontrado» — mandando a buscar al lugar
   equivocado. Ahora el comillado es por carácter.
2. Usaba la barra invertida como marca interna y la quitaba al final, así que una
   barra PRODUCIDA por una sustitución se perdía. La quita de comillas es sobre
   las comillas de la palabra, nunca sobre el resultado de una expansión: hace
   falta una máscara de protección, no un escape dentro del texto.
3. set -e no se suspendía dentro del cuerpo de una función llamada desde una
   condición. Ahora es un contador dinámico, que entra solo donde corresponde.
4. Los delimitadores de la sustitución de orden y de ${ } no respetaban la barra
   invertida: un paréntesis escapado cerraba la sustitución.
5. Las clases entre corchetes no entendían la barra invertida — y la del
   config.status de libtool es literalmente una clase con cuatro escapes.
6. Dentro de comillas dobles el cuerpo de un backtick sigue entre comillas, así
   que la barra también se elimina ante la comilla doble.

Y uno que el banco NO vio y sí vio la prueba de regresión: el trap EXIT HEREDADO
se disparaba en el subshell. POSIX §2.12 manda resetearlo — es la mitad
complementaria del #1396 de brush, y al revés. Los dos oráculos hacen falta; el
caso quedó agregado al fixture 08.
2026-09-21 20:22:40 +00:00

199 lines
7.7 KiB
Rust

//! Las divergencias que el banco diferencial MIDIÓ, convertidas en pruebas.
//!
//! Cada una está acá porque se rompió de verdad —en brush 0.4.0, o en la primera versión de simi—
//! y ninguna se veía sin comparar contra un shell de control. La referencia de todas es el ash de
//! busybox 1.36.1, medido en la misma corrida (`docs/plan-botar-busybox.md`).
//!
//! Corren el binario de verdad, no la librería: lo que importa es el comportamiento observable.
use std::process::Command;
fn correr(guion: &str) -> (i32, String, String) {
let salida = Command::new(env!("CARGO_BIN_EXE_simi"))
.arg("-c")
.arg(guion)
.output()
.expect("no pude ejecutar simi");
(
salida.status.code().unwrap_or(-1),
String::from_utf8_lossy(&salida.stdout).into_owned(),
String::from_utf8_lossy(&salida.stderr).into_owned(),
)
}
fn salida(guion: &str) -> String {
correr(guion).1
}
/// reubeno/brush#1394 · POSIX.1-2024 §2.6.3. La barra invertida dentro de `` ` ` `` es literal
/// salvo ante `$`, `` ` `` y `\`. Es el bug que hacía que el `libtool` generado saliera con un
/// nivel de escapado de menos y que 0 de 3 recetas autotools construyeran.
#[test]
fn barra_invertida_en_comillas_invertidas() {
let g = r#"V=hola
echo "a: [`echo \$V`]"
echo "b: [`echo \\`]"
echo "c: [`echo \\$V`]"
echo "d: [$(echo \\$V)]""#;
assert_eq!(salida(g), "a: [hola]\nb: [\\]\nc: [$V]\nd: [\\hola]\n");
}
/// reubeno/brush#1396 · el `trap EXIT` de un subshell CORRE. brush no lo ejecuta nunca —ni con
/// `exit` explícito— y falla en silencio: `rc=0` y la limpieza sin hacer.
#[test]
fn trap_exit_de_subshell() {
assert_eq!(
salida(r#"trap 'echo "[padre]"' EXIT; ( trap 'echo "[sub]"' EXIT; echo dentro ); echo fuera"#),
"dentro\n[sub]\nfuera\n[padre]\n"
);
// con `exit` explícito y con el estado preservado
let (rc, out, _) = correr(r#"( trap 'echo T' EXIT; exit 3 ); echo rc=$?"#);
assert_eq!((rc, out.as_str()), (0, "T\nrc=3\n"));
// y en una sustitución de orden, que también es un subshell
assert_eq!(salida(r#"x=$( trap 'echo T' EXIT; echo v ); echo "[$x]""#), "[v\nT]\n");
}
/// El trap heredado NO se dispara en el subshell (POSIX §2.12): se resetea al entrar. Es el caso
/// complementario del anterior, y confundirlos es fácil.
#[test]
fn trap_heredado_no_se_dispara_en_el_subshell() {
assert_eq!(
salida(r#"trap 'echo "[exit]"' EXIT; echo padre; ( echo sub ); echo vuelta"#),
"padre\nsub\nvuelta\n[exit]\n"
);
}
/// `shift` más allá de `$#`: estado 1 y los posicionales intactos. brush devolvía 2.
#[test]
fn shift_mas_alla_de_argc() {
assert_eq!(salida("set --; shift 2>/dev/null; echo rc=$?"), "rc=1\n");
assert_eq!(salida("set -- a b; shift 5 2>/dev/null; echo rc=$?-$#"), "rc=1-2\n");
assert_eq!(salida("set -- a b c; shift 2; echo $1"), "c\n");
}
/// El bucle REAL del `config.status` que genera cualquier `configure` de autotools. Es el
/// consumidor que decide, y el que ningún banco sintético reproduce: la primera versión de simi
/// devolvía las tres variables VACÍAS, igual que brush.
#[test]
fn bucle_de_config_status_de_libtool() {
let g = 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]""#;
// La expectativa es lo que da el ash de busybox, medido en la misma corrida — no lo que uno
// cree que debería dar: la primera versión de esta prueba escribió `%s\n` de memoria y el
// control dice `%s\\n`.
assert_eq!(
salida(g),
"ECHO=[\"printf %s\\\\n\"]\nSED=[\"sed\"]\nGREP=[\"grep\"]\n"
);
}
/// Una asignación cuyo valor trae comillas o escapes sigue siendo una ASIGNACIÓN. Si el lexer
/// funde el prefijo desnudo con el valor entrecomillado, `x="a b"` se ejecuta como si fuera una
/// orden — y el fallo se lee como «no encontrado», que manda a buscar al lugar equivocado.
#[test]
fn asignaciones_con_comillas_y_escapes() {
assert_eq!(salida(r#"x="a b"; echo "[$x]"; set -- $x; echo $#"#), "[a b]\n2\n");
assert_eq!(salida(r#"eval "y=\\\"hola\\\""; echo "[$y]""#), "[\"hola\"]\n");
assert_eq!(salida(r#"z=a'b'c; echo "[$z]""#), "[abc]\n");
}
/// `set -e` se SUSPENDE dentro de una condición, y la suspensión entra en el cuerpo de la función
/// llamada desde ahí. Es la semántica más divergente entre shells y la que rompe los scripts de
/// limpieza cuando está mal.
#[test]
fn set_e_se_suspende_en_condiciones() {
let g = r#"set -e
( false ) || echo or-salva
if false; then echo no; fi
f() { false; echo en-funcion-tras-false; }
f || echo funcion-abortada
echo fin"#;
assert_eq!(salida(g), "or-salva\nen-funcion-tras-false\nfin\n");
// …y sigue abortando cuando toca
let (rc, out, _) = correr("set -e\necho antes\nfalse\necho despues");
assert_eq!((rc, out.as_str()), (1, "antes\n"));
}
/// El orden de las expansiones (§2.6): lo entrecomillado no se divide ni se globea, y una barra
/// invertida que SALE de una expansión es un carácter literal, no un escape.
#[test]
fn orden_de_expansiones() {
assert_eq!(salida(r#"echo "/dev/nul*""#), "/dev/nul*\n");
assert_eq!(salida("echo /dev/nul*"), "/dev/null\n");
assert_eq!(salida("echo /no-existe-jamas*"), "/no-existe-jamas*\n");
assert_eq!(salida(r#"V=hola; echo [$(echo \\$V)]"#), "[\\hola]\n");
assert_eq!(salida("IFS=:; x=a:b:c; set -- $x; echo $#"), "3\n");
assert_eq!(salida(r#"v=arch.tar.gz; echo ${v%%.*} ${v##*.} ${#v}"#), "arch gz 11\n");
}
/// El idioma de las 23 cards de arje, entero: `test … || { echo >&2; exit 78; }; exec …`.
#[test]
fn el_idioma_de_las_cards_de_arje() {
let (rc, out, err) = correr(
"test -f /no-existe || { echo 'falta la config' >&2; exit 78; }; exec /bin/true",
);
assert_eq!(rc, 78);
assert_eq!(out, "");
assert_eq!(err, "falta la config\n");
// el camino bueno: el `exec` REEMPLAZA el proceso
let g = "echo $$ > /dev/null; exec /bin/echo reemplazado; echo NO-DEBERIA-LLEGAR";
assert_eq!(salida(g), "reemplazado\n");
}
/// El bucle de espera de las cards de compatibilidad (`arje-polkit-compat`, `arje-logind-compat`).
#[test]
fn bucle_de_espera_con_aritmetica() {
let g = "i=0; while [ ! -S /no-existe ] && [ $i -lt 5 ]; do i=$((i+1)); done; echo $i";
assert_eq!(salida(g), "5\n");
}
/// Los `/init` empotrados de las imágenes: `for`, `case`, funciones, aquí-documentos.
#[test]
fn lo_que_usan_los_init_empotrados() {
let g = r#"say() { echo "==> $*"; }
say hola
for d in proc sys dev raro; do
case $d in proc|sys|dev) echo "salto $d" ;; *) echo "monto $d" ;; esac
done
cat <<FIN
literal $d
FIN
cat <<'CRUDO'
sin expandir $d
CRUDO"#;
assert_eq!(
salida(g),
"==> hola\nsalto proc\nsalto sys\nsalto dev\nmonto raro\nliteral raro\nsin expandir $d\n"
);
}
/// Un error de expansión en un shell no interactivo ABORTA con 2, no sigue con 1.
#[test]
fn errores_de_expansion_abortan_con_2() {
let (rc, out, _) = correr("echo antes; echo ${nada:?falta}; echo despues");
assert_eq!((rc, out.as_str()), (2, "antes\n"));
let (rc, _, _) = correr("set -u; echo $NODEFINIDA");
assert_eq!(rc, 2);
let (rc, _, _) = correr("echo [`echo \"`]");
assert_eq!(rc, 2, "una comilla sin cerrar en una sustitución es error de sintaxis");
}
/// `$(( ))`: la división por cero es un error, no un cero silencioso.
#[test]
fn division_por_cero_no_es_cero() {
let (rc, out, _) = correr("echo $((1/0)); echo despues");
assert_eq!(rc, 2);
assert_eq!(out, "");
}