atuq: el archivo personal — lo que leés queda congelado, y se encuentra

La unidad 8 del SDD 26 (§6.3), en su mitad pagable. Cada página que se lee se congela: el HTML **ya
ejecutado** —lo que la persona vio, no lo que mandó el servidor— va al CAS con su BLAKE3, y la url,
el título y el texto visible al índice. Después se busca, sin el navegador.

    visita 1 (página A)  ⇒ archivada, visitas=1, total=1
    visita 2 (página A)  ⇒ dedup, visitas=2, total=1     ← volver NO duplica
    visita 3 (página B)  ⇒ total=2
    «masa» → 1 de 2 · «tobera» → 1 de 2 · «masa tobera» → 0 · «helicóptero» → 0

LA IDENTIDAD ES EL BLAKE3 DEL HTML, NO LA URL, y de ahí sale lo que hace que esto valga: la misma URL
con otro contenido ES otra página, así que el archivo tiene VERSIONES de lo que leíste. Un historial
de direcciones guarda punteros, y los punteros se pudren.

⚠ LA MITAD QUE NO SE PAGA HOY, dicha en el código, en el LEEME de allá y en el §6.3.bis: preguntarle
al historial en LENGUAJE NATURAL. Eso es el registro semántico y en la suite ya tiene forma —un motor
`rag-motor::RagMotor`, como `willay-rag`—, que necesita un daemon de embeddings y un backend LLM real
y devuelve `None` cuando no están. `archive.search` es el registro LITERAL: todas las palabras tienen
que aparecer. Enseñar lo uno diciendo que es lo otro sería el peor cambio posible.

LO QUE NO SE ARCHIVA ES LA PARTE QUE HAY QUE MIRAR: nada de una ventana privada, nada fuera del marco
principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto visible (el
HTML de un visor de PDF llenaría el archivo de cosas que no se encuentran). Y si no se puede saber si
la pestaña es privada, NO se archiva.

⚠ Y EL CONTROL NEGATIVO NO SÓLO COMPRUEBA QUE NO SE ARCHIVE: DICE QUIÉN LO IMPIDIÓ. La respuesta
medida no es la que uno supondría — hoy lo impide **el navegador**, porque las extensiones no corren
en ventanas privadas salvo que se las habilite, así que nuestro `if (incognito) return` no se ejecuta
nunca. No es código muerto: es lo que haría seguro habilitar `private_browsing` el día que haga
falta. Lo que sí habría sido un error es escribir «la extensión no archiva lo privado» sin saber cuál
de las dos cosas estaba pasando.

DOS TECHOS CON NOMBRE (en el host): 4 KiB de texto por página en el índice —es JSON para poder leerse
con `cat`, y uno sin techo deja de poder— y el corte por CARÁCTER y no por byte, porque `truncate`
sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal.

Del lado de tawasuyu (`357791a8`, pineado acá): `archive.add`/`archive.search`, 6 tests nuevos (20 en
total) y el CAS renombrado de `descargas-cas` a `cas` — el mismo almacén guarda ahora lo bajado y lo
leído, y un nombre que describe la mitad de su contenido es el que se lee mal dentro de seis meses.

Medido sobre `atuq b3:5cfe3221` y `puriy-costura b3:c06f55aa`.
This commit is contained in:
Sergio
2026-09-10 14:58:50 +00:00
parent f3236ef74a
commit 7f6459c132
9 changed files with 534 additions and 5 deletions
+49 -1
View File
@@ -626,6 +626,54 @@ Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después l
propio historial en lenguaje natural, **todo local**. Es la feature que más justifica el proyecto
entero, y sale de piezas que ya están escritas.
#### 6.3.bis La MITAD que se puede pagar hoy, y la que no (2026-09-10)
El §6.3 promete dos cosas y conviene separarlas, porque una está hecha y la otra depende de
infraestructura que la imagen todavía no lleva:
| mitad | estado |
|---|---|
| **congelar cada página leída y poder encontrarla** | ✅ HECHA — `archive.add` / `archive.search` + la extensión `archivo@atuq.tawasuyu` |
| **preguntarle al historial en lenguaje natural** | ❌ falta, y ya tiene forma: un motor `rag-motor::RagMotor`, como `willay-rag` |
Lo segundo no se «aproxima»: `willay-rag` —el motor del centro de eventos, que es el ejemplo a
copiar— necesita un **daemon de embeddings** (`rimay-verbo`) y un **backend LLM real** (`pluma-llm`),
y su `try_build()` devuelve `None` cuando falta cualquiera de los dos, dejando el panel «no
disponible». Enseñar una búsqueda literal diciendo que es la semántica sería el peor cambio posible,
así que el verbo se llama `search`, el aviso está arriba de la función en el código, y acá queda
escrito qué falta exactamente.
**Lo que sí quedó, y por qué vale:** la identidad de una página archivada es el **BLAKE3 de su HTML
ya ejecutado** —lo que la persona vio, no lo que el servidor mandó—, no su URL. De ahí sale todo:
volver a la misma página no la duplica (suma una visita), y **la misma URL con otro contenido es otra
página**, o sea que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
punteros se pudren; esto guarda lo leído.
**Lo que NO se archiva es la parte que hay que mirar:** nada de una ventana privada, nada fuera del
marco principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto
visible (guardar el HTML de un visor de PDF llena el archivo de cosas que después no se encuentran).
El descarte de lo privado se comprueba en el fondo y no en el guión de contenido porque `tabs.get()`
es lo único que sabe si la pestaña lo es; y si no se puede saber, **no se archiva**.
**El control negativo distingue quién protege, y la respuesta medida NO es la que uno supondría.**
`scripts/test-atuq-archivo.py --negative-control` abre la página en una ventana privada, exige que no
se archive nada, y dice por qué. Medido sobre `atuq b3:5cfe3221`:
✓ en ventana privada no se archivó nada — lo impidió el NAVEGADOR
(la extensión no llegó a correr en la ventana privada)
O sea que hoy **el que protege es Gecko**: las extensiones no corren en ventanas privadas salvo que se
las habilite. Nuestro `if (emisor.tab.incognito) return` **nunca se ejecuta** con la configuración
actual. Eso no lo vuelve código muerto: es exactamente lo que haría seguro habilitar
`private_browsing` en `ExtensionSettings` el día que haga falta —y sin él, habilitarlo archivaría en
silencio lo que alguien abrió en privado—. Lo que sí sería un error es haber escrito «la extensión no
archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
**Dos techos con nombre**, no escondidos en un `truncate`: el índice guarda 4 KiB de texto por página
—es JSON para poder leerse con `cat`, y uno sin techo deja de poder— y el corte va por CARÁCTER, no
por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto
con acentos es el caso normal.
### 6.4 `qullqa` con su advertencia puesta
Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
@@ -1255,7 +1303,7 @@ siguiente.
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml``b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
| 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 + RAG | 6.3 | 5, 7 |
| 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 | Torrent, medios, foco | 6.56.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 | — |
+34 -2
View File
@@ -26,6 +26,7 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y
| `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
| `extensions/sct/` | `<appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi` | **Transparencia de scripts** (SDD 26 §6.1). |
| `extensions/descargas/` | `<appdir>/distribution/extensions/descargas@atuq.tawasuyu.xpi` | **Descargas con identidad** (SDD 26 §6.2). |
| `extensions/archivo/` | `<appdir>/distribution/extensions/archivo@atuq.tawasuyu.xpi` | **Archivo personal** (SDD 26 §6.3). |
| `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
| `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
@@ -189,6 +190,35 @@ No es un almacén nuevo: es `arje-cas`, el mismo formato y el mismo hash que usa
`dedup=true` la segunda; con `--negative-control` la segunda descarga es otro contenido y exige lo
contrario.
## `archivo` — el archivo personal (§6.3)
Cada página que leés se congela: el **HTML ya ejecutado** —lo que viste, no lo que mandó el
servidor— va al CAS con su BLAKE3, y la url, el título y el texto visible al índice. Después se
busca. Volver a la misma página **no la duplica**; la misma URL con otro contenido **es otra
página**, así que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
punteros se pudren.
**Lo que NO se archiva es la parte que hay que mirar:**
1. **nada de una ventana privada** — se comprueba en el fondo y no en el guión de contenido, porque
`tabs.get()` es lo único que sabe si la pestaña lo es; y **si no se puede saber, no se archiva**.
⚠ Medido: hoy el que impide archivar en privado es **el navegador** (las extensiones no corren
ahí salvo que se las habilite), así que nuestro `incognito` no se ejecuta nunca — y es justo lo
que haría seguro habilitar `private_browsing` el día que haga falta;
2. nada fuera del marco principal: un `<iframe>` de publicidad no es una página que alguien leyó;
3. nada sin texto visible: el HTML de un visor de PDF llenaría el archivo de cosas que no se
encuentran.
**`archive.search` es el registro LITERAL** —todas las palabras de la consulta tienen que aparecer
en la url, el título o el texto indexado—. **No es preguntarle al historial en lenguaje natural**:
eso es el registro semántico, un motor `rag-motor::RagMotor` como `willay-rag`, que necesita un
daemon de embeddings y un LLM de verdad y devuelve `None` cuando no están. Cuando exista se enchufa
del mismo lado y esto no cambia.
`scripts/test-atuq-archivo.py` visita, revisita y busca; su control negativo abre la página en una
ventana privada, exige que no se archive nada **y dice quién lo impidió** — nuestro código o el
navegador—, porque las dos protegen pero sólo una es nuestra.
## Por qué el CSS no es `userChrome.css`
`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
@@ -248,8 +278,10 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control
- **La v2 de `sct`**: el gancho en el script loader de Gecko, que es lo único que ve los scripts
`inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
- **Los otros verbos del §6** sobre el host que ya está puesto: archivo con RAG (6.3), torrent (6.9),
IA local en la barra lateral (6.7).
- **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).
- **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.
+4
View File
@@ -43,6 +43,10 @@
"descargas@atuq.tawasuyu": {
"installation_mode": "normal_installed",
"install_url": "file:///usr/lib/atuq/distribution/extensions/descargas@atuq.tawasuyu.xpi"
},
"archivo@atuq.tawasuyu": {
"installation_mode": "normal_installed",
"install_url": "file:///usr/lib/atuq/distribution/extensions/archivo@atuq.tawasuyu.xpi"
}
}
}
@@ -0,0 +1,31 @@
"use strict";
// El guión de contenido: corre DENTRO de la página y es el único que puede leerla.
//
// Manda dos cosas al fondo: el HTML tal como quedó tras ejecutarse el JS —que es lo
// que la persona vio, no lo que el servidor mandó— y el texto visible, que es con lo
// que después se busca.
//
// ⚠ NO se archiva todo lo que se abre, y las exclusiones son la parte importante:
// · sólo `http(s)` (el manifiesto ya lo acota) y sólo el marco PRINCIPAL —un
// `<iframe>` de publicidad no es una página que alguien leyó—;
// · **nada en ventana privada**: el fondo lo comprueba con la API de pestañas.
// Archivar lo que alguien abrió en privado sería exactamente lo contrario de lo
// que pidió, y es el tipo de error que no se perdona;
// · páginas sin texto visible (un visor de PDF, un lienzo) no entran: guardar su
// HTML sin nada que buscar llena el archivo de cosas que nunca se encuentran.
(() => {
try {
if (window.top !== window) { return; } // sólo el marco principal
const texto = (document.body && document.body.innerText) || "";
if (!texto.trim()) { return; }
browser.runtime.sendMessage({
tipo: "pagina",
url: location.href,
title: document.title || "",
html: document.documentElement.outerHTML,
text: texto,
});
} catch (e) {
// Un fallo acá no puede romper la página de nadie.
}
})();
+117
View File
@@ -0,0 +1,117 @@
"use strict";
// archivo — el archivo personal (SDD 26 §6.3), v1.
//
// Recibe del guión de contenido lo que la persona LEYÓ —el HTML ya ejecutado y el
// texto visible— y se lo pasa al host, que congela el HTML en el CAS y deja el resto
// en un índice buscable. La identidad de una página es el BLAKE3 de su HTML, no su
// URL: volver a la misma página no la duplica, y la misma URL con otro contenido es
// otra página. Un historial de direcciones guarda punteros; esto guarda lo leído.
//
// ── LO QUE NO SE ARCHIVA, QUE ES LA PARTE QUE HAY QUE MIRAR ──────────────────
// **Nada de una ventana privada.** Se comprueba acá y no en el guión de contenido
// porque `tabs.get()` es lo único que sabe si la pestaña es de un contenedor
// privado. Archivar lo que alguien abrió en privado es lo contrario de lo que pidió.
//
// Y no se archiva lo que no tiene texto visible: guardar el HTML de un visor de PDF
// llena el archivo de cosas que después no se encuentran.
//
// ⚠ LO QUE ESTO NO ES: preguntarle al historial en lenguaje natural. `archive.search`
// es el registro LITERAL —todas las palabras tienen que aparecer—. El semántico es un
// motor `rag-motor::RagMotor` como `willay-rag`, que necesita daemon de embeddings y
// un LLM de verdad. Cuando exista, se enchufa del mismo lado y esto no cambia.
const HOST = "puriy_costura";
let puerto = null;
function di(s) {
try { dump("ARCHIVO " + s + "\n"); } catch (e) {}
}
function conectar() {
try {
puerto = browser.runtime.connectNative(HOST);
puerto.onMessage.addListener(alRecibir);
puerto.onDisconnect.addListener(p => {
di("DESCONECTADO " + (p.error ? p.error.message : "sin error"));
puerto = null;
});
di("CONECTADO " + HOST);
} catch (e) {
di("SIN HOST " + e.message);
puerto = null;
}
}
function enviar(msg) {
if (!puerto) { conectar(); }
if (!puerto) { return false; }
try {
puerto.postMessage(msg);
return true;
} catch (e) {
di("ENVÍO FALLÓ " + e.message);
puerto = null;
return false;
}
}
function alRecibir(r) {
if (!r || r.ok !== true) {
di("RECHAZO " + (r && r.error ? r.error : "sin error"));
return;
}
if (r.verb === "archive.add" && r.page) {
const p = r.page;
di("ARCHIVADA " + p.hash + " visitas=" + p.visits + " bytes=" + p.bytes +
" total=" + r.archived + " dedup=" + (r.deduped === true) + " url=" + p.url);
pintar(r.archived);
} else if (r.verb === "archive.search" && r.pages) {
di("BUSQUEDA " + r.pages.length + " de " + r.archived);
for (const p of r.pages) {
di("HALLADA " + p.hash.slice(0, 12) + " " + p.url + " — " + p.title);
}
}
}
// La insignia dice cuántas páginas hay archivadas. No es una alarma: es el único
// sitio donde se ve que esto está haciendo algo.
function pintar(total) {
try {
browser.browserAction.setBadgeText({ text: total > 999 ? "999+" : String(total) });
browser.browserAction.setBadgeBackgroundColor({ color: "#1b6470" });
browser.browserAction.setTitle({ title: total + " página(s) en tu archivo personal" });
} catch (e) { di("INSIGNIA FALLÓ " + e.message); }
}
browser.runtime.onMessage.addListener(async (msg, emisor) => {
if (!msg || msg.tipo !== "pagina") { return; }
// ⚠ La ventana privada se descarta ACÁ, que es donde se puede saber.
try {
if (emisor.tab && emisor.tab.incognito) {
di("PRIVADA no se archiva " + msg.url);
return;
}
} catch (e) {
// Si no se puede saber si es privada, NO se archiva: ante la duda, no guardar.
di("SIN SABER si es privada: no se archiva " + msg.url);
return;
}
enviar({
verb: "archive.add",
url: msg.url,
title: msg.title,
html: msg.html,
text: msg.text,
});
});
// Una consulta desde afuera (hoy la usan las pruebas; mañana, la barra lateral).
browser.runtime.onMessageExternal.addListener(msg => {
if (msg && msg.tipo === "buscar") {
enviar({ verb: "archive.search", query: msg.query });
}
});
conectar();
di("LISTA v1 congela lo leído; nunca lo privado");
@@ -0,0 +1,35 @@
{
"manifest_version": 2,
"name": "atuq — archivo personal",
"description": "Congela en el CAS cada página que leés y la deja buscable, entera y local (SDD 26 §6.3).",
"version": "1.0",
"browser_specific_settings": {
"gecko": {
"id": "archivo@atuq.tawasuyu",
"strict_min_version": "115.0"
}
},
"permissions": [
"nativeMessaging",
"tabs",
"<all_urls>"
],
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["captura.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"background": {
"scripts": ["fondo.js"]
},
"browser_action": {
"default_title": "Archivo personal",
"default_icon": "atuq128.png"
},
"icons": {
"128": "atuq128.png"
}
}
@@ -5,6 +5,7 @@
"type": "stdio",
"allowed_extensions": [
"sct@atuq.tawasuyu",
"descargas@atuq.tawasuyu"
"descargas@atuq.tawasuyu",
"archivo@atuq.tawasuyu"
]
}
+1 -1
View File
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
[source]
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
commit = "55b918e89970650a9160fb7d532ef4f9b0648400"
commit = "357791a851b8b917c59532af4705c3db705e867f"
# ⚠ 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" }`.
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""¿`atuq` congela lo que leés, y lo encuentra después? (SDD 26 §6.3)
python3 scripts/test-atuq-archivo.py # tres visitas y una búsqueda
python3 scripts/test-atuq-archivo.py --negative-control # en VENTANA PRIVADA no se archiva nada
── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
Un servidor HTTP sirve dos páginas distintas; el navegador las visita; el guión de contenido manda
al fondo el HTML **ya ejecutado** (lo que la persona vio, no lo que el servidor mandó) y el texto
visible; el host congela el HTML en el CAS y lo deja en un índice buscable.
página → guión de contenido → connectNative → puriy-costura → CAS (BLAKE3) + índice
visita 1 (página A) ⇒ archivada, visitas=1, total=1
visita 2 (página A) ⇒ **dedup**, visitas=2, total=1 ← volver no duplica
visita 3 (página B) ⇒ total=2
búsqueda «masa» ⇒ SÓLO la A ← y la mitad que importa es la que DESCARTA
La búsqueda se le pregunta al binario del host DESDE AFUERA de la jaula: es la prueba de que lo que
el navegador archivó quedó en disco y se puede consultar sin el navegador.
── EL CONTROL NEGATIVO ES LA PROMESA MÁS IMPORTANTE ──────────────────────────────────────────
`--negative-control` abre la misma página en una **ventana privada** y exige que NO se archive nada.
Archivar lo que alguien abrió en privado sería exactamente lo contrario de lo que pidió.
Y distingue POR QUÉ no se archivó, que no es lo mismo: si en el log aparece `PRIVADA no se archiva`,
lo impidió NUESTRO código; si no aparece nada, lo impidió el navegador (las extensiones no corren en
ventanas privadas salvo que se les permita). Las dos protegen, pero sólo una es nuestra, y decir cuál
es la diferencia entre saber y suponer.
REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
"""
import http.server
import json
import os
import shutil
import socket
import struct
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"))
PAGINAS = {
"pan.html": ("Cómo hornear pan", "masa madre reposo y horno de leña"),
"cohete.html": ("Motores de cohete", "tobera de campana y empuje específico"),
}
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):
def log_message(self, *a):
pass
def do_GET(self):
nombre = self.path.lstrip("/").split("?")[0]
if nombre not in PAGINAS:
self.send_error(404)
return
titulo, cuerpo = PAGINAS[nombre]
datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title></head>'
f"<body><h1>{titulo}</h1><p>{cuerpo}</p></body></html>").encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
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, url, privada=False):
modo = "--private-window" if privada else ""
guion = f"""
mkdir -p /root /salida/hogar
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
export HOME=/salida/hogar
timeout 45 /usr/bin/atuq --profile /salida/perfil --no-remote --headless {modo} '{url}' \
> /salida/consola.log 2>&1
exit 0
"""
subprocess.run([
"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",
"--setenv", "XDG_RUNTIME_DIR", "/run",
"--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("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")], log
def preguntar(costura, estado, consulta):
"""Le pregunta al binario del host, desde AFUERA, qué encuentra. Es la prueba de que el archivo
quedó en disco y se consulta sin el navegador."""
binario = os.path.join(costura, "usr/bin/puriy-costura")
cuerpo = json.dumps({"verb": "archive.search", "query": consulta}).encode()
entrada = struct.pack("<I", len(cuerpo)) + cuerpo
r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
if len(r.stdout) < 4:
return None
n = struct.unpack("<I", r.stdout[:4])[0]
return json.loads(r.stdout[4:4 + n])
def main():
negativo = "--negative-control" in sys.argv[1:]
if negativo:
print("== MODO CONTROL NEGATIVO: ventana PRIVADA; no se debe archivar nada")
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)}")
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-archivo-")
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')
estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
if negativo:
lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/pan.html",
privada=True)
for l in lineas:
print(" " + l)
archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
if archivadas:
fatal(f"se archivó una página abierta en PRIVADO: {archivadas}")
indice = os.path.join(estado, "archivo.json")
if os.path.isfile(indice) and json.load(open(indice)):
fatal(f"el índice tiene páginas y la ventana era privada: {open(indice).read()[:200]}")
quien = ("NUESTRO código (la extensión dijo PRIVADA)"
if any(l.startswith("PRIVADA") for l in lineas)
else "el NAVEGADOR (la extensión no llegó a correr en la ventana privada)")
print(f"\n✓ control negativo: en ventana privada no se archivó nada — lo impidió {quien}")
return 0
visitas = [("pan.html", 1), ("pan.html", 2), ("cohete.html", 1)]
salidas = []
for i, (pagina, _) in enumerate(visitas, start=1):
lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/{pagina}")
salidas.append(lineas)
print(f"\n-- visita {i}: {pagina}")
for l in lineas:
print(" " + l)
if not any(l.startswith("ARCHIVADA ") for l in lineas):
destino = os.path.join(ROOT, "work", "atuq-archivo.log")
os.makedirs(os.path.dirname(destino), exist_ok=True)
open(destino, "w").write(log)
fatal(f"la visita {i} no llegó al archivo — log en {destino}")
def campos(lineas):
for l in lineas:
if l.startswith("ARCHIVADA "):
partes = l.split()
d = dict(p.split("=", 1) for p in partes if "=" in p)
d["hash"] = partes[1]
return d
return None
a1, a2, b1 = (campos(s) for s in salidas)
if a1["visitas"] != "1" or a1["dedup"] != "false" or a1["total"] != "1":
fatal(f"la primera visita no quedó como primera: {a1}")
if a2["hash"] != a1["hash"] or a2["dedup"] != "true" or a2["visitas"] != "2":
fatal(f"volver a la misma página tenía que DEDUPLICAR y no duplicar: {a2}")
if a2["total"] != "1":
fatal(f"volver a la misma página creó otra entrada: total={a2['total']}")
if b1["hash"] == a1["hash"] or b1["total"] != "2":
fatal(f"la otra página tenía que ser otro objeto: {b1}")
# El HTML congelado está en el CAS, y es el que se ejecutó (no el servido).
cas = os.path.join(estado, "cas")
blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
if a1["hash"] not in blobs or b1["hash"] not in blobs:
fatal(f"falta el HTML congelado en el CAS: {blobs}")
html_a = open(os.path.join(cas, a1["hash"]), errors="replace").read()
if "masa madre" not in html_a:
fatal("el blob del CAS no es la página que se leyó")
# Y ahora la búsqueda, preguntándole al host DESDE AFUERA.
print()
for consulta, esperado in (("masa", 1), ("tobera", 1), ("masa tobera", 0), ("helicóptero", 0)):
r = preguntar(costura, estado, consulta)
if not r or r.get("ok") is not True:
fatal(f"la búsqueda «{consulta}» falló: {r}")
n = len(r["pages"])
print(f" búsqueda «{consulta}» → {n} de {r['archived']}")
if n != esperado:
fatal(f"«{consulta}» devolvió {n} y se esperaban {esperado}"
" — un buscador que devuelve de más se ve igual que uno que funciona")
r = preguntar(costura, estado, "masa")
if "pan.html" not in r["pages"][0]["url"]:
fatal(f"«masa» devolvió la página equivocada: {r['pages'][0]['url']}")
print("\n✓ lo que se lee queda congelado en el CAS con su BLAKE3, y el índice lo cuenta")
print("✓ volver a la misma página NO la duplica; otra página es otro objeto")
print("✓ y se busca sin el navegador: el archivo está en disco y descarta lo que no coincide")
return 0
finally:
servidor.shutdown()
shutil.rmtree(tmp, ignore_errors=True)
sys.exit(main())