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…
⎇ b0085e7cf6978a7f9e27aa863f756a463fc1407e
scripts/test-atuq-archivo.py
History
Who touched what
raw
atuq: el archivo personal — lo que leés queda congelado, y se encuentra
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())