Files
takana/scripts/test-atuq-rootfs.py
Sergio 8730aad34e takana etapa 3b: las 49 invocaciones pasan a ./target/release/takana
Los llamadores EJECUTABLES: scripts/ (incluida toda la granja), los runbooks y
CLAUDE.md. Seguro porque la 3a ya garantiza que el worker emite los dos
binarios, y porque en farm-lab-sync.sh el cargo build remoto precede a la
invocación remota en el mismo script.

Verificado: sintaxis de los 49 (bash -n / py_compile — ojo que
why-differs-barrido.sh es Python con extensión .sh) y `takana hash` devuelve
hash real sobre el store.

NO se toca en esta etapa, a propósito:
- La variable de entorno HAMMER=. Es interfaz de los scripts entre sí y hay
  llamadores que la fijan; renombrarla va con la etapa 4.
- docs/evidencia/ y los HANDOFF: son REGISTRO de lo que se corrió ese día.
  Reescribir un comando dentro de una evidencia la falsifica.
- docs/state/: es generado, se regenera solo.
- Los ADR y los docs de diseño: texto, y `hammer` sigue funcionando. Van con
  la etapa 5, que es la de churn de texto.
2026-09-09 18:25:58 +00:00

272 lines
14 KiB
Python
Executable File

#!/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
python3 scripts/test-atuq-rootfs.py --dlopen # el TERCER escalón: cadenas literales
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.
EL TERCER ESCALÓN, QUE NO LO VE NINGÚN AUDITOR DE `readelf` (`--dlopen`). La jerarquía es:
NEEDED del ejecutable → lo ve este guardián y lo ve vigia-sonames
NEEDED de un .so dlopeado → lo ven los dos, si el fichero está en el artefacto
dlopen("libfoo.so.1") LITERAL → no hay NEEDED en ningún ELF ⇒ NO LO VE NINGUNO
El repo ya tiene el caso caro escrito: OBS hace `dlopen("libGL.so.1")` desde su glad, y por eso
«glvnd sin GLX no lo destraba» aunque todos los NEEDED estén satisfechos. Un navegador vive de eso:
Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una docena más por nombre, y **cuando no están no
falla: apaga la función y sigue**. No hay línea roja; hay una función que nadie ofrece.
`--dlopen` busca las cadenas con forma de soname dentro de los ELF del artefacto y las cruza contra
el rootfs. **Es un HEURÍSTICO y se declara como tal**: una cadena no prueba un `dlopen` —puede estar
en un mensaje de error— y la ausencia de cadena no prueba que no lo haya. Por eso no falla nunca:
imprime un mapa triado para mirar, no un veredicto. Lo que sí se puede afirmar es lo contrario, que
es lo útil: **si la cadena está y el fichero no, esa función no existe en esta imagen.**
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/takana")
# 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"}
# ── Triaje del tercer escalón ─────────────────────────────────────────────────────────────────
# La clasificación es criterio, no medición, así que va acá donde se puede discutir y no escondida
# en un `if`. «decisión» = la distro eligió no tenerlo; «hueco» = falta y se nota; «ruido» = la
# cadena existe pero no corresponde a una función real en musl o en este build.
TRIAJE = {
# decisiones ya tomadas por la distro, con su documento
"libGL.so": ("decisión", "Wayland-only sin GLX: ninguna cola publica libGL (ver sin-opengl-de-escritorio)"),
"libGL.so.1": ("decisión", "idem; Firefox va por EGL. `glxtest` sondea y se apaga"),
"libcurl.so": ("decisión", "sólo lo usa `pingsender`, que manda telemetría — apagada a propósito"),
"libcurl.so.3": ("decisión", "idem pingsender"),
"libcurl.so.4": ("decisión", "idem pingsender"),
"libcurl-gnutls.so": ("decisión", "idem pingsender"),
"libcurl-gnutls.so.3": ("decisión", "idem pingsender"),
"libcurl-gnutls.so.4": ("decisión", "idem pingsender"),
"libgcompat.so.0": ("ruido", "shim glibc-en-musl de Alpine; no lo queremos"),
"libdl.so.2": ("ruido", "musl funde dl/pthread/rt dentro de libc"),
"libpthread.so.0": ("ruido", "idem musl"),
"librt.so.1": ("ruido", "idem musl"),
"libc.so": ("ruido", "el nombre musl de la libc; viene del lab por diseño"),
"libudev.so.0": ("ruido", "soname viejo; el corpus provee libudev.so.1 (libudev-zero)"),
"libfreebl3.so": ("ruido", "NSS de este build expone libfreeblpriv3.so, que SÍ está"),
# huecos de verdad: la función no existe en esta imagen
"libnotify.so.1": ("ruido", "soname viejo; el corpus provee libnotify.so.4 desde 2026-09-07"),
"libsecret-1.so.0": ("hueco", "llavero: contraseñas al keyring. HAY receta en incoming-gnome"),
"libcanberra.so.0": ("hueco", "sonidos del sistema. HAY receta en incoming-kde"),
"libvulkan.so.1": ("hueco", "WebGPU/vulkan. HAY receta (vulkan-loader) en incoming-kde"),
"libvulkan.so": ("hueco", "idem"),
# ⚠ `libva` SÍ tiene receta (corpus, sellada, y en la clausura de los cuatro escritorios por
# mpv) y desde 2026-09-07 está en las raíces de `atuq-nested.sh`. Sigue siendo hueco por la
# OTRA mitad: las tres mesa van con `-Dgallium-va=disabled -Dvideo-codecs=` ⇒ no hay un solo
# `*_drv_video.so` que cargar. Un cargador sin driver no es aceleración.
"libva.so.2": ("hueco", "vídeo por HARDWARE: la lib está; falta el driver (mesa sin gallium-va)"),
"libva-drm.so.2": ("hueco", "idem VA-API: sin ICD que cargar"),
"libspeechd.so.2": ("hueco", "lectura en voz alta. Sin receta"),
"libpci.so": ("hueco", "sólo para el sondeo de `glxtest`; irrelevante sin GLX"),
"libpci.so.3": ("hueco", "idem"),
"libonnxruntime.so": ("fuera de alcance", "funciones de ML del navegador"),
"libaudit.so.1": ("fuera de alcance", "auditoría de NSS"),
"libdbusmenu-glib.so.4": ("fuera de alcance", "menú global estilo Unity"),
"libdbusmenu-gtk3.so.4": ("fuera de alcance", "idem"),
"libgssapi.so.1": ("fuera de alcance", "Kerberos de empresa"),
"libgssapi.so.4": ("fuera de alcance", "idem"),
"libgssapi_krb5.so.2": ("fuera de alcance", "idem"),
}
# `libavcodec*` es una familia entera de sonames: Firefox sondea de la 53 a la 63 y todas las
# variantes `-ffmpeg`. Se trata junta o el informe es una lista de once líneas iguales.
# ⚠ ESTO DECÍA «hueco: sin receta de ffmpeg en el corpus» Y ERA FALSO (corregido 2026-09-07).
# `recipes/ffmpeg.toml` existe, está sellada, publica `libavcodec.so.61` —justo uno de los sonames
# que sondea Firefox— y ya viajaba en la clausura de los cuatro escritorios arrastrada por `mpv`.
# Lo que faltaba era declararla en las raíces del runner. Medido de punta a punta con
# `scripts/test-atuq-codecs.sh`: H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen.
# Quedan como RUIDO y no como hueco: lo que falta son las OTRAS versiones del soname (53-60, 62,
# 63), y Firefox sondea la lista entera hasta que una carga. Con la 61 puesta, la ausencia de las
# demás no apaga ninguna función.
FAMILIA_FFMPEG = ("ruido", "otras versiones del soname; el corpus provee libavcodec.so.61 (ffmpeg 7.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
LITERAL = re.compile(rb"\blib[A-Za-z0-9_+\-]{1,30}\.so(?:\.[0-9]+){0,3}\b")
def literales(paths):
"""Cadenas con forma de soname dentro de los ELF. Heurístico declarado, no prueba."""
cand = {}
for p in paths:
try:
with open(p, "rb") as fh:
datos = fh.read()
except OSError:
continue
for m in set(LITERAL.findall(datos)):
cand.setdefault(m.decode(), set()).add(os.path.basename(p))
return cand
def informe_dlopen(paths_artefacto, provee):
cand = literales(paths_artefacto)
faltan = {k: v for k, v in cand.items() if k not in provee}
grupos = {}
for s, quien in faltan.items():
clase, por_que = (FAMILIA_FFMPEG if s.startswith("libavcodec")
else TRIAJE.get(s, ("SIN TRIAR", "nadie lo miró todavía")))
grupos.setdefault(clase, []).append((s, por_que, quien))
print(f"cadenas con forma de soname: {len(cand)} · sin fichero que las provea: {len(faltan)}\n")
for clase in ("hueco", "SIN TRIAR", "decisión", "fuera de alcance", "ruido"):
filas = sorted(grupos.get(clase, []))
if not filas:
continue
print(f"── {clase} ({len(filas)})")
vistos = set()
for s, por_que, quien in filas:
if clase == "hueco" and por_que in vistos and s.startswith("libavcodec"):
continue
vistos.add(por_que)
print(f" {s:24s} {por_que}")
print(f" {'':24s}{', '.join(sorted(quien))[:64]}")
sin_triar = len(grupos.get("SIN TRIAR", []))
print(f"\nRESULTADO: mapa impreso. Sin triar: {sin_triar}"
+ (" (agregalos a TRIAJE)" if sin_triar else ""))
return 0
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 "--dlopen" in sys.argv[1:]:
nombres_todo = set()
for base in (RFS, atuq):
for b, _, fs in os.walk(base):
nombres_todo.update(fs)
return informe_dlopen([p for p in paths if p.startswith(atuq)], nombres_todo)
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())