La unidad 8 del SDD 26 (§6.3), en su mitad pagable. Cada página que se lee se congela: el HTML **ya
ejecutado** —lo que la persona vio, no lo que mandó el servidor— va al CAS con su BLAKE3, y la url,
el título y el texto visible al índice. Después se busca, sin el navegador.
visita 1 (página A) ⇒ archivada, visitas=1, total=1
visita 2 (página A) ⇒ dedup, visitas=2, total=1 ← volver NO duplica
visita 3 (página B) ⇒ total=2
«masa» → 1 de 2 · «tobera» → 1 de 2 · «masa tobera» → 0 · «helicóptero» → 0
LA IDENTIDAD ES EL BLAKE3 DEL HTML, NO LA URL, y de ahí sale lo que hace que esto valga: la misma URL
con otro contenido ES otra página, así que el archivo tiene VERSIONES de lo que leíste. Un historial
de direcciones guarda punteros, y los punteros se pudren.
⚠ LA MITAD QUE NO SE PAGA HOY, dicha en el código, en el LEEME de allá y en el §6.3.bis: preguntarle
al historial en LENGUAJE NATURAL. Eso es el registro semántico y en la suite ya tiene forma —un motor
`rag-motor::RagMotor`, como `willay-rag`—, que necesita un daemon de embeddings y un backend LLM real
y devuelve `None` cuando no están. `archive.search` es el registro LITERAL: todas las palabras tienen
que aparecer. Enseñar lo uno diciendo que es lo otro sería el peor cambio posible.
LO QUE NO SE ARCHIVA ES LA PARTE QUE HAY QUE MIRAR: nada de una ventana privada, nada fuera del marco
principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto visible (el
HTML de un visor de PDF llenaría el archivo de cosas que no se encuentran). Y si no se puede saber si
la pestaña es privada, NO se archiva.
⚠ Y EL CONTROL NEGATIVO NO SÓLO COMPRUEBA QUE NO SE ARCHIVE: DICE QUIÉN LO IMPIDIÓ. La respuesta
medida no es la que uno supondría — hoy lo impide **el navegador**, porque las extensiones no corren
en ventanas privadas salvo que se las habilite, así que nuestro `if (incognito) return` no se ejecuta
nunca. No es código muerto: es lo que haría seguro habilitar `private_browsing` el día que haga
falta. Lo que sí habría sido un error es escribir «la extensión no archiva lo privado» sin saber cuál
de las dos cosas estaba pasando.
DOS TECHOS CON NOMBRE (en el host): 4 KiB de texto por página en el índice —es JSON para poder leerse
con `cat`, y uno sin techo deja de poder— y el corte por CARÁCTER y no por byte, porque `truncate`
sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal.
Del lado de tawasuyu (`357791a8`, pineado acá): `archive.add`/`archive.search`, 6 tests nuevos (20 en
total) y el CAS renombrado de `descargas-cas` a `cas` — el mismo almacén guarda ahora lo bajado y lo
leído, y un nombre que describe la mitad de su contenido es el que se lee mal dentro de seis meses.
Medido sobre `atuq b3:5cfe3221` y `puriy-costura b3:c06f55aa`.
262 lines
12 KiB
Python
Executable File
262 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""¿`atuq` congela lo que leés, y lo encuentra después? (SDD 26 §6.3)
|
|
|
|
python3 scripts/test-atuq-archivo.py # tres visitas y una búsqueda
|
|
python3 scripts/test-atuq-archivo.py --negative-control # en VENTANA PRIVADA no se archiva nada
|
|
|
|
── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
|
|
Un servidor HTTP sirve dos páginas distintas; el navegador las visita; el guión de contenido manda
|
|
al fondo el HTML **ya ejecutado** (lo que la persona vio, no lo que el servidor mandó) y el texto
|
|
visible; el host congela el HTML en el CAS y lo deja en un índice buscable.
|
|
|
|
página → guión de contenido → connectNative → puriy-costura → CAS (BLAKE3) + índice
|
|
|
|
visita 1 (página A) ⇒ archivada, visitas=1, total=1
|
|
visita 2 (página A) ⇒ **dedup**, visitas=2, total=1 ← volver no duplica
|
|
visita 3 (página B) ⇒ total=2
|
|
búsqueda «masa» ⇒ SÓLO la A ← y la mitad que importa es la que DESCARTA
|
|
|
|
La búsqueda se le pregunta al binario del host DESDE AFUERA de la jaula: es la prueba de que lo que
|
|
el navegador archivó quedó en disco y se puede consultar sin el navegador.
|
|
|
|
── EL CONTROL NEGATIVO ES LA PROMESA MÁS IMPORTANTE ──────────────────────────────────────────
|
|
`--negative-control` abre la misma página en una **ventana privada** y exige que NO se archive nada.
|
|
Archivar lo que alguien abrió en privado sería exactamente lo contrario de lo que pidió.
|
|
|
|
Y distingue POR QUÉ no se archivó, que no es lo mismo: si en el log aparece `PRIVADA no se archiva`,
|
|
lo impidió NUESTRO código; si no aparece nada, lo impidió el navegador (las extensiones no corren en
|
|
ventanas privadas salvo que se les permita). Las dos protegen, pero sólo una es nuestra, y decir cuál
|
|
es la diferencia entre saber y suponer.
|
|
|
|
REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
|
|
"""
|
|
import http.server
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
|
|
ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
|
|
HAMMER = os.path.join(ROOT, "target/release/takana")
|
|
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
|
|
|
|
PAGINAS = {
|
|
"pan.html": ("Cómo hornear pan", "masa madre reposo y horno de leña"),
|
|
"cohete.html": ("Motores de cohete", "tobera de campana y empuje específico"),
|
|
}
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"✗ {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def artefacto(receta, nombre, escotilla=None):
|
|
"""El artefacto VIGENTE, por `takana hash` y nunca por glob. La escotilla grita."""
|
|
forzado = os.environ.get(escotilla) if escotilla else None
|
|
if forzado:
|
|
if not os.path.isdir(forzado):
|
|
fatal(f"{escotilla}={forzado} no existe")
|
|
print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
|
|
return forzado
|
|
r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
|
|
cwd=ROOT, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
|
|
h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
|
|
d = os.path.join(STORE, f"{h}-{nombre}")
|
|
if not os.path.isdir(d):
|
|
fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
|
|
return d
|
|
|
|
|
|
class Servidor(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
nombre = self.path.lstrip("/").split("?")[0]
|
|
if nombre not in PAGINAS:
|
|
self.send_error(404)
|
|
return
|
|
titulo, cuerpo = PAGINAS[nombre]
|
|
datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title></head>'
|
|
f"<body><h1>{titulo}</h1><p>{cuerpo}</p></body></html>").encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(datos)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(datos)
|
|
|
|
|
|
def puerto_libre():
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
p = s.getsockname()[1]
|
|
s.close()
|
|
return p
|
|
|
|
|
|
def correr(atuq, costura, salida, url, privada=False):
|
|
modo = "--private-window" if privada else ""
|
|
guion = f"""
|
|
mkdir -p /root /salida/hogar
|
|
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
|
|
export HOME=/salida/hogar
|
|
timeout 45 /usr/bin/atuq --profile /salida/perfil --no-remote --headless {modo} '{url}' \
|
|
> /salida/consola.log 2>&1
|
|
exit 0
|
|
"""
|
|
subprocess.run([
|
|
"bwrap",
|
|
"--overlay-src", ALPINE, "--overlay-src", RFS,
|
|
"--overlay-src", atuq, "--overlay-src", costura,
|
|
"--tmp-overlay", "/",
|
|
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
|
|
"--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
|
|
"--bind", salida, "/salida",
|
|
"--uid", "0", "--gid", "0",
|
|
"--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
"--setenv", "XDG_RUNTIME_DIR", "/run",
|
|
"--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
|
|
"--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
|
|
"sh", "-c", guion,
|
|
], check=False)
|
|
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
|
|
return [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")], log
|
|
|
|
|
|
def preguntar(costura, estado, consulta):
|
|
"""Le pregunta al binario del host, desde AFUERA, qué encuentra. Es la prueba de que el archivo
|
|
quedó en disco y se consulta sin el navegador."""
|
|
binario = os.path.join(costura, "usr/bin/puriy-costura")
|
|
cuerpo = json.dumps({"verb": "archive.search", "query": consulta}).encode()
|
|
entrada = struct.pack("<I", len(cuerpo)) + cuerpo
|
|
r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
|
|
if len(r.stdout) < 4:
|
|
return None
|
|
n = struct.unpack("<I", r.stdout[:4])[0]
|
|
return json.loads(r.stdout[4:4 + n])
|
|
|
|
|
|
def main():
|
|
negativo = "--negative-control" in sys.argv[1:]
|
|
if negativo:
|
|
print("== MODO CONTROL NEGATIVO: ventana PRIVADA; no se debe archivar nada")
|
|
if not os.path.isdir(RFS):
|
|
fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
|
|
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
|
|
costura = artefacto("puriy-costura", "puriy-costura")
|
|
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
|
|
|
|
puerto = puerto_libre()
|
|
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
|
|
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
|
|
|
tmp = tempfile.mkdtemp(prefix="atuq-archivo-")
|
|
salida = os.path.join(tmp, "salida")
|
|
os.makedirs(os.path.join(salida, "perfil"))
|
|
try:
|
|
with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
|
|
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
|
|
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
|
|
|
|
estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
|
|
|
|
if negativo:
|
|
lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/pan.html",
|
|
privada=True)
|
|
for l in lineas:
|
|
print(" " + l)
|
|
archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
|
|
if archivadas:
|
|
fatal(f"se archivó una página abierta en PRIVADO: {archivadas}")
|
|
indice = os.path.join(estado, "archivo.json")
|
|
if os.path.isfile(indice) and json.load(open(indice)):
|
|
fatal(f"el índice tiene páginas y la ventana era privada: {open(indice).read()[:200]}")
|
|
quien = ("NUESTRO código (la extensión dijo PRIVADA)"
|
|
if any(l.startswith("PRIVADA") for l in lineas)
|
|
else "el NAVEGADOR (la extensión no llegó a correr en la ventana privada)")
|
|
print(f"\n✓ control negativo: en ventana privada no se archivó nada — lo impidió {quien}")
|
|
return 0
|
|
|
|
visitas = [("pan.html", 1), ("pan.html", 2), ("cohete.html", 1)]
|
|
salidas = []
|
|
for i, (pagina, _) in enumerate(visitas, start=1):
|
|
lineas, log = correr(atuq, costura, salida, f"http://127.0.0.1:{puerto}/{pagina}")
|
|
salidas.append(lineas)
|
|
print(f"\n-- visita {i}: {pagina}")
|
|
for l in lineas:
|
|
print(" " + l)
|
|
if not any(l.startswith("ARCHIVADA ") for l in lineas):
|
|
destino = os.path.join(ROOT, "work", "atuq-archivo.log")
|
|
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
|
open(destino, "w").write(log)
|
|
fatal(f"la visita {i} no llegó al archivo — log en {destino}")
|
|
|
|
def campos(lineas):
|
|
for l in lineas:
|
|
if l.startswith("ARCHIVADA "):
|
|
partes = l.split()
|
|
d = dict(p.split("=", 1) for p in partes if "=" in p)
|
|
d["hash"] = partes[1]
|
|
return d
|
|
return None
|
|
|
|
a1, a2, b1 = (campos(s) for s in salidas)
|
|
if a1["visitas"] != "1" or a1["dedup"] != "false" or a1["total"] != "1":
|
|
fatal(f"la primera visita no quedó como primera: {a1}")
|
|
if a2["hash"] != a1["hash"] or a2["dedup"] != "true" or a2["visitas"] != "2":
|
|
fatal(f"volver a la misma página tenía que DEDUPLICAR y no duplicar: {a2}")
|
|
if a2["total"] != "1":
|
|
fatal(f"volver a la misma página creó otra entrada: total={a2['total']}")
|
|
if b1["hash"] == a1["hash"] or b1["total"] != "2":
|
|
fatal(f"la otra página tenía que ser otro objeto: {b1}")
|
|
|
|
# El HTML congelado está en el CAS, y es el que se ejecutó (no el servido).
|
|
cas = os.path.join(estado, "cas")
|
|
blobs = sorted(os.listdir(cas)) if os.path.isdir(cas) else []
|
|
if a1["hash"] not in blobs or b1["hash"] not in blobs:
|
|
fatal(f"falta el HTML congelado en el CAS: {blobs}")
|
|
html_a = open(os.path.join(cas, a1["hash"]), errors="replace").read()
|
|
if "masa madre" not in html_a:
|
|
fatal("el blob del CAS no es la página que se leyó")
|
|
|
|
# Y ahora la búsqueda, preguntándole al host DESDE AFUERA.
|
|
print()
|
|
for consulta, esperado in (("masa", 1), ("tobera", 1), ("masa tobera", 0), ("helicóptero", 0)):
|
|
r = preguntar(costura, estado, consulta)
|
|
if not r or r.get("ok") is not True:
|
|
fatal(f"la búsqueda «{consulta}» falló: {r}")
|
|
n = len(r["pages"])
|
|
print(f" búsqueda «{consulta}» → {n} de {r['archived']}")
|
|
if n != esperado:
|
|
fatal(f"«{consulta}» devolvió {n} y se esperaban {esperado}"
|
|
" — un buscador que devuelve de más se ve igual que uno que funciona")
|
|
r = preguntar(costura, estado, "masa")
|
|
if "pan.html" not in r["pages"][0]["url"]:
|
|
fatal(f"«masa» devolvió la página equivocada: {r['pages'][0]['url']}")
|
|
|
|
print("\n✓ lo que se lee queda congelado en el CAS con su BLAKE3, y el índice lo cuenta")
|
|
print("✓ volver a la misma página NO la duplica; otra página es otro objeto")
|
|
print("✓ y se busca sin el navegador: el archivo está en disco y descarta lo que no coincide")
|
|
return 0
|
|
finally:
|
|
servidor.shutdown()
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
sys.exit(main())
|