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`.
527 lines
27 KiB
Python
527 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Convierte el árbol de `firefox` ya copiado en el árbol de `atuq`.
|
|
|
|
Uso: rebrand.py <appdir> <overlay> <outroot>
|
|
appdir = /out/usr/lib/atuq (la copia del árbol de firefox)
|
|
overlay = /src (este directorio, `recipes/atuq/`)
|
|
outroot = /out
|
|
|
|
POR QUÉ UNA HERRAMIENTA Y NO CUATRO LÍNEAS DE SHELL
|
|
---------------------------------------------------
|
|
El branding de verdad vive DENTRO de `browser/omni.ja`, que es un zip, y re-empacarlo desde shell
|
|
exige `zip`, que el lab no trae. `zipfile` de la stdlib, además de estar disponible, da control
|
|
sobre las tres cosas que el CLI no deja fijar y de las que depende que el artefacto REPRODUZCA:
|
|
el orden de las entradas, su `date_time` y sus permisos.
|
|
|
|
TODO CAMBIO ES VERIFICADO, NINGUNO ES SILENCIOSO
|
|
------------------------------------------------
|
|
Cada reemplazo declara qué entrada espera encontrar y falla si no está. Un rebranding que no
|
|
encuentra la cadena que iba a cambiar produce un navegador que se sigue llamando Nightly y sella
|
|
igual de bien: sería exactamente la clase de fallo que llega hasta el final diciendo que todo fue
|
|
bien (regla 3 del CLAUDE.md).
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import zipfile
|
|
|
|
APPDIR, OVERLAY, OUTROOT = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
|
|
MARCA = "atuq"
|
|
VENDOR = "tawasuyu"
|
|
TAMANOS = (16, 32, 48, 64, 128)
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"rebrand: {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
# ── 1. La huella del overlay, que es también el BuildID ────────────────────────────────────────
|
|
# El `BuildID` es la clave con la que Gecko invalida su startup cache. Si atuq cambia su chrome y
|
|
# el BuildID no se mueve, el navegador arranca con la interfaz vieja cacheada y PARECE que el
|
|
# overlay no agarró — el falso negativo que el SDD 26 §2 anota como gotcha del camino derivado.
|
|
#
|
|
# Se deriva del CONTENIDO del overlay y no de la fecha: cambia exactamente cuando cambia lo que
|
|
# tiene que invalidar, y dos builds del mismo atuq dan el mismo número. La fecha haría lo contrario
|
|
# en los dos sentidos.
|
|
def huella_overlay():
|
|
h = hashlib.sha256()
|
|
for raiz, dirs, ficheros in os.walk(OVERLAY):
|
|
dirs.sort()
|
|
for f in sorted(ficheros):
|
|
p = os.path.join(raiz, f)
|
|
h.update(os.path.relpath(p, OVERLAY).encode())
|
|
h.update(b"\0")
|
|
with open(p, "rb") as fh:
|
|
h.update(fh.read())
|
|
h.update(b"\0")
|
|
return h.hexdigest()
|
|
|
|
|
|
def build_id(huella):
|
|
# 14 dígitos decimales: la forma que Gecko espera (nació de un `YYYYMMDDHHMMSS`).
|
|
return str(int(huella[:16], 16))[:14].rjust(14, "0")
|
|
|
|
|
|
# ── 2. application.ini — la identidad de la aplicación ─────────────────────────────────────────
|
|
# ⚠ La cabecera de este fichero dice «This file is not used», y es un comentario HEREDADO del árbol
|
|
# de desarrollo: en un build empaquetado como éste el launcher SÍ lo lee. Se comprueba en vez de
|
|
# creerle al comentario — si no encontramos las claves, fallamos.
|
|
#
|
|
# `ID` NO se toca a propósito: es el GUID con el que las extensiones declaran compatibilidad
|
|
# (`gecko.id` de Firefox). Cambiarlo convertiría a atuq en una aplicación desconocida para todo el
|
|
# ecosistema de complementos, que es justo lo que un envoltorio no quiere.
|
|
def rebrandear_application_ini(bid):
|
|
ruta = os.path.join(APPDIR, "application.ini")
|
|
if not os.path.isfile(ruta):
|
|
fatal(f"no está {ruta}")
|
|
with open(ruta, encoding="utf-8") as fh:
|
|
lineas = fh.read().splitlines()
|
|
# `CodeName` también: es el residuo de `--with-branding=unofficial` (dice «Nightly») y aparece
|
|
# en about:buildconfig y en los informes de fallo. Dejarlo sería exactamente el rincón por el
|
|
# que atuq se sigue presentando como otro producto.
|
|
cambios = {
|
|
"Vendor": VENDOR,
|
|
"Name": MARCA,
|
|
"RemotingName": MARCA,
|
|
"CodeName": MARCA,
|
|
"BuildID": bid,
|
|
}
|
|
vistas = set()
|
|
salida = []
|
|
for ln in lineas:
|
|
clave = ln.split("=", 1)[0] if "=" in ln else None
|
|
if clave in cambios:
|
|
salida.append(f"{clave}={cambios[clave]}")
|
|
vistas.add(clave)
|
|
else:
|
|
salida.append(ln)
|
|
faltan = set(cambios) - vistas
|
|
if faltan:
|
|
fatal(f"application.ini no tenía las claves {sorted(faltan)} — ¿cambió el formato?")
|
|
with open(ruta, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(salida) + "\n")
|
|
return vistas
|
|
|
|
|
|
# ── 3. omni.ja — el re-empaque determinista ────────────────────────────────────────────────────
|
|
# TRES PROPIEDADES DEL ORIGINAL QUE SE PRESERVAN, Y POR QUÉ:
|
|
#
|
|
# · EL ORDEN de las entradas. Gecko lee este zip en el arranque y el orden es el que Mozilla
|
|
# eligió para que las primeras lecturas caigan juntas. Reordenarlo (p.ej. alfabético, que es lo
|
|
# que sale «gratis» al recomprimir) no rompe nada visible: sólo hace el arranque más lento, en
|
|
# silencio. Cuando exista el PGO, el `jarlog` refina justamente este orden — tirarlo sería
|
|
# tirar media optimización de arranque.
|
|
# · `compress_type = 0` (STORED). El omni.ja de Mozilla va SIN COMPRIMIR a propósito, para poder
|
|
# mapearlo en memoria. Comprimirlo achica el artefacto y encarece cada lectura.
|
|
# · `date_time`. El original ya viene normalizado a 2010-01-01 —Mozilla también persigue
|
|
# reproducibilidad— así que copiarlo tal cual mantiene el zip byte-idéntico salvo por lo que
|
|
# cambiamos de verdad.
|
|
def repack_omni(ruta_ja, reemplazos):
|
|
if not os.path.isfile(ruta_ja):
|
|
fatal(f"no está {ruta_ja}")
|
|
pendientes = dict(reemplazos)
|
|
tmp = ruta_ja + ".nuevo"
|
|
with zipfile.ZipFile(ruta_ja) as orig:
|
|
infos = orig.infolist()
|
|
with zipfile.ZipFile(tmp, "w") as nuevo:
|
|
for i in infos:
|
|
datos = pendientes.pop(i.filename, None)
|
|
if datos is None:
|
|
datos = orig.read(i.filename)
|
|
ni = zipfile.ZipInfo(i.filename, date_time=i.date_time)
|
|
ni.compress_type = i.compress_type
|
|
ni.external_attr = i.external_attr
|
|
ni.create_system = i.create_system
|
|
nuevo.writestr(ni, datos)
|
|
if pendientes:
|
|
os.unlink(tmp)
|
|
fatal(f"{ruta_ja}: estas entradas no existían y no se reemplazó nada: {sorted(pendientes)}")
|
|
os.replace(tmp, ruta_ja)
|
|
return len(infos)
|
|
|
|
|
|
# ── 4. El icono ────────────────────────────────────────────────────────────────────────────────
|
|
# Hasta la v0.2 esto DIBUJABA una marca geométrica en código, con el argumento de que un PNG en el
|
|
# árbol no se puede revisar en un diff. El argumento valía mientras el icono era un marcador de
|
|
# posición inventado por falta de arte. **Ya hay arte** —el zorro en medallón con cenefa andina— y
|
|
# entonces el criterio se invierte: un asset de diseño ES la fuente, no un derivado, y meterlo en el
|
|
# repo es lo correcto. Lo que se conserva es la disciplina: los tamaños salen del MISMO master, se
|
|
# generan una vez y se copian, así que dos builds ponen los mismos bytes.
|
|
#
|
|
# ⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
|
|
# logo con cenefa, no un defecto del escalado: si molesta, la salida es un dibujo SIMPLIFICADO para
|
|
# 16 px, y eso lo decide quien dibujó el zorro, no este script.
|
|
def escribir_iconos():
|
|
origen = os.path.join(OVERLAY, "branding", "icons")
|
|
dir_app = os.path.join(APPDIR, "browser", "chrome", "icons", "default")
|
|
if not os.path.isdir(dir_app):
|
|
fatal(f"no está {dir_app} — el árbol de firefox no tiene la forma esperada")
|
|
escritos = []
|
|
for s in TAMANOS:
|
|
src = os.path.join(origen, f"atuq{s}.png")
|
|
if not os.path.isfile(src) or os.path.getsize(src) == 0:
|
|
fatal(f"falta o está vacío el icono {src}")
|
|
with open(src, "rb") as fh:
|
|
datos = fh.read()
|
|
# (a) el icono de la VENTANA, que Gecko lee del appdir
|
|
p = os.path.join(dir_app, f"default{s}.png")
|
|
with open(p, "wb") as fh:
|
|
fh.write(datos)
|
|
escritos.append(p)
|
|
# (b) el icono del LANZADOR, que lo lee el escritorio por el tema hicolor
|
|
q = os.path.join(OUTROOT, "usr/share/icons/hicolor", f"{s}x{s}", "apps", f"{MARCA}.png")
|
|
os.makedirs(os.path.dirname(q), exist_ok=True)
|
|
with open(q, "wb") as fh:
|
|
fh.write(datos)
|
|
escritos.append(q)
|
|
return escritos
|
|
|
|
|
|
# ── 3.bis QUÉ SE REEMPLAZA DENTRO DE browser/omni.ja ───────────────────────────────────────────
|
|
# La v0.2 cambió sólo `brand.ftl` y el resultado se vio en pantalla: el TÍTULO de la ventana decía
|
|
# «New Tab — atuq» y la PÁGINA seguía mostrando el logo y la palabra «Nightly». La lección es que el
|
|
# branding de Gecko vive en tres sitios y sólo uno es texto FTL:
|
|
#
|
|
# · `brand.ftl` — las cadenas nuevas (título de ventana, menús). Ya estaba.
|
|
# · `brand.properties` — las cadenas VIEJAS, formato heredado. Seguía diciendo Nightly.
|
|
# · los PNG y SVG de `chrome/browser/content/branding/` — TODO lo que se ve: el favicon de las
|
|
# páginas internas (ese orbe azul en la pestaña), el logo de about:, y el «wordmark» que es una
|
|
# IMAGEN con la palabra dibujada, no un texto traducible.
|
|
#
|
|
# Cada imagen se reemplaza por otra de LAS MISMAS DIMENSIONES: son tamaños que el CSS del chrome da
|
|
# por sentados, y meter un PNG de otro tamaño descoloca la página en vez de rebrandearla.
|
|
IMAGENES = {
|
|
"chrome/browser/content/branding/icon16.png": "atuq16.png",
|
|
"chrome/browser/content/branding/icon32.png": "atuq32.png",
|
|
"chrome/browser/content/branding/icon48.png": "atuq48.png",
|
|
"chrome/browser/content/branding/icon64.png": "atuq64.png",
|
|
"chrome/browser/content/branding/icon128.png": "atuq128.png",
|
|
"chrome/browser/content/branding/about-logo.png": "atuq192.png",
|
|
"chrome/browser/content/branding/about-logo@2x.png": "atuq384.png",
|
|
# La variante «private» existe para distinguir la ventana privada. Se usa el mismo medallón a
|
|
# propósito: inventar una segunda marca para un modo del navegador es ruido, y el modo ya se
|
|
# distingue por su propio chrome.
|
|
"chrome/browser/content/branding/about-logo-private.png": "atuq192.png",
|
|
"chrome/browser/content/branding/about-logo-private@2x.png": "atuq384.png",
|
|
"chrome/browser/content/branding/about.png": "atuq-about-300x236.png",
|
|
}
|
|
|
|
|
|
def reemplazos_branding():
|
|
r = {}
|
|
for destino, fuente in IMAGENES.items():
|
|
ruta = os.path.join(OVERLAY, "branding", "icons", fuente)
|
|
if not os.path.isfile(ruta) or os.path.getsize(ruta) == 0:
|
|
fatal(f"falta o está vacío el asset {ruta}")
|
|
with open(ruta, "rb") as fh:
|
|
r[destino] = fh.read()
|
|
for destino, fuente in (
|
|
("localization/en-US/branding/brand.ftl", "brand.ftl"),
|
|
("chrome/en-US/locale/branding/brand.properties", "brand.properties"),
|
|
("chrome/browser/content/branding/about-wordmark.svg", "wordmark.svg"),
|
|
("chrome/browser/content/branding/firefox-wordmark.svg", "wordmark.svg"),
|
|
):
|
|
with open(os.path.join(OVERLAY, "branding", fuente), "rb") as fh:
|
|
r[destino] = fh.read()
|
|
return r
|
|
|
|
|
|
# ── 4.bis Las extensiones de sistema ──────────────────────────────────────────────────────────
|
|
# POR QUÉ EXTENSIONES Y NO PREFS. La página de inicio se podría poner con
|
|
# `browser.startup.homepage`, pero la pestaña NUEVA no se puede redirigir con una pref desde hace
|
|
# años: el único mecanismo soportado es `chrome_url_overrides.newtab`. Y el ruteo por proxy de cada
|
|
# contenedor (SDD 26 §6.8) directamente no tiene forma de pref: es la API `proxy.onRequest`, que
|
|
# sólo existe para una extensión.
|
|
#
|
|
# Esto sólo funciona porque `recipes/firefox.toml` construye con
|
|
# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0` y
|
|
# `xpinstall.signatures.required = false`. Son piezas en ficheros distintos y **todas tienen que
|
|
# estar**: sin la primera Gecko rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO.
|
|
#
|
|
# EL ZIP VA `STORED` Y CON FECHA FIJA, igual que el `omni.ja`: un XPI comprimido metería la versión
|
|
# de zlib como variable del artefacto, y son ficheros de texto — no hay nada que ahorrar.
|
|
#
|
|
# ── EL ID SALE DEL MANIFIESTO, NO DE UNA CONSTANTE DE ACÁ ─────────────────────────────────────
|
|
# La v0.4 tenía el id escrito en dos sitios: `EXT_ID` en este fichero y
|
|
# `browser_specific_settings.gecko.id` en el manifiesto. Dos dueños para un valor es la forma
|
|
# conocida de que se desincronicen, y el fallo que producen es de los caros: el XPI se escribiría
|
|
# con un nombre y Gecko, al abrirlo, encontraría otro id adentro ⇒ la política de instalación
|
|
# apuntaría a un fichero que para Firefox no es el complemento que pidió. Ahora hay un solo dueño
|
|
# —el manifiesto— y el nombre del fichero se DERIVA de él.
|
|
EXT_DIR = "extensions"
|
|
# El icono se INYECTA desde `branding/icons/` en vez de vivir copiado dentro de cada extensión. La
|
|
# v0.4 tenía `extension/atuq128.png` byte a byte igual a `branding/icons/atuq128.png`; con dos
|
|
# extensiones habrían sido tres copias del mismo PNG en el repo, y la marca se desalinea el día que
|
|
# alguien actualiza una y no las otras.
|
|
EXT_ICONO = "atuq128.png"
|
|
|
|
|
|
def _leer_id(manifiesto, donde):
|
|
try:
|
|
datos = json.loads(manifiesto)
|
|
except ValueError as e:
|
|
fatal(f"{donde}: el manifest.json no es JSON válido — {e}")
|
|
try:
|
|
ext_id = datos["browser_specific_settings"]["gecko"]["id"]
|
|
except (KeyError, TypeError):
|
|
fatal(f"{donde}: el manifest.json no declara browser_specific_settings.gecko.id")
|
|
if not isinstance(ext_id, str) or "@" not in ext_id:
|
|
fatal(f"{donde}: el id {ext_id!r} no parece un id de extensión")
|
|
return ext_id
|
|
|
|
|
|
def empaquetar_extensiones():
|
|
base = os.path.join(OVERLAY, EXT_DIR)
|
|
if not os.path.isdir(base):
|
|
fatal(f"no está el árbol de las extensiones en {base}")
|
|
dirs = sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d)))
|
|
if not dirs:
|
|
fatal(f"{base} no tiene ninguna extensión")
|
|
|
|
with open(os.path.join(OVERLAY, "branding", "icons", EXT_ICONO), "rb") as fh:
|
|
icono = fh.read()
|
|
|
|
destino_dir = os.path.join(APPDIR, "distribution", "extensions")
|
|
os.makedirs(destino_dir, exist_ok=True)
|
|
|
|
resultados = []
|
|
for d in dirs:
|
|
origen = os.path.join(base, d)
|
|
ficheros = {}
|
|
for nombre in sorted(os.listdir(origen)):
|
|
ruta = os.path.join(origen, nombre)
|
|
if not os.path.isfile(ruta):
|
|
continue
|
|
with open(ruta, "rb") as fh:
|
|
datos = fh.read()
|
|
if not datos:
|
|
fatal(f"la extensión {d} trae {nombre} VACÍO")
|
|
ficheros[nombre] = datos
|
|
if "manifest.json" not in ficheros:
|
|
fatal(f"la extensión {d} no tiene manifest.json")
|
|
ficheros[EXT_ICONO] = icono
|
|
|
|
ext_id = _leer_id(ficheros["manifest.json"], d)
|
|
destino = os.path.join(destino_dir, f"{ext_id}.xpi")
|
|
# Orden alfabético y no el del `listdir`: el orden del directorio depende del sistema de
|
|
# ficheros, y un zip cuyas entradas cambian de orden entre máquinas ya no reproduce.
|
|
with zipfile.ZipFile(destino, "w") as z:
|
|
for nombre in sorted(ficheros):
|
|
zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0))
|
|
zi.compress_type = zipfile.ZIP_STORED
|
|
zi.external_attr = 0o644 << 16
|
|
z.writestr(zi, ficheros[nombre])
|
|
# La ruta con la que la POLÍTICA va a pedir este XPI, derivada de dónde lo acabamos de
|
|
# escribir. Es lo que hace que el cruce de abajo compruebe algo de verdad y no una constante
|
|
# contra otra constante.
|
|
url = "file:///" + os.path.relpath(destino, OUTROOT)
|
|
resultados.append((d, ext_id, len(ficheros), os.path.getsize(destino), url))
|
|
return resultados
|
|
|
|
|
|
# ── 4.ter El cruce contra la política ─────────────────────────────────────────────────────────
|
|
# ⚠ ESTE COMENTARIO DECÍA QUE EL SIDELOADING «YA NO EXISTE» Y QUE INSTALABA LA POLÍTICA. Es falso, y
|
|
# está medido en tres escenarios por `scripts/test-atuq-instalacion.py` (SDD 26 §7.ter): **instala el
|
|
# escaneo de `distribution/extensions/`**; con la política sola —`install_url` con `file://`— no se
|
|
# instala NADA. La política FIJA el modo y CONFIGURA; instalar, no.
|
|
#
|
|
# El cruce sigue valiendo igual, y por eso no se toca: el id y la ruta tienen que coincidir en los dos
|
|
# lados, porque una extensión empaquetada que la política no declara queda instalada pero sin su
|
|
# `installation_mode` ni su `storage.managed`, y una política que apunta a un XPI que no está deja un
|
|
# `Download failed` que no nombra la causa. Se cruza acá, en el build, donde falla gratis.
|
|
def verificar_politica(resultados):
|
|
ruta = os.path.join(APPDIR, "distribution", "policies.json")
|
|
try:
|
|
with open(ruta, encoding="utf-8") as fh:
|
|
pol = json.load(fh)["policies"]
|
|
except (OSError, ValueError, KeyError) as e:
|
|
fatal(f"no pude leer {ruta}: {e}")
|
|
|
|
ajustes = pol.get("ExtensionSettings", {})
|
|
declarados = {k for k in ajustes if k != "*"}
|
|
empaquetados = {r[1] for r in resultados}
|
|
|
|
faltan = empaquetados - declarados
|
|
if faltan:
|
|
fatal(f"empaquetadas pero NO declaradas en ExtensionSettings: {sorted(faltan)}")
|
|
sobran = declarados - empaquetados
|
|
if sobran:
|
|
fatal(f"declaradas en ExtensionSettings sin XPI que las respalde: {sorted(sobran)}")
|
|
|
|
for _, ext_id, _, _, url in resultados:
|
|
esperada = ajustes[ext_id].get("install_url")
|
|
if esperada != url:
|
|
fatal(f"{ext_id}: la política instala desde {esperada!r} y el XPI quedó en {url!r}")
|
|
|
|
# Los contenedores de la política son la mitad de abajo del §6.8: sin ellos, la extensión del
|
|
# proxy arranca, no encuentra a qué contenedor aplicar nada y queda inerte sin decir por qué.
|
|
contenedores = [c.get("name") for c in pol.get("Containers", {}).get("Default", [])]
|
|
terceros = pol.get("3rdparty", {}).get("Extensions", {})
|
|
for ext_id, conf in terceros.items():
|
|
if ext_id not in empaquetados:
|
|
fatal(f"3rdparty configura {ext_id}, que no es ninguna extensión de atuq")
|
|
huerfanos = sorted(set(conf.get("proxies", {})) - set(contenedores))
|
|
if huerfanos:
|
|
fatal(f"{ext_id}: proxies para contenedores que la política no crea: {huerfanos}")
|
|
return contenedores
|
|
|
|
|
|
# ── 4.quater Los manifiestos de native messaging ──────────────────────────────────────────────
|
|
# DÓNDE VAN, Y POR QUÉ NO EN EL APPDIR. Gecko los busca en `XRESysNativeManifests`, que en Linux es
|
|
# un `/usr/lib/mozilla` **compilado dentro del binario**: el rebranding a `atuq` no lo mueve. Se
|
|
# midió con sonda y control negativo (SDD 26 §7.bis) — el control decía, con su nombre,
|
|
# «No such native application».
|
|
#
|
|
# QUÉ SE CRUZA, y por qué las dos cosas: un manifiesto tiene DOS referencias a cosas que pueden no
|
|
# existir, y las dos fallan igual de calladas.
|
|
# · `path` — si el ejecutable no está, el navegador arranca, la extensión conecta, el puerto se
|
|
# cierra «sin error y sin mensaje», y la función simplemente no existe;
|
|
# · `allowed_extensions` — si nombra un id que no empaquetamos, el host es inalcanzable para
|
|
# siempre y nada lo dice.
|
|
# Las dos se comprueban contra el árbol REAL (el fichero que acabamos de escribir y la lista de XPI
|
|
# que acabamos de empaquetar), no contra constantes de este script.
|
|
#
|
|
# Y se ESCRIBEN acá en vez de copiarse desde el overlay para que el JSON quede canónico —claves
|
|
# ordenadas, sangría fija, un `\n` final—: un fichero copiado tal cual haría del formato del editor
|
|
# de quien lo tocó una variable del artefacto.
|
|
NM_DIR = "native-messaging"
|
|
NM_DESTINO = os.path.join("usr", "lib", "mozilla", "native-messaging-hosts")
|
|
|
|
|
|
def instalar_manifiestos_nativos(resultados):
|
|
base = os.path.join(OVERLAY, NM_DIR)
|
|
if not os.path.isdir(base):
|
|
return []
|
|
empaquetadas = {r[1] for r in resultados}
|
|
destino_dir = os.path.join(OUTROOT, NM_DESTINO)
|
|
os.makedirs(destino_dir, exist_ok=True)
|
|
|
|
salida = []
|
|
for nombre in sorted(os.listdir(base)):
|
|
if not nombre.endswith(".json"):
|
|
continue
|
|
ruta = os.path.join(base, nombre)
|
|
try:
|
|
with open(ruta, encoding="utf-8") as fh:
|
|
man = json.load(fh)
|
|
except (OSError, ValueError) as e:
|
|
fatal(f"{NM_DIR}/{nombre}: no pude leerlo — {e}")
|
|
|
|
# El nombre del host ES el del fichero: Gecko lo busca por `<name>.json` y además compara el
|
|
# campo `name` contra el que la extensión pidió. Dos dueños para un valor otra vez, así que
|
|
# se exige que coincidan acá.
|
|
esperado = nombre[:-len(".json")]
|
|
if man.get("name") != esperado:
|
|
fatal(f"{NM_DIR}/{nombre}: el campo name es {man.get('name')!r} y el fichero se llama "
|
|
f"{esperado!r} — Gecko busca por nombre de fichero y compara el campo")
|
|
if man.get("type") != "stdio":
|
|
fatal(f"{NM_DIR}/{nombre}: type={man.get('type')!r}, se esperaba 'stdio'")
|
|
ejecutable = man.get("path", "")
|
|
if not ejecutable.startswith("/"):
|
|
fatal(f"{NM_DIR}/{nombre}: path {ejecutable!r} no es absoluto (Gecko lo exige en Linux)")
|
|
real = os.path.join(OUTROOT, ejecutable.lstrip("/"))
|
|
if not os.path.isfile(real):
|
|
fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} NO está en el artefacto")
|
|
if not os.access(real, os.X_OK):
|
|
fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} no es ejecutable")
|
|
permitidas = man.get("allowed_extensions") or []
|
|
if not permitidas:
|
|
fatal(f"{NM_DIR}/{nombre}: sin allowed_extensions cualquier extensión podría hablarle")
|
|
huerfanas = sorted(set(permitidas) - empaquetadas)
|
|
if huerfanas:
|
|
fatal(f"{NM_DIR}/{nombre}: allowed_extensions nombra extensiones que atuq no empaqueta: "
|
|
f"{huerfanas}")
|
|
|
|
with open(os.path.join(destino_dir, nombre), "w", encoding="utf-8") as fh:
|
|
json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True)
|
|
fh.write("\n")
|
|
salida.append((esperado, ejecutable, permitidas))
|
|
return salida
|
|
|
|
|
|
# ── 5. Los binarios ────────────────────────────────────────────────────────────────────────────
|
|
# Se renombran los dos y se deja un symlink `firefox` → `atuq` DENTRO del appdir. El symlink no es
|
|
# indecisión: hay código y scripts de terceros que invocan el binario por su nombre histórico, y
|
|
# como Gecko resuelve su directorio por `/proc/self/exe`, entrar por el symlink resuelve al mismo
|
|
# sitio y no cambia nada del comportamiento.
|
|
def renombrar_binarios():
|
|
hechos = []
|
|
for viejo, nuevo in (("firefox", MARCA), ("firefox-bin", f"{MARCA}-bin")):
|
|
vp = os.path.join(APPDIR, viejo)
|
|
if not os.path.isfile(vp):
|
|
fatal(f"no está {vp}")
|
|
os.rename(vp, os.path.join(APPDIR, nuevo))
|
|
hechos.append(f"{viejo} → {nuevo}")
|
|
os.symlink(MARCA, os.path.join(APPDIR, "firefox"))
|
|
return hechos
|
|
|
|
|
|
# ── 6. La marca COMPILADA dentro del binario ───────────────────────────────────────────────────
|
|
# `application.ini` dice `RemotingName=atuq` y aun así el proceso se anunciaba como
|
|
# `firefox-default`: esa cadena es un `MOZ_APP_REMOTINGNAME` horneado en el ELF en tiempo de
|
|
# compilación, y es la que Gecko pasa a `g_set_prgname()` ⇒ es el **app_id de Wayland**. O sea que
|
|
# el escritorio no casaba la ventana con nuestro `.desktop` (`StartupWMClass=atuq`): icono genérico
|
|
# y sin agrupar con el lanzador. Se ve en el log de cualquier arranque: `(firefox-default:7048)`.
|
|
#
|
|
# Se parchea EN SITIO y no se recompila: cambiarlo en `recipes/firefox.toml` brandearía la BASE de
|
|
# atuq como atuq, que es justo lo que un derivado no debe hacer —firefox tiene que seguir siendo
|
|
# firefox para los otros forks—.
|
|
#
|
|
# Es seguro porque es una cadena C terminada en NUL dentro del pool de `.rodata`: acortarla deja
|
|
# NULs muertos detrás y la siguiente cadena empieza donde empezaba. `firefox-default\0` son 16
|
|
# bytes exactos y se escriben 16. Y se EXIGE una única ocurrencia: si upstream la duplica o la
|
|
# renombra, esto falla en vez de parchear el sitio equivocado.
|
|
VIEJA = b"firefox-default\0"
|
|
NUEVA = MARCA.encode() + b"\0" * (len(VIEJA) - len(MARCA))
|
|
|
|
|
|
def rebrandear_binario(rel):
|
|
ruta = os.path.join(APPDIR, rel)
|
|
with open(ruta, "rb") as fh:
|
|
datos = fh.read()
|
|
n = datos.count(VIEJA)
|
|
if n != 1:
|
|
fatal(f"{rel}: esperaba 1 ocurrencia de {VIEJA!r} y hay {n} — ¿cambió upstream?")
|
|
modo = os.stat(ruta).st_mode
|
|
with open(ruta, "wb") as fh:
|
|
fh.write(datos.replace(VIEJA, NUEVA))
|
|
os.chmod(ruta, modo)
|
|
with open(ruta, "rb") as fh:
|
|
if fh.read().count(VIEJA) != 0:
|
|
fatal(f"{rel}: el parche no agarró")
|
|
return f"{rel}: app_id firefox-default → {MARCA}"
|
|
|
|
|
|
def main():
|
|
huella = huella_overlay()
|
|
bid = build_id(huella)
|
|
claves = rebrandear_application_ini(bid)
|
|
n = repack_omni(os.path.join(APPDIR, "browser", "omni.ja"), reemplazos_branding())
|
|
iconos = escribir_iconos()
|
|
binarios = renombrar_binarios()
|
|
# Después del renombrado: los ficheros ya se llaman atuq/atuq-bin.
|
|
parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")]
|
|
exts = empaquetar_extensiones()
|
|
contenedores = verificar_politica(exts)
|
|
nativos = instalar_manifiestos_nativos(exts)
|
|
|
|
print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}")
|
|
print(f"rebrand: application.ini {sorted(claves)}")
|
|
print(f"rebrand: browser/omni.ja re-empacado, {n} entradas, orden y STORED preservados")
|
|
print(f"rebrand: branding dentro del zip — {len(IMAGENES)} imágenes + brand.ftl + brand.properties + 2 wordmarks")
|
|
print(f"rebrand: {len(iconos)} iconos escritos ({', '.join(str(s) for s in TAMANOS)} px)")
|
|
print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})")
|
|
for l in parches:
|
|
print(f"rebrand: {l}")
|
|
for d, ext_id, n, size, _ in exts:
|
|
print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)")
|
|
print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}")
|
|
for nombre, ejecutable, permitidas in nativos:
|
|
print(f"rebrand: host nativo {nombre} → {ejecutable} para {', '.join(permitidas)}")
|
|
|
|
|
|
main()
|