diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 771efa81..95275c6c 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -834,6 +834,68 @@ directas, nadie llamando al proxy—. La única diferencia entre las dos corrida configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto ciega se vería idéntica a una que funciona. +### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso + +Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al +CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del +navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», 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é un proceso aparte y no un verbo más del host — dos razones, y la primera decide:** + +1. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por + puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente + BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña. +2. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las + cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un + cuarto de hora. + +⚠ **Y 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; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la +decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de +arquitectura de una excusa. + +**Perezoso en los dos sentidos**, que es lo que pidió el operador: + +| | qué significa | +|---|---| +| nadie lo arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar | +| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre | + +⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con +él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca +igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una +degradación silenciosa de su única promesa. + +**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`, +`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un +error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en +cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos +raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**. + +**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`): + +1. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA + esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la + cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma + en el propio guardián, en bencode a mano: un binario de prueba 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, y eso es una elección del usuario y no + código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo + su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil). + +Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y +nombrando lo que falta** — no finge haberlo tomado. + +⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un +sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del +daemon, al pie, para que nadie lo lea como «probado de punta a punta». + ### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07) Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf` @@ -1352,7 +1414,7 @@ siguiente. | 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ | | 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ | | 8 | **Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ | -| 9 | **Medios fuera del navegador (6.6) — HECHA 2026-09-10**: `media.open` + la extensión `medios@atuq.tawasuyu`; una navegación de primer nivel a `video/*`/`audio/*` la abre `mpv`, que ya viaja en las cuatro imágenes. Fail-open probado: sin reproductor la navegación VUELVE al navegador. Destapó que un hijo hereda la tubería del protocolo. **Faltan** el foco (6.5), la IA local (6.7) y el torrent (6.9) | 6.5–6.7, 6.9 | 5 ✅ | +| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ | | 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — | Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la diff --git a/recipes/atuq.toml b/recipes/atuq.toml index 6c1ad617..50adc35d 100644 --- a/recipes/atuq.toml +++ b/recipes/atuq.toml @@ -181,7 +181,10 @@ grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no # sin esta línea la imagen llevaría el manifiesto y la extensión, y el host NO estaría — la función # se apagaría sola sin una línea de error. `runtime` no entra en el ArtifactHash: lo que cambia es la # CLAUSURA, que es lo que se hidrata. -runtime = ["gcc-libs", "puriy-costura"] +# Y `puriy-costura-torrent` (§6.9): el brazo LARGO de la costura. Va como dep de RUNTIME y no como +# servicio — es PEREZOSO a propósito: el binario está en la imagen y no corre nada hasta que alguien +# hace clic en un `magnet:`. Un daemon que arranca con la sesión es un daemon que nadie pidió. +runtime = ["gcc-libs", "puriy-costura", "puriy-costura-torrent"] # `firefox` como dep de BUILD y no de runtime: su árbol se materializa en el sandbox, se copia # dentro del artefacto de atuq y a partir de ahí atuq no lo necesita más. El precio es que el # artefacto pesa lo que pesa Firefox otra vez; la salida barata (una granja de symlinks) rompe la diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index 000ae9a9..ba41c609 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -28,6 +28,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y | `extensions/descargas/` | `/distribution/extensions/descargas@atuq.tawasuyu.xpi` | **Descargas con identidad** (SDD 26 §6.2). | | `extensions/archivo/` | `/distribution/extensions/archivo@atuq.tawasuyu.xpi` | **Archivo personal** (SDD 26 §6.3). | | `extensions/medios/` | `/distribution/extensions/medios@atuq.tawasuyu.xpi` | **Medios fuera del navegador** (SDD 26 §6.6). | +| `extensions/torrent/` | `/distribution/extensions/torrent@atuq.tawasuyu.xpi` | **Torrent con identidad** (SDD 26 §6.9). | | `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. | | `bin/puriy-costura-host` | `/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. | @@ -239,6 +240,27 @@ sin pestaña es el único resultado inaceptable, y el control negativo del guard ⚠ El binario **no viene del mensaje**: es una constante del host. Lo único que viaja es la URL, sólo `http(s)`, y detrás de un `--`. +## `torrent` — el magnet lo toma la suite (§6.9) + +La extensión registra un handler para `magnet:` (`protocol_handlers`, que existe en nuestro build), +así que un clic abre **su propia página** con el enlace, y esa página se lo pasa al host. El host se +lo entrega a `puriy-costura-torrent`, que es **otro proceso**. + +**Por qué otro proceso y no un verbo más**: una descarga tiene que sobrevivir al navegador, y el host +no lo hace —Gecko lo mata al cerrarse el puerto—. Y librqbit son 226 crates: dentro del host, ese +binario pasaría de 952 K a ~20 MB para las cinco extensiones que lo comparten. + +**Perezoso**: no hay servicio en la imagen. El binario está y no corre hasta que hay un torrent; lo +levanta su cliente (con `setsid`, o se moriría con el navegador) y se va solo cuando no queda nada +activo. Sembrar cuenta como actividad. + +⚠ **La página no valida la forma del origen**: la valida el host, que es quien decide qué le pasa al +daemon. Tener la regla escrita dos veces es tenerla mintiendo el día que una cambie. + +`scripts/test-atuq-torrent.py` sirve un `.torrent` hecho a mano (un `magnet:` bloquearía esperando +metadata que sin peers no llega) y comprueba que el daemon **aparezca sin que nadie lo arranque**. Su +control negativo saca el cliente de la imagen y exige que el host lo diga en vez de fingirlo. + ## Por qué el CSS no es `userChrome.css` `userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda @@ -300,8 +322,9 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control `inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis). - **La mitad SEMÁNTICA del archivo (6.3)**: un motor `rag-motor::RagMotor` sobre el archivo, que necesita el daemon de embeddings y un backend LLM en la imagen. `willay-rag` es el molde. -- **Los otros verbos del §6** sobre el host que ya está puesto: torrent (6.9) e IA local en la barra - lateral (6.7). +- **La IA local en la barra lateral (6.7)**, que está bloqueada por algo medido: el corpus no tiene + ninguna receta de LLM ni de embeddings. Es también la que traería el motor que le falta al archivo. +- **El foco por `cortafuegos` (6.5)**, el último del §6 que no depende de un modelo ni de un daemon. - **Que una descarga se pueda COMPARTIR con `tejido`**: hoy el objeto está en el CAS de descargas y tejido sirve el del sistema. Falta que el índice de descargas sea una raíz que el GC respete, y ahí las dos raíces pueden volver a ser una. diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json index 160b9622..631e37f5 100644 --- a/recipes/atuq/distribution/policies.json +++ b/recipes/atuq/distribution/policies.json @@ -51,6 +51,10 @@ "medios@atuq.tawasuyu": { "installation_mode": "normal_installed", "install_url": "file:///usr/lib/atuq/distribution/extensions/medios@atuq.tawasuyu.xpi" + }, + "torrent@atuq.tawasuyu": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/atuq/distribution/extensions/torrent@atuq.tawasuyu.xpi" } } } diff --git a/recipes/atuq/extensions/torrent/fondo.js b/recipes/atuq/extensions/torrent/fondo.js new file mode 100644 index 00000000..047fffaf --- /dev/null +++ b/recipes/atuq/extensions/torrent/fondo.js @@ -0,0 +1,109 @@ +"use strict"; +// torrent — un magnet lo toma la suite (SDD 26 §6.9), v1. +// +// El navegador registra un handler para `magnet:` (`protocol_handlers` del +// manifiesto, que existe en nuestro build), así que un clic en un enlace magnet abre +// una página NUESTRA con el enlace en un parámetro. Esa página se lo manda a este +// fondo, y este fondo al host. +// +// ── POR QUÉ IMPORTA, Y NO ES «TENER TORRENT» ───────────────────────────────── +// Vivaldi ya trae torrent; termina en una carpeta. Lo que cambia acá es que lo que +// baja entra al CAS por el mismo camino del §6.2: un objeto BLAKE3 deduplicado y +// verificable, no un archivo con nombre. Por eso el verbo es del host y no una +// biblioteca dentro de la extensión. +// +// ⚠ NO SE INTERCEPTA UN `.torrent` POR `webRequest`, todavía: un fichero servido +// como `application/x-bittorrent` es una descarga, y esa ya la ve la extensión de +// descargas. Meter dos extensiones a decidir sobre la misma respuesta es la forma de +// que ninguna de las dos se entienda después. + +const HOST = "puriy_costura"; + +let puerto = null; +let pendiente = null; // { resolver, enlace } + +function di(s) { + try { dump("TORRENT " + s + "\n"); } catch (e) {} +} + +function conectar() { + try { + puerto = browser.runtime.connectNative(HOST); + puerto.onMessage.addListener(alRecibir); + puerto.onDisconnect.addListener(p => { + const causa = p.error ? p.error.message : "sin error"; + di("DESCONECTADO " + causa); + puerto = null; + if (pendiente) { + pendiente.resolver({ ok: false, error: "el host se desconectó: " + causa }); + pendiente = null; + } + }); + di("CONECTADO " + HOST); + } catch (e) { + di("SIN HOST " + e.message); + puerto = null; + } +} + +function alRecibir(r) { + if (!r || r.verb !== "torrent.add") { + if (r && r.ok === false && pendiente) { + di("RECHAZO " + (r.error || "sin error")); + pendiente.resolver({ ok: false, error: r.error || "sin detalle" }); + pendiente = null; + } + return; + } + // ⚠ Estas claves son las del DAEMON, no unas nuestras: el host pasa su respuesta + // tal cual. La primera versión leía `name`/`files`/`bytes` —que el daemon no + // manda— y el resultado fue un «TOMADO — 0 fichero(s), 0 bytes» que parecía un + // torrent vacío en vez de una clave mal escrita. Un vocabulario inventado del lado + // del lector es la misma trampa que tener la definición dos veces. + const t = r.torrent || {}; + const detalle = [ + t.name || "(sin nombre todavía)", + t.already_there ? "ya estaba en la sesión" : "nuevo", + t.folder ? "en " + t.folder : null, + typeof t.id === "number" ? "id=" + t.id : null, + ].filter(Boolean).join(" · "); + di("TOMADO " + detalle); + if (pendiente) { + pendiente.resolver({ ok: true, detalle }); + pendiente = null; + } +} + +browser.runtime.onMessage.addListener(msg => { + if (!msg || msg.tipo !== "magnet") { return; } + di("MAGNET " + msg.enlace.slice(0, 80)); + if (!puerto) { conectar(); } + if (!puerto) { + return Promise.resolve({ ok: false, error: "no hay host: la suite no está en esta imagen" }); + } + return new Promise(resolver => { + pendiente = { resolver, enlace: msg.enlace }; + try { + puerto.postMessage({ verb: "torrent.add", url: msg.enlace }); + } catch (e) { + pendiente = null; + resolver({ ok: false, error: e.message }); + } + // Un handler que se queda «hablando con la suite…» para siempre es peor que uno + // que dice que no pudo: el torrent puede tardar en resolver, pero la RESPUESTA + // del host no. + setTimeout(() => { + if (pendiente && pendiente.resolver === resolver) { + pendiente = null; + resolver({ ok: false, error: "la suite no contestó a tiempo" }); + } + }, 30000); + }); +}); + +conectar(); +// La URL base de la extensión, para que un guardián pueda abrir su página sin +// adivinar el UUID que Gecko le asigna por perfil. En producción no molesta: es una +// línea de `dump`, que sólo sale con la pref de depuración puesta. +try { di("BASE " + browser.runtime.getURL("")); } catch (e) {} +di("LISTA v1 toma magnet: y se lo pasa a la suite"); diff --git a/recipes/atuq/extensions/torrent/manifest.json b/recipes/atuq/extensions/torrent/manifest.json new file mode 100644 index 00000000..138de710 --- /dev/null +++ b/recipes/atuq/extensions/torrent/manifest.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 2, + "name": "atuq — torrent con identidad", + "description": "Un `magnet:` no se lo lleva otro programa ni una web: lo toma la suite, y lo que baja entra al CAS (SDD 26 §6.9).", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "torrent@atuq.tawasuyu", + "strict_min_version": "115.0" + } + }, + "permissions": [ + "nativeMessaging", + "tabs" + ], + "protocol_handlers": [ + { + "protocol": "magnet", + "name": "atuq", + "uriTemplate": "/tomado.html?m=%s" + } + ], + "background": { + "scripts": ["fondo.js"] + }, + "browser_action": { + "default_title": "Torrent con identidad", + "default_icon": "atuq128.png" + }, + "icons": { + "128": "atuq128.png" + } +} diff --git a/recipes/atuq/extensions/torrent/tomado.html b/recipes/atuq/extensions/torrent/tomado.html new file mode 100644 index 00000000..4046a274 --- /dev/null +++ b/recipes/atuq/extensions/torrent/tomado.html @@ -0,0 +1,18 @@ + + +atuq — torrent + +
+

atuq tomó este enlace

+

Hablando con la suite…

+

+

+
+ diff --git a/recipes/atuq/extensions/torrent/tomado.js b/recipes/atuq/extensions/torrent/tomado.js new file mode 100644 index 00000000..75df764e --- /dev/null +++ b/recipes/atuq/extensions/torrent/tomado.js @@ -0,0 +1,33 @@ +"use strict"; +// La página que recibe el `magnet:`. Existe porque `protocol_handlers` funciona así: +// el navegador abre una URL NUESTRA con el enlace en un parámetro. Es también el +// único sitio donde el usuario ve qué pasó — un handler que no muestra nada deja la +// sensación de que el clic no hizo nada. +(async () => { + const enlace = new URLSearchParams(location.search).get("m") || ""; + document.getElementById("enlace").textContent = enlace; + const estado = document.getElementById("estado"); + const detalle = document.getElementById("detalle"); + if (!enlace) { + estado.textContent = "No vino ningún enlace."; + return; + } + // ⚠ La forma del origen NO se valida acá: la valida el host, que es el que + // decide qué le pasa al daemon. Repetir la regla en esta página sería tenerla + // escrita dos veces, y el día que una cambie la otra empezaría a mentir. Acá se + // muestra el resultado — que es lo único que esta página tiene que hacer. + + try { + const r = await browser.runtime.sendMessage({ tipo: "magnet", enlace }); + if (r && r.ok) { + estado.textContent = "La suite lo tomó."; + detalle.textContent = r.detalle || ""; + } else { + estado.textContent = "La suite no pudo tomarlo."; + detalle.textContent = (r && r.error) || "sin detalle"; + } + } catch (e) { + estado.textContent = "No pude hablar con la suite."; + detalle.textContent = e.message; + } +})(); diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json index 6a8fd5b3..85df956b 100644 --- a/recipes/atuq/native-messaging/puriy_costura.json +++ b/recipes/atuq/native-messaging/puriy_costura.json @@ -7,6 +7,7 @@ "sct@atuq.tawasuyu", "descargas@atuq.tawasuyu", "archivo@atuq.tawasuyu", - "medios@atuq.tawasuyu" + "medios@atuq.tawasuyu", + "torrent@atuq.tawasuyu" ] } diff --git a/recipes/puriy-costura-torrent.toml b/recipes/puriy-costura-torrent.toml new file mode 100644 index 00000000..65634b11 --- /dev/null +++ b/recipes/puriy-costura-torrent.toml @@ -0,0 +1,45 @@ +# puriy-costura-torrent — el brazo LARGO de la costura (SDD 26 §6.9): el daemon de torrent. +# +# ══ POR QUÉ ES UN BINARIO APARTE Y NO UN VERBO DENTRO DEL HOST ════════════════════════════════ +# Dos razones, y la primera decide: **una descarga tiene que sobrevivir al navegador**, y Gecko +# lanza un host de native messaging POR PUERTO y lo mata cuando el puerto se cierra (medido el +# 2026-09-10). Un cliente BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la +# pestaña. La segunda es el precio: librqbit + tokio + rustls son 226 crates, y ese host —952 K, +# compartido por las extensiones del navegador— pasaría a ~20 MB y cada iteración a un cuarto de +# hora. +# +# ⚠ Que esa pila COMPILA para musl con zig-cc se midió antes de decidir nada, con una receta +# desechable: 226 crates y sella. Era una incógnita real —ninguna receta de tawasuyu del corpus +# había construido tokio+rustls; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que +# la decisión no fue «no se puede», fue «no ahí». +# +# ══ PEREZOSO: ESTO NO ES UN SERVICIO ══════════════════════════════════════════════════════════ +# El artefacto es un binario y nada más: NO se declara ningún `.service`, ni nada que corra al +# iniciar sesión. Lo levanta su propio cliente la primera vez que hay un `magnet:` que tomar, y se +# va solo cuando no queda nada bajando. Un daemon que arranca con la sesión es un daemon que nadie +# pidió, y en una distro se nota. +# +# ══ EL COMMIT PINEADO ═════════════════════════════════════════════════════════════════════════ +# `3f69aab2` = el crate + el `Cargo.lock` cerrado (generado en un árbol LIMPIO del commit, que es +# la única forma de que describa `main` y no el árbol compartido de las sesiones). + +name = "puriy-costura-torrent" +version = "0.1.0" +license = "MIT OR Apache-2.0" + +[source] +repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git" +commit = "ca9947b92f89d00ca549d7bbaa3293ec909db7d8" +# Obligatorio para cualquier receta desde este monorepo: tawasuyu commitea su propio `vendor/` con +# un `smithay` parcheado por RUTA, y el `cargo vendor` de takana convertiría ese directorio en uno +# de reemplazo de fuente, donde cada crate tiene que traer `.cargo-checksum.json` — que ese patch +# nunca tuvo. El síntoma no menciona nada de esto (ver `puriy-costura.toml`). +cargo_vendor_dir = ".hammer-cargo-vendor" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +# Estático: lo lanza el navegador (a través del host) y no puede depender de encontrar un loader. +link = "static" +# `--bin` además de `-p` porque el crate tiene lib + bin y `cargo rustc` exige un target único. +flags = ["-p", "puriy-costura-torrent", "--bin", "puriy-costura-torrent"] diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml index a424909d..24319a6e 100644 --- a/recipes/puriy-costura.toml +++ b/recipes/puriy-costura.toml @@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0" [source] repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git" -commit = "2e99d21675de7aa3b60bf4d9d22fd4c8b76ffe0c" +commit = "066b3761f7a7a18415b0a59cb671355953bb48a1" # ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10. # tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip # async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`. diff --git a/scripts/test-atuq-torrent.py b/scripts/test-atuq-torrent.py new file mode 100755 index 00000000..17a6d3ef --- /dev/null +++ b/scripts/test-atuq-torrent.py @@ -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"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 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())