Files
takana/scripts/test-atuq-descargas.py
T
Sergio 897fad178a atuq: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad
La unidad 7 del SDD 26 (§6.2). Cuando una descarga TERMINA, la extensión
`descargas@atuq.tawasuyu` le pasa al host la ruta, el nombre y de dónde salió; el host la ingiere a
un CAS BLAKE3 y contesta el hash, el tamaño y **cuántas veces se bajó ese mismo contenido**. El mismo
contenido con otro nombre es UN objeto y dos nombres, y el navegador lo dice — que es exactamente lo
que ningún navegador sabe hacer: para todos una descarga es un blob con nombre, y por eso la bajás
dos veces y no te enterás.

No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
tejido. El `<hex>` del CAS ES el `expected_hash` de un `.swm`.

⚠ TRES DECISIONES QUE SALIERON DE MEDIR, NO DE DISEÑAR:

1. **La raíz del CAS de descargas no es la del sistema.** `arje_cas::gc` borra todo blob que no esté
   en el set `reachable` de su llamador, y el único que existe (`arje-brain`, `GcCas`) lo arma con la
   cadena de audit y las raíces vivas del grafo. Una descarga del usuario no está en ninguno: el
   primer GC se la llevaría, en silencio. Van a `<estado>/descargas-cas` — mismo formato (mover un
   objeto al CAS del sistema es un `rename`), fuera del alcance del GC de otro. El precio queda
   escrito: tejido sirve el CAS por defecto, así que compartir una descarga hoy exige apuntarlo ahí.
2. **El fichero del usuario no se toca**: se copia y se deja donde estaba. El guardián lo comprueba
   byte a byte. Borrar o mover lo que alguien acaba de bajar no es decisión de un navegador.
3. **La extensión observa, no intercepta.** Se entera cuando la descarga terminó. Meterse en el medio
   obligaría a decidir qué pasa si el CAS falla, y la respuesta correcta —que la descarga siga igual—
   es lo que se consigue no metiéndose.

Y EL HALLAZGO QUE COSTÓ LA TARDE, que no es de esta unidad: **en `--headless` el navegador se cae con
SIGSEGV en cuanto una descarga termina.** Se atribuyó con dos controles antes de tocar nada:

    --headless, con la extensión      → baja el fichero, TERMINADA, Segmentation fault (139)
    --headless, SIN la extensión      → baja el fichero, Segmentation fault (139)   ← no es nuestro
    --headless, panel de descargas apagado → Segmentation fault (139)
    sway headless (compositor REAL)   → baja, INGIERE al CAS, y no se cae          ← no es del producto

Sin el primer control esto se leía como «la extensión de descargas rompe el navegador» (perseguir un
bug que no existe); sin el último, como «atuq no puede descargar» (reportar un bug de producto que
tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`, y
por eso lo dice en su cabecera: un arnés distinto al de los demás guardianes, sin explicación, es una
invitación a «simplificarlo» de vuelta al que se cae. Queda en el §6.10.bis del SDD, que es donde vive
lo que atuq NO puede hacer.

El guardián baja de verdad (`Content-Disposition: attachment`, no una llamada a la API) dos veces
dentro de un mismo compositor, y trae control negativo: con OTRO contenido la segunda vez exige
`dedup=false` y dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a
una que funciona.

Del lado de tawasuyu (`ffa939c6`, pineado acá): `cas.ingest`/`cas.list` en el host y
`arje-cas::almacenar_fichero_en` — ingesta en streaming, porque `store` toma `&[u8]` y una ISO de
4 GiB serían 4 GiB de `Vec`. 5 tests nuevos en arje-cas y 5 en puriy-costura.

Y EL BUG QUE EL GUARDIÁN DESTAPÓ, que vale más que la función: **hay un host por PUERTO, no uno por
perfil.** Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en
memoria del estado, y cada uno escribía el fichero ENTERO al guardar: el de descargas borraba el
registro TOFU de `sct` al salir, y el de `sct` revertía el índice de descargas. Los dos contestaban
bien — el daño estaba sólo en el disco. Se vio porque el guardián lee el índice EN DISCO en vez de
creerle a la respuesta: decía `veces=2` y el fichero decía 1. Arreglado en tawasuyu (`55b918e8`,
pineado acá): cada proceso escribe sólo la parte que tocó, y el test que lo fija se comprobó
ROMPIENDO el arreglo a propósito — porque la primera versión de ese test abría los hosts en secuencia
y pasaba con el bug puesto.
2026-09-10 14:16:32 +00:00

309 lines
15 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])
cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas-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())