Files
app/web/update-extension.py
T

95 lines
4.3 KiB
Python

#!/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()