diff --git a/hydra/build.py b/hydra/build.py index 4440554..5cce97b 100644 --- a/hydra/build.py +++ b/hydra/build.py @@ -58,6 +58,21 @@ sys.exit(code) ''' +def rootfs_retry_guard_script(): + """Refuse deletion of a still-mounted export, including nested mountpoints.""" + return r"""mounts=$(findmnt --list --raw --noheadings --output TARGET) +while IFS= read -r mountpoint; do + case "$mountpoint" in + "$root"|"$root/"*) echo 'Refusing to remove a mounted rootfs export' >&2; exit 1 ;; + esac +done <<< "$mounts" +if [ -e "$root" ] || [ -L "$root" ]; then + [ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; } + rm -rf -- "$root" +fi +""" + + def verify_hash(path, expected): digest = hashlib.sha256() with Path(path).open('rb') as stream: @@ -421,12 +436,7 @@ fi''' 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 = r"""root=/hydra-output/rootfs -if [ -e "$root" ] || [ -L "$root" ]; then - [ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; } - rm -rf -- "$root" -fi -mkdir -p "$root" + script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root" touch "$root/.hydra-export-owned" tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root" /hydra-source/image/configure-rootfs.sh "$root" diff --git a/image/configure-rootfs.sh b/image/configure-rootfs.sh index 58f66aa..8b7f6ef 100755 --- a/image/configure-rootfs.sh +++ b/image/configure-rootfs.sh @@ -7,10 +7,27 @@ fi root=$1 python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source # This is a separate rootfs inside the assembly container, never the host root. +mounted=() +cleanup_mounts() { + status=$? + trap - EXIT + cleanup_failed=0 + for ((index=${#mounted[@]}-1; index>=0; index--)); do + umount "${mounted[index]}" || cleanup_failed=1 + done + if ((cleanup_failed)); then + echo 'configure-rootfs: temporary mount cleanup failed; refusing success' >&2 + status=1 + fi + exit "$status" +} +trap cleanup_mounts EXIT mount --bind /dev "$root/dev" +mounted+=("$root/dev") mount -t proc proc "$root/proc" +mounted+=("$root/proc") mount --bind /sys "$root/sys" -trap 'umount "$root/sys" "$root/proc" "$root/dev"' EXIT +mounted+=("$root/sys") for group in wheel kvm video render audio; do chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group" done diff --git a/tests/test_image.py b/tests/test_image.py index 7c6bed8..5880bdb 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -97,6 +97,58 @@ class ImageTests(unittest.TestCase): 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_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',