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…
⎇ a6cefd9b5d33c416419ef8bacfcc8fa05bb8fc61
scripts/test-atuq-descargas.py
Historia
Quién tocó qué
crudo
descargas: el guardián estaba ROJO hace dos días y nadie lo corría
1#!/usr/bin/env python3
2"""¿Lo que baja `atuq` tiene identidad, y sabe que ya lo tenías? (SDD 26 §6.2)
3
4 python3 scripts/test-atuq-descargas.py # dos descargas del MISMO contenido
5 python3 scripts/test-atuq-descargas.py --negative-control # la segunda es OTRO contenido
6
7── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
8Un servidor HTTP sirve un fichero como adjunto; el navegador lo baja de verdad (no es una llamada a
9la API: es la descarga completa, con su `Content-Disposition`); la extensión `descargas@atuq` ve que
10terminó, le pasa al host la ruta absoluta, y el host lo ingiere a un CAS BLAKE3 y contesta el hash,
11el tamaño y **cuántas veces se bajó ese mismo contenido**.
12
13 servidor HTTP → descarga real → downloads.onChanged → connectNative → puriy-costura
14 → arje-cas (BLAKE3, deduplicado)
15
16── LAS TRES AFIRMACIONES ─────────────────────────────────────────────────────────────────────
17 1ª descarga contenido A, nombre `uno.bin` ⇒ dedup=false, veces=1
18 2ª descarga contenido A, nombre `dos.bin` ⇒ **dedup=true, veces=2** y UN solo blob en disco
19 el fichero que bajó el usuario sigue donde estaba, byte a byte
20
21La segunda es la del §6.2: «lo bajás dos veces y no lo sabés». El nombre cambia y el objeto es el
22mismo, porque la identidad es el contenido.
23
24`--negative-control` baja OTRO contenido la segunda vez y exige `dedup=false` y DOS objetos. Sin él,
25una sonda rota que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
26
27── ⚠ POR QUÉ ESTO CORRE EN UN SWAY DE VERDAD Y NO EN `--headless` ────────────────────────────
28Porque en `--headless` **el navegador se cae con SIGSEGV en cuanto una descarga termina**, y eso se
29midió acá antes de escribir una línea de este guardián:
30
31 con la extensión de descargas → baja el fichero, TERMINADA, Segmentation fault (139)
32 SIN la extensión (control) → baja el fichero, Segmentation fault (139)
33 con el panel de descargas apagado→ Segmentation fault (139)
34
35O sea que **no es nuestro**: es el modo headless de Gecko. Y no es del producto tampoco: con un
36compositor real —sway headless, salida offscreen— la misma descarga completa la cadena entera sin
37caerse. La conclusión importa más que el arnés: si esto se hubiera dado por «la función no anda», se
38habría perseguido un bug que no existe; y si se hubiera dado por «el navegador no puede descargar»,
39se habría reportado un bug de producto que tampoco existe. Queda anotado en el §6.10 del SDD, que es
40donde vive lo que atuq NO puede hacer.
41
42REQUISITOS: el rootfs de sway (`scripts/hydrate-profile.py escritorio-sway --into …`), y `atuq` +
43`puriy-costura` sellados.
44"""
45import http.server
46import json
47import os
48import shutil
49import socket
50import subprocess
51import sys
52import tempfile
53import threading
54
55ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
56RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
57ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
58HAMMER = os.path.join(ROOT, "target/release/takana")
59STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
60
61CONTENIDO_A = b"un objeto con identidad, no un archivo con nombre\n" * 64
62CONTENIDO_B = b"otro contenido cualquiera, para el control negativo\n" * 64
63
64
65def fatal(msg):
66 print(f"✗ {msg}", file=sys.stderr)
67 raise SystemExit(1)
68
69
70def artefacto(receta, nombre, escotilla=None):
71 """El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
72 forzado = os.environ.get(escotilla) if escotilla else None
73 if forzado:
74 if not os.path.isdir(forzado):
75 fatal(f"{escotilla}={forzado} no existe")
76 print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
77 return forzado
78 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
79 cwd=ROOT, capture_output=True, text=True)
80 if r.returncode != 0:
81 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
82 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
83 d = os.path.join(STORE, f"{h}-{nombre}")
84 if not os.path.isdir(d):
85 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
86 return d
87
88
89class Servidor(http.server.SimpleHTTPRequestHandler):
90 """Sirve cada ruta como ADJUNTO. El `Content-Disposition` es lo que hace que el navegador lo
91 baje en vez de pintarlo: sin él esto mediría otra cosa."""
92
93 contenidos = {}
94
95 def log_message(self, *a):
96 pass
97
98 def do_GET(self):
99 nombre = self.path.lstrip("/").split("?")[0]
100 datos = Servidor.contenidos.get(nombre)
101 if datos is None:
102 self.send_error(404)
103 return
104 self.send_response(200)
105 self.send_header("Content-Type", "application/octet-stream")
106 self.send_header("Content-Disposition", f'attachment; filename="{nombre}"')
107 self.send_header("Content-Length", str(len(datos)))
108 self.send_header("Cache-Control", "no-store")
109 self.end_headers()
110 self.wfile.write(datos)
111
112
113def puerto_libre():
114 s = socket.socket()
115 s.bind(("127.0.0.1", 0))
116 p = s.getsockname()[1]
117 s.close()
118 return p
119
120
121def correr(atuq, costura, salida, urls):
122 """Levanta UN sway headless y baja, dentro de él, las URLs en orden (un atuq por descarga).
123
124 Un solo compositor para las dos descargas: levantarlo dos veces costaría el doble y no mediría
125 nada más. El perfil y el `$HOME` se reusan, así que la segunda descarga encuentra el estado que
126 dejó la primera — que es justo lo que hace falta para que la deduplicación signifique algo.
127 """
128 bajadas = " ".join(f"'{u}'" for u in urls)
129 dentro = f"""#!/bin/sh
130export XDG_RUNTIME_DIR=/run/user/0 HOME=/salida/hogar LANG=C XDG_DATA_DIRS=/usr/share
131export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman
132mkdir -p "$XDG_RUNTIME_DIR" /salida/hogar /salida/bajadas; chmod 700 "$XDG_RUNTIME_DIR"
133(
134 i=0
135 while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done
136 [ -S /run/user/0/wayland-1 ] || {{ echo "@@ sin socket wayland"; exit 1; }}
137 export WAYLAND_DISPLAY=wayland-1
138 /usr/bin/swaybg -c '#1a4b8c' & sleep 3
139 export MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland LIBGL_DRIVERS_PATH=/usr/lib/dri
140 # Los sandboxes de Gecko se apagan porque esto YA corre dentro de una jaula sin userns anidado:
141 # con ellos puestos los procesos hijos mueren en bucle. Es el entorno de prueba, no el artefacto.
142 export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
143 for u in {bajadas}; do
144 # La marca va AL MISMO log que lee el guardián: si sale por la consola, el
145 # parser no tiene con qué separar una descarga de la otra.
146 echo "@@ bajando $u" >> /salida/atuq.log
147 timeout 60 /usr/bin/atuq --no-remote --profile /salida/perfil "$u" >> /salida/atuq.log 2>&1
148 echo "@@ atuq salió con $?" >> /salida/atuq.log
149 done
150 sleep 2
151 # El socket de sway se busca explícitamente: esta subshell arrancó ANTES que sway
152 # (es hija del shell que después lo `exec`a), así que no hereda `SWAYSOCK` y sin él
153 # `swaymsg exit` no hace nada — la jaula se quedaría viva hasta el timeout externo.
154 SWAYSOCK=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
155 export SWAYSOCK
156 /usr/bin/swaymsg exit 2>/dev/null
157) &
158exec /usr/bin/sway -d > /salida/sway.log 2>&1
159"""
160 guion = os.path.join(salida, "dentro.sh")
161 with open(guion, "w") as fh:
162 fh.write(dentro)
163 orden = [
164 "bwrap",
165 "--overlay-src", ALPINE, "--overlay-src", RFS,
166 "--overlay-src", atuq, "--overlay-src", costura,
167 "--tmp-overlay", "/",
168 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
169 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
170 "--bind", salida, "/salida",
171 "--uid", "0", "--gid", "0",
172 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
173 "/bin/sh", "/salida/dentro.sh",
174 ]
175 try:
176 subprocess.run(orden, check=False, timeout=300)
177 except subprocess.TimeoutExpired:
178 # El compositor no se cerró solo. No es motivo para perder la corrida: las
179 # descargas y el log ya están en disco, y las aserciones son sobre eso.
180 print(" ⚠ el compositor no se cerró solo; se lo cortó a los 300 s")
181 ruta = os.path.join(salida, "atuq.log")
182 log = open(ruta, errors="replace").read() if os.path.exists(ruta) else ""
183 # Las líneas de cada descarga, separadas por el `@@ bajando` que las precede.
184 tandas, actual = [], None
185 for l in log.splitlines():
186 if l.startswith("@@ bajando"):
187 actual = []
188 tandas.append(actual)
189 elif l.startswith("DESCARGAS ") and actual is not None:
190 actual.append(l[len("DESCARGAS "):])
191 return tandas, log
192
193
194def main():
195 negativo = "--negative-control" in sys.argv[1:]
196 if negativo:
197 print("== MODO CONTROL NEGATIVO: la segunda descarga es OTRO contenido; NO debe deduplicar")
198 if not os.path.isdir(RFS):
199 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
200 atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
201 costura = artefacto("puriy-costura", "puriy-costura")
202 print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
203
204 segundo = CONTENIDO_B if negativo else CONTENIDO_A
205 Servidor.contenidos = {"uno.bin": CONTENIDO_A, "dos.bin": segundo}
206 puerto = puerto_libre()
207 servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
208 threading.Thread(target=servidor.serve_forever, daemon=True).start()
209
210 tmp = tempfile.mkdtemp(prefix="atuq-desc-")
211 salida = os.path.join(tmp, "salida")
212 perfil = os.path.join(salida, "perfil")
213 os.makedirs(perfil)
214 try:
215 with open(os.path.join(perfil, "user.js"), "w") as fh:
216 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
217 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
218 # Bajar SIN preguntar y a un directorio conocido. Sin esto el navegador
219 # headless se queda esperando un diálogo que nadie va a contestar, y la
220 # prueba mediría un timeout en vez de una descarga.
221 fh.write('user_pref("browser.download.folderList", 2);\n')
222 fh.write('user_pref("browser.download.dir", "/salida/bajadas");\n')
223 fh.write('user_pref("browser.download.useDownloadDir", true);\n')
224 fh.write('user_pref("browser.download.always_ask_before_handling_new_types", false);\n')
225 fh.write('user_pref("browser.helperApps.neverAsk.saveToDisk", "application/octet-stream");\n')
226 fh.write('user_pref("browser.download.start_downloads_in_tmp_dir", false);\n')
227
228 urls = [f"http://127.0.0.1:{puerto}/uno.bin", f"http://127.0.0.1:{puerto}/dos.bin"]
229 bajadas, log = correr(atuq, costura, salida, urls)
230 for i, lineas in enumerate(bajadas):
231 print(f"\n-- descarga {('uno.bin', 'dos.bin')[i] if i < 2 else i}")
232 for l in lineas:
233 print(" " + l)
234 if len(bajadas) != 2 or not all(any(l.startswith("INGERIDO ") for l in t) for t in bajadas):
235 destino = os.path.join(ROOT, "work", "atuq-descargas.log")
236 os.makedirs(os.path.dirname(destino), exist_ok=True)
237 open(destino, "w").write(log)
238 fatal(f"alguna descarga no llegó al CAS — log en {destino}")
239
240 def ingerido(lineas):
241 for l in lineas:
242 if l.startswith("INGERIDO "):
243 partes = l.split()
244 campos = dict(p.split("=", 1) for p in partes if "=" in p)
245 campos["hash"] = partes[1]
246 return campos
247 return None
248
249 uno, dos = ingerido(bajadas[0]), ingerido(bajadas[1])
250 # ⚠ El directorio es `cas` y no `descargas-cas` desde que el §6.3 entró: el archivo personal
251 # y las descargas comparten UN CAS, que es justo lo que hace que una página archivada y un
252 # fichero bajado con el mismo contenido sean un solo objeto. Este guardián se quedó mirando
253 # el nombre viejo y estuvo ROJO dos días sin que nadie lo corriera — la prueba de que un
254 # guardián que no se ejecuta no protege de nada.
255 cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "cas")
256 indice = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas.json")
257
258 if uno["dedup"] != "false" or uno["veces"] != "1":
259 fatal(f"la PRIMERA descarga no debía estar en el CAS: {uno}")
260
261 # El fichero del usuario sigue donde estaba, y con sus bytes. Es la promesa
262 # explícita del §6.2: el CAS es una COPIA, no una mudanza.
263 for nombre, contenido in (("uno.bin", CONTENIDO_A), ("dos.bin", segundo)):
264 f = os.path.join(salida, "bajadas", nombre)
265 if not os.path.isfile(f):
266 fatal(f"el fichero bajado {nombre} ya no está donde el usuario lo dejó")
267 if open(f, "rb").read() != contenido:
268 fatal(f"el fichero bajado {nombre} no es byte a byte lo que se sirvió")
269
270 blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
271 objetos = json.load(open(indice)) if os.path.isfile(indice) else {}
272
273 if negativo:
274 if dos["dedup"] != "false":
275 fatal(f"con OTRO contenido dijo dedup={dos['dedup']} — esta prueba no distingue nada")
276 if dos["hash"] == uno["hash"]:
277 fatal("dos contenidos distintos dieron el MISMO hash")
278 if len(blobs) != 2 or len(objetos) != 2:
279 fatal(f"se esperaban DOS objetos y hay blobs={blobs} índice={list(objetos)}")
280 print("\n✓ control negativo: otro contenido es otro objeto, y no dice que ya lo tenías")
281 return 0
282
283 if dos["dedup"] != "true":
284 fatal(f"la SEGUNDA descarga del mismo contenido dijo dedup={dos['dedup']}"
285 " — que es justo lo que ningún navegador sabe hacer")
286 if dos["hash"] != uno["hash"]:
287 fatal(f"el mismo contenido dio dos hashes: {uno['hash']} y {dos['hash']}")
288 if dos["veces"] != "2":
289 fatal(f"no contó las dos veces: {dos}")
290 if dos["nombre"] != "uno.bin":
291 fatal(f"el nombre tenía que ser el de la PRIMERA vez y es {dos['nombre']}")
292 if blobs != [uno["hash"]]:
293 fatal(f"el CAS tiene {blobs} y tenía que tener UN blob llamado {uno['hash']}")
294 if list(objetos) != [uno["hash"]] or objetos[uno["hash"]]["times"] != 2:
295 fatal(f"el índice no cuenta un objeto bajado dos veces: {objetos}")
296
297 # Y el blob es el contenido servido, byte a byte, bajo un nombre que es su hash.
298 if open(os.path.join(cas, uno["hash"]), "rb").read() != CONTENIDO_A:
299 fatal("el blob del CAS no es el contenido que sirvió el servidor")
300
301 print(f"\nCAS: {blobs[0][:16]}… ({len(CONTENIDO_A)} bytes) · índice: {list(objetos)[0][:16]}…"
302 f" veces={objetos[uno['hash']]['times']}")
303 print("\n✓ la descarga entra al CAS con su BLAKE3, y el fichero del usuario no se toca")
304 print("✓ el MISMO contenido con otro nombre es UN objeto, y el navegador lo dice"
305 " («esto ya lo tenías»)")
306 print("✓ el índice queda legible en disco: la lista de lo que bajaste sobrevive al binario")
307 return 0
308 finally:
309 servidor.shutdown()
310 shutil.rmtree(tmp, ignore_errors=True)
311
312
313sys.exit(main())