Files
takana/scripts/test-atuq-descargas.py
Sergio 29831e6296 descargas: el guardián estaba ROJO hace dos días y nadie lo corría
La barrida de regresión del frente (14 guardianes tras rehacer host y navegador) encontró uno en
rojo: `test-atuq-descargas.py` buscaba el CAS en `<estado>/descargas-cas` y el host lo escribe en
`<estado>/cas` desde el commit del archivo personal (357791a85, 2026-09-10) — el §6.3 UNIFICÓ los dos
CAS, que es justo lo que hace que una página archivada y un fichero bajado con el mismo contenido
sean un solo objeto. Lo renombré yo y no actualicé este guardián.

Lo que importa no es el renombre: es que el guardián estuvo rojo dos días sin que nadie se enterara,
porque **un guardián que no se ejecuta no protege de nada** — la misma familia que el cache-hit que
congela regresiones. Los otros 13 pasan.

Arreglado el path, y el README dice ahora por qué el directorio se llama `cas` y no `descargas-cas`.
2026-09-12 10:51:10 +00:00

314 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""¿Lo que baja `atuq` tiene identidad, y sabe que ya lo tenías? (SDD 26 §6.2)
python3 scripts/test-atuq-descargas.py # dos descargas del MISMO contenido
python3 scripts/test-atuq-descargas.py --negative-control # la segunda es OTRO contenido
── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
Un servidor HTTP sirve un fichero como adjunto; el navegador lo baja de verdad (no es una llamada a
la API: es la descarga completa, con su `Content-Disposition`); la extensión `descargas@atuq` ve que
terminó, le pasa al host la ruta absoluta, y el host lo ingiere a un CAS BLAKE3 y contesta el hash,
el tamaño y **cuántas veces se bajó ese mismo contenido**.
servidor HTTP → descarga real → downloads.onChanged → connectNative → puriy-costura
→ arje-cas (BLAKE3, deduplicado)
── LAS TRES AFIRMACIONES ─────────────────────────────────────────────────────────────────────
1ª descarga contenido A, nombre `uno.bin` ⇒ dedup=false, veces=1
2ª descarga contenido A, nombre `dos.bin` ⇒ **dedup=true, veces=2** y UN solo blob en disco
el fichero que bajó el usuario sigue donde estaba, byte a byte
La segunda es la del §6.2: «lo bajás dos veces y no lo sabés». El nombre cambia y el objeto es el
mismo, porque la identidad es el contenido.
`--negative-control` baja OTRO contenido la segunda vez y exige `dedup=false` y DOS objetos. Sin él,
una sonda rota que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
── ⚠ POR QUÉ ESTO CORRE EN UN SWAY DE VERDAD Y NO EN `--headless` ────────────────────────────
Porque en `--headless` **el navegador se cae con SIGSEGV en cuanto una descarga termina**, y eso se
midió acá antes de escribir una línea de este guardián:
con la extensión de descargas → baja el fichero, TERMINADA, Segmentation fault (139)
SIN la extensión (control) → baja el fichero, Segmentation fault (139)
con el panel de descargas apagado→ Segmentation fault (139)
O sea que **no es nuestro**: es el modo headless de Gecko. Y no es del producto tampoco: con un
compositor real —sway headless, salida offscreen— la misma descarga completa la cadena entera sin
caerse. La conclusión importa más que el arnés: si esto se hubiera dado por «la función no anda», se
habría perseguido un bug que no existe; y si se hubiera dado por «el navegador no puede descargar»,
se habría reportado un bug de producto que tampoco existe. Queda anotado en el §6.10 del SDD, que es
donde vive lo que atuq NO puede hacer.
REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`), y `atuq` +
`puriy-costura` sellados.
"""
import http.server
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
HAMMER = os.path.join(ROOT, "target/release/takana")
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
CONTENIDO_A = b"un objeto con identidad, no un archivo con nombre\n" * 64
CONTENIDO_B = b"otro contenido cualquiera, para el control negativo\n" * 64
def fatal(msg):
print(f"✗ {msg}", file=sys.stderr)
raise SystemExit(1)
def artefacto(receta, nombre, escotilla=None):
"""El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
forzado = os.environ.get(escotilla) if escotilla else None
if forzado:
if not os.path.isdir(forzado):
fatal(f"{escotilla}={forzado} no existe")
print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
return forzado
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 — construilo antes")
return d
class Servidor(http.server.SimpleHTTPRequestHandler):
"""Sirve cada ruta como ADJUNTO. El `Content-Disposition` es lo que hace que el navegador lo
baje en vez de pintarlo: sin él esto mediría otra cosa."""
contenidos = {}
def log_message(self, *a):
pass
def do_GET(self):
nombre = self.path.lstrip("/").split("?")[0]
datos = Servidor.contenidos.get(nombre)
if datos is None:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", f'attachment; filename="{nombre}"')
self.send_header("Content-Length", str(len(datos)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(datos)
def puerto_libre():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def correr(atuq, costura, salida, urls):
"""Levanta UN sway headless y baja, dentro de él, las URLs en orden (un atuq por descarga).
Un solo compositor para las dos descargas: levantarlo dos veces costaría el doble y no mediría
nada más. El perfil y el `$HOME` se reusan, así que la segunda descarga encuentra el estado que
dejó la primera — que es justo lo que hace falta para que la deduplicación signifique algo.
"""
bajadas = " ".join(f"'{u}'" for u in urls)
dentro = f"""#!/bin/sh
export XDG_RUNTIME_DIR=/run/user/0 HOME=/salida/hogar LANG=C XDG_DATA_DIRS=/usr/share
export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
mkdir -p "$XDG_RUNTIME_DIR" /salida/hogar /salida/bajadas; chmod 700 "$XDG_RUNTIME_DIR"
(
i=0
while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
[ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland"; exit 1; }}
export WAYLAND_DISPLAY=wayland-1
/usr/bin/swaybg -c '#1a4b8c' & sleep 3
export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland LIBGL_DRIVERS_PATH=/usr/lib/dri
# Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
# con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
for u in {bajadas}; do
# La marca va AL MISMO log que lee el guardián: si sale por la consola, el
# parser no tiene con qué separar una descarga de la otra.
echo "@@ bajando $u" >> /salida/atuq.log
timeout 60 /usr/bin/atuq --no-remote --profile /salida/perfil "$u" >> /salida/atuq.log 2>&1
echo "@@ atuq salió con $?" >> /salida/atuq.log
done
sleep 2
# El socket de sway se busca explícitamente: esta subshell arrancó ANTES que sway
# (es hija del shell que después lo `exec`a), así que no hereda `SWAYSOCK` y sin él
# `swaymsg exit` no hace nada — la jaula se quedaría viva hasta el timeout externo.
SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
export SWAYSOCK
/usr/bin/swaymsg exit 2>/dev/null
) &
exec /usr/bin/sway -d > /salida/sway.log 2>&1
"""
guion = os.path.join(salida, "dentro.sh")
with open(guion, "w") as fh:
fh.write(dentro)
orden = [
"bwrap",
"--overlay-src", ALPINE, "--overlay-src", RFS,
"--overlay-src", atuq, "--overlay-src", costura,
"--tmp-overlay", "/",
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
"--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
"--bind", salida, "/salida",
"--uid", "0", "--gid", "0",
"--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
"/bin/sh", "/salida/dentro.sh",
]
try:
subprocess.run(orden, check=False, timeout=300)
except subprocess.TimeoutExpired:
# El compositor no se cerró solo. No es motivo para perder la corrida: las
# descargas y el log ya están en disco, y las aserciones son sobre eso.
print(" ⚠ el compositor no se cerró solo; se lo cortó a los 300 s")
ruta = os.path.join(salida, "atuq.log")
log = open(ruta, errors="replace").read() if os.path.exists(ruta) else ""
# Las líneas de cada descarga, separadas por el `@@ bajando` que las precede.
tandas, actual = [], None
for l in log.splitlines():
if l.startswith("@@ bajando"):
actual = []
tandas.append(actual)
elif l.startswith("DESCARGAS ") and actual is not None:
actual.append(l[len("DESCARGAS "):])
return tandas, log
def main():
negativo = "--negative-control" in sys.argv[1:]
if negativo:
print("== MODO CONTROL NEGATIVO: la segunda descarga es OTRO contenido; NO debe deduplicar")
if not os.path.isdir(RFS):
fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
costura = artefacto("puriy-costura", "puriy-costura")
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
segundo = CONTENIDO_B if negativo else CONTENIDO_A
Servidor.contenidos = {"uno.bin": CONTENIDO_A, "dos.bin": segundo}
puerto = puerto_libre()
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
tmp = tempfile.mkdtemp(prefix="atuq-desc-")
salida = os.path.join(tmp, "salida")
perfil = os.path.join(salida, "perfil")
os.makedirs(perfil)
try:
with open(os.path.join(perfil, "user.js"), "w") as fh:
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
# Bajar SIN preguntar y a un directorio conocido. Sin esto el navegador
# headless se queda esperando un diálogo que nadie va a contestar, y la
# prueba mediría un timeout en vez de una descarga.
fh.write('user_pref("browser.download.folderList", 2);\n')
fh.write('user_pref("browser.download.dir", "/salida/bajadas");\n')
fh.write('user_pref("browser.download.useDownloadDir", true);\n')
fh.write('user_pref("browser.download.always_ask_before_handling_new_types", false);\n')
fh.write('user_pref("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream");\n')
fh.write('user_pref("browser.download.start_downloads_in_tmp_dir", false);\n')
urls = [f"http://127.0.0.1:{puerto}/uno.bin", f"http://127.0.0.1:{puerto}/dos.bin"]
bajadas, log = correr(atuq, costura, salida, urls)
for i, lineas in enumerate(bajadas):
print(f"\n-- descarga {('uno.bin', 'dos.bin')[i] if i < 2 else i}")
for l in lineas:
print(" " + l)
if len(bajadas) != 2 or not all(any(l.startswith("INGERIDO ") for l in t) for t in bajadas):
destino = os.path.join(ROOT, "work", "atuq-descargas.log")
os.makedirs(os.path.dirname(destino), exist_ok=True)
open(destino, "w").write(log)
fatal(f"alguna descarga no llegó al CAS — log en {destino}")
def ingerido(lineas):
for l in lineas:
if l.startswith("INGERIDO "):
partes = l.split()
campos = dict(p.split("=", 1) for p in partes if "=" in p)
campos["hash"] = partes[1]
return campos
return None
uno, dos = ingerido(bajadas[0]), ingerido(bajadas[1])
# ⚠ El directorio es `cas` y no `descargas-cas` desde que el §6.3 entró: el archivo personal
# y las descargas comparten UN CAS, que es justo lo que hace que una página archivada y un
# fichero bajado con el mismo contenido sean un solo objeto. Este guardián se quedó mirando
# el nombre viejo y estuvo ROJO dos días sin que nadie lo corriera — la prueba de que un
# guardián que no se ejecuta no protege de nada.
cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "cas")
indice = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas.json")
if uno["dedup"] != "false" or uno["veces"] != "1":
fatal(f"la PRIMERA descarga no debía estar en el CAS: {uno}")
# El fichero del usuario sigue donde estaba, y con sus bytes. Es la promesa
# explícita del §6.2: el CAS es una COPIA, no una mudanza.
for nombre, contenido in (("uno.bin", CONTENIDO_A), ("dos.bin", segundo)):
f = os.path.join(salida, "bajadas", nombre)
if not os.path.isfile(f):
fatal(f"el fichero bajado {nombre} ya no está donde el usuario lo dejó")
if open(f, "rb").read() != contenido:
fatal(f"el fichero bajado {nombre} no es byte a byte lo que se sirvió")
blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
objetos = json.load(open(indice)) if os.path.isfile(indice) else {}
if negativo:
if dos["dedup"] != "false":
fatal(f"con OTRO contenido dijo dedup={dos['dedup']} — esta prueba no distingue nada")
if dos["hash"] == uno["hash"]:
fatal("dos contenidos distintos dieron el MISMO hash")
if len(blobs) != 2 or len(objetos) != 2:
fatal(f"se esperaban DOS objetos y hay blobs={blobs} índice={list(objetos)}")
print("\n✓ control negativo: otro contenido es otro objeto, y no dice que ya lo tenías")
return 0
if dos["dedup"] != "true":
fatal(f"la SEGUNDA descarga del mismo contenido dijo dedup={dos['dedup']}"
" — que es justo lo que ningún navegador sabe hacer")
if dos["hash"] != uno["hash"]:
fatal(f"el mismo contenido dio dos hashes: {uno['hash']} y {dos['hash']}")
if dos["veces"] != "2":
fatal(f"no contó las dos veces: {dos}")
if dos["nombre"] != "uno.bin":
fatal(f"el nombre tenía que ser el de la PRIMERA vez y es {dos['nombre']}")
if blobs != [uno["hash"]]:
fatal(f"el CAS tiene {blobs} y tenía que tener UN blob llamado {uno['hash']}")
if list(objetos) != [uno["hash"]] or objetos[uno["hash"]]["times"] != 2:
fatal(f"el índice no cuenta un objeto bajado dos veces: {objetos}")
# Y el blob es el contenido servido, byte a byte, bajo un nombre que es su hash.
if open(os.path.join(cas, uno["hash"]), "rb").read() != CONTENIDO_A:
fatal("el blob del CAS no es el contenido que sirvió el servidor")
print(f"\nCAS: {blobs[0][:16]}… ({len(CONTENIDO_A)} bytes) · índice: {list(objetos)[0][:16]}…"
f" veces={objetos[uno['hash']]['times']}")
print("\n✓ la descarga entra al CAS con su BLAKE3, y el fichero del usuario no se toca")
print("✓ el MISMO contenido con otro nombre es UN objeto, y el navegador lo dice"
" («esto ya lo tenías»)")
print("✓ el índice queda legible en disco: la lista de lo que bajaste sobrevive al binario")
return 0
finally:
servidor.shutdown()
shutil.rmtree(tmp, ignore_errors=True)
sys.exit(main())