Encontrado con `ps` sobre la máquina, no por un test: al cerrar el sandbox quedaban vivos el daemon y su `bwrap` interno, sosteniendo overlays ya borrados. Es correcto que el daemon se quede —su torrent de prueba no tiene enjambre, así que nunca termina y nunca está «sin nada»—, lo que faltaba era despedirlo y MEDIR que se fue. - el guión de adentro le pide `puriy-costura-torrent stop` (el verbo del producto); - la huella del socket se anota adentro y ANTES del stop: el daemon lo borra al irse; - el guardián censa daemons antes/después con `ps -C` (nunca `pkill -f`) y falla nombrando el PID; el `finally` barre lo propio para que un test rojo no deje basura; - tope de 180 s al sandbox, como seguro contra un cuelgue. Comprobado en los dos sentidos con una copia rota fuera del repo: sin el `stop` el guardián daba ✓ igual, y con la aserción nueva sale ✗ nombrando el PID. La hipótesis de que se COLGARÍA era falsa: el `bwrap` externo vuelve; el que espera es el interno. Verde: positivo, control negativo y rotura a propósito.
320 lines
15 KiB
Python
Executable File
320 lines
15 KiB
Python
Executable File
#!/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"<html><body>ok</body></html>", "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())
|