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
This commit is contained in:
Sergio
2026-09-07 00:38:16 +00:00
co-authored by Claude Opus 5
parent c6d749134e
commit a20d1b7453
2 changed files with 154 additions and 0 deletions
+9
View File
@@ -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»
+145
View File
@@ -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())