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 1373Shell 116Rust 74Markdown 641915 Definiciones

⎇ 2201fac08e77b430ca7544672c009c5c8622c119 scripts/test-atuq-descargas.py Python · 308 líneas · 15.3 KB Historia Quién tocó qué crudo

atuq: una descarga deja de ser un archivo con nombre y pasa a ser un objeto con identidad 897fad178 · 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        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())