La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía
carga 4 (mismo script) fase=stable eventos=0 insignia vacía
carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control
carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1"
EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B
La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado
del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el
primero es el que está certificado sin red.
CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:
1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
`XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
`command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
(se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen ANTES de conocer su código real,
y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por
encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
una visita», así que si vuelve, falla ruidoso.
DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino
crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa.
EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.
CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que
el veredicto de la corrida positiva viene del host y no de la extensión inventándolo.
Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.
Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.
`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.
⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.
Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.
Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
190 lines
14 KiB
TOML
190 lines
14 KiB
TOML
# atuq 0.1.0 — el navegador de la distro, como ARTEFACTO DERIVADO de firefox.
|
|
#
|
|
# ══ POR QUÉ DERIVADO Y NO UN FORK DE FUENTE ════════════════════════════════════════════════════
|
|
# Zen —el fork de Firefox al que esto reemplaza en nuestro catálogo— parchea el árbol de Gecko y
|
|
# recompila. Nosotros no distribuimos un binario: sellamos artefactos. Entonces atuq puede DEPENDER
|
|
# de `firefox` y poner su capa encima, y eso cambia el precio de todo:
|
|
# · una iteración de chrome cuesta SEGUNDOS, no las cuatro horas de un build de Gecko;
|
|
# · no mueve el `ArtifactHash` de `firefox` ⇒ el corpus no se invalida;
|
|
# · las CVE se heredan gratis: cuando sube `firefox`, atuq se reconstruye solo.
|
|
# El razonamiento completo, con lo que se promete y lo que NO (spoiler: no habrá «modo Tor»), está
|
|
# en `docs/26-atuq-envoltorio-gecko.md`.
|
|
#
|
|
# ══ SU FUENTE ES ESTE REPO ═════════════════════════════════════════════════════════════════════
|
|
# `source.dir` es el modo de fuente que se agregó para esto (takana-core/src/recipe.rs): el árbol
|
|
# vive en `recipes/atuq/` y se hashea por CONTENIDO con `ArtifactHash::of_tree`, igual que ya se
|
|
# hacía con los `patches`. No hay commit que pinear y no hay fetch: editar un CSS mueve el hash.
|
|
#
|
|
# ══ v0.2: EL BRANDING VIVE DENTRO DEL ZIP ══════════════════════════════════════════════════════
|
|
# La v0.1 dejó el branding fuera porque había que mirar el árbol real antes de adivinar. Ya se miró,
|
|
# y lo que se encontró decide la forma de esta versión:
|
|
#
|
|
# · `application.ini` del appdir arranca con el comentario «This file is not used». Es HERENCIA
|
|
# del árbol de desarrollo: en un build empaquetado el launcher SÍ lo lee. `rebrand.py` no le
|
|
# cree al comentario, comprueba que las claves estén y falla si no.
|
|
# · Las cadenas que la gente VE están dentro de `browser/omni.ja` (`brand.ftl`), así que sin
|
|
# re-empacar el zip no hay branding posible. Y el original dice **Nightly**, no «Firefox»,
|
|
# porque construimos con `--with-branding=browser/branding/unofficial`.
|
|
# · Los ICONOS, en cambio, están FUERA del zip (`browser/chrome/icons/default/`) ⇒ se reemplazan
|
|
# sin tocarlo.
|
|
# · `browser/omni.ja` son 56 MB con TODAS sus entradas sin comprimir y con `date_time` ya
|
|
# normalizado a 2010-01-01: Mozilla también persigue reproducibilidad. El re-empaque preserva
|
|
# orden, método y fecha — el orden porque es el que Gecko lee al arrancar, y es lo que el
|
|
# `jarlog` del PGO refinará cuando exista.
|
|
# ══ v0.5: PROXY POR CONTENEDOR (SDD 26 §6.8) ═══════════════════════════════════════════════════
|
|
# El primer DIFERENCIADOR de la lista del §6 que se paga entero acá, sin depender del host de
|
|
# native messaging (§7) ni de nada de tawasuyu: es API de Firefox y nada más. Cada contenedor
|
|
# —Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
|
|
#
|
|
# Son tres piezas y ninguna alcanza sola:
|
|
# · `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que vienen apagados;
|
|
# · `distribution/policies.json` los CREA con `Containers.Default` —el único mecanismo que los
|
|
# pone en un perfil nuevo— y deja la config de fábrica del proxy en `3rdparty.Extensions`;
|
|
# · `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el
|
|
# `cookieStoreId` de la petición.
|
|
#
|
|
# Las tres se comprobaron DENTRO del artefacto de firefox antes de escribirlas, no en la
|
|
# documentación de Mozilla: `Containers` y `3rdparty` están en el `policies-schema.json` de
|
|
# `browser/omni.ja`, y `cookieStoreId` en el `schemas/proxy.json` de `omni.ja`. Es la regla que dejó
|
|
# el §2.sexies: cuando la duda es «¿existe esto en NUESTRO build?», la respuesta está adentro.
|
|
#
|
|
# ⚠ LO QUE NO ES, Y VA ESCRITO EN LA PROPIA PÁGINA DE OPCIONES: separación de tráfico, NO anonimato.
|
|
# No toca la huella del navegador. El §4 del SDD ya se había comprometido a decirlo primero.
|
|
name = "atuq"
|
|
# El campo se había quedado en 0.2.0 mientras los commits ya iban por v0.4; se pone al día acá.
|
|
version = "0.5.0"
|
|
# El artefacto CONTIENE Firefox, así que hereda su licencia. El overlay de `recipes/atuq/` es
|
|
# nuestro, pero eso no cambia lo que se distribuye.
|
|
license = "MPL-2.0"
|
|
|
|
[source]
|
|
dir = "atuq"
|
|
|
|
[build]
|
|
# No se compila nada: esta receta copia y edita ficheros. `compiler` y `link` entran igual en el
|
|
# hash, así que se dejan en el default del corpus para no inventar una identidad que no significa
|
|
# nada.
|
|
target = "x86_64-linux-musl"
|
|
|
|
[build.phases]
|
|
configure = "echo 'atuq: nada que configurar — es un artefacto derivado'"
|
|
compile = "echo 'atuq: nada que compilar — el motor lo pone la dep firefox'"
|
|
install = '''
|
|
set -e
|
|
SRC=/usr/lib/firefox
|
|
DST=/out/usr/lib/atuq
|
|
|
|
# ── LAS ASERCIONES VAN PRIMERO, Y FALLAN RUIDOSAS ─────────────────────────────────────────────
|
|
# Un derivado que no encuentra su base no debe producir un artefacto flaco: produciría un
|
|
# directorio con cuatro ficheros de configuración, `Store::has` lo daría por presente y el fallo
|
|
# aparecería el día que alguien intente abrir el navegador. Un ausente falla fuerte; un vacío llega
|
|
# hasta el final diciendo que todo fue bien (regla 3 del CLAUDE.md).
|
|
[ -d "$SRC" ] || { echo "atuq: no está el árbol de firefox en $SRC — ¿la dep se materializó?" >&2; exit 1; }
|
|
[ -x "$SRC/firefox" ] || { echo "atuq: $SRC/firefox no existe o no es ejecutable" >&2; exit 1; }
|
|
[ -f "$SRC/browser/omni.ja" ] || { echo "atuq: falta $SRC/browser/omni.ja" >&2; exit 1; }
|
|
|
|
mkdir -p "$DST"
|
|
cp -a "$SRC"/. "$DST"/
|
|
# ── EL ÁRBOL COPIADO LLEGA DE SÓLO LECTURA ────────────────────────────────────────────────────
|
|
# `cp -a` preserva los modos, y los ficheros del store están sellados sin permiso de escritura. Todo
|
|
# lo que viene después —el `.ini`, el `omni.ja`, los iconos, el parche del app_id— los MODIFICA.
|
|
# En el worker esto pasaba inadvertido porque corre como root, que ignora los bits de permiso; el
|
|
# primer build fuera de root murió con `PermissionError: /out/usr/lib/atuq/application.ini`.
|
|
# Es la clase de bug que sólo aparece en la máquina de otro, así que se arregla acá y no en el
|
|
# entorno: un artefacto no debe depender de con qué uid lo construiste.
|
|
chmod -R u+w "$DST"
|
|
|
|
# ── LA CAPA DE CONFIGURACIÓN ──────────────────────────────────────────────────────────────────
|
|
# `defaults/pref/` es el único sitio desde el que se puede pedir el autoconfig ANTES de que exista
|
|
# un perfil; el resto cuelga de ahí.
|
|
mkdir -p "$DST/defaults/pref" "$DST/chrome" "$DST/distribution"
|
|
cp /src/prefs/autoconfig.js "$DST/defaults/pref/autoconfig.js"
|
|
cp /src/atuq.cfg "$DST/atuq.cfg"
|
|
cp /src/chrome/atuq.css "$DST/chrome/atuq.css"
|
|
cp /src/distribution/policies.json "$DST/distribution/policies.json"
|
|
|
|
# ── EL HOST NATIVO (SDD 26 §7) ────────────────────────────────────────────────────────────────
|
|
# El lanzador va ANTES de `rebrand.py` a propósito: la herramienta comprueba que el `path` de cada
|
|
# manifiesto de native messaging exista y sea ejecutable DENTRO del artefacto, y esa comprobación no
|
|
# mide nada si el fichero se copia después. Un manifiesto que apunta a un ejecutable ausente no
|
|
# falla: el navegador arranca, la extensión conecta, el puerto se cierra «sin error y sin mensaje» y
|
|
# la función no existe. Es el peor informe posible, y ya nos costó una tarde (§7.bis).
|
|
cp /src/bin/puriy-costura-host "$DST/puriy-costura-host"
|
|
chmod 0755 "$DST/puriy-costura-host"
|
|
|
|
# ── EL BRANDING ───────────────────────────────────────────────────────────────────────────────
|
|
# Todo lo que exige entrar al zip, editar el .ini o dibujar un PNG está en una herramienta aparte:
|
|
# el shell no tiene `zip` en este lab, y `zipfile` además deja fijar orden, fecha y permisos, que
|
|
# es de lo que depende que el artefacto reproduzca. La herramienta VERIFICA cada cambio y falla si
|
|
# la entrada que iba a reemplazar no estaba.
|
|
python3 /src/tools/rebrand.py "$DST" /src /out
|
|
|
|
# ── INTEGRACIÓN CON EL ESCRITORIO ─────────────────────────────────────────────────────────────
|
|
mkdir -p /out/usr/bin /out/usr/share/applications
|
|
cp /src/atuq.desktop /out/usr/share/applications/atuq.desktop
|
|
# El lanzador es un script y no un symlink: ni el binario ni sus `.so` traen RPATH (comprobado con
|
|
# `readelf -d`), así que sin `LD_LIBRARY_PATH` el motor no encuentra su propio libnspr4 y muere en
|
|
# XPCOMGlueLoad antes de pintar nada. El porqué completo está en el propio fichero.
|
|
cp /src/bin/atuq /out/usr/bin/atuq
|
|
chmod 0755 /out/usr/bin/atuq
|
|
|
|
# ── INVENTARIO ────────────────────────────────────────────────────────────────────────────────
|
|
# Lo que distingue este artefacto de su base, listado con tamaños, para que el log del build
|
|
# responda solo la primera pregunta del diagnóstico cuando «atuq se ve como Firefox».
|
|
echo "atuq: capa aplicada —"
|
|
for f in defaults/pref/autoconfig.js atuq.cfg chrome/atuq.css distribution/policies.json; do
|
|
[ -s "$DST/$f" ] || { echo "atuq: $f quedó VACÍO o ausente" >&2; exit 1; }
|
|
echo " $f ($(wc -c < "$DST/$f") bytes)"
|
|
done
|
|
# Las extensiones de sistema: que el XPI EXISTA y tenga bytes. `rebrand.py` ya cruzó cada id contra
|
|
# `ExtensionSettings` y contra la ruta desde la que la política lo va a pedir; esto es el otro lado,
|
|
# el del fichero. Un `install_url` correcto apuntando a un XPI de cero bytes falla en el arranque
|
|
# del navegador, lejos de acá y con un mensaje que no nombra a esta receta.
|
|
for x in "$DST"/distribution/extensions/*.xpi; do
|
|
[ -s "$x" ] || { echo "atuq: el XPI $x quedó vacío o no existe" >&2; exit 1; }
|
|
echo " $(basename "$x") ($(wc -c < "$x") bytes)"
|
|
done
|
|
|
|
# El manifiesto de native messaging: que exista y tenga bytes. `rebrand.py` ya cruzó su `path`
|
|
# contra el árbol y sus `allowed_extensions` contra los XPI empaquetados; esto es el otro lado —que
|
|
# el fichero llegara al artefacto—. Va en /usr/lib/mozilla y NO en el appdir porque esa ruta está
|
|
# COMPILADA dentro de Gecko (`XRESysNativeManifests`) y el rebranding no la mueve.
|
|
for m in /out/usr/lib/mozilla/native-messaging-hosts/*.json; do
|
|
[ -s "$m" ] || { echo "atuq: el manifiesto nativo $m quedó vacío o no existe" >&2; exit 1; }
|
|
echo " $(basename "$m") ($(wc -c < "$m") bytes)"
|
|
done
|
|
[ -x "$DST/puriy-costura-host" ] || { echo "atuq: el lanzador del host nativo no quedó ejecutable" >&2; exit 1; }
|
|
|
|
[ -x "$DST/atuq" ] || { echo "atuq: el binario renombrado no quedó ejecutable" >&2; exit 1; }
|
|
[ -x /out/usr/bin/atuq ] || { echo "atuq: el lanzador no quedó ejecutable" >&2; exit 1; }
|
|
grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no quedó rebrandeado" >&2; exit 1; }
|
|
'''
|
|
|
|
[deps]
|
|
# ⚠ DEP DE EJECUCIÓN, NO DE BUILD — Y NO ES DECORACIÓN (2026-09-06).
|
|
# Este artefacto declara `NEEDED libstdc++.so.6` / `libgcc_s.so.1`, y hasta hoy NADIE en el corpus
|
|
# las proveía: sólo existían en `.dev-fs/alpine`, o sea en el LAB. El binario sellaba, REPRODUCÍA
|
|
# bit a bit, pasaba todos los guardianes… y no arrancaba fuera del lab. Se descubrió corriendo
|
|
# `atuq` bajo un sway headless, no leyendo el grafo.
|
|
# `runtime` NO entra en el ArtifactHash (igual que `license`), así que declararlo NO re-hashea:
|
|
# lo que cambia es la CLAUSURA, que es lo que se hidrata en la imagen. Lo vigila
|
|
# `scripts/vigia-sonames.py`, que entró en el latido en `02facebe` — y NO `scripts/audit-needed.sh`,
|
|
# que ese mismo commit BORRÓ. Quedan otras siete recetas apuntando al script muerto; no se tocan
|
|
# desde acá porque son de otro frente.
|
|
#
|
|
# ⚠ Y hubo un segundo agujero, en el INSTRUMENTO y no en el artefacto (2026-09-07): la clausura
|
|
# declaraba bien `gcc-libs`, pero `scripts/atuq-nested.sh` no lo hidrataba, y como la jaula monta
|
|
# `.dev-fs/alpine` como capa de abajo, cada «atuq corre» de estos días resolvió `libstdc++.so.6`
|
|
# contra el LAB. Corregido allá. Los dos ficheros ni se parecen —20.850.968 bytes el del corpus,
|
|
# 2.804.104 el del lab—, así que ante la duda de cuál se cargó, se mira el tamaño.
|
|
# Y `puriy-costura` (SDD 26 §7): el lanzador del appdir hace `exec /usr/bin/puriy-costura`, así que
|
|
# sin esta línea la imagen llevaría el manifiesto y la extensión, y el host NO estaría — la función
|
|
# se apagaría sola sin una línea de error. `runtime` no entra en el ArtifactHash: lo que cambia es la
|
|
# CLAUSURA, que es lo que se hidrata.
|
|
runtime = ["gcc-libs", "puriy-costura"]
|
|
# `firefox` como dep de BUILD y no de runtime: su árbol se materializa en el sandbox, se copia
|
|
# dentro del artefacto de atuq y a partir de ahí atuq no lo necesita más. El precio es que el
|
|
# artefacto pesa lo que pesa Firefox otra vez; la salida barata (una granja de symlinks) rompe la
|
|
# resolución del appdir por /proc/self/exe, así que la v0.1 paga el disco y lo dice.
|
|
build = ["firefox", "python3"]
|