Reuse raw builder exports only with matching verified provenance
This commit is contained in:
+25
-3
@@ -426,14 +426,36 @@ fi'''
|
||||
if self.complete('rootfs'):
|
||||
return
|
||||
source_inputs = image.source_identity(self.source_dir)
|
||||
self.record('rootfs', complete=False)
|
||||
previous = self.manifest().get('rootfs', {})
|
||||
inputs = self.input_id()
|
||||
inventory_checksum = image.digest(self.work / 'artifacts/packages.json')
|
||||
if inventory_checksum != self.manifest().get('packages', {}).get('inventory_sha256'):
|
||||
raise BuildError('Package inventory changed before rootfs export')
|
||||
original = self.work / 'artifacts/builder-rootfs.tar'
|
||||
if original.is_symlink() or not original.parent.resolve().is_relative_to(self.work):
|
||||
raise BuildError('Raw export output must remain inside the dedicated work directory')
|
||||
if original.exists() and (not original.is_file() or original.stat().st_nlink != 1):
|
||||
raise BuildError('Raw export output must be a singly linked regular file')
|
||||
snapshot = None
|
||||
previous_artifacts = previous.get('artifacts', {})
|
||||
if (previous.get('complete') is True and previous.get('inputs') == inputs
|
||||
and isinstance(previous_artifacts, dict)
|
||||
and previous_artifacts.get('packages.json') == inventory_checksum
|
||||
and isinstance(previous.get('snapshot_sha256'), str)
|
||||
and re.fullmatch('[a-f0-9]{64}', previous['snapshot_sha256'])
|
||||
and original.is_file()):
|
||||
try:
|
||||
if image.digest(original) == previous['snapshot_sha256']:
|
||||
snapshot = previous['snapshot_sha256']
|
||||
except OSError:
|
||||
pass # Unreadable cache is never trusted; attempt an ordinary export.
|
||||
# Preserve the prior completed provenance until the cache decision is made.
|
||||
self.record('rootfs', complete=False)
|
||||
original.touch(mode=0o600, exist_ok=True)
|
||||
original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust.
|
||||
self.pod('export', '--output', str(original), self.name)
|
||||
snapshot = image.digest(original)
|
||||
if snapshot is None:
|
||||
self.pod('export', '--output', str(original), self.name)
|
||||
snapshot = image.digest(original)
|
||||
assembler = self.assembly(snapshot)
|
||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
||||
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
||||
|
||||
@@ -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