Reuse raw builder exports only with matching verified provenance
This commit is contained in:
@@ -27,6 +27,148 @@ class BuilderTests(unittest.TestCase):
|
||||
def builder(self):
|
||||
return self.module.Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman')
|
||||
|
||||
def rootfs_fixture(self):
|
||||
from hydra import image
|
||||
import subprocess
|
||||
(self.project / 'image').mkdir()
|
||||
(self.project / 'image/packages').write_text('app-emulation/qemu\n')
|
||||
(self.project / 'hydra').mkdir()
|
||||
(self.project / 'hydra/runtime.py').write_text('# runtime revision two\n')
|
||||
builder = self.builder()
|
||||
artifacts = builder.work / 'artifacts'
|
||||
artifacts.mkdir(parents=True)
|
||||
inventory = artifacts / 'packages.json'
|
||||
inventory.write_text('[{"package":"app-emulation/qemu-9.2.4","use":["spice"],"build_time":"123"}]')
|
||||
raw = artifacts / 'builder-rootfs.tar'
|
||||
raw.write_bytes(b'verified raw builder snapshot')
|
||||
original_complete = builder.complete
|
||||
builder.complete = lambda step: True if step == 'prepare' else original_complete(step)
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.record('rootfs', source_inputs='prior-runtime-identity', snapshot_sha256=image.digest(raw),
|
||||
artifacts={'packages.json': image.digest(inventory)})
|
||||
commands = []
|
||||
def run(argv, capture=False, check=True):
|
||||
if argv[0] == 'git':
|
||||
return subprocess.CompletedProcess(argv, 0, 'source-commit\n', '')
|
||||
args = argv[7:]
|
||||
commands.append(args)
|
||||
output = ''
|
||||
if args[:2] == ['container', 'exists']:
|
||||
return subprocess.CompletedProcess(argv, 0, '', '')
|
||||
if args[:2] == ['container', 'inspect']:
|
||||
output = json.dumps([{'Image': 'trusted-assembler-image', 'Mounts': [
|
||||
{'Destination': '/hydra-source', 'Source': str(builder.source_dir), 'RW': False},
|
||||
{'Destination': '/hydra-output', 'Source': str(builder.work), 'RW': True}]}])
|
||||
elif args[:2] == ['image', 'inspect']:
|
||||
output = json.dumps([{'RootFS': {'Layers': ['sha256:' + image.digest(raw)]}}])
|
||||
elif args[0] == 'export':
|
||||
raw.write_bytes(b'fresh raw builder snapshot')
|
||||
elif args[0] == 'exec':
|
||||
for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'):
|
||||
(artifacts / name).write_bytes(b'reassembled ' + name.encode())
|
||||
(artifacts / 'rootfs-manifest.json').write_text(json.dumps({
|
||||
'kernel': '6.18-test', 'packages': json.loads(inventory.read_text()),
|
||||
'source_files': image.source_files(builder.source_dir)}))
|
||||
return subprocess.CompletedProcess(argv, 0, output, '')
|
||||
builder.run = run # Fake only the external Podman/git boundary.
|
||||
return builder, raw, inventory, commands
|
||||
|
||||
def test_runtime_change_reuses_verified_raw_export_and_reassembles(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
original_bytes = raw.read_bytes()
|
||||
builder.rootfs()
|
||||
self.assertFalse(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), original_bytes)
|
||||
self.assertEqual(raw.stat().st_mode & 0o777, 0o600)
|
||||
self.assertTrue(any(args[0] == 'exec' for args in commands), 'raw reuse must still run assembly')
|
||||
self.assertEqual((inventory.parent / 'squashfs.img').read_bytes(), b'reassembled squashfs.img')
|
||||
self.assertEqual(builder.manifest()['rootfs']['snapshot_sha256'], image.digest(raw))
|
||||
self.assertTrue(builder.complete('rootfs'))
|
||||
|
||||
def test_corrupt_raw_export_falls_back_to_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.write_bytes(b'corrupted raw cache')
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), b'fresh raw builder snapshot')
|
||||
|
||||
def test_changed_image_inputs_require_fresh_raw_export(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
(self.project / 'image/packages').write_text('app-emulation/qemu\nsys-fs/btrfs-progs\n')
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_changed_validated_package_inventory_requires_fresh_export(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
inventory.write_text('[{"package":"app-emulation/qemu-10.0.0","use":["spice"],"build_time":"124"}]')
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_incomplete_raw_export_checkpoint_requires_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
builder.record('rootfs', complete=False)
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_missing_raw_export_falls_back_to_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.unlink()
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), b'fresh raw builder snapshot')
|
||||
|
||||
def test_invalid_package_inventory_blocks_even_matching_raw_cache(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
original_bytes = raw.read_bytes()
|
||||
inventory.write_text('[]')
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'completed packages'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(raw.read_bytes(), original_bytes)
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_parent_escape_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
outside = self.project / 'outside-artifacts'
|
||||
inventory.parent.rename(outside)
|
||||
(builder.work / 'artifacts').symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'dedicated work'):
|
||||
builder.rootfs()
|
||||
self.assertEqual((outside / raw.name).read_bytes(), b'verified raw builder snapshot')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_hardlinked_raw_export_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
linked = self.project / 'outside-private'
|
||||
os.link(raw, linked)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'singly linked'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(linked.read_bytes(), b'verified raw builder snapshot')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_symlink_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
outside = self.project / 'outside-private'
|
||||
outside.write_bytes(b'private sentinel')
|
||||
raw.unlink()
|
||||
raw.symlink_to(outside)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'dedicated work'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(outside.read_bytes(), b'private sentinel')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_directory_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.unlink()
|
||||
raw.mkdir()
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'regular file'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_bad_cached_hash_is_rejected(self):
|
||||
artifact = self.project / 'archive'
|
||||
artifact.write_bytes(b'tampered')
|
||||
|
||||
Reference in New Issue
Block a user