diff --git a/scripts/atuq-nested.sh b/scripts/atuq-nested.sh index 89e8dc5a..c93f76c1 100755 --- a/scripts/atuq-nested.sh +++ b/scripts/atuq-nested.sh @@ -39,6 +39,7 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep libffi-shared zlib-shared harfbuzz-shared fribidi freetype-shared fontconfig-shared pixman libpng-shared libjpeg-turbo-shared wayland libxkbcommon mesa libdrm dbus pipewire pulseaudio alsa-lib libxml2-shared dejavu-fonts + bzip2-shared expat-shared ncurses-shared libsndfile libudev-zero zstd dbus-shared xkeyboard-config grim" # ⚠ `gcc-libs` FALTABA, y su ausencia hacía MENTIR a esta prueba entera (2026-09-07). `atuq` declara # `NEEDED libstdc++.so.6` y `libgcc_s.so.1` —lo hereda de firefox, que va con clang++ y la libstdc++ @@ -49,6 +50,14 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep # sonames da CERO—, lo que estaba mal era ESTA lista, o sea el instrumento y no el artefacto. # Se nota a simple vista porque los dos ficheros no se parecen: el del corpus pesa 20.850.968 bytes # y el del lab 2.804.104. Si algún día hay duda de cuál se cargó, se mira el tamaño. +# +# La segunda tanda salió del guardián que nació de ese fallo, `scripts/test-atuq-rootfs.py`, que +# mira el rootfs HIDRATADO en vez del grafo: `bzip2-shared expat-shared ncurses-shared libsndfile +# libudev-zero zstd dbus-shared` — siete sonames más que también se resolvían contra el lab. +# `libexpat` y `libzstd` los pide mesa (iris_dri, libEGL, libgbm), o sea el camino de pintado; +# `libdbus-1.so.3` lo pide pipewire y NO lo trae `dbus` sino `dbus-shared`, el mismo cuadro de las +# variantes `-shared` de gtk3. Mantener esta lista a mano es lo que falla: por eso ahora hay un +# guardián que la audita en vez de confiar en que alguien se acuerde. # `grim` no lo necesita atuq: lo necesita QUIEN LO MIRA. Con `CAPTURA=` este script # saca la pantalla real desde dentro de la jaula, contra el mismo socket wayland. Es lo que separó # «el proceso vive» de «la ventana muestra lo que queremos»: durante horas dije «mirá tu pantalla» diff --git a/scripts/test-atuq-rootfs.py b/scripts/test-atuq-rootfs.py new file mode 100755 index 00000000..1409d53b --- /dev/null +++ b/scripts/test-atuq-rootfs.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""¿El rootfs de `atuq-nested.sh` resuelve TODOS los NEEDED, o alguno se cae al LAB? + + python3 scripts/test-atuq-rootfs.py # sale 0 si el rootfs se basta solo + python3 scripts/test-atuq-rootfs.py --negative-control # esconde una lib y exige que se note + +POR QUÉ EXISTE. `scripts/atuq-nested.sh` hidrata una **lista de raíces escrita a mano**, y la jaula +monta `.dev-fs/alpine` como capa de abajo. Esas dos cosas juntas hacen que una raíz que falte no se +note: el navegador arranca igual, resolviendo la librería contra el LAB. Pasó de verdad +(2026-09-07): faltaba `gcc-libs` y durante dos días todas las corridas de «atuq corre» cargaron la +`libstdc++.so.6` del lab —2.804.104 bytes— en vez de la del corpus —20.850.968—. **El artefacto +estaba sano; el instrumento era ciego**, que es la forma que más cuesta ver porque el resultado es +«todo bien». + +El vigía de sonames (`scripts/vigia-sonames.py`) cubre lo mismo pero sobre la CLAUSURA de un perfil, +que es un grafo de recetas. Esto mira el otro objeto: **el directorio hidratado que la jaula monta +de verdad**. Son preguntas distintas y la segunda es la que corresponde al runner, porque su lista +no sale del grafo. + +LO QUE SÍ VIENE DEL LAB, A PROPÓSITO. El intérprete `/lib/ld-musl-x86_64.so.1` y `libc.so` no los +provee NINGÚN artefacto del corpus —`musl` sella cabeceras y estáticas— y las imágenes lo copian del +devfs (`scripts/mirada-usb.sh`). Así que van en una lista explícita de excepciones: si algún día +aparece una receta que los provea, esta lista se achica y el guardián se vuelve más estricto solo. +""" +import os +import re +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs") +STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) +HAMMER = os.path.join(ROOT, "target/release/hammer") + +# Lo que el corpus NO provee y las imágenes copian del devfs. Documentado, no olvidado. +DEL_LAB = {"libc.so", "libc.musl-x86_64.so.1", "ld-musl-x86_64.so.1"} + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(receta, nombre): + r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear {receta}: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-{nombre}") + if not os.path.isdir(d): + fatal(f"{nombre} b3:{h[:8]} no está sellado") + return d + + +def elfs(raiz): + """Los ficheros ELF de un árbol, por número mágico y no por extensión. + + Por nombre no alcanza: hay `.so` que son scripts de linker y ejecutables sin extensión.""" + for base, _, ficheros in os.walk(raiz): + for f in ficheros: + p = os.path.join(base, f) + if os.path.islink(p) or not os.path.isfile(p): + continue + try: + with open(p, "rb") as fh: + if fh.read(4) != b"\x7fELF": + continue + except OSError: + continue + yield p + + +NEEDED = re.compile(r"\(NEEDED\).*\[([^\]]+)\]") +SONAME = re.compile(r"\(SONAME\).*\[([^\]]+)\]") + + +def dinamico(paths): + """(pide, provee) leídos con readelf en tandas.""" + pide, provee = {}, set() + for i in range(0, len(paths), 80): + lote = paths[i:i + 80] + r = subprocess.run(["readelf", "-d"] + lote, capture_output=True, text=True) + actual = None + for linea in r.stdout.splitlines(): + if linea.startswith("File: "): + actual = linea[6:].strip() + m = NEEDED.search(linea) + if m: + pide.setdefault(m.group(1), set()).add(actual or lote[0]) + m = SONAME.search(linea) + if m: + provee.add(m.group(1)) + if len(lote) == 1: + # readelf no imprime «File:» cuando recibe UNO solo: sin esto, el único fichero de la + # última tanda quedaría sin dueño y el informe no se podría triar. + for k in pide: + pide[k].discard(None) + return pide, provee + + +def main(): + negativo = "--negative-control" in sys.argv[1:] + if not os.path.isdir(RFS): + fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`") + atuq = artefacto("atuq", "atuq") + + # El appdir de atuq trae DECENAS de `.so` propias (libxul, libnspr4…): proveen y piden a la vez. + paths = sorted(set(list(elfs(RFS)) + list(elfs(atuq)))) + print(f"rootfs {RFS}\nartefacto {os.path.basename(atuq)}\nELF mirados: {len(paths)}") + pide, provee = dinamico(paths) + + # Un fichero puede proveer por su SONAME o por su NOMBRE: `libgcc_s.so.1` no declara SONAME en + # algunas construcciones y se resuelve por el nombre del fichero. Se aceptan los dos. + nombres = set() + for base in (RFS, atuq): + for b, _, fs in os.walk(base): + nombres.update(fs) + provee |= nombres + + if negativo: + # SABOTAJE: se hace de cuenta que el rootfs no trae libstdc++. Si el guardián no lo caza, + # es que no está mirando — que es justo el modo de fallo que este fichero existe para negar. + provee -= {"libstdc++.so.6", "libstdc++.so.6.0.34"} + print("== MODO CONTROL NEGATIVO: se esconde libstdc++.so.6 y se exige que el guardián lo cace") + + faltan = {s: v for s, v in pide.items() if s not in provee and s not in DEL_LAB} + for s, quien in sorted(faltan.items()): + muestra = sorted(os.path.basename(q) for q in quien if q)[:3] + print(f" ✗ {s} ← lo piden: {', '.join(muestra)}") + delegados = sorted(s for s in pide if s in DEL_LAB) + print(f"del lab por diseño ({len(delegados)}): {', '.join(delegados) or 'ninguno'}") + print(f"sonames pedidos: {len(pide)} · sin proveedor en el rootfs: {len(faltan)}") + + if negativo: + ok = "libstdc++.so.6" in faltan + print("RESULTADO:", "el guardián CAZA la librería escondida" if ok + else "NO SIRVE: no vio el sabotaje") + return 0 if ok else 1 + print("RESULTADO:", "el rootfs se basta solo" if not faltan + else "hay NEEDED que hoy resuelve el LAB — el runner miente") + return 0 if not faltan else 1 + + +sys.exit(main())