#!/usr/bin/env python3 # vigia-sonames.py — ¿qué `.so` pide una imagen que NINGÚN artefacto de su cierre provee? # # ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════ # El grafo de estado responde «¿está sellado?». No responde «¿arranca?». Y hay una fuga que pasa # entera por debajo del grafo: # # un binario `link = "dynamic"` sale con `NEEDED libffi.so.8`; la receta canónica de libffi es # `--disable-shared`; ningún artefacto del cierre publica ese SONAME; y el rootfs hidratado lo # resuelve contra el **sysroot Alpine DEL LAB**. # # El artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina que tenga # Alpine debajo. Es PEOR que una dep faltante, porque el lab no entra en `hash_inputs`: el store no # puede ni notarlo. Es el mismo patrón que [[artefacto-vacio-envenena-cache]] — algo que se lee como # presencia y no lo es. # # Se descubrió DOS VECES a mano y nunca hubo instrumento: la primera al armar la imagen de sway # (zlib/expat/libffi, ver el comentario del perfil `escritorio-sway` en targets.toml), y la segunda # el 2026-09-03 al verificar que mpv anduviera — donde resultó que KDE, GNOME y COSMIC tenían la # MISMA fuga desde siempre, con mesa y wayland pidiendo expat y libffi. Este script es el guardián # que sale de ese punto ciego: la regla del repo es que cada uno se convierte en uno. # # ══ POR QUÉ NO SE PUEDE LEER DE build-state.json ═══════════════════════════════════════════════ # Porque el JSON colapsa los nodos POR NOMBRE y hay nombres que viven en dos colas: su campo # `perfiles` puede quedar vacío para una receta que sí está en la imagen (por la otra cola). La # membresía honesta se identifica por PAR `(cola, nombre)` y de eso sabe yupana, que resuelve # sibling-first igual que el sandbox. Misma lección que el bug de libdrm: QUÉ NODOS PUNTÚO es una # decisión de vista; QUIÉN ME CONSUME —y a qué imagen pertenezco— es un hecho. # # ══ CÓMO LEER LA SALIDA, que tiene ruido legítimo ══════════════════════════════════════════════ # El cierre de un perfil incluye sus HERRAMIENTAS DE BUILD (python3, cmake, perl, go…), y la # hidratación de la imagen NO las instala: va por lista explícita. Sus NEEDED aparecen igual acá y # **no son un bug de la imagen**. Lo accionable es lo que pide un componente de RUNTIME — mesa, # wayland, el compositor, una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa # columna el informe no se puede triar. # # uso: python3 scripts/vigia-sonames.py [perfil...] [--fail] # --fail → exit 1 si algo falta (para cron/CI) import os, sys, glob, subprocess sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)))) import yupana import targets as targets_mod ROOT = yupana.ROOT STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store")) HAMMER = str(ROOT / "target/release/hammer") _cache = {} def artefacto(cola, nombre): """Directorio del artefacto de (cola,nombre) en el store, o None.""" if (cola, nombre) in _cache: return _cache[(cola, nombre)] receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus" else "recipes/%s/%s.toml" % (cola, nombre)) d = None if receta.exists(): r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)], capture_output=True, text=True) h = r.stdout.strip().replace("b3:", "") if len(h) == 64: cand = glob.glob(os.path.join(STORE, h + "-*")) d = cand[0] if cand else None _cache[(cola, nombre)] = d return d def elf(path): """(NEEDED[], SONAME[]) de un ELF, o None si no lo es.""" try: out = subprocess.run(["readelf", "-d", path], capture_output=True, text=True, timeout=30).stdout except Exception: return None if "(NEEDED)" not in out and "(SONAME)" not in out: return None tag = lambda t: [l.split("[")[1].split("]")[0] for l in out.splitlines() if t in l] return tag("(NEEDED)"), tag("(SONAME)") def escanear(nodos): provee, pide = set(), {} for (cola, nombre) in sorted(nodos): d = artefacto(cola, nombre) if not d: continue for raiz, _, ficheros in os.walk(d): for fn in ficheros: p = os.path.join(raiz, fn) # ⚠ `testdata/` NO ES CIERRE (2026-09-06). Go shipea su árbol de fuentes entero, y # `usr/lib/go/src/debug/elf/testdata/` trae ELF de MUESTRA para los tests de su # propio paquete `debug/elf`. Uno de ellos pide `libc.so.6` —glibc— y el vigía lo # contaba como dep de ejecución del perfil sway: un binario que nadie ejecuta, # pidiendo una libc que esta distro no usa. Era el último «hueco» del informe y era # falso. Mismo criterio que el `libperl.so` de abajo: el ruido de un vigía es lo que # enseña a ignorarlo. if "/testdata/" in p: continue if os.path.islink(p): # un symlink `libfoo.so.1 -> libfoo.so.1.2.3` TAMBIÉN provee ese nombre if ".so" in fn: provee.add(fn) continue if ".so" not in fn and not raiz.endswith(("/bin", "/sbin", "/libexec")): continue r = elf(p) if not r: continue need, son = r provee.update(son) if son: provee.add(fn) elif ".so" in fn: # ⚠ ELF COMPARTIDO **SIN SONAME**: provee por NOMBRE DE FICHERO (2026-09-06). # Sin esta rama el vigía daba un hueco FALSO en los cinco perfiles: # # FALTA libperl.so ← lo piden: perl # # y `libperl.so` estaba ahí, 9,6 MB, en # `usr/lib/perl5/5.40.2/x86_64-linux/CORE/`. Perl la construye con # `-Duseshrplib` y NO le pone SONAME: el binario la encuentra por su # `RUNPATH`, que apunta a ese CORE. O sea que el enlace resuelve por NOMBRE # DE FICHERO, y eso es exactamente lo que hay que indexar. # # Importa arreglarlo aunque «sólo» sea un falso positivo: un vigía que grita # en falso enseña a ignorarlo, y así es como `libstdc++.so.6` —que sí rompía # el navegador— estuvo en esta misma salida sin que nadie lo mirara. provee.add(fn) for n in need: pide.setdefault(n, set()).add(nombre) return provee, pide def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] fallar = "--fail" in sys.argv colas = yupana.cargar_colas() perfiles = targets_mod.load() memb = yupana.membresia(colas, perfiles) quiero = args or [p for p in perfiles if p.startswith("escritorio")] huecos = 0 for perfil in quiero: nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"} provee, pide = escanear(nodos) # `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre. faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"} print("== %-20s %3d nodos · %4d sonames provistos · %2d sin proveedor" % (perfil, len(nodos), len(provee), len(faltan))) for s, quienes in sorted(faltan.items()): print(" FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes)))) huecos += len(faltan) return 1 if (fallar and huecos) else 0 if __name__ == "__main__": sys.exit(main())