gioser

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 1571Shell 203Rust 109Markdown 993728 Definitions

⎇ ac3a2afc4f89c4347078c7ac1d5c5a0c387e8ed8 scripts/test-atuq-rootfs.py Python · 271 lines · 13.9 KB History Who touched what raw

takana etapa 3b: las 49 invocaciones pasan a ./target/release/takana 8730aad34 · Sergio ·

1#!/usr/bin/env python3
2"""¿El rootfs de `atuq-nested.sh` resuelve TODOS los NEEDED, o alguno se cae al LAB?
3
4    python3 scripts/test-atuq-rootfs.py                     # sale 0 si el rootfs se basta solo
5    python3 scripts/test-atuq-rootfs.py --negative-control  # esconde una lib y exige que se note
6    python3 scripts/test-atuq-rootfs.py --dlopen            # el TERCER escalón: cadenas literales
7
8POR QUÉ EXISTE. `scripts/atuq-nested.sh` hidrata una **lista de raíces escrita a mano**, y la jaula
9monta `.dev-fs/alpine` como capa de abajo. Esas dos cosas juntas hacen que una raíz que falte no se
10note: el navegador arranca igual, resolviendo la librería contra el LAB. Pasó de verdad
11(2026-09-07): faltaba `gcc-libs` y durante dos días todas las corridas de «atuq corre» cargaron la
12`libstdc++.so.6` del lab —2.804.104 bytes— en vez de la del corpus —20.850.968—. **El artefacto
13estaba sano; el instrumento era ciego**, que es la forma que más cuesta ver porque el resultado es
14«todo bien».
15
16El vigía de sonames (`scripts/vigia-sonames.py`) cubre lo mismo pero sobre la CLAUSURA de un perfil,
17que es un grafo de recetas. Esto mira el otro objeto: **el directorio hidratado que la jaula monta
18de verdad**. Son preguntas distintas y la segunda es la que corresponde al runner, porque su lista
19no sale del grafo.
20
21EL TERCER ESCALÓN, QUE NO LO VE NINGÚN AUDITOR DE `readelf` (`--dlopen`). La jerarquía es:
22
23    NEEDED del ejecutable          → lo ve este guardián y lo ve vigia-sonames
24    NEEDED de un .so dlopeado      → lo ven los dos, si el fichero está en el artefacto
25    dlopen("libfoo.so.1") LITERAL  → no hay NEEDED en ningún ELF ⇒ NO LO VE NINGUNO
26
27El repo ya tiene el caso caro escrito: OBS hace `dlopen("libGL.so.1")` desde su glad, y por eso
28«glvnd sin GLX no lo destraba» aunque todos los NEEDED estén satisfechos. Un navegador vive de eso:
29Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una docena más por nombre, y **cuando no están no
30falla: apaga la función y sigue**. No hay línea roja; hay una función que nadie ofrece.
31
32`--dlopen` busca las cadenas con forma de soname dentro de los ELF del artefacto y las cruza contra
33el rootfs. **Es un HEURÍSTICO y se declara como tal**: una cadena no prueba un `dlopen` —puede estar
34en un mensaje de error— y la ausencia de cadena no prueba que no lo haya. Por eso no falla nunca:
35imprime un mapa triado para mirar, no un veredicto. Lo que sí se puede afirmar es lo contrario, que
36es lo útil: **si la cadena está y el fichero no, esa función no existe en esta imagen.**
37
38LO QUE SÍ VIENE DEL LAB, A PROPÓSITO. El intérprete `/lib/ld-musl-x86_64.so.1` y `libc.so` no los
39provee NINGÚN artefacto del corpus —`musl` sella cabeceras y estáticas— y las imágenes lo copian del
40devfs (`scripts/mirada-usb.sh`). Así que van en una lista explícita de excepciones: si algún día
41aparece una receta que los provea, esta lista se achica y el guardián se vuelve más estricto solo.
42"""
43import os
44import re
45import subprocess
46import sys
47
48ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
50STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
51HAMMER = os.path.join(ROOT, "target/release/takana")
52
53# Lo que el corpus NO provee y las imágenes copian del devfs. Documentado, no olvidado.
54DEL_LAB = {"libc.so", "libc.musl-x86_64.so.1", "ld-musl-x86_64.so.1"}
55
56
57# ── Triaje del tercer escalón ─────────────────────────────────────────────────────────────────
58# La clasificación es criterio, no medición, así que va acá donde se puede discutir y no escondida
59# en un `if`. «decisión» = la distro eligió no tenerlo; «hueco» = falta y se nota; «ruido» = la
60# cadena existe pero no corresponde a una función real en musl o en este build.
61TRIAJE = {
62    # decisiones ya tomadas por la distro, con su documento
63    "libGL.so": ("decisión", "Wayland-only sin GLX: ninguna cola publica libGL (ver sin-opengl-de-escritorio)"),
64    "libGL.so.1": ("decisión", "idem; Firefox va por EGL. `glxtest` sondea y se apaga"),
65    "libcurl.so": ("decisión", "sólo lo usa `pingsender`, que manda telemetría — apagada a propósito"),
66    "libcurl.so.3": ("decisión", "idem pingsender"),
67    "libcurl.so.4": ("decisión", "idem pingsender"),
68    "libcurl-gnutls.so": ("decisión", "idem pingsender"),
69    "libcurl-gnutls.so.3": ("decisión", "idem pingsender"),
70    "libcurl-gnutls.so.4": ("decisión", "idem pingsender"),
71    "libgcompat.so.0": ("ruido", "shim glibc-en-musl de Alpine; no lo queremos"),
72    "libdl.so.2": ("ruido", "musl funde dl/pthread/rt dentro de libc"),
73    "libpthread.so.0": ("ruido", "idem musl"),
74    "librt.so.1": ("ruido", "idem musl"),
75    "libc.so": ("ruido", "el nombre musl de la libc; viene del lab por diseño"),
76    "libudev.so.0": ("ruido", "soname viejo; el corpus provee libudev.so.1 (libudev-zero)"),
77    "libfreebl3.so": ("ruido", "NSS de este build expone libfreeblpriv3.so, que SÍ está"),
78    # huecos de verdad: la función no existe en esta imagen
79    "libnotify.so.1": ("ruido", "soname viejo; el corpus provee libnotify.so.4 desde 2026-09-07"),
80    "libsecret-1.so.0": ("hueco", "llavero: contraseñas al keyring. HAY receta en incoming-gnome"),
81    "libcanberra.so.0": ("hueco", "sonidos del sistema. HAY receta en incoming-kde"),
82    "libvulkan.so.1": ("hueco", "WebGPU/vulkan. HAY receta (vulkan-loader) en incoming-kde"),
83    "libvulkan.so": ("hueco", "idem"),
84    # ⚠ `libva` SÍ tiene receta (corpus, sellada, y en la clausura de los cuatro escritorios por
85    # mpv) y desde 2026-09-07 está en las raíces de `atuq-nested.sh`. Sigue siendo hueco por la
86    # OTRA mitad: las tres mesa van con `-Dgallium-va=disabled -Dvideo-codecs=` ⇒ no hay un solo
87    # `*_drv_video.so` que cargar. Un cargador sin driver no es aceleración.
88    "libva.so.2": ("hueco", "vídeo por HARDWARE: la lib está; falta el driver (mesa sin gallium-va)"),
89    "libva-drm.so.2": ("hueco", "idem VA-API: sin ICD que cargar"),
90    "libspeechd.so.2": ("hueco", "lectura en voz alta. Sin receta"),
91    "libpci.so": ("hueco", "sólo para el sondeo de `glxtest`; irrelevante sin GLX"),
92    "libpci.so.3": ("hueco", "idem"),
93    "libonnxruntime.so": ("fuera de alcance", "funciones de ML del navegador"),
94    "libaudit.so.1": ("fuera de alcance", "auditoría de NSS"),
95    "libdbusmenu-glib.so.4": ("fuera de alcance", "menú global estilo Unity"),
96    "libdbusmenu-gtk3.so.4": ("fuera de alcance", "idem"),
97    "libgssapi.so.1": ("fuera de alcance", "Kerberos de empresa"),
98    "libgssapi.so.4": ("fuera de alcance", "idem"),
99    "libgssapi_krb5.so.2": ("fuera de alcance", "idem"),
100}
101# `libavcodec*` es una familia entera de sonames: Firefox sondea de la 53 a la 63 y todas las
102# variantes `-ffmpeg`. Se trata junta o el informe es una lista de once líneas iguales.
103# ⚠ ESTO DECÍA «hueco: sin receta de ffmpeg en el corpus» Y ERA FALSO (corregido 2026-09-07).
104# `recipes/ffmpeg.toml` existe, está sellada, publica `libavcodec.so.61` —justo uno de los sonames
105# que sondea Firefox— y ya viajaba en la clausura de los cuatro escritorios arrastrada por `mpv`.
106# Lo que faltaba era declararla en las raíces del runner. Medido de punta a punta con
107# `scripts/test-atuq-codecs.sh`: H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen.
108# Quedan como RUIDO y no como hueco: lo que falta son las OTRAS versiones del soname (53-60, 62,
109# 63), y Firefox sondea la lista entera hasta que una carga. Con la 61 puesta, la ausencia de las
110# demás no apaga ninguna función.
111FAMILIA_FFMPEG = ("ruido", "otras versiones del soname; el corpus provee libavcodec.so.61 (ffmpeg 7.1)")
112
113
114def fatal(msg):
115    print(f"✗ {msg}", file=sys.stderr)
116    raise SystemExit(1)
117
118
119def artefacto(receta, nombre):
120    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
121                       cwd=ROOT, capture_output=True, text=True)
122    if r.returncode != 0:
123        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
124    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
125    d = os.path.join(STORE, f"{h}-{nombre}")
126    if not os.path.isdir(d):
127        fatal(f"{nombre} b3:{h[:8]} no está sellado")
128    return d
129
130
131def elfs(raiz):
132    """Los ficheros ELF de un árbol, por número mágico y no por extensión.
133
134    Por nombre no alcanza: hay `.so` que son scripts de linker y ejecutables sin extensión."""
135    for base, _, ficheros in os.walk(raiz):
136        for f in ficheros:
137            p = os.path.join(base, f)
138            if os.path.islink(p) or not os.path.isfile(p):
139                continue
140            try:
141                with open(p, "rb") as fh:
142                    if fh.read(4) != b"\x7fELF":
143                        continue
144            except OSError:
145                continue
146            yield p
147
148
149NEEDED = re.compile(r"\(NEEDED\).*\[([^\]]+)\]")
150SONAME = re.compile(r"\(SONAME\).*\[([^\]]+)\]")
151
152
153def dinamico(paths):
154    """(pide, provee) leídos con readelf en tandas."""
155    pide, provee = {}, set()
156    for i in range(0, len(paths), 80):
157        lote = paths[i:i + 80]
158        r = subprocess.run(["readelf", "-d"] + lote, capture_output=True, text=True)
159        actual = None
160        for linea in r.stdout.splitlines():
161            if linea.startswith("File: "):
162                actual = linea[6:].strip()
163            m = NEEDED.search(linea)
164            if m:
165                pide.setdefault(m.group(1), set()).add(actual or lote[0])
166            m = SONAME.search(linea)
167            if m:
168                provee.add(m.group(1))
169        if len(lote) == 1:
170            # readelf no imprime «File:» cuando recibe UNO solo: sin esto, el único fichero de la
171            # última tanda quedaría sin dueño y el informe no se podría triar.
172            for k in pide:
173                pide[k].discard(None)
174    return pide, provee
175
176
177LITERAL = re.compile(rb"\blib[A-Za-z0-9_+\-]{1,30}\.so(?:\.[0-9]+){0,3}\b")
178
179
180def literales(paths):
181    """Cadenas con forma de soname dentro de los ELF. Heurístico declarado, no prueba."""
182    cand = {}
183    for p in paths:
184        try:
185            with open(p, "rb") as fh:
186                datos = fh.read()
187        except OSError:
188            continue
189        for m in set(LITERAL.findall(datos)):
190            cand.setdefault(m.decode(), set()).add(os.path.basename(p))
191    return cand
192
193
194def informe_dlopen(paths_artefacto, provee):
195    cand = literales(paths_artefacto)
196    faltan = {k: v for k, v in cand.items() if k not in provee}
197    grupos = {}
198    for s, quien in faltan.items():
199        clase, por_que = (FAMILIA_FFMPEG if s.startswith("libavcodec")
200                          else TRIAJE.get(s, ("SIN TRIAR", "nadie lo miró todavía")))
201        grupos.setdefault(clase, []).append((s, por_que, quien))
202    print(f"cadenas con forma de soname: {len(cand)} · sin fichero que las provea: {len(faltan)}\n")
203    for clase in ("hueco", "SIN TRIAR", "decisión", "fuera de alcance", "ruido"):
204        filas = sorted(grupos.get(clase, []))
205        if not filas:
206            continue
207        print(f"── {clase} ({len(filas)})")
208        vistos = set()
209        for s, por_que, quien in filas:
210            if clase == "hueco" and por_que in vistos and s.startswith("libavcodec"):
211                continue
212            vistos.add(por_que)
213            print(f"   {s:24s} {por_que}")
214            print(f"   {'':24s} ← {', '.join(sorted(quien))[:64]}")
215    sin_triar = len(grupos.get("SIN TRIAR", []))
216    print(f"\nRESULTADO: mapa impreso. Sin triar: {sin_triar}"
217          + ("  (agregalos a TRIAJE)" if sin_triar else ""))
218    return 0
219
220
221def main():
222    negativo = "--negative-control" in sys.argv[1:]
223    if not os.path.isdir(RFS):
224        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
225    atuq = artefacto("atuq", "atuq")
226
227    # El appdir de atuq trae DECENAS de `.so` propias (libxul, libnspr4…): proveen y piden a la vez.
228    paths = sorted(set(list(elfs(RFS)) + list(elfs(atuq))))
229    print(f"rootfs {RFS}\nartefacto {os.path.basename(atuq)}\nELF mirados: {len(paths)}")
230    pide, provee = dinamico(paths)
231
232    # Un fichero puede proveer por su SONAME o por su NOMBRE: `libgcc_s.so.1` no declara SONAME en
233    # algunas construcciones y se resuelve por el nombre del fichero. Se aceptan los dos.
234    nombres = set()
235    for base in (RFS, atuq):
236        for b, _, fs in os.walk(base):
237            nombres.update(fs)
238    provee |= nombres
239
240    if "--dlopen" in sys.argv[1:]:
241        nombres_todo = set()
242        for base in (RFS, atuq):
243            for b, _, fs in os.walk(base):
244                nombres_todo.update(fs)
245        return informe_dlopen([p for p in paths if p.startswith(atuq)], nombres_todo)
246
247    if negativo:
248        # SABOTAJE: se hace de cuenta que el rootfs no trae libstdc++. Si el guardián no lo caza,
249        # es que no está mirando — que es justo el modo de fallo que este fichero existe para negar.
250        provee -= {"libstdc++.so.6", "libstdc++.so.6.0.34"}
251        print("== MODO CONTROL NEGATIVO: se esconde libstdc++.so.6 y se exige que el guardián lo cace")
252
253    faltan = {s: v for s, v in pide.items() if s not in provee and s not in DEL_LAB}
254    for s, quien in sorted(faltan.items()):
255        muestra = sorted(os.path.basename(q) for q in quien if q)[:3]
256        print(f"  ✗ {s}  ← lo piden: {', '.join(muestra)}")
257    delegados = sorted(s for s in pide if s in DEL_LAB)
258    print(f"del lab por diseño ({len(delegados)}): {', '.join(delegados) or 'ninguno'}")
259    print(f"sonames pedidos: {len(pide)} · sin proveedor en el rootfs: {len(faltan)}")
260
261    if negativo:
262        ok = "libstdc++.so.6" in faltan
263        print("RESULTADO:", "el guardián CAZA la librería escondida" if ok
264              else "NO SIRVE: no vio el sabotaje")
265        return 0 if ok else 1
266    print("RESULTADO:", "el rootfs se basta solo" if not faltan
267          else "hay NEEDED que hoy resuelve el LAB — el runner miente")
268    return 0 if not faltan else 1
269
270
271sys.exit(main())