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 import argparse
from dataclasses import dataclass from dataclasses import dataclass
import getpass import getpass
import fcntl
import json import json
import os import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -17,6 +18,7 @@ import re
import shlex import shlex
import shutil import shutil
import stat import stat
import struct
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -241,17 +243,12 @@ class SystemProbe:
# Mounted loops may hide the physical filesystem that stores their file. # Mounted loops may hide the physical filesystem that stores their file.
for path, node in topo.nodes.items(): for path, node in topo.nodes.items():
if node['type'] == 'loop' and path in topo.protected: if node['type'] == 'loop' and path in topo.protected:
backing_file = Path('/sys/dev/block') / node['maj:min'] / 'loop/backing_file' identity = self._loop_backing_identity(path, node['maj:min'])
try: sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
backing = backing_file.read_text().strip() if len(sources) != 1:
except OSError as exc: raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
raise InstallError(f'Cannot inspect mounted loop backing file: {path}') from exc source = sources[0]
backing = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), backing) topo.protect(source)
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}')
topo.link(path, source) topo.link(path, source)
if not Path(payload).is_file(): if not Path(payload).is_file():
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}') raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
@@ -262,6 +259,29 @@ class SystemProbe:
topo.protect(source) topo.protect(source)
return topo 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 @staticmethod
def _mounts(raw): def _mounts(raw):
try: 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 json
import os import os
import pty import pty
import stat
import struct
from types import SimpleNamespace
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
@@ -403,6 +406,86 @@ class AdditionalFailureTests(unittest.TestCase):
i.prompt_password() 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): class SnapshotTests(unittest.TestCase):
def test_snapshot_connects_live_loop_to_its_physical_backing(self): def test_snapshot_connects_live_loop_to_its_physical_backing(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -416,12 +499,8 @@ class SnapshotTests(unittest.TestCase):
if args[0] == 'lsblk': if args[0] == 'lsblk':
return json.dumps(data) return json.dumps(data)
return json.dumps({'filesystems': [media] if '--target' in args else [media, loop]}) return json.dumps({'filesystems': [media] if '--target' in args else [media, loop]})
real_read = Path.read_text with patch.object(i.SystemProbe, '_loop_backing_identity',
def read(path, *args, **kw): return_value=media['maj:min']):
if str(path).startswith('/sys/dev/block/'):
return str(payload)
return real_read(path, *args, **kw)
with patch.object(Path, 'read_text', read):
topo = i.SystemProbe(ReadOnlyRunner()).snapshot(str(payload)) topo = i.SystemProbe(ReadOnlyRunner()).snapshot(str(payload))
self.assertFalse(topo.component('/dev/vda') & topo.protected) self.assertFalse(topo.component('/dev/vda') & topo.protected)
self.assertIn('/dev/loop0', topo.component('/dev/vdb')) self.assertIn('/dev/loop0', topo.component('/dev/vdb'))