import importlib.util import bz2 import json import os import shlex import sys from pathlib import Path import subprocess import tempfile import tomllib import unittest try: from hydra import image except ImportError: image = None class ImageTests(unittest.TestCase): def setUp(self): self.assertIsNotNone(image, 'image pipeline is missing') self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) / 'rootfs' self.root.mkdir() self.vendor_tmpfiles = { 'home.conf': '# vendor home policy\nQ /home 0755 - - -\nq /srv 0755 - - -\n', 'portables.conf': '# vendor portables policy\nQ /var/lib/portables 0700\n', 'systemd-nspawn.conf': ( '# vendor nspawn policy\nQ /var/lib/machines 0700 - - -\n' 'R! /var/lib/machines/.#*\nR! /.#machine.*\n' 'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n' 'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'), } for name, content in self.vendor_tmpfiles.items(): self.file('usr/lib/tmpfiles.d/' + name, content) def file(self, relative, content='preserved'): path = self.root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) return path def kernel(self, version='6.12.58-gentoo-dist'): (self.root / 'lib/modules' / version).mkdir(parents=True) self.file('boot/vmlinuz-' + version, 'kernel') return version def test_managed_root_tmpfiles_creates_ordinary_directories_without_losing_vendor_rules(self): self.kernel() source = Path(__file__).resolve().parents[1] retained = [self.file(path + '/retained-data', 'user data') for path in ('srv', 'var/lib/portables', 'var/lib/machines')] image.configure_rootfs(self.root, source) expected = { 'home.conf': '# vendor home policy\nQ /home 0755 - - -\nd /srv 0755 - - -\n', 'portables.conf': '# vendor portables policy\nd /var/lib/portables 0700\n', 'systemd-nspawn.conf': ( '# vendor nspawn policy\nd /var/lib/machines 0700 - - -\n' 'R! /var/lib/machines/.#*\nR! /.#machine.*\n' 'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n' 'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'), } for name, content in expected.items(): with self.subTest(name=name): override = self.root / 'etc/tmpfiles.d' / name self.assertTrue(override.is_file(), f'missing managed-root policy: {name}') self.assertEqual(override.read_text(), content) self.assertEqual(override.stat().st_mode & 0o777, 0o644) self.assertEqual((self.root / 'usr/lib/tmpfiles.d' / name).read_text(), self.vendor_tmpfiles[name]) self.assertEqual([path.read_text() for path in retained], ['user data'] * 3) def test_managed_root_tmpfiles_refuses_missing_duplicate_or_changed_vendor_rule(self): import shutil self.kernel() source = Path(__file__).resolve().parents[1] for index, content in enumerate((None, '# no managed path\n', 'd /var/lib/machines 0700\n', 'Q /var/lib/machines 0700\nq /var/lib/machines 0700\n', 'Q /var/lib/machines 0700\nd /var/lib/machines 0700\n')): with self.subTest(content=content): root = Path(self.tmp.name) / ('drift-' + str(index)) shutil.copytree(self.root, root) vendor = root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf' if content is None: vendor.unlink() else: vendor.write_text(content) with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): image.configure_rootfs(root, source) self.assertFalse((root / 'etc/tmpfiles.d/home.conf').exists()) def test_managed_root_tmpfiles_refuses_existing_override(self): self.kernel() original = self.file('etc/tmpfiles.d/home.conf', 'd /srv 0710 local local -\n') with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) self.assertEqual(original.read_text(), 'd /srv 0710 local local -\n') def test_managed_root_tmpfiles_refuses_vendor_symlink(self): self.kernel() original = self.file('usr/lib/tmpfiles.d/other.conf', 'Q /var/lib/machines 0700\n') vendor = self.root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf' vendor.unlink() vendor.symlink_to('other.conf') with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) self.assertEqual(original.read_text(), 'Q /var/lib/machines 0700\n') def test_installs_independent_updater_and_base_recovery_assets(self): self.kernel() source = Path(__file__).resolve().parents[1] image.configure_rootfs(self.root, source) for relative, asset, mode in ( ('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644), ('usr/bin/hydra-update', 'bin/hydra-update', 0o755), ('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755), ('usr/lib/hydra/bin/hydra-system', 'bin/hydra-system', 0o755), ('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644), ): with self.subTest(relative=relative): path = self.root / relative self.assertTrue(path.is_file(), relative) self.assertEqual(path.read_bytes(), (source / asset).read_bytes()) self.assertEqual(path.stat().st_mode & 0o777, mode) wrapper = self.root / 'usr/bin/hydra-system' self.assertTrue(wrapper.is_file(), 'base recovery launcher is missing') self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755) self.assertEqual(wrapper.read_text(), '#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n' 'from hydra.system_recovery import main\nraise SystemExit(main())\n') self.assertEqual((self.root / 'usr/lib/hydra/hydra/doctor.py').read_bytes(), (source / 'hydra/doctor.py').read_bytes()) def test_root_assembly_sets_trusted_asset_ownership(self): from unittest.mock import patch self.kernel() with patch('hydra.image.os.geteuid', return_value=0), patch('hydra.image.os.chown') as chown: image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) for relative in ('usr/lib/hydra-update/engine.py', 'usr/lib/hydra-update', 'usr/bin/hydra-update', 'usr/bin/hydra-update-recover', 'etc/hydra/update.pub'): chown.assert_any_call(self.root / relative, 0, 0) def test_missing_trusted_asset_refuses_configuration_before_mutation(self): import shutil source = Path(__file__).resolve().parents[1] fixture = Path(self.tmp.name) / 'source' fixture.mkdir() for directory in ('bin', 'hydra', 'image'): shutil.copytree(source / directory, fixture / directory, ignore=shutil.ignore_patterns('__pycache__')) for asset in ('bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py', 'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'): with self.subTest(asset=asset): self.root = Path(self.tmp.name) / ('root-' + asset.replace('/', '-')) self.root.mkdir() self.kernel('6.18.54-gentoo-dist-bin') original = self.file('etc/machine-id', 'preserved-builder-id') missing = fixture / asset saved = missing.read_bytes() missing.unlink() try: with self.assertRaisesRegex(image.ImageError, 'missing'): image.configure_rootfs(self.root, fixture) self.assertEqual(original.read_text(), 'preserved-builder-id') self.assertFalse((self.root / 'usr').exists()) finally: missing.write_bytes(saved) def test_kernel_from_payload_not_running_host(self): self.kernel() self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist') def test_real_gentoo_kernel_bin_layout(self): version = '6.18.54-gentoo-dist-bin' (self.root / 'lib/modules' / version).mkdir(parents=True) kernel = self.file('boot/kernel-' + version, 'real Gentoo kernel') self.assertEqual(image.kernel_version(self.root), version) self.assertEqual(image.kernel_image(self.root), kernel) def test_configured_real_kernel_layout_is_accepted_by_installer(self): from hydra.installer import select_kernel version = '6.18.54-gentoo-dist-bin' (self.root / 'lib/modules' / version).mkdir(parents=True) kernel = self.file('boot/kernel-' + version, 'real Gentoo kernel') image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) self.assertEqual(select_kernel(self.root, None), version) alias = self.root / ('boot/vmlinuz-' + version) self.assertTrue(alias.samefile(kernel)) self.assertEqual(image.kernel_image(self.root), alias) def test_exported_repository_sync_works_with_extracted_tree(self): import configparser self.kernel() self.file('etc/portage/repos.conf/gentoo.conf', '[gentoo]\nlocation=/var/db/repos/gentoo\nsync-type=git\nauto-sync=no\n') self.file('var/db/repos/gentoo/profiles/repo_name', 'gentoo') image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) config = configparser.ConfigParser() config.read(self.root / 'etc/portage/repos.conf/gentoo.conf') repo = config['gentoo'] self.assertEqual(repo['sync-type'], 'rsync') self.assertEqual(repo['sync-uri'], 'rsync://rsync.gentoo.org/gentoo-portage') self.assertEqual(repo['auto-sync'], 'yes') self.assertEqual(repo['sync-rsync-verify-metamanifest'], 'yes') self.assertEqual(repo['sync-openpgp-key-path'], '/usr/share/openpgp-keys/gentoo-release.asc') self.assertFalse((self.root / 'var/db/repos/gentoo/.git').exists()) source = Path(__file__).resolve().parents[1] builder_config = (source / 'image/portage/repos.conf/gentoo.conf').read_text() self.assertIn('auto-sync = no', builder_config) def test_conflicting_kernel_images_refused(self): version = self.kernel() self.file('boot/kernel-' + version, 'different kernel') with self.assertRaises(image.ImageError): image.kernel_version(self.root) def test_ambiguous_or_missing_kernel_refused(self): with self.assertRaises(image.ImageError): image.kernel_version(self.root) self.kernel('6.1-one') self.kernel('6.1-two') with self.assertRaises(image.ImageError): image.kernel_version(self.root) def test_module_directory_without_kernel_refused(self): self.kernel() (self.root / 'boot/vmlinuz-6.12.58-gentoo-dist').unlink() with self.assertRaises(image.ImageError): image.kernel_version(self.root) def test_mount_faults_clean_only_successful_bindings(self): source = Path(__file__).resolve().parents[1] tools = Path(self.tmp.name) / 'tools' tools.mkdir() for name in ('python3', 'chroot', 'ln', 'systemctl'): script = tools / name script.write_text('#!/bin/sh\nexit 0\n') script.chmod(0o755) mount = tools / 'mount' mount.write_text('#!/bin/sh\nn=$(cat "$STATE/count" 2>/dev/null || echo 0)\n' 'n=$((n+1)); echo "$n" > "$STATE/count"\n' 'test "$n" != "$FAIL_MOUNT"\n') mount.chmod(0o755) unmount = tools / 'umount' unmount.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$STATE/unmounts"\n' 'test "$FAIL_UNMOUNT" != 1\n') unmount.chmod(0o755) for failed, cleanup_failed in ((1, 0), (2, 0), (3, 0), (0, 0), (0, 1)): with self.subTest(failed=failed, cleanup_failed=cleanup_failed): state = Path(self.tmp.name) / f'state-{failed}-{cleanup_failed}' state.mkdir() env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'], STATE=str(state), FAIL_MOUNT=str(failed), FAIL_UNMOUNT=str(cleanup_failed)) result = subprocess.run(['bash', str(source / 'image/configure-rootfs.sh'), '/hydra-output/rootfs'], env=env, capture_output=True, text=True) expected = ['/hydra-output/rootfs/dev', '/hydra-output/rootfs/proc', '/hydra-output/rootfs/sys'] expected = list(reversed(expected[:failed - 1 if failed else 3])) actual = (state / 'unmounts').read_text().splitlines() if (state / 'unmounts').exists() else [] self.assertEqual(actual, expected) self.assertEqual(result.returncode == 0, not failed and not cleanup_failed) def test_retry_guard_uses_valid_real_findmnt_options(self): from hydra.build import rootfs_retry_guard_script self.file('.hydra-export-owned') script = 'set -eu\nroot=' + shlex.quote(str(self.root)) + '\n' + rootfs_retry_guard_script() result = subprocess.run(['bash', '-c', script], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) self.assertFalse(self.root.exists()) def test_retry_refuses_removing_owned_rootfs_with_stale_child_mount(self): from hydra.build import rootfs_retry_guard_script marker = self.file('.hydra-export-owned') preserved = self.file('dev/guest-data') tools = Path(self.tmp.name) / 'guard-tools' tools.mkdir() findmnt = tools / 'findmnt' findmnt.write_text('#!/bin/sh\nprintf "%s\\n" / "$STALE_MOUNT"\n') findmnt.chmod(0o755) env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'], STALE_MOUNT=str(self.root / 'dev')) script = 'set -eu\nroot=' + shlex.quote(str(self.root)) + '\n' + rootfs_retry_guard_script() result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertTrue(marker.is_file()) self.assertTrue(preserved.is_file()) self.assertIn('mounted', result.stderr) env['STALE_MOUNT'] = str(Path(self.tmp.name) / 'different-rootfs') result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) self.assertFalse(self.root.exists()) def test_export_sanitization_preserves_update_inputs(self): preserved = ['var/db/pkg/sys-apps/portage-3/USE', 'var/lib/portage/world', 'var/db/repos/gentoo/profiles/repo_name', 'usr/share/openpgp-keys/gentoo-release.asc', 'etc/portage/patches/app-emulation/qemu/patch.patch', 'etc/portage/make.conf'] removed = ['etc/portage/gnupg/private-keys-v1.d/key', 'etc/portage/gnupg.hydra-untrusted/key', 'var/lib/hydra-build/packages.log', 'root/.ssh/id_ed25519', 'root/.bash_history', 'var/cache/binpkgs/package.tbz2', 'var/tmp/portage/build/log', 'run/container-state', 'var/log/emerge.log', 'var/lib/systemd/random-seed', 'hydra-source/private', 'hydra-cache/private'] for name in preserved + removed: self.file(name) self.file('etc/machine-id', 'builder-id') self.file('etc/resolv.conf', 'nameserver 127.0.0.1') image.sanitize_rootfs(self.root) for name in preserved: self.assertEqual((self.root / name).read_text(), 'preserved', name) for name in removed: self.assertFalse((self.root / name).exists(), name) self.assertEqual((self.root / 'etc/machine-id').read_text(), '') self.assertEqual((self.root / 'etc/hostname').read_text(), 'hydra-os\n') self.assertTrue((self.root / 'etc/resolv.conf').is_symlink()) def test_export_filters_saved_environment_proxies_but_retains_package_metadata(self): self.assertTrue(callable(getattr(image, 'sanitize_saved_environments', None)), 'saved environment scrubber is missing') # Portable stand-in for the external Portage filter API, not the scrubber. helper = self.file('usr/lib/portage/python3.14/filter-bash-environment.py', "import re\ndef filter_bash_environment(pattern, source, output):\n" " for line in source:\n" " match = re.match(rb'declare -x ([A-Za-z_][A-Za-z0-9_]*)=', line)\n" " if not match or not pattern.fullmatch(match.group(1)):\n" " output.write(line)\n") path = self.root / 'var/db/pkg/app-emulation/qemu-9.2.4/environment.bz2' path.parent.mkdir(parents=True) package_metadata = b'declare -x LICENSE="GPL-2"\ndeclare -x EAPI="8"\n' path.write_bytes(bz2.compress(package_metadata + b'declare -x HTTPS_PROXY="https://user:secret@builder.invalid"\n' b'declare -x HTTP_PROXY="http://127.0.0.1:1234"\n' b'declare -x NO_PROXY="localhost"\n')) result = image.sanitize_saved_environments(self.root) self.assertEqual(bz2.decompress(path.read_bytes()), package_metadata) self.assertEqual(result['filtered_files'], 1) self.assertEqual(result['variable_names'], ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY']) def test_saved_environment_filter_refuses_missing_portage_helper(self): self.assertTrue(callable(getattr(image, 'sanitize_saved_environments', None)), 'saved environment scrubber is missing') path = self.root / 'var/db/pkg/app-emulation/qemu-9.2.4/environment.bz2' path.parent.mkdir(parents=True) original = bz2.compress(b'declare -x HTTPS_PROXY="private"\n') path.write_bytes(original) with self.assertRaises(image.ImageError): image.sanitize_saved_environments(self.root) self.assertEqual(path.read_bytes(), original) def test_sanitization_cannot_follow_exported_symlink_to_host(self): outside = Path(self.tmp.name) / 'host' outside.mkdir() secret = outside / 'machine-id' secret.write_text('host-id') (self.root / 'etc').symlink_to(outside) with self.assertRaises(image.ImageError): image.sanitize_rootfs(self.root) self.assertEqual(secret.read_text(), 'host-id') def test_installed_configuration_restores_package_sandbox_defaults(self): self.kernel() self.file('etc/portage/make.conf', 'FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox"\n') image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) features = (self.root / 'etc/portage/make.conf').read_text() for option in ('-usersandbox', '-sandbox', '-network-sandbox', '-ipc-sandbox', '-pid-sandbox'): self.assertNotIn(option, features) self.assertIn('binpkg-request-signature', features) self.assertEqual((self.root / 'usr/share/hydra/build-manifest.json').is_file(), True) def test_installed_helper_uses_relocated_payload_and_opens_empty_recovery(self): self.kernel() image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) terminal = Path(self.tmp.name) / 'terminal' terminal.write_text('#!/bin/sh\nshift\nexec "$@"\n') terminal.chmod(0o755) manifest = Path(self.tmp.name) / 'session.json' manifest.write_text(json.dumps({'config': str(Path(self.tmp.name) / 'config'), 'state': str(Path(self.tmp.name) / 'state'), 'runtime': str(Path(self.tmp.name) / 'runtime'), 'terminal': str(terminal), 'names': []})) script = r"""import json, pathlib, subprocess, sys sys.path.insert(0, sys.argv[1]) from hydra.session import _module_argv helper = _module_argv('clients', pathlib.Path(sys.argv[2])) help_result = subprocess.run(helper[:2] + ['--help'], text=True, capture_output=True) print(help_result.stdout) print(help_result.stderr, file=sys.stderr) if help_result.returncode: sys.exit(help_result.returncode) result = subprocess.run(helper, input=':quit\n', text=True, capture_output=True, timeout=10) print(result.stdout) print(result.stderr, file=sys.stderr) sys.exit(result.returncode) """ result = subprocess.run([sys.executable, '-I', '-c', script, str(self.root / 'usr/lib/hydra'), str(manifest)], cwd=self.tmp.name, text=True, capture_output=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn('Hydra OS', result.stdout) self.assertIn('run', result.stdout) def test_installed_updates_exclude_unadapted_qemu_and_viewer_binaries(self): self.kernel() source = Path(__file__).resolve().parents[1] self.file('etc/portage/make.conf', (source / 'image/portage/make.conf').read_text()) image.configure_rootfs(self.root, source) settings = subprocess.run(['bash', '-euc', 'source "$1"; printf "%s" "$EMERGE_DEFAULT_OPTS"', '--', str(self.root / 'etc/portage/make.conf')], text=True, capture_output=True, check=True) defaults = shlex.split(settings.stdout) exclusions = [item.split('=', 1)[1].split() for item in defaults if item.startswith('--usepkg-exclude=')] self.assertEqual(exclusions, [['app-emulation/qemu', 'app-emulation/virt-viewer']]) self.assertIn('--getbinpkg', defaults) def test_export_enables_pulse_socket_without_starting_user_services(self): source = Path(__file__).resolve().parents[1] self.assertTrue(callable(getattr(image, 'enable_user_audio', None)), 'user audio enable is missing') units = self.root / 'usr/lib/systemd/user' units.mkdir(parents=True) for name, section in [('pipewire.socket', 'Socket'), ('pipewire-pulse.socket', 'Socket'), ('wireplumber.service', 'Service')]: target = 'sockets.target' if section == 'Socket' else 'default.target' (units / name).write_text(f'[{section}]\n[Install]\nWantedBy={target}\n') image.enable_user_audio(self.root) for target, name in [('sockets.target', 'pipewire.socket'), ('sockets.target', 'pipewire-pulse.socket'), ('default.target', 'wireplumber.service')]: link = self.root / 'etc/systemd/user' / (target + '.wants') / name self.assertTrue(link.is_symlink(), name) self.assertEqual(os.readlink(link), '/usr/lib/systemd/user/' + name) def test_rootfs_configuration_separates_live_overrides_and_installed_auth(self): source = Path(__file__).resolve().parents[1] image.copy_overlay(self.root, source / 'image/rootfs') installed = tomllib.loads((self.root / 'etc/greetd/config.toml').read_text()) live = tomllib.loads((self.root / 'etc/greetd/live.toml').read_text()) self.assertNotIn('initial_session', installed) self.assertEqual(live['initial_session']['user'], 'hydra-live') manifest = json.loads((self.root / 'etc/hydra/live-mode/remove-on-install.json').read_text()) for name in manifest: self.assertTrue((self.root / name.lstrip('/')).is_file(), name) self.assertIn('/etc/sudoers.d/90-hydra-live', manifest) self.assertIn('/etc/greetd/live.toml', manifest) self.assertNotIn('root=live:', (self.root / 'etc/dracut.conf.d/50-hydra.conf').read_text()) defaults = tomllib.loads((self.root / 'etc/hydra/defaults.toml').read_text()) self.assertEqual(defaults, dict(network='bridge', bridge='hydrabr0', width=1920, height=1080, refresh_millihz=144000)) def test_overlay_cannot_write_through_host_symlink(self): outside = Path(self.tmp.name) / 'host' outside.mkdir() (self.root / 'etc').symlink_to(outside) with self.assertRaises(image.ImageError): image.copy_overlay(self.root, Path(__file__).resolve().parents[1] / 'image/rootfs') self.assertEqual(list(outside.iterdir()), []) def test_live_kernel_args_match_label_and_never_copy_payload_to_ram(self): args = image.live_kernel_args() self.assertIn('root=live:CDLABEL=HYDRA_OS', args) self.assertIn('rd.live.overlay.overlayfs=1', args) self.assertNotIn('rd.live.ram=1', args) cfg = (Path(__file__).resolve().parents[1] / 'image/grub.cfg').read_text() linux = next(line.strip() for line in cfg.splitlines() if line.strip().startswith('linux ')) self.assertEqual(linux.split()[2:], args) def test_manifest_records_actual_kernel_packages_and_source_content(self): self.kernel() self.file('var/db/pkg/gui-wm/labwc-0.20.0/USE', 'X png') self.file('var/db/pkg/gui-wm/labwc-0.20.0/BUILD_TIME', '123') source = Path(__file__).resolve().parents[1] result = image.payload_manifest(self.root, source) self.assertEqual(result['kernel'], '6.12.58-gentoo-dist') self.assertEqual(result['packages'], [{'package': 'gui-wm/labwc-0.20.0', 'use': ['X', 'png'], 'build_time': '123'}]) self.assertIn('hydra/session.py', result['source_files']) self.assertIn('image/rootfs/etc/hydra/defaults.toml', result['source_files']) def test_artifact_checkpoint_checks_bytes_not_flags(self): path = self.file('artifacts/squashfs.img', 'actual-payload') state = {'artifacts': {'squashfs.img': image.digest(path)}} self.assertTrue(image.artifacts_valid(path.parent, state)) path.write_text('changed-payload') self.assertFalse(image.artifacts_valid(path.parent, state)) path.unlink() self.assertFalse(image.artifacts_valid(path.parent, state)) self.assertFalse(image.artifacts_valid(path.parent, {'artifacts': {}})) self.assertFalse(image.artifacts_valid(path.parent, {'artifacts': {'../escape': '0' * 64}})) class ImageBuildTests(unittest.TestCase): def setUp(self): from hydra.build import Builder self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.project = Path(self.tmp.name) self.builder = Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman') def inventory(self, records=None): records = records if records is not None else [{'package': 'app-emulation/qemu-9.2.4', 'use': ['spice'], 'build_time': '123'}] self.builder.work.mkdir(exist_ok=True) artifacts = self.builder.work / 'artifacts' artifacts.mkdir(exist_ok=True) path = artifacts / 'packages.json' path.write_text(json.dumps(records)) original = self.builder.complete self.builder.complete = lambda step: True if step == 'prepare' else original(step) self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path)) return path def test_changed_inventory_invalidates_package_checkpoint(self): path = self.inventory() self.assertTrue(self.builder.complete('packages')) path.write_text('[{"package":"app-emulation/qemu-8.0", "use": [], "build_time": null}]') self.assertFalse(self.builder.complete('packages')) def test_iso_refuses_hfs_hybrid_that_shadows_live_volume_on_usb(self): from hydra.build import BuildError self.inventory() self.builder.complete = lambda step: step == 'rootfs' self.builder.assembly = lambda: 'isolated-assembler' artifacts = self.builder.work / 'artifacts' def pod(*args, **kwargs): self.assertEqual(args[:2], ('exec', 'isolated-assembler')) (artifacts / 'hydra-os.iso').write_bytes(b'ISO fixture') (artifacts / 'iso-inspection.txt').write_text( 'Volume id : HYDRA_OS\n' 'El Torito boot img : 1 UEFI y none\n' 'System area summary: MBR protective-msdos-label GPT APM\n' 'GPT partname local : 3 HFSPLUS\n') self.builder.pod = pod with self.assertRaisesRegex(BuildError, 'HFS|APM'): self.builder.iso() self.assertFalse(self.builder.manifest()['iso']['complete']) def test_malformed_inventory_with_matching_digest_is_refused(self): for data in ['{', '{}', '[]', '[{"package":"wrong"}]', '[{"package":"app-emulation/qemu-9.2.4", "use":"spice", "build_time":"123"}]', '[{"package":"app-emulation/qemu-9.2.4", "use":[], "build_time":42}]']: with self.subTest(data=data): path = self.inventory() path.write_text(data) self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path)) self.assertFalse(self.builder.complete('packages')) def test_changed_inventory_invalidates_completed_rootfs_and_iso(self): path = self.inventory() artifacts = path.parent self.builder.active_package_job = lambda: False self.builder.assembly = lambda snapshot=None: 'isolated-assembler' self.builder.run = lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, 'commit\n', '') def pod(*args, **kwargs): if args[0] == 'export': snapshot = artifacts / 'builder-rootfs.tar' self.assertTrue(snapshot.is_file(), 'raw snapshot must be created privately before export') self.assertEqual(snapshot.stat().st_mode & 0o777, 0o600) snapshot.write_bytes(b'exported fixture') elif args[0] == 'exec' and 'configure-rootfs.sh' in args[-1]: for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'): (artifacts / name).write_bytes(b'artifact') (artifacts / 'rootfs-manifest.json').write_text(json.dumps({'kernel': '6.12.58', 'source_files': {}, 'packages': [{'package': 'app-emulation/qemu-9.2.4', 'use': ['spice'], 'build_time': '123'}]})) elif args[0] == 'exec' and 'grub-mkrescue' in args[-1]: (artifacts / 'hydra-os.iso').write_bytes(b'ISO fixture') (artifacts / 'iso-inspection.txt').write_text('HYDRA_OS\nEl Torito boot img : 1 UEFI\n') else: self.fail('Unexpected Podman operation: ' + repr(args)) self.builder.pod = pod self.builder.rootfs() self.builder.iso() self.assertTrue(self.builder.complete('rootfs')) self.assertTrue(self.builder.complete('iso')) path.write_text('[{"package":"app-emulation/qemu-9.2.5", "use":[], "build_time":null}]') self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path)) self.assertTrue(self.builder.complete('packages')) self.assertFalse(self.builder.complete('rootfs')) self.assertFalse(self.builder.complete('iso')) def test_export_inventory_must_match_validated_package_checkpoint(self): from hydra.build import BuildError path = self.inventory() self.builder.complete = lambda step: step == 'packages' self.builder.active_package_job = lambda: False self.builder.assembly = lambda snapshot: 'isolated-assembler' self.builder.run = lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, 'commit\n', '') def pod(*args, **kwargs): artifacts = self.builder.work / 'artifacts' if args[0] == 'export': (artifacts / 'builder-rootfs.tar').write_bytes(b'exported fixture') elif args[0] == 'exec': for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'): (artifacts / name).write_bytes(b'artifact') # Actual exported package differs from the checkpoint inventory. (artifacts / 'rootfs-manifest.json').write_text(json.dumps({'kernel': '6.12.58', 'source_files': {}, 'packages': [{'package': 'app-emulation/qemu-9.2.5', 'use': ['spice'], 'build_time': '456'}]})) else: self.fail('Unexpected Podman operation: ' + repr(args)) self.builder.pod = pod with self.assertRaisesRegex(BuildError, 'inventory'): self.builder.rootfs() self.assertFalse(self.builder.manifest()['rootfs']['complete']) def test_corrupt_local_package_archive_invalidates_fetch_checkpoint(self): import hashlib source = self.project / 'image' source.mkdir() (source / 'sources.json').write_text(json.dumps({ 'stage3': {'url': 'https://example.test/stage.tar', 'sha256': hashlib.sha256(b'stage').hexdigest()}, 'portage': {'url': 'https://example.test/repo.tar', 'sha256': hashlib.sha256(b'repo').hexdigest()}, 'local_packages': [{'url': 'https://example.test/brightnessctl.tar.gz', 'filename': 'brightnessctl.tar.gz', 'sha256': hashlib.sha256(b'original-source').hexdigest()}]})) with self.builder.locked(): (self.builder.cache / 'stage.tar').write_bytes(b'stage') (self.builder.cache / 'repo.tar').write_bytes(b'repo') archive = self.builder.cache / 'brightnessctl.tar.gz' archive.write_bytes(b'original-source') self.builder.record('fetch') self.assertTrue(self.builder.complete('fetch')) archive.write_bytes(b'corrupted-source') self.assertFalse(self.builder.complete('fetch')) def test_changed_canonical_patch_invalidates_package_input_identity(self): patches = self.project / 'patches' patches.mkdir() patch = patches / 'qemu-virtio-refresh-rate.patch' patch.write_text('old patch') before = self.builder.input_id() patch.write_text('new patch') self.assertNotEqual(self.builder.input_id(), before) def test_running_package_job_blocks_export(self): from hydra.build import BuildError self.builder.active_package_job = lambda: True self.builder.pod = lambda *args, **kw: self.fail('Running builder was modified or exported') self.assertTrue(callable(getattr(self.builder, 'rootfs', None)), 'rootfs pipeline is missing') with self.assertRaisesRegex(BuildError, 'running'): self.builder.rootfs() def test_unverified_packages_block_export(self): from hydra.build import BuildError self.builder.active_package_job = lambda: False self.builder.pod = lambda *args, **kw: self.fail('Unverified builder was exported') self.assertTrue(callable(getattr(self.builder, 'rootfs', None)), 'rootfs pipeline is missing') with self.assertRaises(BuildError): self.builder.rootfs() def test_iso_requires_checked_rootfs_artifacts(self): from hydra.build import BuildError self.builder.pod = lambda *args, **kw: self.fail('ISO build started without checked rootfs') self.assertTrue(callable(getattr(self.builder, 'iso', None)), 'iso pipeline is missing') with self.assertRaises(BuildError): self.builder.iso() class NetworkTests(unittest.TestCase): def setUp(self): self.assertIsNotNone(image, 'network preparation is missing') def test_default_route_does_not_conflict(self): image.check_network_conflicts([{'dst': 'default', 'gateway': '192.168.1.1', 'dev': 'eth0'}, {'dst': '192.168.1.0/24', 'dev': 'eth0'}], []) def test_overlapping_lan_vpn_address_or_route_fails_closed(self): for routes, addresses in [([{'dst': '10.203.0.0/16', 'dev': 'tun0'}], []), ([], [{'ifname': 'wlan0', 'addr_info': [{'family': 'inet', 'local': '10.203.0.77', 'prefixlen': 24}]}]), ([{'dst': '10.203.0.8/32', 'dev': 'eth0'}], [])]: with self.subTest(routes=routes, addresses=addresses), self.assertRaises(image.ImageError): image.check_network_conflicts(routes, addresses) def test_existing_owned_bridge_route_allowed(self): image.check_network_conflicts([{'dst': '10.203.0.0/24', 'dev': 'hydrabr0'}], [{'ifname': 'hydrabr0', 'addr_info': [{'family': 'inet', 'local': '10.203.0.1', 'prefixlen': 24}]}]) def test_malformed_data_not_silently_accepted(self): for routes, addresses in [('bad', []), ([{'dst': 'nonsense', 'dev': 'eth0'}], []), ([], [{'ifname': 'eth0', 'addr_info': [{'family': 'inet', 'local': 'bad', 'prefixlen': 24}]}])]: with self.subTest(routes=routes), self.assertRaises(image.ImageError): image.check_network_conflicts(routes, addresses) def test_network_conflict_stops_before_any_mutation(self): calls = [] def run(argv, **kwargs): calls.append(argv) if argv == ['ip', '-json', '-4', 'route', 'show', 'table', 'all']: return subprocess.CompletedProcess(argv, 0, json.dumps([{'dst': '10.203.0.0/24', 'dev': 'eth0'}]), '') if argv == ['ip', '-json', '-4', 'address', 'show']: return subprocess.CompletedProcess(argv, 0, '[]', '') self.fail('Unexpected operation before conflict validation: ' + repr(argv)) with self.assertRaises(image.ImageError): image.configure_network(run) self.assertEqual(len(calls), 2) def test_manual_bridge_has_nat_without_forward_filter(self): changes = [] def run(argv, **kwargs): responses = { ('ip', '-json', '-4', 'route', 'show', 'table', 'all'): '[]', ('ip', '-json', '-4', 'address', 'show'): '[]', ('ip', '-json', '-details', 'link', 'show'): '[]', } if tuple(argv) in responses: return subprocess.CompletedProcess(argv, 0, responses[tuple(argv)], '') if argv[0] == 'nmcli' and argv[1] == '--get-values': return subprocess.CompletedProcess(argv, 10, '', 'not found') if argv == ['nft', 'list', 'table', 'ip', 'hydra_nat']: return subprocess.CompletedProcess(argv, 1, '', 'not found') changes.append((argv, kwargs)) return subprocess.CompletedProcess(argv, 0, '', '') image.configure_network(run) addition = next(argv for argv, kwargs in changes if argv[:3] == ['nmcli', 'connection', 'add']) self.assertEqual(addition[addition.index('ipv4.method') + 1], 'manual') rules = next(kwargs['input'] for argv, kwargs in changes if argv == ['nft', '-f', '-']) self.assertIn('masquerade', rules) self.assertNotIn('hook forward', rules) self.assertNotIn('reject', rules) self.assertNotIn('flush ruleset', rules) def test_foreign_bridge_name_refused(self): calls = [] def run(argv, **kwargs): calls.append(argv) if argv[0] == 'ip': output = '[{"ifname": "hydrabr0", "linkinfo": {"info_kind": "bridge"}}]' if 'link' in argv else '[]' return subprocess.CompletedProcess(argv, 0, output, '') if argv[0] == 'nmcli' and argv[1] == '--get-values': return subprocess.CompletedProcess(argv, 10, '', 'not found') self.fail('Foreign interface triggered a network mutation: ' + repr(argv)) with self.assertRaises(image.ImageError): image.configure_network(run) def test_changed_owned_connection_refused(self): def run(argv, **kwargs): if argv[0] == 'ip': return subprocess.CompletedProcess(argv, 0, '[]', '') if argv[0] == 'nmcli' and argv[1] == '--get-values': return subprocess.CompletedProcess(argv, 0, image.CONNECTION_UUID + '\nbridge\nhydrabr0\nshared\n10.203.0.1/24\n', '') self.fail('Changed connection triggered a network mutation: ' + repr(argv)) with self.assertRaises(image.ImageError): image.configure_network(run) if __name__ == '__main__': unittest.main()