#!/usr/bin/env python3 """Exercise comparison and frozen-lock rejection contracts without claiming an OS rebuild.""" import hashlib import importlib.machinery import importlib.util import json from pathlib import Path import shutil import subprocess import sys import tempfile project = Path(__file__).resolve().parents[2] loader = importlib.machinery.SourceFileLoader('fds_frozen_fixture', str(project / 'tools/frozen-inputs')) spec = importlib.util.spec_from_loader(loader.name, loader) frozen = importlib.util.module_from_spec(spec) loader.exec_module(frozen) work = Path(tempfile.mkdtemp(prefix='m12-release-contracts.', dir=project / 'out')) def invoke(*args, ok=True): result = subprocess.run(list(map(str, args)), capture_output=True, text=True, timeout=60) with (work / 'commands.log').open('a') as log: log.write(repr(args) + '\n' + result.stdout + result.stderr) assert (result.returncode == 0) == ok, (args, result.returncode, result.stdout, result.stderr) return result lock = {'format': 1, 'version': '0.1.0', 'void_commit': 'a' * 40, 'source_epoch': 123, 'source_sha256': 'b' * 64, 'rust_toolchain': '1.98.0', 'host_prerequisites': {}, 'files': []} snapshot = work / 'snapshot' (snapshot / 'project').mkdir(parents=True) (snapshot / 'project/source.txt').write_bytes(b'bounded input fixture\n') (snapshot / 'temporary').mkdir() (snapshot / 'temporary').chmod(0o1777) (snapshot / 'project/executable').write_bytes(b'archive mode fixture\n') (snapshot / 'project/executable').chmod(0o775) (snapshot / 'source-link').symlink_to('project/source.txt') lock['files'] = frozen.inventory(snapshot) lock['source_sha256'] = frozen.source_digest(lock['files']) (snapshot / 'lock.json').write_text(json.dumps(lock)) verify = project / 'tools/frozen-inputs' invoke(verify, 'verify', snapshot) for case in ('bytes', 'mode', 'extra', 'missing', 'link', 'metadata'): damaged = work / ('damaged-' + case) shutil.copytree(snapshot, damaged, symlinks=True) leaf = damaged / 'project/source.txt' if case == 'bytes': leaf.write_bytes(b'changed input fixture\n') elif case == 'mode': leaf.chmod(0o600) elif case == 'extra': (damaged / 'extra').mkdir() elif case == 'missing': leaf.unlink() elif case == 'link': (damaged / 'source-link').unlink() (damaged / 'source-link').symlink_to('/etc/passwd') elif case == 'metadata': wrong = dict(lock, rust_toolchain='../unexpected') (damaged / 'lock.json').write_text(json.dumps(wrong)) invoke(verify, 'verify', damaged, ok=False) print('PASS: frozen input byte/mode/path/symlink and metadata tampering rejected', flush=True) # Exercise the actual release archive writer and the documented extraction # recipe. Default tar extraction masks modes and strips sticky bits, which would # make an otherwise intact build-input archive fail its frozen lock immediately. sys.path.insert(0, str(project / 'tools')) archive_loader = importlib.machinery.SourceFileLoader('fds_assembly_fixture', str(project / 'tools/assemble-release')) archive_spec = importlib.util.spec_from_loader(archive_loader.name, archive_loader) assembly = importlib.util.module_from_spec(archive_spec) archive_loader.exec_module(assembly) for name in ('one', 'two'): assembly.packed_tree(snapshot, work / f'{name}.tar.zst', lock['source_epoch']) assert frozen.digest(work / 'one.tar.zst') == frozen.digest(work / 'two.tar.zst') unpacked = work / 'unpacked' unpacked.mkdir() invoke('tar', '--extract', '--zstd', '--same-permissions', '--no-same-owner', '--file', work / 'one.tar.zst', '--directory', unpacked) invoke(verify, 'verify', unpacked) print('PASS: deterministic release archive and complete mode/symlink-preserving extraction', flush=True) first, second = work / 'first', work / 'second' paths = [f'out/{name}' for name in ('fds-system-cli.img', 'fds-system-development.img', 'fds-recovery.img', 'fds-boot.img', 'fds-internal.img', 'rootfs-cli.tar', 'rootfs-development.tar', 'rootfs-recovery.tar')] paths += ['out/initramfs/initramfs.cpio' + suffix for suffix in ('', '.gz', '.lz4', '.zst')] paths += ['out/kernel/boot/' + name for name in ('kernel_2712.img', 'bcm2712-rpi-5-b.dtb')] paths += ['out/eeprom-production-latest/' + name for name in ('configured.bin', 'rollback.bin', 'configured.conf', 'original.conf')] for name in ('base', 'base-files', 'init', 'cli', 'cartridged', 'dasungd', 'kernel', 'dhcpcd', 'eink'): paths.append(f'out/packages/fds-{name}-0.1.0_1.aarch64.xbps') for name in paths: path = first / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(hashlib.sha256(name.encode()).digest()) (first / '.host/frozen').mkdir(parents=True) (first / '.host/frozen/lock.json').write_text(json.dumps(lock)) shutil.copytree(first, second) compare = project / 'tools/compare-builds' invoke(compare, first, second, '--report', work / 'equal.json') result = json.loads((work / 'equal.json').read_text()) assert result['status'] == 'passed' and len(result['artifacts']) == 27 invoke(compare, first, first, '--report', work / 'same-tree.json', ok=False) invoke(compare, first, second, '--report', work / 'equal.json', ok=False) changed = second / 'out/fds-system-cli.img' changed.write_bytes(b'changed image fixture') invoke(compare, first, second, '--report', work / 'different.json', ok=False) result = json.loads((work / 'different.json').read_text()) assert result['status'] == 'failed' assert [x['name'] for x in result['artifacts'] if not x['identical']] == ['fds-system-cli.img'] changed.unlink() invoke(compare, first, second, '--report', work / 'missing.json', ok=False) assert not (work / 'missing.json').exists() shutil.copyfile(first / 'out/fds-system-cli.img', changed) (second / '.host/frozen/lock.json').write_text(json.dumps(dict(lock, source_epoch=456))) invoke(compare, first, second, '--report', work / 'wrong-inputs.json', ok=False) assert not (work / 'wrong-inputs.json').exists() print('PASS: complete comparison, changed/missing artifact, same-tree, existing-report and different-input rejection', flush=True) # Even before a complete build exists, assembly must reject an existing output # and must not resolve a private-key symlink past the signer's no-follow policy. key = work / 'test.key' key.write_bytes(bytes(32)) key.chmod(0o600) output = work / 'existing-output' output.mkdir() (output / 'marker').write_text('preserve') command = [project / 'tools/assemble-release', '--inputs', snapshot, '--comparison-build', second, '--output-directory', output, '--key', key] invoke(*command, ok=False) assert (output / 'marker').read_text() == 'preserve' and len(list(output.iterdir())) == 1 linked = work / 'linked.key' linked.symlink_to(key) command[-1] = linked result = invoke(*command, ok=False) assert 'not a symlink' in result.stderr print(f'PASS: release output preservation and private-key symlink rejection: {work}') # Restore small real Git bundles through both workspace layouts. These fixtures # exercise path selection and cache preservation, not an operating-system build. upstream = work / 'upstream' upstream.mkdir() invoke('git', 'init', '-q', upstream) (upstream / 'xbps-src').write_text('# inert source fixture\n') (upstream / '.gitignore').write_text('hostdir/\nmasterdir-*/\netc/conf\netc/xbps.d/custom/\n') invoke('git', '-C', upstream, 'add', '.') invoke('git', '-C', upstream, '-c', 'user.name=FDS', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture') pin = invoke('git', '-C', upstream, 'rev-parse', 'HEAD').stdout.strip() epoch = int(invoke('git', '-C', upstream, 'show', '-s', '--format=%ct', 'HEAD').stdout) for layout in ('separate', 'legacy'): fixture = work / ('restore-input-' + layout) (fixture / 'project/tools').mkdir(parents=True) (fixture / 'project/config').mkdir() (fixture / 'project/VOID_PACKAGES_COMMIT').write_text(pin + '\n') (fixture / 'project/config/xbps-src.conf').write_text('# fixture\n') if layout == 'separate': shutil.copy2(project / 'tools/prepare-void-workspace', fixture / 'project/tools/prepare-void-workspace') invoke('git', '-C', upstream, 'bundle', 'create', fixture / 'void.bundle', 'HEAD') for name in ('masterdir/etc/xbps.d', 'sources', 'repositories/build', 'repositories/cli', 'repositories/development', 'repositories/recovery', 'xbps', 'image-tools', 'eeprom', 'cache', 'downloads', 'cargo-vendor', 'rust-toolchain'): (fixture / name).mkdir(parents=True, exist_ok=True) (fixture / 'sources/retained.tar.gz').write_bytes(b'cached source fixture') restore_lock = dict(lock, void_commit=pin, source_epoch=epoch) restore_lock['files'] = frozen.inventory(fixture) restore_lock['source_sha256'] = frozen.source_digest(restore_lock['files']) (fixture / 'lock.json').write_text(json.dumps(restore_lock)) destination = work / ('restored-' + layout) invoke(verify, 'restore', fixture, destination) build = destination / ('.host/void-packages' if layout == 'separate' else 'vendor/void-packages') assert (build / 'hostdir/sources/retained.tar.gz').read_bytes() == b'cached source fixture' assert (build / 'masterdir-x86_64/etc/xbps.d/05-fds-frozen-local.conf').read_text() == 'repository=/host/frozen-repository\n' assert invoke('git', '-C', build, 'rev-parse', 'HEAD').stdout.strip() == pin if layout == 'separate': reference = destination / 'vendor/void-packages' assert not (reference / 'hostdir').exists() assert not invoke('git', '-C', reference, 'status', '--porcelain', '--untracked-files=all').stdout.strip() invoke(verify, 'verify', fixture) print('PASS: separate-workspace and legacy frozen restores retain source caches and offline repositories', flush=True) print('NOTE: these are small contract fixtures; actual source-to-image offline reproduction remains a separate gate')