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…
⎇ 2201fac08e77b430ca7544672c009c5c8622c119
scripts/test-atuq-descargas.py
Historia
Quién tocó qué
crudo
atuq: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad
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 cas = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas-cas")
251 indice = os.path.join(salida, "hogar", ".local", "state", "puriy-costura", "descargas.json")
252
253 if uno["dedup"] != "false" or uno["veces"] != "1":
254 fatal(f"la PRIMERA descarga no debía estar en el CAS: {uno}")
255
256 # El fichero del usuario sigue donde estaba, y con sus bytes. Es la promesa
257 # explícita del §6.2: el CAS es una COPIA, no una mudanza.
258 for nombre, contenido in (("uno.bin", CONTENIDO_A), ("dos.bin", segundo)):
259 f = os.path.join(salida, "bajadas", nombre)
260 if not os.path.isfile(f):
261 fatal(f"el fichero bajado {nombre} ya no está donde el usuario lo dejó")
262 if open(f, "rb").read() != contenido:
263 fatal(f"el fichero bajado {nombre} no es byte a byte lo que se sirvió")
264
265 blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
266 objetos = json.load(open(indice)) if os.path.isfile(indice) else {}
267
268 if negativo:
269 if dos["dedup"] != "false":
270 fatal(f"con OTRO contenido dijo dedup={dos['dedup']} — esta prueba no distingue nada")
271 if dos["hash"] == uno["hash"]:
272 fatal("dos contenidos distintos dieron el MISMO hash")
273 if len(blobs) != 2 or len(objetos) != 2:
274 fatal(f"se esperaban DOS objetos y hay blobs={blobs} índice={list(objetos)}")
275 print("\n✓ control negativo: otro contenido es otro objeto, y no dice que ya lo tenías")
276 return 0
277
278 if dos["dedup"] != "true":
279 fatal(f"la SEGUNDA descarga del mismo contenido dijo dedup={dos['dedup']}"
280 " — que es justo lo que ningún navegador sabe hacer")
281 if dos["hash"] != uno["hash"]:
282 fatal(f"el mismo contenido dio dos hashes: {uno['hash']} y {dos['hash']}")
283 if dos["veces"] != "2":
284 fatal(f"no contó las dos veces: {dos}")
285 if dos["nombre"] != "uno.bin":
286 fatal(f"el nombre tenía que ser el de la PRIMERA vez y es {dos['nombre']}")
287 if blobs != [uno["hash"]]:
288 fatal(f"el CAS tiene {blobs} y tenía que tener UN blob llamado {uno['hash']}")
289 if list(objetos) != [uno["hash"]] or objetos[uno["hash"]]["times"] != 2:
290 fatal(f"el índice no cuenta un objeto bajado dos veces: {objetos}")
291
292 # Y el blob es el contenido servido, byte a byte, bajo un nombre que es su hash.
293 if open(os.path.join(cas, uno["hash"]), "rb").read() != CONTENIDO_A:
294 fatal("el blob del CAS no es el contenido que sirvió el servidor")
295
296 print(f"\nCAS: {blobs[0][:16]}… ({len(CONTENIDO_A)} bytes) · índice: {list(objetos)[0][:16]}…"
297 f" veces={objetos[uno['hash']]['times']}")
298 print("\n✓ la descarga entra al CAS con su BLAKE3, y el fichero del usuario no se toca")
299 print("✓ el MISMO contenido con otro nombre es UN objeto, y el navegador lo dice"
300 " («esto ya lo tenías»)")
301 print("✓ el índice queda legible en disco: la lista de lo que bajaste sobrevive al binario")
302 return 0
303 finally:
304 servidor.shutdown()
305 shutil.rmtree(tmp, ignore_errors=True)
306
307
308sys.exit(main())