Files
fds-os/tests/integration/m12-release-build.py
T
2026-09-21 22:29:23 +08:00

135 lines
6.9 KiB
Python

#!/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}')
print('NOTE: these are small contract fixtures; actual source-to-image offline reproduction remains a separate gate')