Files
StarPilot/openpilot/tools/arabesque_firefox/setup.py
T
2026-11-03 14:07:29 -05:00

636 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
"""Stage the pinned ARM64 browser under /data; never install packages into AGNOS."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import platform
import pwd
import re
import shutil
import stat
import subprocess
import tarfile
import tempfile
import urllib.request
import uuid
ROOT = Path('/data/arabesque-firefox')
BROWSER_UID = 61234
FORMAT = 1
FLOOR = 6 * 1024**3
DISPLAY_PACKAGE = 'display-xvfb.deb'
FREETYPE_PACKAGE = 'graphics-libfreetype6.deb'
SUPPORTED_AGNOS = ('19.8.1', '20.2-starpilot.1', '20.2.1')
PHASES = {'staging', 'copying', 'qualifying', 'activate', 'committed', 'restore-copy', 'restore', 'restored'}
def manifest(value):
if not isinstance(value, dict):
raise ValueError('Unsupported Firefox bundle receipt')
value = dict(value)
value.setdefault('bundle_revision', 0)
value.setdefault('bundle_format', FORMAT)
if (set(value) != {'version', 'agnos', 'packages', 'bundle_revision', 'bundle_format'} or
type(value['bundle_format']) is not int or value['bundle_format'] != FORMAT or
type(value['bundle_revision']) is not int or value['bundle_revision'] < 0 or
not isinstance(value['version'], str) or not re.fullmatch(r'\d+\.\d+(?:\.\d+)?esr', value['version']) or
value['agnos'] != '19.8.1' or not isinstance(value['packages'], list) or not 1 <= len(value['packages']) <= 64):
raise ValueError('Unsupported Firefox bundle receipt')
names = set()
for package in value['packages']:
if not isinstance(package, dict):
raise ValueError('Invalid pinned Firefox package')
digest = 'sha512' if 'sha512' in package else 'sha256'
if (set(package) != {'name', 'url', 'size', digest} or not isinstance(package['name'], str) or
not re.fullmatch(r'[a-zA-Z0-9_.+-]+\.(?:deb|tar\.xz)', package['name']) or package['name'] in names or
not isinstance(package['url'], str) or not package['url'].startswith('https://') or
type(package['size']) is not int or not 0 < package['size'] < 512 * 1024**2 or
not isinstance(package[digest], str) or not re.fullmatch('[0-9a-f]{' + str(128 if digest == 'sha512' else 64) + '}', package[digest])):
raise ValueError('Invalid pinned Firefox package')
names.add(package['name'])
return value
def read_json(path):
with path.open('rb') as stream:
data = stream.read(65537)
if len(data) > 65536 or not isinstance(value := json.loads(data), dict):
raise ValueError('Invalid Firefox metadata')
return value
def approved():
return manifest(read_json(Path(__file__).with_name('runtime.json')))
def bundle_id(value):
return hashlib.sha256(json.dumps(manifest(value), sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def sync_dir(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def write_json(path, value):
temporary = path.with_name(path.name + '.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
with os.fdopen(fd, 'w') as stream:
json.dump(value, stream, sort_keys=True)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
sync_dir(path.parent)
def safe(path, uid=0, private=False):
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or info.st_uid != uid or info.st_mode & (0o077 if private else 0o022):
raise RuntimeError(f'Unsafe Firefox path: {path}')
return info
def receipt(path):
safe(path)
safe(path / 'ready.json')
safe(path / 'firefox/firefox')
return manifest(read_json(path / 'ready.json'))
def transaction_id(value):
if not isinstance(value, str) or not re.fullmatch('[0-9a-f]{32}', value):
raise ValueError('Invalid Firefox transaction identity')
return value
def journal():
path = ROOT / 'update.json'
if not path.exists():
return None
safe(path)
return update_record(read_json(path))
def update_record(value):
if (set(value) != {'format', 'id', 'phase', 'target', 'previous'} or type(value['format']) is not int or value['format'] != FORMAT or
not isinstance(value['phase'], str) or value['phase'] not in PHASES):
raise ValueError('Invalid Firefox update journal; preserve it for review')
transaction_id(value['id'])
value['target'] = manifest(value['target'])
value['previous'] = manifest(value['previous'])
safe(ROOT / 'updates')
safe(ROOT / 'updates' / value['id'])
return value
def has_package(bundle, name):
return any(package['name'] == name for package in bundle['packages'])
def host_display():
return all(os.access(path, os.X_OK) for path in ('/usr/bin/Xvfb', '/usr/bin/xauth', '/usr/bin/xkbcomp'))
def display_available(bundle):
if has_package(bundle, DISPLAY_PACKAGE):
return has_package(bundle, FREETYPE_PACKAGE) or Path('/usr/lib/aarch64-linux-gnu/libfreetype.so.6').is_file()
return host_display()
def inspect():
"""Small local reads only; safe to call when the Firefox tool opens."""
target = approved()
if ROOT.is_symlink():
raise RuntimeError('Firefox runtime path must not be a symlink')
if not ROOT.exists():
return {'action': 'install', 'target': target}
safe(ROOT)
record = journal()
if record and record['phase'] not in ('committed', 'restored'):
return {'action': 'recover', 'target': target}
if not (ROOT / 'runtime').exists():
if record:
raise RuntimeError('Firefox runtime is missing; preserve its update journal for review')
return {'action': 'install', 'target': target}
current = receipt(ROOT / 'runtime')
safe(ROOT / 'state', BROWSER_UID, private=True)
if record and bundle_id(current) != bundle_id(record['target'] if record['phase'] == 'committed' else record['previous']):
raise RuntimeError('Firefox no longer matches its recorded transaction; preserve it for review')
if current['bundle_revision'] == target['bundle_revision'] and bundle_id(current) != bundle_id(target):
raise RuntimeError('Firefox bundle changed without a new bundle revision')
def version(value):
return tuple((list(map(int, value.removesuffix('esr').split('.'))) + [0, 0])[:3])
newer = target['bundle_revision'] > current['bundle_revision'] and version(target['version']) >= version(current['version'])
repair = (newer and not display_available(current) and has_package(target, DISPLAY_PACKAGE) and display_available(target) and
current['version'] == target['version'] and all(package in target['packages'] for package in current['packages']) and
all(package in current['packages'] or package['name'].startswith(('display-', 'graphics-')) for package in target['packages']))
restorable = bool(record and record['phase'] == 'committed' and not (ROOT / 'recovery').exists() and
not (ROOT / 'recovery-state').exists() and
display_available(record['previous']) and
(ROOT / 'updates' / record['id'] / 'previous').is_dir() and
(ROOT / 'updates' / record['id'] / 'profile-backup').is_dir())
return {'action': 'update' if newer else 'browse', 'target': target, 'current': current, 'restorable': restorable, 'repair': repair}
def report(message):
print(message, flush=True)
def verify(path, package):
algorithm = 'sha512' if 'sha512' in package else 'sha256'
with path.open('rb') as stream:
digest = hashlib.file_digest(stream, algorithm).hexdigest()
if path.stat().st_size != package['size'] or digest != package[algorithm]:
raise ValueError(f'Package checksum mismatch: {package["name"]}')
def copy_package(package, response, partial, label):
remaining = package['size']
with partial.open('wb') as target:
while block := response.read(min(1024 * 1024, remaining + 1)):
remaining -= len(block)
if remaining < 0:
raise ValueError('Browser package exceeds pinned size')
target.write(block)
report(f'{label}… {100 * (package["size"] - remaining) // package["size"]}%')
def download(package, cache, *, cache_uid=0, offline_only=False):
digest = package.get('sha512', package.get('sha256'))
path = cache / (digest + '-' + package['name'])
legacy = cache / package['name']
for existing in (path, legacy):
if existing.exists():
safe(existing, cache_uid)
try:
verify(existing, package)
if existing == legacy:
legacy.rename(path)
return path
except ValueError:
if existing == path:
existing.unlink()
if not path.exists():
partial = path.with_suffix('.partial')
bundled = Path(__file__).with_name('offline') / path.name
label = 'Firefox' if package['name'].startswith('firefox-') else 'browser libraries'
try:
try:
fd = os.open(bundled, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
if offline_only:
raise FileNotFoundError(f'Missing offline Firefox archive: {bundled.name}') from None
with urllib.request.urlopen(package['url'], timeout=30) as response:
if not response.url.startswith('https://'):
raise ValueError('Refusing an insecure browser package redirect') from None
copy_package(package, response, partial, f'Downloading {label}')
else:
with os.fdopen(fd, 'rb') as response:
info = os.fstat(response.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size != package['size']:
raise ValueError(f'Invalid bundled Firefox package: {package["name"]}')
copy_package(package, response, partial, f'Copying bundled {label}')
verify(partial, package)
partial.rename(path)
finally:
partial.unlink(missing_ok=True)
verify(path, package)
return path
def unpack(path, destination):
if path.suffix == '.deb':
subprocess.run(['/usr/bin/dpkg-deb', '--extract', str(path), str(destination)], check=True, timeout=30)
else:
with tarfile.open(path) as archive:
archive.extractall(destination, filter='data')
def check_host(target):
# Keep the receipt compatible with older checkouts while qualifying the private display on newer images.
supported = SUPPORTED_AGNOS if has_package(target, DISPLAY_PACKAGE) else (target['agnos'],)
if os.geteuid() != 0 or platform.machine() != 'aarch64' or Path('/VERSION').read_text().strip() not in supported:
raise RuntimeError(f'Prepare this runtime as root on ARM64 AGNOS {", ".join(supported)}')
def prepare():
target = approved()
check_host(target)
for path in (ROOT, ROOT / 'runtime', ROOT / 'state', ROOT / 'packages'):
if path.is_symlink():
raise RuntimeError(f'Refusing symlink: {path}')
if ROOT.exists() and (ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022):
raise RuntimeError('Unsafe browser directory ownership')
ROOT.mkdir(mode=0o755, exist_ok=True)
fd = os.open(ROOT / '.prepare.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o644)
with os.fdopen(fd, 'r+') as lock:
safe(ROOT / '.prepare.lock')
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
prepare_locked(target)
def prepare_locked(manifest):
cache = ROOT / 'packages'
if (ROOT / 'runtime').exists():
raise RuntimeError('Runtime already exists; stop and review an update separately. The profile is never replaced.')
if (ROOT / 'update.json').exists():
raise RuntimeError('Open Firefox in Arabesque to finish its pending recovery before manual preparation')
quiescent()
if shutil.disk_usage(ROOT).free < FLOOR:
raise RuntimeError('At least 6 GiB free on /data is required to prepare Firefox')
# A hard stop can interrupt extraction; only this root-owned staging area is disposable.
for path in ROOT.glob('.prepare-*'):
if path.is_symlink():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
private_directory(ROOT / 'state')
cache.mkdir(mode=0o700, exist_ok=True)
safe(cache, private=True)
with tempfile.TemporaryDirectory(prefix='.prepare-', dir=ROOT) as temporary:
staging = Path(temporary) / 'runtime'
build(manifest, staging)
staging.rename(ROOT / 'runtime')
sync_dir(ROOT)
report('Firefox is prepared. Starting the browser…')
def quiescent():
try:
pwd.getpwuid(BROWSER_UID)
except KeyError:
pass
else:
raise RuntimeError(f'UID {BROWSER_UID} is already assigned')
for entry in Path('/proc').iterdir():
if entry.name.isdigit():
try:
if entry.stat().st_uid == BROWSER_UID:
raise RuntimeError(f'UID {BROWSER_UID} already has running processes')
except FileNotFoundError:
pass
active = subprocess.check_output(['systemctl', '--no-pager', '--plain', '--no-legend', 'list-units',
'--state=activating,active,deactivating', 'arabesque-firefox-*'], text=True, timeout=5)
own_unit = next((Path(line[3:]).name for line in Path('/proc/self/cgroup').read_text().splitlines() if line.startswith('0::')), '')
if any(line.split()[0] != own_unit for line in active.splitlines() if line.split()):
raise RuntimeError('Close all Firefox sessions before preparing the runtime')
def build(manifest, staging, *, offline_only=False):
cache = ROOT / 'packages'
cache.mkdir(mode=0o700, exist_ok=True)
safe(cache, private=True)
staging.mkdir()
for index, package in enumerate(manifest['packages'], 1):
report(f'Preparing Firefox ({index}/{len(manifest["packages"])})…')
archive = download(package, cache, offline_only=offline_only)
safe(archive)
report('Unpacking Firefox…' if package['name'].startswith('firefox-') else 'Preparing browser libraries…')
# Cairo and Xvfb both need the pinned Pixman library.
display_only = package['name'].startswith('display-') and package['name'] != 'display-libpixman-1-0.deb'
unpack(archive, staging / 'display' if display_only else staging)
if has_package(manifest, DISPLAY_PACKAGE):
# Ubuntu Xvfb resolves xkbcomp at /usr/bin. Expose just that helper through a unit-local /usr overlay.
extension = staging / 'isolation/xkb/usr'
(extension / 'bin').mkdir(parents=True)
release = extension / 'lib/extension-release.d'
release.mkdir(parents=True)
(release / 'extension-release.xkb').write_text('ID=_any\n')
(staging / 'display/usr/bin/xkbcomp').rename(extension / 'bin/xkbcomp')
fonts = staging / 'etc/fonts/fonts.conf'
if fonts.is_file():
# The stock config and its links target the system tree when installed by dpkg.
fonts.write_text(fonts.read_text().replace('<dir>/usr/share/fonts</dir>',
'<dir prefix="relative">../../usr/share/fonts</dir>\n\t<dir>/usr/share/fonts</dir>'))
for link in (fonts.parent / 'conf.d').glob('*.conf'):
if link.is_symlink():
target = link.readlink()
if target.parent == Path('/usr/share/fontconfig/conf.avail'):
link.unlink()
link.symlink_to(Path('../../../usr/share/fontconfig/conf.avail') / target.name)
schemas = staging / 'usr/share/glib-2.0/schemas'
for source in Path('/usr/share/glib-2.0/schemas').glob('*.xml'):
if not (schemas / source.name).exists():
shutil.copyfile(source, schemas / source.name)
subprocess.run(['/usr/bin/glib-compile-schemas', '--strict', str(schemas)], check=True, timeout=10)
distribution = staging / 'firefox/distribution'
distribution.mkdir(exist_ok=True)
(distribution / 'policies.json').write_text(json.dumps({'policies': {
'DisableAppUpdate': True, 'DontCheckDefaultBrowser': True, 'DisableTelemetry': True, 'DisableFirefoxStudies': True,
}}))
for name in ('empty', 'devices', 'devices/shm'):
(staging / 'isolation' / name).mkdir(parents=True, exist_ok=True)
for name in ('null', 'zero', 'random', 'urandom'):
(staging / 'isolation/devices' / name).touch()
(staging / 'ready.json').write_text(json.dumps(manifest, indent=2) + '\n')
for path in (staging, *staging.rglob('*')):
if not path.is_symlink():
os.chown(path, 0, 0)
path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644)
if path.is_file():
with path.open('rb') as stream:
os.fsync(stream.fileno())
for path, _, _ in os.walk(staging, topdown=False):
sync_dir(path)
(ROOT / '.isolation-probe').write_text('This file must be hidden from the Firefox worker.\n')
def save(record, phase):
record['phase'] = phase
write_json(ROOT / 'update.json', record)
def move(source, destination):
source.rename(destination)
sync_dir(destination.parent)
if source.parent != destination.parent:
sync_dir(source.parent)
def matches(path, value):
return path.exists() and bundle_id(receipt(path)) == bundle_id(value)
def activate(record):
"""Finish forward after durable intent; never touch a profile during application activation."""
tx = ROOT / 'updates' / record['id']
current, candidate, previous = ROOT / 'runtime', tx / 'candidate', tx / 'previous'
if current.exists() and matches(current, record['target']) and not candidate.exists():
if not matches(previous, record['previous']):
raise RuntimeError('Missing previous Firefox runtime; preserve the transaction for review')
else:
if not matches(candidate, record['target']):
raise RuntimeError('Invalid staged Firefox runtime; preserve the transaction for review')
if current.exists():
if previous.exists() or not matches(current, record['previous']):
raise RuntimeError('Firefox changed outside the recorded update; preserve it for review')
move(current, previous)
elif not matches(previous, record['previous']):
raise RuntimeError('Ambiguous interrupted Firefox activation')
move(candidate, current)
save(record, 'committed')
def restore(record):
tx, recovery = ROOT / 'updates' / record['id'], ROOT / 'recovery'
current, previous, failed = ROOT / 'runtime', tx / 'previous', recovery / 'runtime'
# Same-parent renames avoid needing access to another UID's private directory to update its '..' entry.
state, replacement, preserved = ROOT / 'state', ROOT / 'restore-state', ROOT / 'recovery-state'
app_done = current.exists() and matches(current, record['previous']) and not previous.exists()
if app_done:
if not matches(failed, record['target']):
raise RuntimeError('Missing preserved Firefox runtime')
else:
if not matches(previous, record['previous']):
raise RuntimeError('Invalid previous Firefox runtime')
if current.exists():
if failed.exists() or not matches(current, record['target']):
raise RuntimeError('Firefox changed outside the recorded restoration')
elif not matches(failed, record['target']):
raise RuntimeError('Ambiguous interrupted Firefox restoration')
for path in (state, replacement, preserved):
if path.exists() or path.is_symlink():
if not stat.S_ISDIR(safe(path, BROWSER_UID, private=True).st_mode):
raise RuntimeError(f'Unsafe Firefox private directory: {path}')
state_before = state.exists() and replacement.exists() and not preserved.exists()
state_between = not state.exists() and replacement.exists() and preserved.exists()
state_done = state.exists() and not replacement.exists() and preserved.exists()
if not (state_before or state_between or state_done) or not app_done and not state_before:
raise RuntimeError('Ambiguous Firefox restoration order; all copies were preserved')
recovery.mkdir(mode=0o755, exist_ok=True)
safe(recovery)
metadata = {'saved': record['target'], 'restored': record['previous']}
if (recovery / 'receipt.json').exists() and read_json(recovery / 'receipt.json') != metadata:
raise RuntimeError('A different saved Firefox recovery already exists')
write_json(recovery / 'receipt.json', metadata)
sync_dir(ROOT)
if not app_done:
if current.exists():
move(current, failed)
move(previous, current)
if not state_done:
quiescent()
if state_before:
move(state, preserved)
move(replacement, state)
save(record, 'restored')
def private_directory(path):
if not path.exists():
path.mkdir(mode=0o700)
info = path.lstat()
if not stat.S_ISDIR(info.st_mode):
raise RuntimeError(f'Unsafe Firefox private directory: {path}')
# A power loss can separate mkdir from chown; only an empty, private root-owned directory is repairable.
if info.st_uid == 0 and not info.st_mode & 0o077 and not any(path.iterdir()):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.chown(path, BROWSER_UID, BROWSER_UID)
os.fsync(fd)
finally:
os.close(fd)
safe(path, BROWSER_UID, private=True)
sync_dir(path.parent)
def transaction_directories():
result = []
for path in (ROOT / 'updates').iterdir():
transaction_id(path.name)
safe(path)
result.append(path.name)
return result
def remove_transactions(identities):
for identity in identities:
path = ROOT / 'updates' / transaction_id(identity)
safe(path)
for name in ('profile-backup', 'probe-state'):
child = path / name
if child.exists():
safe(child, BROWSER_UID, private=True)
child.rmdir() # The unprivileged cleanup job must have emptied it first.
shutil.rmtree(path)
sync_dir(ROOT / 'updates')
def maintain(operation, expected, job):
"""Root control plane. Jobs run in independent, unprivileged transient services."""
target = approved()
check_host(target)
safe(ROOT)
safe(ROOT / '.prepare.lock')
fd = os.open(ROOT / '.prepare.lock', os.O_RDWR | os.O_NOFOLLOW)
with os.fdopen(fd, 'r+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
quiescent()
def run_job(kind, identity, garbage=()):
for transaction in {identity, *garbage}:
for name in ('profile-backup', 'probe-state'):
path = ROOT / 'updates' / transaction / name
if path.exists():
private_directory(path)
scratch = ROOT / 'restore-state'
if kind in ('restore-copy', 'cleanup') and scratch.exists():
private_directory(scratch)
job(kind, identity, lock.fileno(), list(garbage))
quiescent()
if kind == 'cleanup' and scratch.exists():
scratch.rmdir()
sync_dir(ROOT)
record = journal()
if operation == 'update':
if expected != bundle_id(target) or inspect()['action'] != 'update':
raise RuntimeError('Firefox update changed; reopen Firefox to review the current offer')
updates = ROOT / 'updates'
updates.mkdir(mode=0o755, exist_ok=True)
safe(updates)
identity = uuid.uuid4().hex
tx = updates / identity
tx.mkdir(mode=0o755)
sync_dir(updates)
if record:
write_json(tx / 'prior.json', record)
record = {'format': FORMAT, 'id': identity, 'phase': 'staging', 'target': target, 'previous': receipt(ROOT / 'runtime')}
save(record, 'staging')
required = FLOOR + sum(p['size'] for p in target['packages']) * 7
if shutil.disk_usage(ROOT).free < required:
raise RuntimeError('Insufficient free storage to stage Firefox while preserving the current installation')
build(target, tx / 'candidate')
sync_dir(tx)
for name in ('profile-backup', 'probe-state'):
private_directory(tx / name)
save(record, 'copying')
run_job('copy', identity)
save(record, 'qualifying')
run_job('qualify', identity)
report('Activating verified Firefox...')
save(record, 'activate')
activate(record)
elif operation == 'restore':
if (not record or record['phase'] != 'committed' or
(ROOT / 'recovery').exists() or (ROOT / 'recovery-state').exists()):
raise RuntimeError('No complete previous Firefox pair is available, or a saved recovery already exists')
if not display_available(record['previous']):
raise RuntimeError('Previous Firefox is missing display dependencies on this AGNOS image')
tx = ROOT / 'updates' / record['id']
if not matches(ROOT / 'runtime', record['target']) or not matches(tx / 'previous', record['previous']):
raise RuntimeError('Firefox no longer matches its recorded backup')
safe(tx / 'profile-backup', BROWSER_UID, private=True)
private_directory(ROOT / 'restore-state')
save(record, 'restore-copy')
run_job('restore-copy', record['id'])
report('Restoring previous Firefox...')
save(record, 'restore')
restore(record)
elif operation == 'recover':
if not record:
raise RuntimeError('No Firefox update transaction is available to recover')
if record['phase'] == 'activate':
activate(record)
elif record['phase'] == 'restore':
restore(record)
elif record['phase'] in ('staging', 'copying', 'qualifying', 'restore-copy'):
if not matches(ROOT / 'runtime', record['target'] if record['phase'] == 'restore-copy' else record['previous']):
raise RuntimeError('Firefox changed during an interrupted operation; preserve it for review')
prior_record = None
prior = ROOT / 'updates' / record['id'] / 'prior.json'
if record['phase'] != 'restore-copy' and prior.exists():
safe(prior)
prior_record = update_record(read_json(prior))
if prior_record['phase'] not in ('committed', 'restored') or not matches(
ROOT / 'runtime', prior_record['target'] if prior_record['phase'] == 'committed' else prior_record['previous']):
raise RuntimeError('Invalid previous Firefox transaction')
run_job('cleanup', record['id'], [] if record['phase'] == 'restore-copy' else [record['id']])
if record['phase'] == 'restore-copy':
# Keep the recorded previous pair; only the incomplete replacement is disposable.
save(record, 'committed')
return
if prior_record is not None:
write_json(ROOT / 'update.json', prior_record)
else:
(ROOT / 'update.json').unlink()
sync_dir(ROOT)
remove_transactions([record['id']])
return
else:
raise ValueError('Unknown Firefox maintenance operation')
garbage = [identity for identity in transaction_directories() if identity != record['id']]
run_job('cleanup', record['id'], garbage)
remove_transactions(garbage)
cache = ROOT / 'packages'
if cache.exists():
safe(cache, private=True)
wanted = {p.get('sha512', p.get('sha256')) + '-' + p['name'] for p in receipt(ROOT / 'runtime')['packages']}
for path in cache.iterdir():
safe(path)
if path.is_file() and path.name not in wanted:
path.unlink()
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--stage-offline', action='store_true', help='Refresh the offline archives in the source tree.')
if parser.parse_args().stage_offline:
offline = Path(__file__).with_name('offline')
offline.mkdir(mode=0o755, exist_ok=True)
safe(offline, os.getuid())
wanted = {download(package, offline, cache_uid=os.getuid()) for package in approved()['packages']}
for path in offline.iterdir():
if path not in wanted and re.fullmatch(r'(?:[0-9a-f]{64}|[0-9a-f]{128})-[a-zA-Z0-9_.+-]+[.](?:deb|tar[.]xz)', path.name):
path.unlink()
else:
prepare()