#!/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 python3 scripts/test-atuq-rootfs.py --dlopen # el TERCER escalón: cadenas literales 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. EL TERCER ESCALÓN, QUE NO LO VE NINGÚN AUDITOR DE `readelf` (`--dlopen`). La jerarquía es: NEEDED del ejecutable → lo ve este guardián y lo ve vigia-sonames NEEDED de un .so dlopeado → lo ven los dos, si el fichero está en el artefacto dlopen("libfoo.so.1") LITERAL → no hay NEEDED en ningún ELF ⇒ NO LO VE NINGUNO El repo ya tiene el caso caro escrito: OBS hace `dlopen("libGL.so.1")` desde su glad, y por eso «glvnd sin GLX no lo destraba» aunque todos los NEEDED estén satisfechos. Un navegador vive de eso: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una docena más por nombre, y **cuando no están no falla: apaga la función y sigue**. No hay línea roja; hay una función que nadie ofrece. `--dlopen` busca las cadenas con forma de soname dentro de los ELF del artefacto y las cruza contra el rootfs. **Es un HEURÍSTICO y se declara como tal**: una cadena no prueba un `dlopen` —puede estar en un mensaje de error— y la ausencia de cadena no prueba que no lo haya. Por eso no falla nunca: imprime un mapa triado para mirar, no un veredicto. Lo que sí se puede afirmar es lo contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta imagen.** 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/takana") # 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"} # ── Triaje del tercer escalón ───────────────────────────────────────────────────────────────── # La clasificación es criterio, no medición, así que va acá donde se puede discutir y no escondida # en un `if`. «decisión» = la distro eligió no tenerlo; «hueco» = falta y se nota; «ruido» = la # cadena existe pero no corresponde a una función real en musl o en este build. TRIAJE = { # decisiones ya tomadas por la distro, con su documento "libGL.so": ("decisión", "Wayland-only sin GLX: ninguna cola publica libGL (ver sin-opengl-de-escritorio)"), "libGL.so.1": ("decisión", "idem; Firefox va por EGL. `glxtest` sondea y se apaga"), "libcurl.so": ("decisión", "sólo lo usa `pingsender`, que manda telemetría — apagada a propósito"), "libcurl.so.3": ("decisión", "idem pingsender"), "libcurl.so.4": ("decisión", "idem pingsender"), "libcurl-gnutls.so": ("decisión", "idem pingsender"), "libcurl-gnutls.so.3": ("decisión", "idem pingsender"), "libcurl-gnutls.so.4": ("decisión", "idem pingsender"), "libgcompat.so.0": ("ruido", "shim glibc-en-musl de Alpine; no lo queremos"), "libdl.so.2": ("ruido", "musl funde dl/pthread/rt dentro de libc"), "libpthread.so.0": ("ruido", "idem musl"), "librt.so.1": ("ruido", "idem musl"), "libc.so": ("ruido", "el nombre musl de la libc; viene del lab por diseño"), "libudev.so.0": ("ruido", "soname viejo; el corpus provee libudev.so.1 (libudev-zero)"), "libfreebl3.so": ("ruido", "NSS de este build expone libfreeblpriv3.so, que SÍ está"), # huecos de verdad: la función no existe en esta imagen "libnotify.so.1": ("ruido", "soname viejo; el corpus provee libnotify.so.4 desde 2026-09-07"), "libsecret-1.so.0": ("hueco", "llavero: contraseñas al keyring. HAY receta en incoming-gnome"), "libcanberra.so.0": ("hueco", "sonidos del sistema. HAY receta en incoming-kde"), "libvulkan.so.1": ("hueco", "WebGPU/vulkan. HAY receta (vulkan-loader) en incoming-kde"), "libvulkan.so": ("hueco", "idem"), # ⚠ `libva` SÍ tiene receta (corpus, sellada, y en la clausura de los cuatro escritorios por # mpv) y desde 2026-09-07 está en las raíces de `atuq-nested.sh`. Sigue siendo hueco por la # OTRA mitad: las tres mesa van con `-Dgallium-va=disabled -Dvideo-codecs=` ⇒ no hay un solo # `*_drv_video.so` que cargar. Un cargador sin driver no es aceleración. "libva.so.2": ("hueco", "vídeo por HARDWARE: la lib está; falta el driver (mesa sin gallium-va)"), "libva-drm.so.2": ("hueco", "idem VA-API: sin ICD que cargar"), "libspeechd.so.2": ("hueco", "lectura en voz alta. Sin receta"), "libpci.so": ("hueco", "sólo para el sondeo de `glxtest`; irrelevante sin GLX"), "libpci.so.3": ("hueco", "idem"), "libonnxruntime.so": ("fuera de alcance", "funciones de ML del navegador"), "libaudit.so.1": ("fuera de alcance", "auditoría de NSS"), "libdbusmenu-glib.so.4": ("fuera de alcance", "menú global estilo Unity"), "libdbusmenu-gtk3.so.4": ("fuera de alcance", "idem"), "libgssapi.so.1": ("fuera de alcance", "Kerberos de empresa"), "libgssapi.so.4": ("fuera de alcance", "idem"), "libgssapi_krb5.so.2": ("fuera de alcance", "idem"), } # `libavcodec*` es una familia entera de sonames: Firefox sondea de la 53 a la 63 y todas las # variantes `-ffmpeg`. Se trata junta o el informe es una lista de once líneas iguales. # ⚠ ESTO DECÍA «hueco: sin receta de ffmpeg en el corpus» Y ERA FALSO (corregido 2026-09-07). # `recipes/ffmpeg.toml` existe, está sellada, publica `libavcodec.so.61` —justo uno de los sonames # que sondea Firefox— y ya viajaba en la clausura de los cuatro escritorios arrastrada por `mpv`. # Lo que faltaba era declararla en las raíces del runner. Medido de punta a punta con # `scripts/test-atuq-codecs.sh`: H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen. # Quedan como RUIDO y no como hueco: lo que falta son las OTRAS versiones del soname (53-60, 62, # 63), y Firefox sondea la lista entera hasta que una carga. Con la 61 puesta, la ausencia de las # demás no apaga ninguna función. FAMILIA_FFMPEG = ("ruido", "otras versiones del soname; el corpus provee libavcodec.so.61 (ffmpeg 7.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 LITERAL = re.compile(rb"\blib[A-Za-z0-9_+\-]{1,30}\.so(?:\.[0-9]+){0,3}\b") def literales(paths): """Cadenas con forma de soname dentro de los ELF. Heurístico declarado, no prueba.""" cand = {} for p in paths: try: with open(p, "rb") as fh: datos = fh.read() except OSError: continue for m in set(LITERAL.findall(datos)): cand.setdefault(m.decode(), set()).add(os.path.basename(p)) return cand def informe_dlopen(paths_artefacto, provee): cand = literales(paths_artefacto) faltan = {k: v for k, v in cand.items() if k not in provee} grupos = {} for s, quien in faltan.items(): clase, por_que = (FAMILIA_FFMPEG if s.startswith("libavcodec") else TRIAJE.get(s, ("SIN TRIAR", "nadie lo miró todavía"))) grupos.setdefault(clase, []).append((s, por_que, quien)) print(f"cadenas con forma de soname: {len(cand)} · sin fichero que las provea: {len(faltan)}\n") for clase in ("hueco", "SIN TRIAR", "decisión", "fuera de alcance", "ruido"): filas = sorted(grupos.get(clase, [])) if not filas: continue print(f"── {clase} ({len(filas)})") vistos = set() for s, por_que, quien in filas: if clase == "hueco" and por_que in vistos and s.startswith("libavcodec"): continue vistos.add(por_que) print(f" {s:24s} {por_que}") print(f" {'':24s} ← {', '.join(sorted(quien))[:64]}") sin_triar = len(grupos.get("SIN TRIAR", [])) print(f"\nRESULTADO: mapa impreso. Sin triar: {sin_triar}" + (" (agregalos a TRIAJE)" if sin_triar else "")) return 0 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 "--dlopen" in sys.argv[1:]: nombres_todo = set() for base in (RFS, atuq): for b, _, fs in os.walk(base): nombres_todo.update(fs) return informe_dlopen([p for p in paths if p.startswith(atuq)], nombres_todo) 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())