`libxul.so` lleva la cadena `libnotify.so.4` adentro y la abre por dlopen cuando
una página pide permiso para notificar. Nadie en el corpus la proveía, así que la
función quedaba apagada SIN UN SOLO MENSAJE: el navegador arranca, la web pide
notificaciones, y no pasa nada. No lo veía ninguna herramienta porque no hay
NEEDED en ningún ELF — es el tercer escalón, y sobrevivió a vigia-sonames con los
cinco perfiles en CERO.
Receta nueva, 0.8.8, SÓLO en variante compartida y eso no es un olvido: a un
`dlopen("libnotify.so.4")` una `.a` no le sirve de nada, así que una receta
estática sería un artefacto que nadie puede consumir.
Dos cosas que la receta se comió y quedan escritas:
1. `libpng-shared` hace falta porque las deps de hammer NO son transitivas: el
`.pc` de gdk-pixbuf-2.0 declara `Requires: libpng` y el meson muere con un
mensaje que nombra a gdk-pixbuf —que sí está— en vez de a lo que falta.
2. El guardián informaba «18 bytes» y parecía una librería vacía: `stat -c%s` no
sigue el symlink, y `libnotify.so.4` apunta a `libnotify.so.4.0.0`. El
artefacto estaba bien y el MENSAJE mentía. Con `-L` son 162.928 bytes. Se
arregla el mensaje porque es lo que alguien va a leer a las tres de la mañana,
y de paso el guardián exige el fichero real, no sólo el nombre.
⚠ Y lo que NO arregla, escrito en la receta para que nadie lea de más: libnotify
no trae daemon, manda org.freedesktop.Notifications por D-Bus. Tenerla resuelve
la mitad —que firefox la encuentre—; la otra mitad es que en la imagen haya
alguien escuchando ese nombre.
Verificado con el propio mapa: `--dlopen` pasa de 25 huecos a 24 y libnotify.so.4
desaparece; el soname viejo `.so.1` se reclasifica de «hueco» a «ruido», que es lo
que ahora es. El guardián normal sigue en CERO con un soname más pedido (66).
Queda pendiente la membresía de perfil en `docs/state/targets.toml`, que es
catálogo compartido y no lo toco sin decidirlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
260 lines
13 KiB
Python
Executable File
260 lines
13 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/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"}
|
|
|
|
|
|
# ── 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.so.2": ("hueco", "decodificación de vídeo por HARDWARE. Sin receta"),
|
|
"libva-drm.so.2": ("hueco", "idem VA-API"),
|
|
"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.
|
|
FAMILIA_FFMPEG = ("hueco", "CÓDECS DEL SISTEMA (H.264/AAC): sin receta de ffmpeg en el corpus")
|
|
|
|
|
|
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())
|