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:
@@ -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»
|
||||
|
||||
Executable
+145
@@ -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())
|
||||
Reference in New Issue
Block a user