From ec40a5a30536420efced1f98859334e0fb36d25f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 22:25:43 +0300 Subject: [PATCH] Reuse raw builder exports only with matching verified provenance --- hydra/build.py | 28 ++++++++- tests/test_build.py | 142 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/hydra/build.py b/hydra/build.py index 6c945ee..5ba2119 100644 --- a/hydra/build.py +++ b/hydra/build.py @@ -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" diff --git a/tests/test_build.py b/tests/test_build.py index 8f25114..9fc596d 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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')