diff --git a/coriolis/tests/integration/deployments/test_luks_osmorphing.py b/coriolis/tests/integration/deployments/test_luks_osmorphing.py index c15696c3..061dedc3 100644 --- a/coriolis/tests/integration/deployments/test_luks_osmorphing.py +++ b/coriolis/tests/integration/deployments/test_luks_osmorphing.py @@ -23,16 +23,32 @@ from coriolis import constants from coriolis.tests.integration import base as integration_base from coriolis.tests.integration import harness as integration_harness -from coriolis.tests.integration import utils as test_utils +from coriolis.tests.integration import osmorphing_utils _LUKS_PASSPHRASE = "it-luks-encrypted" +_MIGRATION_PASSPHRASE = "it-luks-migratable" +_TPM_SLOT_SECRET = "fake-tpm-sealed-secret" class _LUKSOSMorphingMixin: + """Mixin for LUKS OS morphing integration tests. + + Simulates the pre-migration workflow during setUp: the source disk is + encrypted with an original key (which may be TPM-sealed on a real VM), + then a separate migration key is added via the volume master key without + needing the original passphrase. Only the migration passphrase is given + to Coriolis, matching what would happen in an actual migration. + + After OS morphing: + - The original keyslot is intact (Coriolis never touches it). + - The migration passphrase is written to /etc/luks/coriolis_.key, so + the firstboot script can remove the keyslot autonomously. + """ # Extra space for initramfs-tools and cryptsetup-initramfs packages that # the LUKS morphing tools install on top of the base OS image. _SCSI_DEBUG_SIZE_MB = 512 + _CONTAINER_IMAGE = "ubuntu:24.04" @classmethod def setUpClass(cls): @@ -49,16 +65,59 @@ def setUp(self): fh.write(_LUKS_PASSPHRASE) self.addCleanup(os.unlink, self._key_file) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".key", delete=False) as fh: + self._migration_key_file = fh.name + fh.write(_MIGRATION_PASSPHRASE) + self.addCleanup(os.unlink, self._migration_key_file) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".key", delete=False) as fh: + self._tpm_key_file = fh.name + fh.write(_TPM_SLOT_SECRET) + self.addCleanup(os.unlink, self._tpm_key_file) + super().setUp() self._prepare_src_device() def _prepare_src_device(self): - test_utils.make_luks_device( - self._src_device, self._key_file, "ubuntu:24.04") + osmorphing_utils.make_luks_device( + self._src_device, self._key_file, self._CONTAINER_IMAGE) + + # Simulate the real pre-migration step: the disk is already mounted + # (the VM is running), so the migration key can be added via the + # volume master key without needing the original passphrase (which + # may be TPM-sealed, Tang-bound, etc.). + with osmorphing_utils.luks_open( + self._src_device, self._key_file, + disable_keyring=True) as mapper: + osmorphing_utils.luks_add_key_from_mapper( + mapper, self._src_device, self._migration_key_file) + + # Add a keyslot representing the TPM-sealed key. LUKS fills slots in + # ascending order: original (0), migration (1), and this is slot 2. + osmorphing_utils.luks_add_key( + self._src_device, self._key_file, self._tpm_key_file) + + # Inject a fake systemd-tpm2 token pointing at the TPM keyslot. + osmorphing_utils.luks_add_tpm2_token(self._src_device, keyslot_id=2) + + # Overwrite crypttab with the real LUKS UUID but adding + # tpm2-device=auto, matching what systemd-cryptenroll sets up on a + # TPM-enrolled system. The real UUID is required so + # _update_crypttab_keyfile can match it. + luks_uuid = osmorphing_utils.get_luks_uuid(self._src_device) + osmorphing_utils.write_file_on_luks_device( + self._src_device, self._key_file, "etc/crypttab", + "luks-%s\tUUID=%s\tnone\tluks,tpm2-device=auto,tpm2-pcrs=7\n" + % (luks_uuid, luks_uuid), + ) + # Pass only the migration passphrase to Coriolis; the original key + # is never shared. dest_env = { "devices": [self._dst_device], - constants.ENCRYPTED_DISKS_PASS: _LUKS_PASSPHRASE, + constants.ENCRYPTED_DISKS_PASS: _MIGRATION_PASSPHRASE, } self._client.transfers.update( self._transfer.id, @@ -66,8 +125,8 @@ def _prepare_src_device(self): ) def _check_path_exists(self, device, path): - with test_utils.luks_open(device, self._key_file) as mapper_path: - return test_utils.path_exists_on_device(mapper_path, path) + with osmorphing_utils.luks_open(device, self._key_file) as mapper_path: + return osmorphing_utils.path_exists_on_device(mapper_path, path) def _assert_luks_common_firstboot_files(self): dst_basename = os.path.basename(self._dst_device) @@ -98,6 +157,44 @@ def test_deployment_with_os_morphing(self): ) self._assert_firstboot_setup() + # The migration keyslot must still be present after morphing; it is + # supposed to be removed on the first VM boot by the firstboot script. + self.assertTrue( + osmorphing_utils.luks_can_open( + self._dst_device, self._migration_key_file), + "Migration LUKS keyslot should still be present after OS morphing", + ) + + # Coriolis writes the migration passphrase to the keyfile on the OS, + # so the filesystem can be unlocked and mounted on the first boot, and + # the firstboot script can run. + dst_basename = os.path.basename(self._dst_device) + keyfile_content = osmorphing_utils.read_file_from_luks_device( + self._dst_device, self._key_file, + "etc/luks/coriolis_%s.key" % dst_basename) + self.assertEqual( + keyfile_content.strip(), _MIGRATION_PASSPHRASE, + "Migration keyfile content does not match migration passphrase", + ) + + self.assertFalse( + osmorphing_utils.luks_has_tpm2_token(self._dst_device), + "systemd-tpm2 token not removed from destination after morphing", + ) + self.assertFalse( + osmorphing_utils.luks_can_open( + self._dst_device, self._tpm_key_file), + "TPM2 keyslot was not killed on destination after OS morphing", + ) + crypttab = osmorphing_utils.read_file_from_luks_device( + self._dst_device, self._key_file, "etc/crypttab") + self.assertIsNotNone( + crypttab, "/etc/crypttab not found on destination") + self.assertNotIn( + "tpm2-", crypttab, + "TPM2 options remain in /etc/crypttab after OS morphing", + ) + class LUKSOSMorphingDeploymentTest( _LUKSOSMorphingMixin, integration_base.ReplicaIntegrationTestBase): @@ -116,18 +213,7 @@ class LUKSRockyLinuxOSMorphingDeploymentTest( _LUKSOSMorphingMixin, integration_base.ReplicaIntegrationTestBase): """LUKS + dracut OS morphing test using Rocky Linux 9.""" - def _prepare_src_device(self): - test_utils.make_luks_device( - self._src_device, self._key_file, "rockylinux:9") - - dest_env = { - "devices": [self._dst_device], - constants.ENCRYPTED_DISKS_PASS: _LUKS_PASSPHRASE, - } - self._client.transfers.update( - self._transfer.id, - {"destination_environment": dest_env}, - ) + _CONTAINER_IMAGE = "rockylinux:9" def _assert_firstboot_setup(self): self._assert_luks_common_firstboot_files() diff --git a/coriolis/tests/integration/deployments/test_osmorphing.py b/coriolis/tests/integration/deployments/test_osmorphing.py index eaa07a95..8180ca82 100644 --- a/coriolis/tests/integration/deployments/test_osmorphing.py +++ b/coriolis/tests/integration/deployments/test_osmorphing.py @@ -14,7 +14,7 @@ from coriolis.tests.integration import base as integration_base from coriolis.tests.integration import harness as integration_harness -from coriolis.tests.integration import utils as test_utils +from coriolis.tests.integration import osmorphing_utils class OsMorphingDeploymentTest(integration_base.ReplicaIntegrationTestBase): @@ -33,18 +33,21 @@ def setUpClass(cls): def setUp(self): super().setUp() - test_utils.write_os_image_to_disk(self._src_device, "ubuntu:24.04") + osmorphing_utils.write_os_image_to_disk( + self._src_device, "ubuntu:24.04") def test_deployment_with_os_morphing(self): self.assertFalse( - test_utils.path_exists_on_device(self._src_device, "usr/bin/jq"), + osmorphing_utils.path_exists_on_device( + self._src_device, "usr/bin/jq"), "jq was found on the source device before OS morphing", ) self._execute_transfer_and_deployment() self.assertTrue( - test_utils.path_exists_on_device(self._dst_device, "usr/bin/jq"), + osmorphing_utils.path_exists_on_device( + self._dst_device, "usr/bin/jq"), "jq was not found on the destination device after OS morphing", ) @@ -64,7 +67,7 @@ def test_os_morphing_global_script_basic_format(self): } self._execute_transfer_and_deployment(deployment_kwargs) - file_contents = test_utils.read_file_from_device( + file_contents = osmorphing_utils.read_file_from_device( self._dst_device, "cookie") self.assertEqual(expected_string, file_contents) @@ -82,7 +85,7 @@ def test_os_morphing_instance_script_basic_format(self): } self._execute_transfer_and_deployment(deployment_kwargs) - file_contents = test_utils.read_file_from_device( + file_contents = osmorphing_utils.read_file_from_device( self._dst_device, "cookie") self.assertEqual(expected_string, file_contents) @@ -124,10 +127,10 @@ def test_os_morphing_global_script_extended_format(self): } self._execute_transfer_and_deployment(deployment_kwargs) - pre_mounts = test_utils.read_file_from_device( + pre_mounts = osmorphing_utils.read_file_from_device( self._dst_device, "pre_mounts") - post_mounts = test_utils.read_file_from_device( + post_mounts = osmorphing_utils.read_file_from_device( self._dst_device, "post_mounts") @@ -167,7 +170,7 @@ def test_os_morphing_global_script_first_boot(self): # actually get executed. We'll merely verify that those files # have been injected at the expected location. first_boot_script_dir = "usr/lib/coriolis/firstboot/user" - first_boot_scripts = test_utils.list_files_from_device( + first_boot_scripts = osmorphing_utils.list_files_from_device( self._dst_device, first_boot_script_dir) if not first_boot_scripts: raise AssertionError("Couldn't find first boot script dir.") @@ -177,7 +180,7 @@ def test_os_morphing_global_script_first_boot(self): if re.match(r"\d+-\w+\.sh", file_name): first_boot_script_path = os.path.join( first_boot_script_dir, file_name) - first_boot_script = test_utils.read_file_from_device( + first_boot_script = osmorphing_utils.read_file_from_device( self._dst_device, first_boot_script_path) if payload == first_boot_script: diff --git a/coriolis/tests/integration/osmorphing_utils.py b/coriolis/tests/integration/osmorphing_utils.py new file mode 100644 index 00000000..58491028 --- /dev/null +++ b/coriolis/tests/integration/osmorphing_utils.py @@ -0,0 +1,280 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +""" +OS morphing integration test utilities. + +LUKS / OS morphing helpers (loopback, LUKS, bootable VM disk setup) +""" + +import contextlib +import json +import os +import subprocess +import tempfile +from typing import Iterator + +from oslo_log import log as logging + +from coriolis.tests.integration.utils import _run + +LOG = logging.getLogger(__name__) + + +# LUKS / OS Morphing utils + + +@contextlib.contextmanager +def mounted(device_path, read_only=False) -> Iterator[str]: + """Mount *device_path* in a temporary directory, yield the mount point.""" + opts = ["-o", "ro"] if read_only else [] + + with tempfile.TemporaryDirectory() as mount_point: + _run(["mount"] + opts + [device_path, mount_point]) + try: + yield mount_point + finally: + _run(["umount", mount_point]) + + +def write_os_image_to_disk(device_path, container_image): + """Write a real Linux rootfs to *device_path*. + + Exports the filesystem of a container image via ``docker export`` and + extracts it onto an ext4-formatted device, giving a chroot-able root with + that container OS' standard filesystem and binaries present. + """ + _run(["mkfs.ext4", "-F", device_path]) + + result = _run(["docker", "create", container_image]) + container_id = result.stdout.decode().strip() + + try: + with mounted(device_path) as mount_point: + export = subprocess.Popen( + ["docker", "export", container_id], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["tar", "-x", "-C", mount_point], + stdin=export.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + export.stdout.close() + export.wait() + + finally: + _run(["docker", "rm", "-f", container_id], check=False) + + +def _fixup_luks_inner_os(mapper_path, luks_uuid): + """Patch the OS image inside a LUKS mapper to work with OS morphing. + + Docker container images are not full OS installs, so a few things need + fixing before Coriolis can morph them: + + 1. /etc/crypttab is missing: the LUKS mixin needs a UUID= entry there to + configure initramfs auto-unlock. + 2. /boot may be absent (e.g. Rocky Linux 9 Docker image): the osmount + root-finder requires etc, bin, sbin, and boot to all be present. + """ + mapper_name = "luks-%s" % luks_uuid + crypttab_entry = "%s\tUUID=%s\tnone\tluks\n" % (mapper_name, luks_uuid) + + with mounted(mapper_path) as mount_point: + etc_dir = os.path.join(mount_point, "etc") + os.makedirs(etc_dir, exist_ok=True) + crypttab_path = os.path.join(etc_dir, "crypttab") + + with open(crypttab_path, "w") as fh: + fh.write(crypttab_entry) + + os.makedirs(os.path.join(mount_point, "boot"), exist_ok=True) + + +def make_luks_device(device_path, key_file, container_image): + """Format *device_path* with LUKS and write a minimal Linux OS inside. + + The mapper device is opened only for the duration of the call. It is closed + before returning, leaving the raw device encrypted. + + Exports the filesystem the container image onto the given device, then + writes a /etc/crypttab entry so that the LUKS mixin can find the UUID + when configuring initramfs auto-unlock during OS morphing. + """ + _run([ + "cryptsetup", "luksFormat", "--batch-mode", "--type", "luks2", + "--key-file", key_file, device_path, + ]) + + luks_uuid = get_luks_uuid(device_path) + + with luks_open(device_path, key_file) as mapper_path: + write_os_image_to_disk(mapper_path, container_image) + _fixup_luks_inner_os(mapper_path, luks_uuid) + + +def get_luks_uuid(device_path): + """Return the LUKS UUID of `device_path`.""" + result = _run(["cryptsetup", "luksUUID", device_path]) + return result.stdout.decode().strip() + + +@contextlib.contextmanager +def luks_open(device_path, key_file, disable_keyring=False): + mapper_name = "coriolis_luks_setup_%s" % os.path.basename(device_path) + cmd = ["cryptsetup", "luksOpen", "--key-file", key_file] + if disable_keyring: + cmd.append("--disable-keyring") + cmd += [device_path, mapper_name] + + _run(cmd) + + try: + yield "/dev/mapper/%s" % mapper_name + finally: + _run(["cryptsetup", "luksClose", mapper_name]) + + +def luks_can_open(device_path, key_file): + """Return True if `key_file` can unlock `device_path`, False otherwise.""" + result = _run([ + "cryptsetup", "luksOpen", "--test-passphrase", + "--key-file", key_file, device_path, + ], check=False) + + return result.returncode == 0 + + +def luks_add_tpm2_token(device_path, keyslot_id): + """Inject a systemd-tpm2 token into device_path pointing at keyslot_id. + + The token contains no real TPM material, it exists only so the LUKS2 + header reports a systemd-tpm2 token, letting tests verify that Coriolis + removes it during OS morphing. + """ + token = json.dumps( + {"type": "systemd-tpm2", "keyslots": [str(keyslot_id)]}) + # --disable-external-tokens bypasses the libcryptsetup-token-systemd-tpm2 + # plugin that validates real systemd-tpm2 token fields (tpm2-pcrs, blob, + # etc.). Our token is intentionally minimal, just enough for the test to + # verify Coriolis removes it during OS morphing. + subprocess.run( + [ + "cryptsetup", "token", "import", + "--disable-external-tokens", device_path, + ], + input=token.encode(), + stdout=subprocess.DEVNULL, + check=True, + ) + + +def luks_has_tpm2_token(device_path): + """Return True if `device_path` has any systemd-tpm2 LUKS2 tokens.""" + result = _run(["cryptsetup", "luksDump", device_path], check=False) + return result.returncode == 0 and b"systemd-tpm2" in result.stdout + + +def write_file_on_luks_device(device_path, key_file, rel_path, content): + """Write content into the given rel_path on the given LUKS device. + + Open `device_path` with `key_file`, mount it, and write `content` to + `rel_path` inside the filesystem, creating parent directories as needed. + """ + with luks_open(device_path, key_file) as mapper_path: + with mounted(mapper_path) as mount_point: + full_path = os.path.join(mount_point, rel_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as fh: + fh.write(content) + + +def read_file_from_luks_device(device_path, key_file, rel_path): + """Read the rel_path file from the given LUKS device. + + Open `device_path` with `key_file`, mount read-only, and return the + contents of `rel_path`, or None if the path does not exist. + """ + with luks_open(device_path, key_file) as mapper_path: + with mounted(mapper_path, read_only=True) as mount_point: + full_path = os.path.join(mount_point, rel_path) + if not os.path.exists(full_path): + return None + with open(full_path) as fh: + return fh.read() + + +def luks_add_key(device_path, existing_key_file, new_key_file): + """Add a new keyslot to `device_path` using `existing_key_file` to auth.""" + _run([ + "cryptsetup", "luksAddKey", + "--pbkdf-memory", "65536", + "--key-file", existing_key_file, + device_path, new_key_file, + ]) + + +def luks_add_key_from_mapper(mapper_path, device_path, new_key_file): + """Add a new keyslot to `device_path` to an open dm-crypt device. + + Does not require the original passphrase, the master key is extracted + directly from the live, already mounted device via dmsetup. + """ + mapper_name = os.path.basename(mapper_path) + result = _run(["dmsetup", "table", "--showkeys", mapper_name]) + fields = result.stdout.decode().split() + + # dmsetup may prefix the line with ": "; detect by trailing colon. + offset = 1 if fields[0].endswith(':') else 0 + + # dm-crypt table: crypt ... + master_key_hex = fields[offset + 4] + + with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as fh: + master_key_path = fh.name + fh.write(bytes.fromhex(master_key_hex)) + + try: + _run([ + "cryptsetup", "luksAddKey", + "--pbkdf-memory", "65536", + "--master-key-file", master_key_path, + device_path, new_key_file, + ]) + finally: + os.unlink(master_key_path) + + +def path_exists_on_device(device_path, rel_path): + """Checks if *rel_path* exists on the filesystem of *device_path*. + + Uses lexists so dangling symlinks (e.g. absolute targets only valid inside + the OS, not on the host) are still reported as present. + """ + with mounted(device_path, read_only=True) as mount_point: + return os.path.lexists(os.path.join(mount_point, rel_path)) + + +def read_file_from_device(device_path, rel_path): + """Retrieves the specified file from the filesystem of *device_path*. + + Mounts the device read-only into a temporary directory, reads the file, + then unmounts. + """ + with mounted(device_path, read_only=True) as mount_point: + with open(os.path.join(mount_point, rel_path)) as f: + return f.read() + + +def list_files_from_device(device_path, rel_path): + """Enumerates files from the filesystem of *device_path*. + + Mounts the device read-only into a temporary directory, enumerates files, + then unmounts. + """ + with mounted(device_path, read_only=True) as mount_point: + return os.listdir(os.path.join(mount_point, rel_path)) diff --git a/coriolis/tests/integration/utils.py b/coriolis/tests/integration/utils.py index abd8afc0..3de2c61f 100644 --- a/coriolis/tests/integration/utils.py +++ b/coriolis/tests/integration/utils.py @@ -5,7 +5,6 @@ Integration test utils. """ -import contextlib import json import os import socket @@ -352,157 +351,3 @@ def unplug_device_from_container(container_id, device_path): "nsenter", "--target", str(pid), "--mount", "--", "rm", "-f", device_path, ], check=False) - - -# OS Morphing utils - - -def write_os_image_to_disk(device_path, container_image): - """Write a real Linux rootfs to *device_path*. - - Exports the filesystem of a container image via ``docker export`` and - extracts it onto an ext4-formatted device, giving a chroot-able root with - that container OS' standard filesystem and binaries present. - """ - _run(["mkfs.ext4", "-F", device_path]) - - result = _run(["docker", "create", container_image]) - container_id = result.stdout.decode().strip() - - try: - with tempfile.TemporaryDirectory() as mount_point: - _run(["mount", device_path, mount_point]) - - try: - export = subprocess.Popen( - ["docker", "export", container_id], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - subprocess.run( - ["tar", "-x", "-C", mount_point], - stdin=export.stdout, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - export.stdout.close() - export.wait() - finally: - _run(["umount", mount_point]) - - finally: - _run(["docker", "rm", "-f", container_id], check=False) - - -def _fixup_luks_inner_os(mapper_path, luks_uuid): - """Patch the OS image inside a LUKS mapper to work with OS morphing. - - Docker container images are not full OS installs, so a few things need - fixing before Coriolis can morph them: - - 1. /etc/crypttab is missing: the LUKS mixin needs a UUID= entry there to - configure initramfs auto-unlock. - 2. /boot may be absent (e.g. Rocky Linux 9 Docker image): the osmount - root-finder requires etc, bin, sbin, and boot to all be present. - """ - mapper_name = "luks-%s" % luks_uuid - crypttab_entry = "%s\tUUID=%s\tnone\tluks\n" % (mapper_name, luks_uuid) - - with tempfile.TemporaryDirectory() as mount_point: - _run(["mount", mapper_path, mount_point]) - - try: - etc_dir = os.path.join(mount_point, "etc") - os.makedirs(etc_dir, exist_ok=True) - crypttab_path = os.path.join(etc_dir, "crypttab") - - with open(crypttab_path, "w") as fh: - fh.write(crypttab_entry) - - os.makedirs(os.path.join(mount_point, "boot"), exist_ok=True) - finally: - _run(["umount", mount_point]) - - -def make_luks_device(device_path, key_file, container_image): - """Format *device_path* with LUKS and write a minimal Linux OS inside. - - The mapper device is opened only for the duration of the call. It is closed - before returning, leaving the raw device encrypted. - - Exports the filesystem the container image onto the given device, then - writes a /etc/crypttab entry so that the LUKS mixin can find the UUID - when configuring initramfs auto-unlock during OS morphing. - """ - _run([ - "cryptsetup", "luksFormat", "--batch-mode", "--key-file", key_file, - device_path, - ]) - - luks_uuid = _run( - ["cryptsetup", "luksUUID", device_path]).stdout.decode().strip() - - with luks_open(device_path, key_file) as mapper_path: - write_os_image_to_disk(mapper_path, container_image) - _fixup_luks_inner_os(mapper_path, luks_uuid) - - -@contextlib.contextmanager -def luks_open(device_path, key_file): - mapper_name = "coriolis_luks_setup_%s" % os.path.basename(device_path) - _run([ - "cryptsetup", "luksOpen", "--key-file", key_file, device_path, - mapper_name, - ]) - - try: - yield "/dev/mapper/%s" % mapper_name - finally: - _run(["cryptsetup", "luksClose", mapper_name]) - - -def path_exists_on_device(device_path, rel_path): - """Checks if *rel_path* exists on the filesystem of *device_path*. - - Uses lexists so dangling symlinks (e.g. absolute targets only valid inside - the OS, not on the host) are still reported as present. - """ - with tempfile.TemporaryDirectory() as mount_point: - _run(["mount", "-o", "ro", device_path, mount_point]) - - try: - return os.path.lexists(os.path.join(mount_point, rel_path)) - finally: - _run(["umount", mount_point]) - - -def read_file_from_device(device_path, rel_path): - """Retrieves the specified file from the filesystem of *device_path*. - - Mounts the device read-only into a temporary directory, reads the file, - then unmounts. - """ - with tempfile.TemporaryDirectory() as mount_point: - _run(["mount", "-o", "ro", device_path, mount_point]) - - try: - with open(os.path.join(mount_point, rel_path)) as f: - return f.read() - finally: - _run(["umount", mount_point]) - - -def list_files_from_device(device_path, rel_path): - """Enumerates files from the filesystem of *device_path*. - - Mounts the device read-only into a temporary directory, enumerates files, - then unmounts. - """ - with tempfile.TemporaryDirectory() as mount_point: - _run(["mount", "-o", "ro", device_path, mount_point]) - - try: - return os.listdir(os.path.join(mount_point, rel_path)) - finally: - _run(["umount", mount_point])