#!/usr/bin/env python3 """¿QUIÉN instala de verdad las extensiones de `atuq`: la política o la carpeta? (SDD 26 §7.bis) python3 scripts/test-atuq-instalacion.py # los tres escenarios y el veredicto python3 scripts/test-atuq-instalacion.py --scenario as-is # uno solo, para diagnosticar ── POR QUÉ EXISTE ESTA PRUEBA ──────────────────────────────────────────────────────────────── Dos documentos nuestros se contradecían sobre el mecanismo del que dependen TODAS las extensiones de atuq —hoy `inicio` y `proxy`, mañana la de `sct`—: · `recipes/atuq/README.md` decía que el *sideloading* desde `distribution/extensions/` «ya no funciona» y que lo que instala es `ExtensionSettings.install_url` de la política; · el §7.bis del SDD 26 midió lo contrario: que `install_url` con `file://` no instala nada y que quien instala es el escaneo de la carpeta. Los dos mecanismos APUNTAN A LOS MISMOS FICHEROS, así que ninguna observación del artefacto los distingue: hay que romper uno por vez y mirar qué pasa. Eso es lo que hace este guión. ── LOS TRES ESCENARIOS ─────────────────────────────────────────────────────────────────────── as-is el artefacto tal cual ⇒ las extensiones tienen que estar no-policy `policies.json` SIN `ExtensionSettings` ⇒ si siguen: instala LA CARPETA policy-only los XPI mudados fuera de la carpeta y la política apuntando a la ruta nueva ⇒ si no están: la política NO instala En los tres se mira lo mismo, y por dos vías independientes: el MOTOR una sonda con permiso `browserSettings` pregunta el `homepageOverride`; si el override de `inicio` agarró, es `moz-extension://…/inicio.html` el PERFIL `extensions.json` dice si el complemento ESTÁ y si está `active` ⚠ El `location` de `extensions.json` NO sirve para distinguir el mecanismo, y esto es medido: los dos XPI de atuq salen `app-profile` **también cuando los instala el escaneo de la carpeta** y no hay ninguna política que los nombre. Se lo imprime igual porque es barato, pero la única señal que discrimina es la de arriba: romper un mecanismo por vez y ver si la extensión sigue. La sonda es además el CONTROL de que la corrida sirvió: vive en `distribution/extensions/` y NO está declarada en ninguna política, así que si habla, el escaneo de la carpeta está vivo en esa corrida. Una corrida muda no se interpreta como «no instaló»: se declara rota. REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado. """ import json import os import shutil import subprocess import sys import tempfile import zipfile 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")) APPDIR = "/usr/lib/atuq" EXTDIR = f"{APPDIR}/distribution/extensions" ALTDIR = f"{APPDIR}/xpi-politica" IDS = ("inicio@atuq.tawasuyu", "proxy@atuq.tawasuyu") SONDA = "sonda-instalacion@atuq.tawasuyu" MANIFIESTO = { "manifest_version": 2, "name": "sonda-instalacion", "version": "1.0", "browser_specific_settings": {"gecko": {"id": SONDA}}, "permissions": ["browserSettings"], "background": {"scripts": ["fondo.js"]}, } FONDO = """ function di(s) { try { dump("INSTALACION-TEST " + s + "\\n"); } catch (e) {} } (async () => { try { const h = await browser.browserSettings.homepageOverride.get({}); di("HOMEPAGE " + h.value); } catch (e) { di("EXCEPCIÓN " + e.message); } })(); """ ESCENARIOS = ("as-is", "no-policy", "policy-only") def fatal(msg): print(f"✗ {msg}", file=sys.stderr) raise SystemExit(1) def artefacto(): """El artefacto VIGENTE de atuq, por `takana hash` y nunca por glob. ⚠ ESCOTILLA `ATUQ_DIR` con su aviso puesto: el corpus es compartido y otro frente puede re-hashear `firefox` dejando el atuq vigente sin sellar durante las horas del build. Con la escotilla se puede medir contra uno viejo A SABIENDAS, y por eso grita. """ forzado = os.environ.get("ATUQ_DIR") if forzado: if not os.path.isdir(forzado): fatal(f"ATUQ_DIR={forzado} no existe") print(f"⚠ ATUQ_DIR: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") return forzado r = subprocess.run([HAMMER, "--store", STORE, "hash", "recipes/atuq.toml"], cwd=ROOT, capture_output=True, text=True) if r.returncode != 0: fatal(f"no pude hashear atuq: {r.stderr.strip()}") h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") d = os.path.join(STORE, f"{h}-atuq") if not os.path.isdir(d): fatal(f"atuq b3:{h[:8]} no está sellado — construilo antes") return d def politica_sin_extensionsettings(base): p = json.load(open(base)) p["policies"].pop("ExtensionSettings", None) return p def politica_apuntando_a(base, directorio): p = json.load(open(base)) for ident, cfg in p["policies"]["ExtensionSettings"].items(): cfg["install_url"] = f"file://{directorio}/{ident}.xpi" return p def correr(escenario, atuq): """Una corrida de atuq headless con perfil nuevo. Devuelve (homepage, locations, log).""" tmp = tempfile.mkdtemp(prefix=f"atuq-inst-{escenario}-") salida = os.path.join(tmp, "salida") capa_ext = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution", "extensions") perfil = os.path.join(salida, "perfil") os.makedirs(perfil) os.makedirs(capa_ext) try: with zipfile.ZipFile(os.path.join(capa_ext, f"{SONDA}.xpi"), "w") as z: z.writestr("manifest.json", json.dumps(MANIFIESTO, indent=1)) z.writestr("fondo.js", FONDO) with open(os.path.join(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') base = os.path.join(atuq, APPDIR.lstrip("/"), "distribution", "policies.json") preparar = "true" if escenario == "no-policy": nueva = politica_sin_extensionsettings(base) elif escenario == "policy-only": nueva = politica_apuntando_a(base, ALTDIR) preparar = (f"mkdir -p {ALTDIR} && " + " && ".join(f"mv {EXTDIR}/{i}.xpi {ALTDIR}/{i}.xpi" for i in IDS)) else: nueva = None if nueva is not None: capa_pol = os.path.join(tmp, "capa", APPDIR.lstrip("/"), "distribution") with open(os.path.join(capa_pol, "policies.json"), "w") as fh: json.dump(nueva, fh, indent=2) guion = f""" mkdir -p /root [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id {preparar} timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless about:blank \ > /salida/consola.log 2>&1 cp /salida/perfil/extensions.json /salida/extensions.json 2>/dev/null || true exit 0 """ subprocess.run([ "bwrap", "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, "--overlay-src", os.path.join(tmp, "capa"), "--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", "HOME", "/root", "--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() homepage = None for l in log.splitlines(): if l.startswith("INSTALACION-TEST HOMEPAGE "): homepage = l.split(" ", 2)[2] locations = {} ej = os.path.join(salida, "extensions.json") if os.path.exists(ej): try: for a in json.load(open(ej)).get("addons", []): if a.get("id") in IDS or a.get("id") == SONDA: locations[a["id"]] = (a.get("location"), bool(a.get("active"))) except (ValueError, KeyError) as e: print(f" ⚠ no pude leer extensions.json: {e}") return homepage, locations, log finally: shutil.rmtree(tmp, ignore_errors=True) def main(): argv = sys.argv[1:] pedido = None if "--scenario" in argv: pedido = argv[argv.index("--scenario") + 1] if pedido not in ESCENARIOS: fatal(f"escenario desconocido: {pedido} (hay {', '.join(ESCENARIOS)})") 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() print(f"artefacto: {os.path.basename(atuq)}") res = {} for e in (ESCENARIOS if pedido is None else (pedido,)): print(f"\n== escenario {e}") homepage, locations, log = correr(e, atuq) nuestra = bool(homepage and "inicio.html" in homepage) print(f" MOTOR homepage = {homepage}" f" ⇒ {'la de atuq' if nuestra else 'la de Firefox'}") if not homepage: destino = os.path.join(ROOT, "work", f"atuq-instalacion-{e}.log") os.makedirs(os.path.dirname(destino), exist_ok=True) open(destino, "w").write(log) fatal(f"la sonda no dijo NADA en «{e}» — corrida rota, no resultado. Log en {destino}") for ident, (loc, activo) in sorted(locations.items()): print(f" PERFIL {ident:34s} location={loc} activo={activo}") for ident in IDS: if ident not in locations: print(f" PERFIL {ident:34s} NO ESTÁ en extensions.json") res[e] = (nuestra, locations) if pedido is not None: return 0 print() fallos = [] if not res["as-is"][0]: fallos.append("tal cual, la página de inicio NO es la de atuq — atuq está roto, " "no es un problema del mecanismo") if not res["no-policy"][0]: fallos.append("sin `ExtensionSettings` las extensiones NO se instalan ⇒ el escaneo de " "`distribution/extensions/` YA NO alcanza: hay que revisar el §7.bis y el " "README, porque todas las extensiones de atuq cuelgan de ahí") if res["policy-only"][0]: fallos.append("con los XPI FUERA de la carpeta y sólo la política apuntándolos, las " "extensiones SÍ se instalaron ⇒ `install_url` con `file://` ahora funciona " "y el §7.bis quedó viejo") if fallos: for f in fallos: print(f"✗ {f}", file=sys.stderr) return 1 print("✓ quien instala es el ESCANEO de `distribution/extensions/`: sin la política siguen " "puestas, y con la política sola no aparecen.") print(" La política las FIJA y las CONFIGURA; instalarlas, no. (SDD 26 §7.bis)") return 0 sys.exit(main())