diff --git a/backend/web/update-extension.py b/backend/web/update-extension.py new file mode 100644 index 0000000..cfa09f0 --- /dev/null +++ b/backend/web/update-extension.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Replace a loaded Persimmons folder from the dedicated HTTPS release. + +Run: python3 update-extension.py /path/to/persimmons-extension +Close the popup first; afterward use chrome://extensions -> Reload. +No browser profile, local vault storage, credentials or native host is accessed. +""" +import argparse +import base64 +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import tempfile +import urllib.request +import zipfile + +EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg' +URL = 'https://persimmons.app/assets/extension.zip' +LIMIT = 4 * 1024 * 1024 + + +def identity(manifest): + digest = hashlib.sha256(base64.b64decode(manifest['key'], validate=True)).hexdigest()[:32] + return ''.join(chr(ord('a') + int(c, 16)) for c in digest) + + +def update(folder, raw): + folder = Path(folder).expanduser().absolute() + if folder.is_symlink() or any(p.is_symlink() for p in folder.parents): + raise ValueError('Use a real extension folder, not a symbolic link.') + installed = json.loads((folder / 'manifest.json').read_text()) + if identity(installed) != EXTENSION_ID: + raise ValueError('The installed extension identity does not match.') + files = {} + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + entries = archive.infolist() + if len(entries) > 100 or sum(e.file_size for e in entries) > LIMIT: + raise ValueError('Release exceeds size limit.') + for entry in entries: + path = PurePosixPath(entry.filename) + if (not path.parts or path.parts[0] != 'persimmons-extension' or + '..' in path.parts or '\\' in entry.filename or entry.is_dir() or + (entry.external_attr >> 16) & 0o170000 == 0o120000): + raise ValueError('Unexpected release path.') + relative = Path(*path.parts[1:]) + if relative.suffix not in {'.mjs', '.json', '.html', '.css', '.png'} or relative in files: + raise ValueError('Unexpected release file.') + destination = folder / relative + if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + raise ValueError('Refusing to replace symbolic links.') + files[relative] = archive.read(entry) + candidate = json.loads(files[Path('manifest.json')]) + if identity(candidate) != EXTENSION_ID or candidate['key'] != installed['key']: + raise ValueError('Release identity does not match; no files replaced.') + version = lambda value: tuple(int(n) for n in value.split('.')) + if version(candidate['version']) < version(installed['version']): + raise ValueError('Refusing an older release.') + # Validate the entire archive before any write. Stage beside the destination + # for atomic per-file replacements; manifest goes last. Retry after interruption. + with tempfile.TemporaryDirectory(prefix='.persimmons-update-', dir=folder.parent) as staging: + stage = Path(staging) + for relative, data in files.items(): + (stage / relative).parent.mkdir(parents=True, exist_ok=True) + (stage / relative).write_bytes(data) + for relative in sorted(files, key=lambda p: (p.name == 'manifest.json', str(p))): + (folder / relative).parent.mkdir(parents=True, exist_ok=True) + os.replace(stage / relative, folder / relative) + return candidate['version'] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise ValueError('Release URL redirected; no files replaced.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('folder', help='Existing folder shown under chrome://extensions -> Details -> Loaded from') + args = parser.parse_args() + try: + with urllib.request.build_opener(NoRedirect).open(URL, timeout=30) as response: + raw = response.read(LIMIT + 1) + if len(raw) > LIMIT: + raise ValueError('Release exceeds size limit.') + release = update(args.folder, raw) + print(f'Installed files for {release}. Now click Reload at chrome://extensions, or Reload installed files in the popup.') + except Exception: + parser.exit(1, 'Update failed safely. Check the folder, network and release; retry before reloading.\n') + + +if __name__ == '__main__': + main()