184 lines
8.7 KiB
Python
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()
|