Files
archy/scripts/tests/test_unsigned_uat_iso.py

184 lines
8.7 KiB
Python

"""Unsigned UAT tooling fixtures, not candidate ISO/boot acceptance."""
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
def load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
uat = load('uat', SCRIPTS / 'build-iso-uat.py')
security = load('kernel_security', SCRIPTS / 'kernel-security.py')
class UATTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='unsigned-uat-fixture-', dir='/tmp/opencode')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.staged = self.root / 'staged'
self.image = self.root / 'image/archipelago'
self.staged.mkdir()
(self.image / 'bin').mkdir(parents=True)
for name, installed in [('backend', 'archipelago'), ('reticulum', 'archy-reticulum-daemon'),
('rnodeconf', 'archy-rnodeconf'), ('esptool', 'archy-esptool')]:
(self.staged / name).write_bytes(('synthetic ' + name).encode())
shutil.copyfile(self.staged / name, self.image / 'bin' / installed)
for root in (self.staged, self.image):
(root / 'unsigned-uat-oci').mkdir()
(root / 'unsigned-uat-oci/BUNDLE.json').write_text('{"fixture":true}')
(root / 'web-ui/packages').mkdir(parents=True)
(root / 'web-ui/index.html').write_text('synthetic dashboard')
(root / 'web-ui/packages/archipelago-companion.apk').write_bytes(b'synthetic APK')
(root / 'web-ui/packages/archipelago-companion.json').write_text('{"fixture":true}')
def test_real_iso_roundtrip_and_byte_tamper(self):
iso = self.root / 'fixture.iso'
uat.run(['xorriso', '-as', 'mkisofs', '-R', '-o', iso, self.root / 'image'],
self.root, self.root / 'mkiso.log')
extracted = self.root / 'extracted'
uat.run(['xorriso', '-osirrox', 'on', '-indev', iso, '-extract', '/', extracted],
self.root, self.root / 'extract.log')
actual = extracted / 'archipelago'
uat.compare_payload(actual, self.staged)
for name in ('bin/archipelago', 'web-ui/index.html',
'web-ui/packages/archipelago-companion.apk',
'web-ui/packages/archipelago-companion.json', 'bin/archy-rnodeconf'):
path = actual / name
original = path.read_bytes()
path.write_bytes(original + b'tampered')
with self.subTest(name=name), self.assertRaisesRegex(ValueError, 'byte mismatch'):
uat.compare_payload(actual, self.staged)
path.write_bytes(original)
def test_extra_and_symlink_payload_rejected(self):
extra = self.image / 'web-ui/extra'
extra.write_bytes(b'stale')
with self.assertRaises(ValueError):
uat.compare_payload(self.image, self.staged)
extra.unlink()
extra.symlink_to(self.staged / 'backend')
with self.assertRaisesRegex(ValueError, 'symlink'):
uat.compare_payload(self.image, self.staged)
def receipt(self):
result = {'schema': 'archipelago-unsigned-uat-input-v3',
'qualification': 'qualified-for-unsigned-uat', 'reviewed_by': 'fixture reviewer'}
for name in (*uat.ARTIFACTS, 'evidence'):
path = self.root / name
path.write_text('synthetic input ' + name)
result[name] = {'path': str(path), 'sha256': uat.sha(path)}
archive = self.root / 'kernel.deb'
archive.write_bytes(b'fixture archive')
pin = dict(architecture='amd64', package='linux-image-6.12.113+deb13-amd64',
version='6.12.113-1', source='linux-signed-amd64',
archive_sha256=uat.sha(archive), vmlinuz_sha256=uat.payload.digest(b'kernel'))
payload = self.root / 'kernel-payload.json'
payload.write_text(json.dumps({**pin, 'origin': 'Debian',
'files': {'/boot/vmlinuz-6.12.113+deb13-amd64': pin['vmlinuz_sha256']}}))
result['kernel'] = {'pin': pin, **{key: {'path': str(path), 'sha256': uat.sha(path)}
for key, path in [('archive', archive), ('payload_receipt', payload), ('evidence', payload)]}}
return result
def test_qualification_and_artifact_binding(self):
receipt = self.receipt()
self.assertEqual(len(uat.inputs(receipt)), 9)
for name in (*uat.ARTIFACTS, 'evidence'):
old = receipt[name]['sha256']
receipt[name]['sha256'] = 'wrong'
with self.subTest(name=name), self.assertRaises(ValueError):
uat.inputs(receipt)
receipt[name]['sha256'] = old
receipt['qualification'] = 'not yet qualified'
with self.assertRaises(ValueError):
uat.inputs(receipt)
def test_signed_manifest_is_not_uat_receipt(self):
with self.assertRaisesRegex(ValueError, 'not an unsigned UAT'):
uat.inputs({'version': '2.0.1-alpha', 'signature': 'fixture'})
def test_dirty_source_refused_before_build(self):
root = SCRIPTS.parent
commit = 'a' * 40 # Deliberately synthetic identity; no Git commit is created.
with patch.object(uat, 'git', side_effect=[commit, ' M source']), patch.object(uat, 'run') as runner:
with self.assertRaisesRegex(ValueError, 'clean frozen source'):
uat.source(root, commit, '2.0.1-alpha')
runner.assert_not_called()
def test_source_commit_mismatch(self):
with self.assertRaisesRegex(ValueError, 'exact full source commit'):
uat.source(SCRIPTS.parent, 'not-a-commit', '2.0.1-alpha')
def kernel_fixture(self, version='6.12.113-1'):
release = '6.12.113+deb13-amd64'
files = {'/boot/vmlinuz-' + release: b'synthetic kernel',
'/usr/lib/modules/' + release + '/kernel/test.ko': b'synthetic module'}
receipt = dict(package='linux-image-' + release, version=version, source='linux',
origin='Debian', archive_sha256=uat.payload.digest(b'synthetic deb'),
files={n: uat.payload.digest(v) for n, v in files.items()})
return receipt, files
def kernel_tar(self, receipt, files):
path = self.root / 'rootfs.tar'
contents = {**files, '/var/lib/archipelago/kernel-security/kernel.json': json.dumps(receipt).encode()}
with tarfile.open(path, 'w') as archive:
for name, raw in contents.items():
member = tarfile.TarInfo('./' + name.lstrip('/'))
member.size = len(raw)
archive.addfile(member, io.BytesIO(raw))
return path
def test_rootfs_kernel_receipt_and_module_integrity(self):
receipt, files = self.kernel_fixture()
uat.kernel_tar(self.kernel_tar(receipt, files), security)
module = next(n for n in files if n.endswith('.ko'))
files[module] = b'stale module'
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
uat.kernel_tar(self.kernel_tar(receipt, files), security)
def test_old_unknown_and_missing_kernel_provenance(self):
for version in ('6.12.112-1', 'vendor-unknown'):
receipt, files = self.kernel_fixture(version)
with self.subTest(version=version), self.assertRaisesRegex(ValueError, 'known fixed'):
uat.kernel_tar(self.kernel_tar(receipt, files), security)
receipt, files = self.kernel_fixture()
receipt['files'] = {}
with self.assertRaisesRegex(ValueError, 'incomplete'):
uat.validate_kernel_receipt(receipt, security)
def test_boot_log_requires_exact_kernel_build(self):
receipt, _ = self.kernel_fixture()
log = self.root / 'serial.log'
log.write_text('Linux version 6.12.113+deb13-amd64 (fixture) #1 SMP Debian 6.12.113-1 (fixture)\n')
uat.boot_kernel(log, receipt)
for text in ('Welcome to Debian', 'Linux version 6.12.112+deb13-amd64 Debian 6.12.112-1 '):
log.write_text(text)
with self.assertRaises(ValueError):
uat.boot_kernel(log, receipt)
def test_gate_command_failure_propagates_with_retained_log(self):
log = self.root / 'failure.log'
with self.assertRaises(subprocess.CalledProcessError):
uat.run([sys.executable, '-c', 'raise SystemExit(7)'], self.root, log)
self.assertTrue(log.exists())
self.assertFalse((self.root / 'UNSIGNED-UAT.json').exists())
if __name__ == '__main__':
unittest.main()