fix(installer): identify live loop backing after dracut switch-root

This commit is contained in:
Codex
2026-10-13 05:34:39 +03:00
parent 2f48de0aa2
commit 43dc742e08
4 changed files with 361 additions and 17 deletions
+31 -11
View File
@@ -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:
+205
View File
@@ -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"
}
]
}
]
}
]
}
+40
View File
@@ -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": []
}
]
}
+85 -6
View File
@@ -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'))