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 604Rust 56Shell 36Markdown 281227 Definiciones

atuq: un guardián para el rootfs del runner — y encontró SIETE sonames más cayendo al lab

La lista de raíces de `atuq-nested.sh` se mantiene 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 contra el lab. Ayer eso
costó `gcc-libs`. La regla del repo es que cada punto ciego se convierte en un
guardián, así que acá está el guardián en vez del parche.

`scripts/test-atuq-rootfs.py` mira el objeto que la jaula monta de verdad —el
directorio HIDRATADO— y no el grafo de recetas, que es lo que ya cubre
`vigia-sonames.py`. Son preguntas distintas: la lista del runner no sale del
grafo, así que el grafo no puede auditarla.

Lo primero que hizo fue encontrar SIETE sonames más que se estaban resolviendo
contra el lab, y no son cosmética:

    libexpat.so.1, libzstd.so.1   <- los pide mesa (iris_dri, libEGL, libgbm)
    libdbus-1.so.3                <- lo pide pipewire; lo trae `dbus-shared`, no `dbus`
    libbz2.so.1                   <- freetype
    libudev.so.1                  <- libspa-alsa
    libsndfile.so.1, libncursesw.so.6

Los siete tienen proveedor en el corpus. Agregados a las raíces: el rootfs pasa
de 7 huecos a CERO, y la única excepción que queda es `libc.so`, que va en una
lista explícita porque ningún artefacto lo provee — las imágenes lo copian del
devfs. Si algún día hay receta que lo provea, esa lista se achica y el guardián
se vuelve más estricto solo.

CONTROL NEGATIVO incluido, que sin él esto no probaría nada:
`--negative-control` esconde libstdc++.so.6 y exige que el guardián lo cace.
Corrido: lo caza, y nombra a quién lo pide (atuq, atuq-bin).

Y `scripts/test-atuq-ruteo.py` vuelve a pasar entero contra el rootfs completo,
o sea que el ruteo por contenedor está probado ahora sobre un rootfs que no le
pide nada al lab salvo el intérprete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ

a20d1b74537173dab017de5acb68cb7c7d5036ff · por Sergio · (hace 1 mes) · padre c6d749134 · ver el árbol en este commit

Delta semántico · 5

scripts/test-atuq-rootfs.py

2 archivos · +154 −0

diff --git a/scripts/atuq-nested.sh b/scripts/atuq-nested.sh
index 89e8dc5a..c93f76c1 100755
--- a/scripts/atuq-nested.sh
+++ b/scripts/atuq-nested.sh
@@ -39,6 +39,7 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep
         libffi-shared zlib-shared harfbuzz-shared fribidi freetype-shared fontconfig-shared pixman libpng-shared
         libjpeg-turbo-shared wayland libxkbcommon mesa libdrm dbus pipewire pulseaudio alsa-lib
         libxml2-shared dejavu-fonts
+        bzip2-shared expat-shared ncurses-shared libsndfile libudev-zero zstd dbus-shared
         xkeyboard-config grim"
 # ⚠ `gcc-libs` FALTABA, y su ausencia hacía MENTIR a esta prueba entera (2026-09-07). `atuq` declara
 # `NEEDED libstdc++.so.6` y `libgcc_s.so.1` —lo hereda de firefox, que va con clang++ y la libstdc++
@@ -49,6 +50,14 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep
 # sonames da CERO—, lo que estaba mal era ESTA lista, o sea el instrumento y no el artefacto.
 # Se nota a simple vista porque los dos ficheros no se parecen: el del corpus pesa 20.850.968 bytes
 # y el del lab 2.804.104. Si algún día hay duda de cuál se cargó, se mira el tamaño.
+#
+# La segunda tanda salió del guardián que nació de ese fallo, `scripts/test-atuq-rootfs.py`, que
+# mira el rootfs HIDRATADO en vez del grafo: `bzip2-shared expat-shared ncurses-shared libsndfile
+# libudev-zero zstd dbus-shared` — siete sonames más que también se resolvían contra el lab.
+# `libexpat` y `libzstd` los pide mesa (iris_dri, libEGL, libgbm), o sea el camino de pintado;
+# `libdbus-1.so.3` lo pide pipewire y NO lo trae `dbus` sino `dbus-shared`, el mismo cuadro de las
+# variantes `-shared` de gtk3. Mantener esta lista a mano es lo que falla: por eso ahora hay un
+# guardián que la audita en vez de confiar en que alguien se acuerde.
 # `grim` no lo necesita atuq: lo necesita QUIEN LO MIRA. Con `CAPTURA=<fichero.png>` este script
 # saca la pantalla real desde dentro de la jaula, contra el mismo socket wayland. Es lo que separó
 # «el proceso vive» de «la ventana muestra lo que queremos»: durante horas dije «mirá tu pantalla»
diff --git a/scripts/test-atuq-rootfs.py b/scripts/test-atuq-rootfs.py
new file mode 100755
index 00000000..1409d53b
--- /dev/null
+++ b/scripts/test-atuq-rootfs.py
@@ -0,0 +1,145 @@
+#!/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
+
+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.
+
+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/hammer")
+
+# 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"}
+
+
+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
+
+
+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 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())