135 lines
6.9 KiB
Python
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')
|