#!/usr/bin/env python3 """Generate Firefox update metadata from LinkLog signed XPI artifacts.""" import hashlib import json import re import sys import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SIGNED_DIR = ROOT / 'XPI' / 'signed' MANIFEST_PATH = ROOT / 'webextension' / 'manifest.json' UPDATES_PATH = ROOT / 'webextension' / 'updates.json' ABOUT_TEMPLATE_PATH = ROOT / 'frontend' / 'templates' / 'about.html' RAW_BASE_URL = 'https://git.kolkman.org/olaf/Link-Log/raw/branch/main' XPI_NAME_RE = re.compile(r'LinkLog-(\d+\.\d+\.\d+)\.xpi') ABOUT_XPI_URL_RE = re.compile(rf'{re.escape(RAW_BASE_URL)}/XPI/signed/LinkLog-\d+\.\d+\.\d+\.xpi') def fail(message: str) -> None: raise SystemExit(f'update metadata generation failed: {message}') def version_key(version: str) -> tuple[int, int, int]: return tuple(int(part) for part in version.split('.')) def read_packaged_manifest(xpi_path: Path) -> dict: try: with zipfile.ZipFile(xpi_path) as archive: if archive.testzip() is not None: fail(f'{xpi_path.relative_to(ROOT)} contains a corrupt member') return json.loads(archive.read('manifest.json')) except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as error: fail(f'could not read {xpi_path.relative_to(ROOT)}: {error}') def sha256_digest(xpi_path: Path) -> str: with xpi_path.open('rb') as xpi_file: return hashlib.file_digest(xpi_file, 'sha256').hexdigest() def update_about_plugin_link(xpi_path: Path) -> None: about_template = ABOUT_TEMPLATE_PATH.read_text() latest_xpi_url = f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}' updated_template, replacements = ABOUT_XPI_URL_RE.subn(latest_xpi_url, about_template) if replacements != 1: fail(f'expected one signed XPI link in {ABOUT_TEMPLATE_PATH.relative_to(ROOT)}; found {replacements}') ABOUT_TEMPLATE_PATH.write_text(updated_template) def main() -> None: source_manifest = json.loads(MANIFEST_PATH.read_text()) addon_id = source_manifest.get('browser_specific_settings', {}).get('gecko', {}).get('id') if not addon_id: fail('webextension/manifest.json is missing browser_specific_settings.gecko.id') releases = [] for xpi_path in SIGNED_DIR.glob('LinkLog-*.xpi'): match = XPI_NAME_RE.fullmatch(xpi_path.name) if not match: continue version = match.group(1) manifest = read_packaged_manifest(xpi_path) if manifest.get('version') != version: fail(f'{xpi_path.relative_to(ROOT)} manifest version does not match its filename') gecko = manifest.get('browser_specific_settings', {}).get('gecko', {}) if gecko.get('id') != addon_id: fail(f'{xpi_path.relative_to(ROOT)} add-on id does not match webextension/manifest.json') strict_min_version = gecko.get('strict_min_version') if not strict_min_version: fail(f'{xpi_path.relative_to(ROOT)} is missing browser_specific_settings.gecko.strict_min_version') releases.append((version, xpi_path, strict_min_version, sha256_digest(xpi_path))) if not releases: fail(f'no signed LinkLog release artifacts found in {SIGNED_DIR.relative_to(ROOT)}') releases.sort(key=lambda release: version_key(release[0]), reverse=True) updates = [ { 'version': version, 'update_link': f'{RAW_BASE_URL}/{xpi_path.relative_to(ROOT).as_posix()}', 'update_hash': f'sha256:{digest}', 'applications': { 'gecko': { 'strict_min_version': strict_min_version, }, }, } for version, xpi_path, strict_min_version, digest in releases ] UPDATES_PATH.write_text(json.dumps({'addons': {addon_id: {'updates': updates}}}, indent=2) + '\n') update_about_plugin_link(releases[0][1]) print(f'updated {UPDATES_PATH.relative_to(ROOT)} with {len(updates)} signed release(s)') if __name__ == '__main__': main()