From 43dc742e08538eb149968cc5279d8199af2181aa Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 05:34:39 +0300 Subject: [PATCH] fix(installer): identify live loop backing after dracut switch-root --- hydra/installer.py | 42 +++- tests/fixtures/installer-dracut-findmnt.json | 205 +++++++++++++++++++ tests/fixtures/installer-dracut-lsblk.json | 40 ++++ tests/test_installer.py | 91 +++++++- 4 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/installer-dracut-findmnt.json create mode 100644 tests/fixtures/installer-dracut-lsblk.json diff --git a/hydra/installer.py b/hydra/installer.py index d08a6ed..20d9168 100644 --- a/hydra/installer.py +++ b/hydra/installer.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse from dataclasses import dataclass import getpass +import fcntl import json import os from pathlib import Path, PurePosixPath @@ -17,6 +18,7 @@ import re import shlex import shutil import stat +import struct import subprocess import sys import tempfile @@ -241,17 +243,12 @@ class SystemProbe: # Mounted loops may hide the physical filesystem that stores their file. for path, node in topo.nodes.items(): if node['type'] == 'loop' and path in topo.protected: - backing_file = Path('/sys/dev/block') / node['maj:min'] / 'loop/backing_file' - try: - backing = backing_file.read_text().strip() - except OSError as exc: - raise InstallError(f'Cannot inspect mounted loop backing file: {path}') from exc - backing = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), backing) - if not backing.startswith('/'): - backing = '/' + backing - source = self._source_for_path(backing, topo) - if not source: - raise InstallError(f'Unknown physical backing for mounted loop: {path}') + identity = self._loop_backing_identity(path, node['maj:min']) + sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity] + if len(sources) != 1: + raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})') + source = sources[0] + topo.protect(source) topo.link(path, source) if not Path(payload).is_file(): raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}') @@ -262,6 +259,29 @@ class SystemProbe: topo.protect(source) return topo + @staticmethod + def _loop_backing_identity(path, expected_identity): + # LOOP_GET_STATUS64 reports the backing inode's filesystem device, + # independent of names which dracut switch_root may have relocated. + # lo_rdevice is nonzero when the backing inode is itself a block device. + try: + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + info = os.fstat(descriptor) + identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}' + if not stat.S_ISBLK(info.st_mode) or identity != expected_identity: + raise InstallError(f'Mounted loop device identity changed: {path}') + status = fcntl.ioctl(descriptor, 0x4C05, bytes(232)) + if not isinstance(status, bytes) or len(status) != 232: + raise InstallError(f'Malformed loop status: {path}') + filesystem_device, _, block_device = struct.unpack_from('=QQQ', status) + finally: + os.close(descriptor) + except (OSError, struct.error) as exc: + raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc + device = block_device or filesystem_device + return f'{os.major(device)}:{os.minor(device)}' + @staticmethod def _mounts(raw): try: diff --git a/tests/fixtures/installer-dracut-findmnt.json b/tests/fixtures/installer-dracut-findmnt.json new file mode 100644 index 0000000..3a895f2 --- /dev/null +++ b/tests/fixtures/installer-dracut-findmnt.json @@ -0,0 +1,205 @@ +{ + "filesystems": [ + { + "source": "LiveOS_rootfs", + "target": "/", + "fstype": "overlay", + "maj:min": "0:31", + "options": "rw,relatime,lowerdir=/run/rootfsbase,upperdir=/run/overlayfs,workdir=/run/ovlwork,uuid=on", + "children": [ + { + "source": "tmpfs", + "target": "/tmp", + "fstype": "tmpfs", + "maj:min": "0:40", + "options": "rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota" + }, + { + "source": "devtmpfs", + "target": "/dev", + "fstype": "devtmpfs", + "maj:min": "0:6", + "options": "rw,nosuid,size=1930828k,nr_inodes=482707,mode=755,inode64", + "children": [ + { + "source": "hugetlbfs", + "target": "/dev/hugepages", + "fstype": "hugetlbfs", + "maj:min": "0:36", + "options": "rw,nosuid,nodev,relatime,pagesize=2M" + }, + { + "source": "mqueue", + "target": "/dev/mqueue", + "fstype": "mqueue", + "maj:min": "0:21", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "tmpfs", + "target": "/dev/shm", + "fstype": "tmpfs", + "maj:min": "0:24", + "options": "rw,nosuid,nodev,inode64,usrquota" + }, + { + "source": "devpts", + "target": "/dev/pts", + "fstype": "devpts", + "maj:min": "0:25", + "options": "rw,nosuid,noexec,relatime,gid=5,mode=600,ptmxmode=000" + } + ] + }, + { + "source": "sysfs", + "target": "/sys", + "fstype": "sysfs", + "maj:min": "0:23", + "options": "rw,nosuid,nodev,noexec,relatime", + "children": [ + { + "source": "fusectl", + "target": "/sys/fs/fuse/connections", + "fstype": "fusectl", + "maj:min": "0:37", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "tracefs", + "target": "/sys/kernel/tracing", + "fstype": "tracefs", + "maj:min": "0:13", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "debugfs", + "target": "/sys/kernel/debug", + "fstype": "debugfs", + "maj:min": "0:8", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "securityfs", + "target": "/sys/kernel/security", + "fstype": "securityfs", + "maj:min": "0:7", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "cgroup2", + "target": "/sys/fs/cgroup", + "fstype": "cgroup2", + "maj:min": "0:27", + "options": "rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot,memory_hugetlb_accounting" + }, + { + "source": "none", + "target": "/sys/fs/pstore", + "fstype": "pstore", + "maj:min": "0:28", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "efivarfs", + "target": "/sys/firmware/efi/efivars", + "fstype": "efivarfs", + "maj:min": "0:29", + "options": "rw,nosuid,nodev,noexec,relatime" + }, + { + "source": "bpf", + "target": "/sys/fs/bpf", + "fstype": "bpf", + "maj:min": "0:30", + "options": "rw,nosuid,nodev,noexec,relatime,mode=700" + }, + { + "source": "configfs", + "target": "/sys/kernel/config", + "fstype": "configfs", + "maj:min": "0:19", + "options": "rw,nosuid,nodev,noexec,relatime" + } + ] + }, + { + "source": "proc", + "target": "/proc", + "fstype": "proc", + "maj:min": "0:22", + "options": "rw,nosuid,nodev,noexec,relatime", + "children": [ + { + "source": "systemd-1", + "target": "/proc/sys/fs/binfmt_misc", + "fstype": "autofs", + "maj:min": "0:33", + "options": "rw,relatime,fd=38,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=5489", + "children": [ + { + "source": "binfmt_misc", + "target": "/proc/sys/fs/binfmt_misc", + "fstype": "binfmt_misc", + "maj:min": "0:42", + "options": "rw,nosuid,nodev,noexec,relatime" + } + ] + } + ] + }, + { + "source": "tmpfs", + "target": "/run", + "fstype": "tmpfs", + "maj:min": "0:26", + "options": "rw,nosuid,nodev,size=797732k,nr_inodes=819200,mode=755,inode64", + "children": [ + { + "source": "none", + "target": "/run/credentials/systemd-journald.service", + "fstype": "tmpfs", + "maj:min": "0:35", + "options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap" + }, + { + "source": "tmpfs", + "target": "/run/user/1000", + "fstype": "tmpfs", + "maj:min": "0:46", + "options": "rw,nosuid,nodev,relatime,size=398864k,nr_inodes=99716,mode=700,uid=1000,gid=1000,inode64" + }, + { + "source": "/dev/sr0", + "target": "/run/initramfs/live", + "fstype": "iso9660", + "maj:min": "11:0", + "options": "ro,relatime,nojoliet,check=s,map=n,blocksize=2048,iocharset=utf8" + }, + { + "source": "/dev/loop0", + "target": "/run/rootfsbase", + "fstype": "squashfs", + "maj:min": "7:0", + "options": "ro,relatime,errors=continue" + }, + { + "source": "none", + "target": "/run/credentials/getty@tty2.service", + "fstype": "tmpfs", + "maj:min": "0:47", + "options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap" + }, + { + "source": "none", + "target": "/run/credentials/serial-getty@ttyS0.service", + "fstype": "tmpfs", + "maj:min": "0:48", + "options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap" + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/installer-dracut-lsblk.json b/tests/fixtures/installer-dracut-lsblk.json new file mode 100644 index 0000000..022cc92 --- /dev/null +++ b/tests/fixtures/installer-dracut-lsblk.json @@ -0,0 +1,40 @@ +{ + "blockdevices": [ + { + "path": "/dev/loop0", + "type": "loop", + "size": 2220142592, + "maj:min": "7:0", + "pkname": null, + "mountpoints": [ + "/run/rootfsbase" + ] + }, + { + "path": "/dev/sr0", + "type": "rom", + "size": 2345107456, + "maj:min": "11:0", + "pkname": null, + "mountpoints": [ + "/run/initramfs/live" + ] + }, + { + "path": "/dev/sr1", + "type": "rom", + "size": 1073741312, + "maj:min": "11:1", + "pkname": null, + "mountpoints": [] + }, + { + "path": "/dev/vda", + "type": "disk", + "size": 34359738368, + "maj:min": "253:0", + "pkname": null, + "mountpoints": [] + } + ] +} diff --git a/tests/test_installer.py b/tests/test_installer.py index 5085b07..373dea7 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -4,6 +4,9 @@ import io import json import os import pty +import stat +import struct +from types import SimpleNamespace from pathlib import Path import subprocess import tempfile @@ -403,6 +406,86 @@ class AdditionalFailureTests(unittest.TestCase): i.prompt_password() +class RelocatedLiveLoopTests(unittest.TestCase): + def probe(self, *, backing_device=None, backing_rdevice=0, ioctl_error=None, + opened_device=None, opened_mode=stat.S_IFBLK): + fixtures = Path(__file__).parent / 'fixtures' + data = json.loads((fixtures / 'installer-dracut-lsblk.json').read_text()) + mounts = json.loads((fixtures / 'installer-dracut-findmnt.json').read_text()) + media = next(m for m in i.SystemProbe._mounts(json.dumps(mounts)) if m['source'] == '/dev/sr0') + calls = [] + class ReadOnlyRunner: + def run(self, args): + calls.append(args) + if args[0] == 'lsblk': + return json.dumps(data) + if '--target' not in args: + return json.dumps(mounts) + if args[args.index('--target') + 1] != i.DEFAULT_PAYLOAD: + raise i.InstallError('Real dracut stale backing path has no current mount') + return json.dumps({'filesystems': [media]}) + probe = i.SystemProbe(ReadOnlyRunner()) + probe.physical_disk = lambda path: path == '/dev/vda' + packet = struct.pack('=QQQ', os.makedev(11, 0) if backing_device is None else backing_device, + 3462, backing_rdevice) + bytes(208) + with contextlib.ExitStack() as stack: + stack.enter_context(patch.object(Path, 'is_file', return_value=True)) + stack.enter_context(patch.object(Path, 'read_text', return_value='/LiveOS/squashfs.img')) + opened = stack.enter_context(patch('os.open', return_value=91)) + closed = stack.enter_context(patch('os.close')) + stack.enter_context(patch('os.fstat', return_value=SimpleNamespace(st_mode=opened_mode, + st_rdev=os.makedev(7, 0) if opened_device is None else opened_device))) + # Patch the syscall even before the installer imports fcntl. + syscall = stack.enter_context(patch('fcntl.ioctl', side_effect=ioctl_error, + return_value=packet)) + topo = probe.snapshot(i.DEFAULT_PAYLOAD) + opened.assert_called_once_with('/dev/loop0', os.O_RDONLY | os.O_CLOEXEC) + closed.assert_called_once_with(91) + return probe, topo, calls, syscall + + def test_actual_switched_root_topology_resolves_loop_by_kernel_identity(self): + probe, topo, calls, syscall = self.probe() + self.assertFalse(topo.component('/dev/vda') & topo.protected) + self.assertIn('/dev/loop0', topo.component('/dev/sr0')) + self.assertTrue(topo.component('/dev/sr0') & topo.protected) + self.assertEqual(syscall.call_args.args, (91, 0x4C05, bytes(232))) + self.assertFalse(any('/LiveOS/squashfs.img' in args for args in calls)) + probe.snapshot = lambda payload: topo + self.assertEqual(i.validate_request(i.Request('/dev/vda', 'hydratest'), probe), '/dev/vda') + probe.physical_disk = lambda path: path == '/dev/sr0' + with self.assertRaises(i.InstallError): + i.validate_request(i.Request('/dev/sr0', 'hydratest'), probe) + # A mounted loop backed by a normal disk still forbids that whole disk. + topo.nodes['/dev/sr0']['type'] = 'disk' + topo.nodes['/dev/sr0']['size'] = 32 * 1024 ** 3 + with self.assertRaisesRegex(i.InstallError, 'backs mounted/root/live storage'): + i.validate_request(i.Request('/dev/sr0', 'hydratest'), probe) + + def test_unknown_loop_filesystem_identity_fails_closed(self): + for identity in (os.makedev(8, 1), os.makedev(0, 99)): + with self.subTest(identity=identity), self.assertRaisesRegex(i.InstallError, 'Unknown physical backing'): + self.probe(backing_device=identity) + + def test_unreadable_or_changed_loop_device_fails_closed(self): + cases = [{'ioctl_error': OSError('cannot read loop status')}, + {'opened_device': os.makedev(7, 1)}, {'opened_mode': stat.S_IFREG}] + for case in cases: + with self.subTest(case=case), self.assertRaises(i.InstallError): + self.probe(**case) + + def test_truncated_loop_status_fails_closed_and_closes_descriptor(self): + with patch('os.open', return_value=91), patch('os.close') as closed, \ + patch('os.fstat', return_value=SimpleNamespace(st_mode=stat.S_IFBLK, st_rdev=os.makedev(7, 0))), \ + patch('fcntl.ioctl', return_value=bytes(16)): + with self.assertRaisesRegex(i.InstallError, 'Malformed loop status'): + i.SystemProbe._loop_backing_identity('/dev/loop0', '7:0') + closed.assert_called_once_with(91) + + def test_direct_block_backing_uses_kernel_rdevice(self): + _, topo, _, _ = self.probe(backing_device=os.makedev(0, 6), backing_rdevice=os.makedev(11, 0)) + self.assertIn('/dev/loop0', topo.component('/dev/sr0')) + + class SnapshotTests(unittest.TestCase): def test_snapshot_connects_live_loop_to_its_physical_backing(self): with tempfile.TemporaryDirectory() as tmp: @@ -416,12 +499,8 @@ class SnapshotTests(unittest.TestCase): if args[0] == 'lsblk': return json.dumps(data) return json.dumps({'filesystems': [media] if '--target' in args else [media, loop]}) - real_read = Path.read_text - def read(path, *args, **kw): - if str(path).startswith('/sys/dev/block/'): - return str(payload) - return real_read(path, *args, **kw) - with patch.object(Path, 'read_text', read): + with patch.object(i.SystemProbe, '_loop_backing_identity', + return_value=media['maj:min']): topo = i.SystemProbe(ReadOnlyRunner()).snapshot(str(payload)) self.assertFalse(topo.component('/dev/vda') & topo.protected) self.assertIn('/dev/loop0', topo.component('/dev/vdb'))