Files
archy/scripts/os_update_plan.py

138 lines
7.5 KiB
Python

"""Strict release-bound OS plan; no command fragments or application repositories."""
import hashlib
import json
import re
HEX = re.compile(r'[0-9a-f]{64}\Z')
NAME = re.compile(r'[a-z0-9][a-z0-9+.-]+\Z')
VERSION = re.compile(r'[0-9][A-Za-z0-9.+:~\-]*\Z')
CUSTODY = ('archipelago', 'podman', 'conmon', 'crun', 'runc', 'systemd',
'dbus', 'sudo', 'openssh', 'cryptsetup', 'grub', 'shim', 'linux-image')
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode()
def digest(raw):
return hashlib.sha256(raw).hexdigest()
def require(condition, message):
if not condition:
raise ValueError(message)
def keys(value, fields):
require(isinstance(value, dict) and set(value) == set(fields.split()), 'unexpected contract fields')
def hashed(value):
require(isinstance(value, str) and HEX.fullmatch(value), 'invalid SHA256')
def protected(name):
return name.startswith(CUSTODY)
def validate_package(package, architecture, pockets):
keys(package, 'name architecture version sha256 size filename pocket')
require(NAME.fullmatch(package['name']) and VERSION.fullmatch(package['version']), 'package identity')
require(package['architecture'] in (architecture, 'all'), 'package architecture')
hashed(package['sha256'])
require(type(package['size']) is int and 0 < package['size'] <= 2 * 1024**3, 'package size')
require(package['pocket'] in pockets and re.fullmatch(r'pool/[A-Za-z0-9+_.%/~\-]+\.deb', package['filename']) and '..' not in package['filename'] and '%' not in package['filename'], 'package location')
def archives(plan):
result = list(plan['packages'])
kernel = plan['boot']['package']
if kernel not in result:
result.append(kernel)
return result
def validate(plan):
keys(plan, 'version suite architecture before_sha256 repositories packages transaction removals custody service_policy preservation_sha256 boot' +
(' manager_reexec' if 'manager_reexec' in plan else ''))
require(type(plan['version']) is int and plan['version'] == 1, 'plan version')
require(plan['suite'] in ('bookworm', 'trixie'), 'unsupported suite')
require(plan['architecture'] in ('amd64', 'arm64'), 'unsupported architecture')
if 'manager_reexec' in plan:
policy = plan['manager_reexec']
keys(policy, 'version operation package package_version architecture expected_executable_sha256 max_unavailable_seconds')
require(type(policy['version']) is int and policy['version'] == 1 and
policy['operation'] == 'debian-systemd-daemon-reexec' and policy['package'] == 'systemd', 'manager operation')
require(policy['architecture'] == plan['architecture'] and VERSION.fullmatch(policy['package_version']), 'manager package identity')
hashed(policy['expected_executable_sha256'])
require(type(policy['max_unavailable_seconds']) is int and 1 <= policy['max_unavailable_seconds'] <= 120, 'manager reconnect bound')
for package in plan['packages']:
if package['name'] == 'systemd':
require(package['version'] == policy['package_version'] and package['architecture'] == policy['architecture'], 'manager package differs from transaction')
for field in ('before_sha256', 'preservation_sha256'):
hashed(plan[field])
require(plan['service_policy'] == 'reviewed-maintenance-window', 'maintenance authority required')
pockets = set()
require(isinstance(plan['repositories'], list) and plan['repositories'], 'repositories required')
for repo in plan['repositories']:
keys(repo, 'pocket inrelease_sha256 indexes')
require(repo['pocket'] in (plan['suite'], plan['suite'] + '-updates', plan['suite'] + '-security') and repo['pocket'] not in pockets, 'repository pocket')
pockets.add(repo['pocket'])
hashed(repo['inrelease_sha256'])
require(repo['indexes'], 'indexes required')
seen = set()
for index in repo['indexes']:
keys(index, 'path sha256 size')
require(re.fullmatch(r'(main|contrib|non-free|non-free-firmware)/binary-' + plan['architecture'] + r'/Packages(?:\.xz)?', index['path']) and index['path'] not in seen, 'index path')
seen.add(index['path'])
hashed(index['sha256'])
require(type(index['size']) is int and 0 < index['size'] <= 512 * 1024**2, 'index size')
require({p.split('/')[0] for p in seen} == {'main', 'contrib', 'non-free', 'non-free-firmware'}, 'whole-suite component coverage required')
require(len(seen) == 4, 'exactly one index per component required')
require(pockets == {plan['suite'], plan['suite'] + '-updates', plan['suite'] + '-security'}, 'whole-suite pocket coverage required')
seen = set()
for package in plan['packages']:
validate_package(package, plan['architecture'], pockets)
identity = (package['name'], package['architecture'])
require(identity not in seen, 'duplicate package')
seen.add(identity)
for item in plan['transaction']:
keys(item, 'operation name version')
require(item['operation'] in ('Inst', 'Remv', 'Conf') and re.fullmatch(r'[a-z0-9][a-z0-9+.-]+(?::(?:amd64|arm64|all))?', item['name']) and VERSION.fullmatch(item['version']), 'transaction record')
require(plan['transaction'] == sorted(plan['transaction'], key=canonical), 'transaction must be sorted')
removed = sorted(x['name'] for x in plan['transaction'] if x['operation'] == 'Remv')
require(plan['removals'] == removed and len(removed) == len(set(removed)), 'explicit removal disposition')
require(not any(protected(x.split(':')[0]) for x in removed), 'custody removal refused')
seen = set()
for item in plan['custody']:
keys(item, 'name disposition')
require(NAME.fullmatch(item['name']) and item['name'] not in seen and item['disposition'] in ('retain', 'upgrade-reviewed'), 'custody disposition')
seen.add(item['name'])
keys(plan['boot'], 'running_release retained_files expected_release package')
validate_package(plan['boot']['package'], plan['architecture'], pockets)
require(plan['boot']['package']['name'] == 'linux-image-' + plan['boot']['expected_release'], 'expected kernel identity')
for package in plan['packages']:
if package['name'] == plan['boot']['package']['name']:
require(package == plan['boot']['package'], 'ambiguous expected kernel provenance')
for field in ('running_release', 'expected_release'):
require(re.fullmatch(r'[A-Za-z0-9.+_\-]+', plan['boot'][field]), 'kernel release')
files = plan['boot']['retained_files']
require(isinstance(files, dict) and {'/boot/vmlinuz-' + plan['boot']['running_release'], '/boot/initrd.img-' + plan['boot']['running_release'], '/boot/grub/grub.cfg'} <= set(files), 'old boot evidence required')
for path, sha in files.items():
require(path.startswith('/boot/') and '..' not in path and '//' not in path, 'boot path')
hashed(sha)
return plan
def simulation(text):
result = []
for line in text.splitlines():
if line.startswith(('Inst ', 'Conf ', 'Remv ')):
match = re.match(r'^(Inst|Conf|Remv) (\S+)(?: \[([^\]]+)\])?(?: \((\S+).*)?$', line)
require(match is not None, 'unknown APT transaction syntax')
operation, name, old, new = match.groups()
version = old if operation == 'Remv' else new
require(version is not None, 'missing solver version')
result.append(dict(operation=operation, name=name, version=version))
return sorted(result, key=canonical)