95 lines
4.3 KiB
Python
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()
|