atuq: el torrent lo toma un daemon propio, perezoso, que sobrevive al navegador
El 6.9 del SDD 26, con la arquitectura que pidió el operador: daemon PROPIO,
CONFIGURABLE y PEREZOSO. Vivaldi ya trae torrent y termina en una carpeta; acá lo que
baja entra al CAS —un objeto BLAKE3 con la misma identidad que una descarga del
navegador (§6.2) o un `.swm`—, y ésa es la razón por la que esto vale.
página de la extensión → fondo → connectNative → puriy-costura
→ /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está)
→ daemon: librqbit, y al completarse, el CAS
POR QUÉ NO ES UN VERBO MÁS DEL HOST — dos razones, y la primera decide:
1. una descarga tiene que sobrevivir al navegador, y Gecko mata al host cuando se
cierra el puerto (medido hoy);
2. librqbit + tokio + rustls son 226 crates: dentro del host, ese binario —952 K,
compartido por CINCO extensiones— pasaría a ~20 MB y cada iteración de cualquier
función del navegador a un cuarto de hora.
⚠ Que esa pila COMPILA para musl con zig-cc se midió ANTES de decidir, con una receta
desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus
había construido tokio+rustls desde tawasuyu— así que la decisión no fue «no se
puede», fue «no ahí». El artefacto del daemon pesa 7,7 M.
PEREZOSO EN LOS DOS SENTIDOS: no hay servicio en la imagen (el binario está y no corre
hasta que hay un torrent; lo levanta su cliente con `setsid`, sin lo cual moriría con
el navegador) y se va solo tras `inactividad_seg` sin nada activo — sembrar cuenta como
actividad.
CONFIGURABLE: TOML opcional; sin fichero anda, y uno ROTO es error. El CAS por defecto
es el del host, con un test que falla si esas raíces se separan.
LO QUE EL GUARDIÁN NO HACE, Y POR QUÉ (está en su cabecera y en el §6.9):
· no usa un `magnet:` sino un `.torrent` servido por HTTP —dar de alta un magnet
BLOQUEA esperando metadata que sin peers no llega: se mediría un timeout y no la
cadena—. El `.torrent` se arma en el propio guardián, en bencode a mano, porque un
binario de prueba en el repo es una dependencia que nadie revisa;
· no pasa por el despacho de protocolos de Gecko: un clic en `magnet:` abre el diálogo
de «¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del
usuario y no código nuestro. Se abre la página de la extensión —la misma que el
handler abriría— descubriendo su URL base del `dump` de una primera corrida, porque
el UUID lo asigna Gecko por perfil y adivinarlo sería inventar.
El control negativo saca el cliente del daemon de la imagen y exige que el host lo diga
NOMBRANDO lo que falta, en vez de fingir que lo tomó.
⚠ Y sigue sin haber test automático de un transfer REAL entre peers: haría falta un
sembrador y una espera que volverían la suite una que nadie corre. Dicho en el test del
daemon y en el §6.9, para que nadie lo lea como «probado de punta a punta».
La página de la extensión NO valida la forma del origen: la valida el host, que es
quien decide qué le pasa al daemon. Tener esa regla escrita dos veces es tenerla
mintiendo el día que una cambie.
Del §6 quedan el foco (6.5) y la IA local (6.7), ésta bloqueada por algo medido: el
corpus no tiene ninguna receta de LLM ni de embeddings.
MEDIDO sobre `atuq b3:d1a444ad`, `puriy-costura b3:cad5c855` y
`puriy-costura-torrent b3:76afb7a8` (7,8 M):
MAGNET http://…/prueba.torrent
TOMADO prueba-atuq.bin · nuevo · en /salida/hogar/Downloads · id=0
socket del daemon: run/puriy-costura-torrent.sock ← nadie lo arrancó
⚠ Y DOS COSAS QUE EL GUARDIÁN DESTAPÓ, las dos por comprobar el NOMBRE y no sólo que
la respuesta llegara:
1. **la extensión leía claves que el daemon no manda** (`name`/`files`/`bytes` contra
`nombre`/`carpeta`), y el síntoma era «TOMADO — 0 fichero(s), 0 bytes»: parecía un
torrent vacío y era un vocabulario inventado del lado del lector — la misma trampa
que había evitado en el host y no acá;
2. mirándolo apareció que **el socket y la config del daemon estaban en castellano**, y
la regla 7 de tawasuyu nombra explícitamente las claves de configuración entre lo
que se tipea. Corregido allá antes de que llegara a ninguna imagen: un protocolo y
un fichero de config son contrato, y renombrarlos después rompe lo que alguien ya
escribió.
tawasuyu: 4f36f057 (el crate) · 3f69aab2 (lock) · 3b56db26 (el verbo del host) ·
066b3761 (el log del daemon, que faltaba y hacía invisible su único fallo de arranque)
· ca9947b9 (las claves en inglés).
This commit is contained in:
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/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 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/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"
|
||||
|
||||
|
||||
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 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
|
||||
exit 0
|
||||
"""
|
||||
capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
|
||||
"--overlay-src", costura]
|
||||
if daemon:
|
||||
capas += ["--overlay-src", daemon]
|
||||
subprocess.run([
|
||||
"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,
|
||||
], check=False)
|
||||
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)}")
|
||||
|
||||
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ó.
|
||||
sock = os.path.join(salida, "run", "puriy-costura-torrent.sock")
|
||||
if not os.path.exists(sock):
|
||||
fatal("no hay socket del daemon: no llegó a levantarse")
|
||||
print(f"\nsocket del daemon: {os.path.relpath(sock, salida)}")
|
||||
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")
|
||||
return 0
|
||||
finally:
|
||||
servidor.shutdown()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user