Reuse raw builder exports only with matching verified provenance

This commit is contained in:
Codex
2026-10-13 22:25:43 +03:00
parent f0101f8cb6
commit ec40a5a305
2 changed files with 167 additions and 3 deletions
+25 -3
View File
@@ -426,14 +426,36 @@ fi'''
if self.complete('rootfs'): if self.complete('rootfs'):
return return
source_inputs = image.source_identity(self.source_dir) 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' original = self.work / 'artifacts/builder-rootfs.tar'
if original.is_symlink() or not original.parent.resolve().is_relative_to(self.work): 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') 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.touch(mode=0o600, exist_ok=True)
original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust. original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust.
self.pod('export', '--output', str(original), self.name) if snapshot is None:
snapshot = image.digest(original) self.pod('export', '--output', str(original), self.name)
snapshot = image.digest(original)
assembler = self.assembly(snapshot) assembler = self.assembly(snapshot)
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip() 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" script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
+142
View File
@@ -27,6 +27,148 @@ class BuilderTests(unittest.TestCase):
def builder(self): def builder(self):
return self.module.Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman') 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): def test_bad_cached_hash_is_rejected(self):
artifact = self.project / 'archive' artifact = self.project / 'archive'
artifact.write_bytes(b'tampered') artifact.write_bytes(b'tampered')