tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1506Shell 192Rust 108Markdown 983656 Definitions

⎇ 8ff6ae7b3a767bc2478173b74196936df254d2f8 scripts/vigia-sonames.py Python · 151 lines · 8.0 KB History Who touched what raw

vigia-sonames: dos falsos positivos menos — los cinco perfiles en CERO 44b603252 · Sergio ·

1#!/usr/bin/env python3
2# vigia-sonames.py — ¿qué `.so` pide una imagen que NINGÚN artefacto de su cierre provee?
3#
4# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
5# El grafo de estado responde «¿está sellado?». No responde «¿arranca?». Y hay una fuga que pasa
6# entera por debajo del grafo:
7#
8#   un binario `link = "dynamic"` sale con `NEEDED libffi.so.8`; la receta canónica de libffi es
9#   `--disable-shared`; ningún artefacto del cierre publica ese SONAME; y el rootfs hidratado lo
10#   resuelve contra el **sysroot Alpine DEL LAB**.
11#
12# El artefacto se sella, el perfil reporta 100%, y la imagen sólo arranca en una máquina que tenga
13# Alpine debajo. Es PEOR que una dep faltante, porque el lab no entra en `hash_inputs`: el store no
14# puede ni notarlo. Es el mismo patrón que [[artefacto-vacio-envenena-cache]] — algo que se lee como
15# presencia y no lo es.
16#
17# Se descubrió DOS VECES a mano y nunca hubo instrumento: la primera al armar la imagen de sway
18# (zlib/expat/libffi, ver el comentario del perfil `escritorio-sway` en targets.toml), y la segunda
19# el 2026-09-03 al verificar que mpv anduviera — donde resultó que KDE, GNOME y COSMIC tenían la
20# MISMA fuga desde siempre, con mesa y wayland pidiendo expat y libffi. Este script es el guardián
21# que sale de ese punto ciego: la regla del repo es que cada uno se convierte en uno.
22#
23# ══ POR QUÉ NO SE PUEDE LEER DE build-state.json ═══════════════════════════════════════════════
24# Porque el JSON colapsa los nodos POR NOMBRE y hay nombres que viven en dos colas: su campo
25# `perfiles` puede quedar vacío para una receta que sí está en la imagen (por la otra cola). La
26# membresía honesta se identifica por PAR `(cola, nombre)` y de eso sabe yupana, que resuelve
27# sibling-first igual que el sandbox. Misma lección que el bug de libdrm: QUÉ NODOS PUNTÚO es una
28# decisión de vista; QUIÉN ME CONSUME —y a qué imagen pertenezco— es un hecho.
29#
30# ══ CÓMO LEER LA SALIDA, que tiene ruido legítimo ══════════════════════════════════════════════
31# El cierre de un perfil incluye sus HERRAMIENTAS DE BUILD (python3, cmake, perl, go…), y la
32# hidratación de la imagen NO las instala: va por lista explícita. Sus NEEDED aparecen igual acá y
33# **no son un bug de la imagen**. Lo accionable es lo que pide un componente de RUNTIME — mesa,
34# wayland, el compositor, una app. Por eso se imprime siempre QUIÉN pide cada soname: sin esa
35# columna el informe no se puede triar.
36#
37#   uso:  python3 scripts/vigia-sonames.py [perfil...] [--fail]
38#         --fail  → exit 1 si algo falta (para cron/CI)
39import os, sys, glob, subprocess
40sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
41import yupana
42import targets as targets_mod
43
44ROOT = yupana.ROOT
45STORE = os.environ.get("HAMMER_STORE", str(ROOT / "store"))
46HAMMER = str(ROOT / "target/release/hammer")
47
48_cache = {}
49def artefacto(cola, nombre):
50    """Directorio del artefacto de (cola,nombre) en el store, o None."""
51    if (cola, nombre) in _cache:
52        return _cache[(cola, nombre)]
53    receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
54                     else "recipes/%s/%s.toml" % (cola, nombre))
55    d = None
56    if receta.exists():
57        r = subprocess.run([HAMMER, "--store", STORE, "hash", str(receta)],
58                           capture_output=True, text=True)
59        h = r.stdout.strip().replace("b3:", "")
60        if len(h) == 64:
61            cand = glob.glob(os.path.join(STORE, h + "-*"))
62            d = cand[0] if cand else None
63    _cache[(cola, nombre)] = d
64    return d
65
66def elf(path):
67    """(NEEDED[], SONAME[]) de un ELF, o None si no lo es."""
68    try:
69        out = subprocess.run(["readelf", "-d", path], capture_output=True,
70                             text=True, timeout=30).stdout
71    except Exception:
72        return None
73    if "(NEEDED)" not in out and "(SONAME)" not in out:
74        return None
75    tag = lambda t: [l.split("[")[1].split("]")[0] for l in out.splitlines() if t in l]
76    return tag("(NEEDED)"), tag("(SONAME)")
77
78def escanear(nodos):
79    provee, pide = set(), {}
80    for (cola, nombre) in sorted(nodos):
81        d = artefacto(cola, nombre)
82        if not d:
83            continue
84        for raiz, _, ficheros in os.walk(d):
85            for fn in ficheros:
86                p = os.path.join(raiz, fn)
87                # ⚠ `testdata/` NO ES CIERRE (2026-09-06). Go shipea su árbol de fuentes entero, y
88                # `usr/lib/go/src/debug/elf/testdata/` trae ELF de MUESTRA para los tests de su
89                # propio paquete `debug/elf`. Uno de ellos pide `libc.so.6` —glibc— y el vigía lo
90                # contaba como dep de ejecución del perfil sway: un binario que nadie ejecuta,
91                # pidiendo una libc que esta distro no usa. Era el último «hueco» del informe y era
92                # falso. Mismo criterio que el `libperl.so` de abajo: el ruido de un vigía es lo que
93                # enseña a ignorarlo.
94                if "/testdata/" in p:
95                    continue
96                if os.path.islink(p):
97                    # un symlink `libfoo.so.1 -> libfoo.so.1.2.3` TAMBIÉN provee ese nombre
98                    if ".so" in fn:
99                        provee.add(fn)
100                    continue
101                if ".so" not in fn and not raiz.endswith(("/bin", "/sbin", "/libexec")):
102                    continue
103                r = elf(p)
104                if not r:
105                    continue
106                need, son = r
107                provee.update(son)
108                if son:
109                    provee.add(fn)
110                elif ".so" in fn:
111                    # ⚠ ELF COMPARTIDO **SIN SONAME**: provee por NOMBRE DE FICHERO (2026-09-06).
112                    # Sin esta rama el vigía daba un hueco FALSO en los cinco perfiles:
113                    #
114                    #     FALTA libperl.so   ← lo piden: perl
115                    #
116                    # y `libperl.so` estaba ahí, 9,6 MB, en
117                    # `usr/lib/perl5/5.40.2/x86_64-linux/CORE/`. Perl la construye con
118                    # `-Duseshrplib` y NO le pone SONAME: el binario la encuentra por su
119                    # `RUNPATH`, que apunta a ese CORE. O sea que el enlace resuelve por NOMBRE
120                    # DE FICHERO, y eso es exactamente lo que hay que indexar.
121                    #
122                    # Importa arreglarlo aunque «sólo» sea un falso positivo: un vigía que grita
123                    # en falso enseña a ignorarlo, y así es como `libstdc++.so.6` —que sí rompía
124                    # el navegador— estuvo en esta misma salida sin que nadie lo mirara.
125                    provee.add(fn)
126                for n in need:
127                    pide.setdefault(n, set()).add(nombre)
128    return provee, pide
129
130def main():
131    args = [a for a in sys.argv[1:] if not a.startswith("--")]
132    fallar = "--fail" in sys.argv
133    colas = yupana.cargar_colas()
134    perfiles = targets_mod.load()
135    memb = yupana.membresia(colas, perfiles)
136    quiero = args or [p for p in perfiles if p.startswith("escritorio")]
137    huecos = 0
138    for perfil in quiero:
139        nodos = {n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"}
140        provee, pide = escanear(nodos)
141        # `libc.so` es el intérprete musl: lo pone el rootfs base, no una receta del cierre.
142        faltan = {s: v for s, v in pide.items() if s not in provee and s != "libc.so"}
143        print("== %-20s %3d nodos · %4d sonames provistos · %2d sin proveedor"
144              % (perfil, len(nodos), len(provee), len(faltan)))
145        for s, quienes in sorted(faltan.items()):
146            print("   FALTA %-24s ← lo piden: %s" % (s, ", ".join(sorted(quienes))))
147        huecos += len(faltan)
148    return 1 if (fallar and huecos) else 0
149
150if __name__ == "__main__":
151    sys.exit(main())