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.
272 lines
14 KiB
Python
Executable File
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())
|