gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ d611f8577d0ebb0f265143be9007a5b94a7dd1fc scripts/test-atuq-archivo.py Python · 261 lines · 12.2 KB History Who touched what raw

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

1#!/usr/bin/env python3
2"""¿`atuq` congela lo que leés, y lo encuentra después? (SDD 26 §6.3)
3
4    python3 scripts/test-atuq-archivo.py                     # tres visitas y una búsqueda
5    python3 scripts/test-atuq-archivo.py --negative-control   # en VENTANA PRIVADA no se archiva nada
6
7── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
8Un servidor HTTP sirve dos páginas distintas; el navegador las visita; el guión de contenido manda
9al fondo el HTML **ya ejecutado** (lo que la persona vio, no lo que el servidor mandó) y el texto
10visible; el host congela el HTML en el CAS y lo deja en un índice buscable.
11
12    página → guión de contenido → connectNative → puriy-costura → CAS (BLAKE3) + índice
13
14    visita 1 (página A)   ⇒ archivada, visitas=1, total=1
15    visita 2 (página A)   ⇒ **dedup**, visitas=2, total=1   ← volver no duplica
16    visita 3 (página B)   ⇒ total=2
17    búsqueda «masa»       ⇒ SÓLO la A         ← y la mitad que importa es la que DESCARTA
18
19La búsqueda se le pregunta al binario del host DESDE AFUERA de la jaula: es la prueba de que lo que
20el navegador archivó quedó en disco y se puede consultar sin el navegador.
21
22── EL CONTROL NEGATIVO ES LA PROMESA MÁS IMPORTANTE ──────────────────────────────────────────
23`--negative-control` abre la misma página en una **ventana privada** y exige que NO se archive nada.
24Archivar lo que alguien abrió en privado sería exactamente lo contrario de lo que pidió.
25
26Y distingue POR QUÉ no se archivó, que no es lo mismo: si en el log aparece `PRIVADA no se archiva`,
27lo impidió NUESTRO código; si no aparece nada, lo impidió el navegador (las extensiones no corren en
28ventanas privadas salvo que se les permita). Las dos protegen, pero sólo una es nuestra, y decir cuál
29es la diferencia entre saber y suponer.
30
31REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
32"""
33import http.server
34import json
35import os
36import shutil
37import socket
38import struct
39import subprocess
40import sys
41import tempfile
42import threading
43
44ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
45RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
46ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
47HAMMER = os.path.join(ROOT, "target/release/takana")
48STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
49
50PAGINAS = {
51    "pan.html": ("Cómo hornear pan", "masa madre reposo y horno de leña"),
52    "cohete.html": ("Motores de cohete", "tobera de campana y empuje específico"),
53}
54
55
56def fatal(msg):
57    print(f"✗ {msg}", file=sys.stderr)
58    raise SystemExit(1)
59
60
61def artefacto(receta, nombre, escotilla=None):
62    """El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
63    forzado = os.environ.get(escotilla) if escotilla else None
64    if forzado:
65        if not os.path.isdir(forzado):
66            fatal(f"{escotilla}={forzado} no existe")
67        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
68        return forzado
69    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
70                       cwd=ROOT, capture_output=True, text=True)
71    if r.returncode != 0:
72        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
73    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
74    d = os.path.join(STORE, f"{h}-{nombre}")
75    if not os.path.isdir(d):
76        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
77    return d
78
79
80class Servidor(http.server.SimpleHTTPRequestHandler):
81    def log_message(self, *a):
82        pass
83
84    def do_GET(self):
85        nombre = self.path.lstrip("/").split("?")[0]
86        if nombre not in PAGINAS:
87            self.send_error(404)
88            return
89        titulo, cuerpo = PAGINAS[nombre]
90        datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title></head>'
91                 f"<body><h1>{titulo}</h1><p>{cuerpo}</p></body></html>").encode()
92        self.send_response(200)
93        self.send_header("Content-Type", "text/html; charset=utf-8")
94        self.send_header("Content-Length", str(len(datos)))
95        self.send_header("Cache-Control", "no-store")
96        self.end_headers()
97        self.wfile.write(datos)
98
99
100def puerto_libre():
101    s = socket.socket()
102    s.bind(("127.0.0.1", 0))
103    p = s.getsockname()[1]
104    s.close()
105    return p
106
107
108def correr(atuq, costura, salida, url, privada=False):
109    modo = "--private-window" if privada else ""
110    guion = f"""
111    mkdir -p /root /salida/hogar
112    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
113    export HOME=/salida/hogar
114    timeout 45 /usr/bin/atuq --profile /salida/perfil --no-remote --headless {modo} '{url}' \
115        > /salida/consola.log 2>&1
116    exit 0
117    """
118    subprocess.run([
119        "bwrap",
120        "--overlay-src", ALPINE, "--overlay-src", RFS,
121        "--overlay-src", atuq, "--overlay-src", costura,
122        "--tmp-overlay", "/",
123        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
124        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
125        "--bind", salida, "/salida",
126        "--uid", "0", "--gid", "0",
127        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
128        "--setenv", "XDG_RUNTIME_DIR", "/run",
129        "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
130        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
131        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
132        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
133        "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
134        "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
135        "sh", "-c", guion,
136    ], check=False)
137    log = open(os.path.join(salida, "consola.log"), errors="replace").read()
138    return [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")], log
139
140
141def preguntar(costura, estado, consulta):
142    """Le pregunta al binario del host, desde AFUERA, qué encuentra. Es la prueba de que el archivo
143    quedó en disco y se consulta sin el navegador."""
144    binario = os.path.join(costura, "usr/bin/puriy-costura")
145    cuerpo = json.dumps({"verb": "archive.search", "query": consulta}).encode()
146    entrada = struct.pack("<I", len(cuerpo)) + cuerpo
147    r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
148    if len(r.stdout) < 4:
149        return None
150    n = struct.unpack("<I", r.stdout[:4])[0]
151    return json.loads(r.stdout[4:4 + n])
152
153
154def main():
155    negativo = "--negative-control" in sys.argv[1:]
156    if negativo:
157        print("== MODO CONTROL NEGATIVO: ventana PRIVADA; no se debe archivar nada")
158    if not os.path.isdir(RFS):
159        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
160    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
161    costura = artefacto("puriy-costura", "puriy-costura")
162    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}")
163
164    puerto = puerto_libre()
165    servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
166    threading.Thread(target=servidor.serve_forever, daemon=True).start()
167
168    tmp = tempfile.mkdtemp(prefix="atuq-archivo-")
169    salida = os.path.join(tmp, "salida")
170    os.makedirs(os.path.join(salida, "perfil"))
171    try:
172        with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
173            fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
174            fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
175
176        estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
177
178        if negativo:
179            lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/pan.html",
180                                 privada=True)
181            for l in lineas:
182                print("   " + l)
183            archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
184            if archivadas:
185                fatal(f"se archivó una página abierta en PRIVADO: {archivadas}")
186            indice = os.path.join(estado, "archivo.json")
187            if os.path.isfile(indice) and json.load(open(indice)):
188                fatal(f"el índice tiene páginas y la ventana era privada: {open(indice).read()[:200]}")
189            quien = ("NUESTRO código (la extensión dijo PRIVADA)"
190                     if any(l.startswith("PRIVADA") for l in lineas)
191                     else "el NAVEGADOR (la extensión no llegó a correr en la ventana privada)")
192            print(f"\n✓ control negativo: en ventana privada no se archivó nada — lo impidió {quien}")
193            return 0
194
195        visitas = [("pan.html", 1), ("pan.html", 2), ("cohete.html", 1)]
196        salidas = []
197        for i, (pagina, _) in enumerate(visitas, start=1):
198            lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/{pagina}")
199            salidas.append(lineas)
200            print(f"\n-- visita {i}: {pagina}")
201            for l in lineas:
202                print("   " + l)
203            if not any(l.startswith("ARCHIVADA ") for l in lineas):
204                destino = os.path.join(ROOT, "work", "atuq-archivo.log")
205                os.makedirs(os.path.dirname(destino), exist_ok=True)
206                open(destino, "w").write(log)
207                fatal(f"la visita {i} no llegó al archivo — log en {destino}")
208
209        def campos(lineas):
210            for l in lineas:
211                if l.startswith("ARCHIVADA "):
212                    partes = l.split()
213                    d = dict(p.split("=", 1) for p in partes if "=" in p)
214                    d["hash"] = partes[1]
215                    return d
216            return None
217
218        a1, a2, b1 = (campos(s) for s in salidas)
219        if a1["visitas"] != "1" or a1["dedup"] != "false" or a1["total"] != "1":
220            fatal(f"la primera visita no quedó como primera: {a1}")
221        if a2["hash"] != a1["hash"] or a2["dedup"] != "true" or a2["visitas"] != "2":
222            fatal(f"volver a la misma página tenía que DEDUPLICAR y no duplicar: {a2}")
223        if a2["total"] != "1":
224            fatal(f"volver a la misma página creó otra entrada: total={a2['total']}")
225        if b1["hash"] == a1["hash"] or b1["total"] != "2":
226            fatal(f"la otra página tenía que ser otro objeto: {b1}")
227
228        # El HTML congelado está en el CAS, y es el que se ejecutó (no el servido).
229        cas = os.path.join(estado, "cas")
230        blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
231        if a1["hash"] not in blobs or b1["hash"] not in blobs:
232            fatal(f"falta el HTML congelado en el CAS: {blobs}")
233        html_a = open(os.path.join(cas, a1["hash"]), errors="replace").read()
234        if "masa madre" not in html_a:
235            fatal("el blob del CAS no es la página que se leyó")
236
237        # Y ahora la búsqueda, preguntándole al host DESDE AFUERA.
238        print()
239        for consulta, esperado in (("masa", 1), ("tobera", 1), ("masa tobera", 0), ("helicóptero", 0)):
240            r = preguntar(costura, estado, consulta)
241            if not r or r.get("ok") is not True:
242                fatal(f"la búsqueda «{consulta}» falló: {r}")
243            n = len(r["pages"])
244            print(f"   búsqueda «{consulta}» → {n} de {r['archived']}")
245            if n != esperado:
246                fatal(f"«{consulta}» devolvió {n} y se esperaban {esperado}"
247                      " — un buscador que devuelve de más se ve igual que uno que funciona")
248        r = preguntar(costura, estado, "masa")
249        if "pan.html" not in r["pages"][0]["url"]:
250            fatal(f"«masa» devolvió la página equivocada: {r['pages'][0]['url']}")
251
252        print("\n✓ lo que se lee queda congelado en el CAS con su BLAKE3, y el índice lo cuenta")
253        print("✓ volver a la misma página NO la duplica; otra página es otro objeto")
254        print("✓ y se busca sin el navegador: el archivo está en disco y descarta lo que no coincide")
255        return 0
256    finally:
257        servidor.shutdown()
258        shutil.rmtree(tmp, ignore_errors=True)
259
260
261sys.exit(main())