#!/usr/bin/env python3 """¿El navegador le entrega un torrent a la suite, y lo toma un daemon que le sobrevive? (SDD 26 §6.9) python3 scripts/test-atuq-torrent.py # el daemon lo toma python3 scripts/test-atuq-torrent.py --negative-control # sin el daemon: lo dice, no lo finge ── QUÉ SE EJERCITA ─────────────────────────────────────────────────────────────────────────── página de la extensión → fondo → connectNative → puriy-costura → /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está) → daemon: alta en la sesión de librqbit El daemon **no estaba corriendo**: no hay servicio en la imagen. Que aparezca es la mitad de «perezoso», y es lo que este guardián comprueba. ── DOS COSAS QUE ESTE GUARDIÁN NO HACE, Y POR QUÉ ──────────────────────────────────────────── 1. **No usa un `magnet:` sino un `.torrent` servido por HTTP.** Dar de alta un magnet BLOQUEA esperando la metadata del enjambre —lo dice el propio `foreign-torrent::resolve`— y sin peers eso no llega nunca: el guardián mediría un timeout, no la cadena. Con un `.torrent` la metadata viene en el fichero, el alta termina, y lo único que falta son los peers (que no hacen falta para probar la costura). El `.torrent` se arma acá, en bencode a mano: un binario de prueba metido en el repo es una dependencia que nadie revisa. 2. **No pasa por el despacho de protocolos de Gecko.** Un clic en un `magnet:` abre el diálogo de «¿con qué lo abro?», que en headless no contesta nadie; eso es una elección del usuario, no código nuestro. Se abre directamente la página de la extensión —la misma que el handler abriría— descubriendo su URL base del `dump` de la primera corrida, porque el UUID lo asigna Gecko por perfil y adivinarlo sería inventar. REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` + `puriy-costura-torrent` sellados. """ import hashlib import http.server import os import shutil import signal import socket import subprocess import sys import tempfile import threading import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-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")) CLIENTE = "/usr/bin/puriy-costura-torrent" # Tope del sandbox entero: 60 s los pone `timeout` sobre el navegador, y el resto es # arranque + despedida del daemon. Si se pasa, no hay veredicto: hay algo colgado. TOPE_SANDBOX = 180 def fatal(msg): print(f"✗ {msg}", file=sys.stderr) raise SystemExit(1) def artefacto(receta, nombre, escotilla=None): 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 def bencode(v): """Bencode, lo justo para un `.torrent` de un solo fichero.""" if isinstance(v, int): return b"i" + str(v).encode() + b"e" if isinstance(v, bytes): return str(len(v)).encode() + b":" + v if isinstance(v, list): return b"l" + b"".join(bencode(x) for x in v) + b"e" if isinstance(v, dict): salida = b"d" for k in sorted(v): salida += bencode(k) + bencode(v[k]) return salida + b"e" raise TypeError(type(v)) CONTENIDO = b"un objeto con identidad, bajado por el enjambre\n" * 16 PIEZA = 16384 def torrent_de_prueba(puerto): info = { b"name": b"prueba-atuq.bin", b"piece length": PIEZA, b"length": len(CONTENIDO), b"pieces": hashlib.sha1(CONTENIDO).digest(), } # El tracker no existe y no hace falta: el alta se completa con la metadata del # propio fichero, que es justo lo que este guardián necesita. return bencode({b"announce": f"http://127.0.0.1:{puerto}/announce".encode(), b"info": info}) class Servidor(http.server.SimpleHTTPRequestHandler): puerto = 0 def log_message(self, *a): pass def do_GET(self): if self.path.startswith("/prueba.torrent"): datos, tipo = torrent_de_prueba(Servidor.puerto), "application/x-bittorrent" else: datos, tipo = b"ok", "text/html" self.send_response(200) self.send_header("Content-Type", tipo) self.send_header("Content-Length", str(len(datos))) self.end_headers() self.wfile.write(datos) def daemons_vivos(): """PIDs de daemons de torrent vivos AHORA, vistos desde el hub. El daemon corre dentro del sandbox, pero `bwrap` no oculta los procesos al host: `ps -C` los ve con su PID de acá. Es por nombre de proceso a propósito — `pkill -f` empareja la línea de comandos entera y se lleva puesta la shell que corre el test (medido, sale 144 y lo que sigue no corre). """ r = subprocess.run(["ps", "-o", "pid=", "-C", "puriy-costura-torrent"], capture_output=True, text=True, check=False) return {int(l) for l in r.stdout.split()} 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, daemon, salida, url, negativo): borrar = f"rm -f {CLIENTE}" if negativo else "true" guion = f""" mkdir -p /root /salida/hogar /salida/run [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run XDG_STATE_HOME=/salida/hogar/.local/state {borrar} timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ > /salida/consola.log 2>&1 # El socket se anota ACÁ ADENTRO, antes de despedir al daemon: al irse lo borra, # así que mirarlo después desde el hub no mediría nada. [ -S /salida/run/puriy-costura-torrent.sock ] && echo si > /salida/socket-visto # Y hay que despedirlo a mano. Medido (2026-09-10): el daemon se va «tras 300s sin # nada», pero un torrent sin enjambre NUNCA termina ⇒ nunca está «sin nada»; y # `bwrap --unshare-pid` no vuelve hasta que su namespace de PIDs queda vacío. # Sin este `stop` el guardián se cuelga PARA SIEMPRE, y de paso deja el sandbox # entero vivo (un `bwrap` huérfano pinneando el overlay borrado). command -v puriy-costura-torrent >/dev/null 2>&1 && puriy-costura-torrent stop >/dev/null 2>&1 exit 0 """ capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, "--overlay-src", costura] if daemon: capas += ["--overlay-src", daemon] proceso = subprocess.Popen([ "bwrap", *capas, "--tmp-overlay", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", "--tmpfs", "/tmp", "--unshare-pid", "--bind", salida, "/salida", "--uid", "0", "--gid", "0", "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin", "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1", "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1", "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1", "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", "sh", "-c", guion, ]) try: proceso.wait(timeout=TOPE_SANDBOX) except subprocess.TimeoutExpired: # No es un fallo del navegador: es que algo quedó vivo dentro del namespace. proceso.kill() proceso.wait(timeout=30) fatal(f"el sandbox no volvió en {TOPE_SANDBOX}s — quedó algo vivo adentro" " (el sospechoso es el daemon: `bwrap` espera a que el namespace de PIDs" " se vacíe)") log = open(os.path.join(salida, "consola.log"), errors="replace").read() return [l[len("TORRENT "):] for l in log.splitlines() if l.startswith("TORRENT ")], log def main(): negativo = "--negative-control" in sys.argv[1:] if negativo: print("== MODO CONTROL NEGATIVO: sin el cliente del daemon; tiene que DECIRLO") if not os.path.isdir(RFS): fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`") atuq = artefacto("atuq", "atuq", "ATUQ_DIR") costura = artefacto("puriy-costura", "puriy-costura") daemon = artefacto("puriy-costura-torrent", "puriy-costura-torrent") print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}" f"\n {os.path.basename(daemon)}") # Censo previo: puede haber un daemon de otra corrida o de otro agente, y ése no # es nuestro. Lo que se juzga al final es la DIFERENCIA. ajenos = daemons_vivos() puerto = puerto_libre() Servidor.puerto = puerto servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor) threading.Thread(target=servidor.serve_forever, daemon=True).start() tmp = tempfile.mkdtemp(prefix="atuq-torrent-") salida = os.path.join(tmp, "salida") os.makedirs(os.path.join(salida, "perfil")) try: with open(os.path.join(salida, "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') # Corrida 1: sólo para saber la URL base de la extensión (Gecko le asigna un # UUID por perfil; adivinarlo sería inventar). lineas, log = correr(atuq, costura, daemon, salida, "about:blank", negativo) base = None for l in lineas: if l.startswith("BASE "): base = l.split(" ", 1)[1].strip() if not base: destino = os.path.join(ROOT, "work", "atuq-torrent.log") os.makedirs(os.path.dirname(destino), exist_ok=True) open(destino, "w").write(log) fatal(f"la extensión no dijo su URL base — ¿se instaló? log en {destino}") print(f"\nextensión en {base}") # Corrida 2: su propia página, con el `.torrent` como origen. origen = f"http://127.0.0.1:{puerto}/prueba.torrent" pagina = f"{base}tomado.html?m={origen}" lineas, log = correr(atuq, costura, daemon, salida, pagina, negativo) for l in lineas: print(" " + l) # La página no valida la forma del origen (lo hace el host, que es quien # decide), así que le pasa el `.torrent` al fondo igual que un `magnet:`. Si # no hubiera línea MAGNET, el mensaje no llegó al fondo y esto falla — que es # lo correcto. if not any(l.startswith("MAGNET ") for l in lineas): destino = os.path.join(ROOT, "work", "atuq-torrent.log") os.makedirs(os.path.dirname(destino), exist_ok=True) open(destino, "w").write(log) fatal(f"el fondo de la extensión no recibió el origen — log en {destino}") tomado = [l for l in lineas if l.startswith("TOMADO ")] rechazo = [l for l in lineas if l.startswith("RECHAZO ")] if negativo: if tomado: fatal(f"sin el cliente del daemon dijo que lo tomó: {tomado}") if not rechazo: fatal("sin el cliente, el host tenía que fallar DICIÉNDOLO") if "no hay cliente de torrent" not in " ".join(rechazo): fatal(f"el motivo no nombra lo que falta: {rechazo}") print("\n✓ control negativo: sin el daemon no se finge nada, y el motivo lo nombra") return 0 if rechazo: fatal(f"el host rechazó el torrent: {rechazo}") if not tomado: fatal("el daemon no acusó recibo del torrent") if "prueba-atuq.bin" not in tomado[0]: fatal(f"el daemon no leyó la metadata del .torrent: {tomado[0]}" " — si dice «(sin nombre todavía)», el alta no trajo el nombre") # Y la prueba de la PEREZA: el daemon no estaba corriendo, y su socket # apareció porque el cliente lo levantó. # La huella la dejó el propio sandbox antes de despedir al daemon: el socket # se lo lleva al salir, así que buscarlo ahora sería medir el reloj, no el hecho. if not os.path.exists(os.path.join(salida, "socket-visto")): fatal("no hubo socket del daemon: no llegó a levantarse") print("\nsocket del daemon: run/puriy-costura-torrent.sock (visto adentro)") print("\n✓ el navegador le entrega el torrent a la suite y el daemon lo toma") print("✓ y NADIE lo arrancó: no hay servicio en la imagen — lo levantó su cliente") # Y que se haya IDO. Esto no es prolijidad: el sandbox se cerró, sus overlays # están borrados, y un daemon vivo los sostiene abiertos indefinidamente — # su torrent de prueba no tiene enjambre, así que nunca termina y nunca está # «sin nada» que hacer. Medido: sin el `stop` de adentro el guardián decía ✓ # igual y dejaba el daemon Y su `bwrap` corriendo. Un residuo que nadie mide # se ve igual que no haberlo dejado. for _ in range(15): quedaron = daemons_vivos() - ajenos if not quedaron: break time.sleep(1) if quedaron: fatal(f"quedó un daemon vivo tras cerrar el sandbox: PID(s) {sorted(quedaron)}" " — se despide con `puriy-costura-torrent stop` DENTRO del sandbox") print("✓ y se fue cuando se lo pidieron: el sandbox no deja daemon suelto") return 0 finally: servidor.shutdown() # Barrer SÓLO lo que apareció durante esta corrida, y por PID: un test rojo no # tiene derecho a dejar procesos, ni a matar los de otro agente. for pid in sorted(daemons_vivos() - ajenos): try: os.kill(pid, signal.SIGTERM) except OSError: pass shutil.rmtree(tmp, ignore_errors=True) sys.exit(main())