From f9da495b8c95c960bc3ecd78783a9731085a1396 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 03:42:31 +0000 Subject: [PATCH] =?UTF-8?q?vig=C3=ADa=20de=20sonames:=20KDE,=20GNOME=20y?= =?UTF-8?q?=20COSMIC=20ten=C3=ADan=20la=20MISMA=20fuga=20al=20lab=20que=20?= =?UTF-8?q?sway=20ya=20hab=C3=ADa=20documentado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verificando que mpv anduviera en las cuatro imágenes salió esto: `libEGL.so.1` (mesa) pide `libexpat.so.1` y `libwayland-client.so.0` pide `libffi.so.8`, las recetas canónicas de expat y libffi son `--disable-shared`, y ningún artefacto del cierre publica esos SONAME ⇒ los tres rootfs los resolvían contra el **sysroot Alpine DEL LAB**. Es peor que una dep faltante: el lab NO entra en `hash_inputs`, así que el store no puede notarlo — el artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina con Alpine debajo. El perfil de sway ya tenía escrito exactamente este párrafo desde 2026-08-26; lo que faltaba era el instrumento para ver que las otras tres estaban igual. Arreglo (gratis, sin rebuild: las dos recetas ya estaban selladas por sway): `expat-shared` y `libffi-shared` pasan a raíces de los tres perfiles. Van de raíces y no de `[deps]` por la misma razón que las fuentes y el XKB — son data de RUNTIME y ninguna arista de build las alcanza. `scripts/vigia-sonames.py` es el guardián que sale del punto ciego: recorre los NEEDED de todo el cierre de cada imagen contra los SONAME que ese mismo cierre publica. Dos decisiones de diseño: · keying por PAR `(cola, nombre)` vía yupana, NO por nombre — `build-state.json` colapsa los nombres que viven en dos colas y su campo `perfiles` puede quedar vacío para una receta que sí está en la imagen. · imprime SIEMPRE quién pide cada soname, porque el cierre incluye herramientas de build (python3, cmake, perl, go) que la hidratación no instala: sin esa columna el informe no se tría. Después del arreglo, `libexpat.so.1` y `libffi.so.8` desaparecen de los tres. Lo que queda son hallazgos REALES que no son de este commit y quedan anotados: · kde: karchive pide libbz2.so.1 y liblzma.so.5 · gnome: spidermonkey pide libstdc++.so.6 y libgcc_s.so.1; libadwaita pide liblzma.so.5; freetype-shared pide libbz2.so.1; sqlite-shared pide libreadline.so.8 · cosmic: llvm18 pide libgcc_s.so.1 · los tres: `pw-top` de pipewire enlaza `libncursesw.so.6` DEL LAB — y la receta afirma en un comentario que sin la dep «meson saltea pw-top». Es falso desde al menos el 2026-08-29: `dependency('ncursesw')` lo encuentra igual en el sysroot del lab. El comentario dice una cosa y el binario otra. --- docs/state/targets.toml | 36 +++++++++++ scripts/vigia-sonames.py | 126 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100755 scripts/vigia-sonames.py diff --git a/docs/state/targets.toml b/docs/state/targets.toml index 653c3a4a..ba6a668e 100644 --- a/docs/state/targets.toml +++ b/docs/state/targets.toml @@ -78,6 +78,18 @@ paquetes = [ # `-Dpipewire-alsa=disabled` en las pipewire (⇒ no suena con PipeWire vivo), sin Lua (⇒ sin OSC) # y sin vaapi (⇒ sin decodificación por hardware). "mpv", + # ── LAS `.so` QUE mesa Y wayland PIDEN EN RUNTIME Y NINGÚN ARTEFACTO DEL CIERRE PROVEE ──────── + # Medido el 2026-09-03 recorriendo los NEEDED de todo el cierre de esta imagen contra los SONAME + # que ese mismo cierre publica: `libEGL.so.1` pide `libexpat.so.1` y `libwayland-client.so.0` pide + # `libffi.so.8`, y las recetas canónicas de expat y libffi son `--disable-shared`. O sea que el + # rootfs hidratado los resolvía contra el **sysroot Alpine DEL LAB**. + # Es peor que una dep faltante: el lab NO entra en `hash_inputs`, así que la fuga es invisible para + # el store — el artefacto se da por bueno y sólo arranca en una máquina con Alpine debajo. + # No es un descubrimiento nuevo: es EXACTAMENTE lo que el perfil `escritorio-sway` ya documentó + # para `sway`, sólo que ahí se encontró a mano al armar la imagen y estas tres nunca se revisaron. + # Van de raíces (y no de `[deps]`) por lo mismo que las fuentes y el XKB: son data de RUNTIME y + # nadie las declara como dep de BUILD, así que la clausura no puede alcanzarlas por ninguna arista. + "expat-shared", "libffi-shared", ] [perfil.escritorio-gnome] @@ -118,6 +130,18 @@ paquetes = [ # `-Dpipewire-alsa=disabled` en las pipewire (⇒ no suena con PipeWire vivo), sin Lua (⇒ sin OSC) # y sin vaapi (⇒ sin decodificación por hardware). "mpv", + # ── LAS `.so` QUE mesa Y wayland PIDEN EN RUNTIME Y NINGÚN ARTEFACTO DEL CIERRE PROVEE ──────── + # Medido el 2026-09-03 recorriendo los NEEDED de todo el cierre de esta imagen contra los SONAME + # que ese mismo cierre publica: `libEGL.so.1` pide `libexpat.so.1` y `libwayland-client.so.0` pide + # `libffi.so.8`, y las recetas canónicas de expat y libffi son `--disable-shared`. O sea que el + # rootfs hidratado los resolvía contra el **sysroot Alpine DEL LAB**. + # Es peor que una dep faltante: el lab NO entra en `hash_inputs`, así que la fuga es invisible para + # el store — el artefacto se da por bueno y sólo arranca en una máquina con Alpine debajo. + # No es un descubrimiento nuevo: es EXACTAMENTE lo que el perfil `escritorio-sway` ya documentó + # para `sway`, sólo que ahí se encontró a mano al armar la imagen y estas tres nunca se revisaron. + # Van de raíces (y no de `[deps]`) por lo mismo que las fuentes y el XKB: son data de RUNTIME y + # nadie las declara como dep de BUILD, así que la clausura no puede alcanzarlas por ninguna arista. + "expat-shared", "libffi-shared", ] [perfil.escritorio-cosmic] @@ -199,6 +223,18 @@ paquetes = [ # `-Dpipewire-alsa=disabled` en las pipewire (⇒ no suena con PipeWire vivo), sin Lua (⇒ sin OSC) # y sin vaapi (⇒ sin decodificación por hardware). "mpv", + # ── LAS `.so` QUE mesa Y wayland PIDEN EN RUNTIME Y NINGÚN ARTEFACTO DEL CIERRE PROVEE ──────── + # Medido el 2026-09-03 recorriendo los NEEDED de todo el cierre de esta imagen contra los SONAME + # que ese mismo cierre publica: `libEGL.so.1` pide `libexpat.so.1` y `libwayland-client.so.0` pide + # `libffi.so.8`, y las recetas canónicas de expat y libffi son `--disable-shared`. O sea que el + # rootfs hidratado los resolvía contra el **sysroot Alpine DEL LAB**. + # Es peor que una dep faltante: el lab NO entra en `hash_inputs`, así que la fuga es invisible para + # el store — el artefacto se da por bueno y sólo arranca en una máquina con Alpine debajo. + # No es un descubrimiento nuevo: es EXACTAMENTE lo que el perfil `escritorio-sway` ya documentó + # para `sway`, sólo que ahí se encontró a mano al armar la imagen y estas tres nunca se revisaron. + # Van de raíces (y no de `[deps]`) por lo mismo que las fuentes y el XKB: son data de RUNTIME y + # nadie las declara como dep de BUILD, así que la clausura no puede alcanzarlas por ninguna arista. + "expat-shared", "libffi-shared", ] [perfil.escritorio-sway] diff --git a/scripts/vigia-sonames.py b/scripts/vigia-sonames.py new file mode 100755 index 00000000..5c2adbce --- /dev/null +++ b/scripts/vigia-sonames.py @@ -0,0 +1,126 @@ +#!/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...] [--fallar] +# --fallar → 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) + 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) + 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 = "--fallar" 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())