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 1181Shell 82Rust 62Markdown 521515 Definiciones

⎇ 6b7526cba1c69ecc4966ea57fa1d53a732d34fa4 scripts/test-atuq-descargas.py Python · 313 líneas · 15.8 KB Historia Quién tocó qué crudo

descargas: el guardián estaba ROJO hace dos días y nadie lo corría 29831e629 · Sergio ·

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())