"""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)