445 lines
16 KiB
Python
445 lines
16 KiB
Python
import importlib.util
|
|
from importlib.machinery import SourceFileLoader
|
|
import json
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
|
|
SCRIPT = Path(__file__).parents[2] / "bin/.local/bin/labfy-removable-media"
|
|
SPEC = importlib.util.spec_from_loader(
|
|
"labfy_removable_media", SourceFileLoader("labfy_removable_media", str(SCRIPT))
|
|
)
|
|
assert SPEC and SPEC.loader
|
|
media = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = media
|
|
SPEC.loader.exec_module(media)
|
|
|
|
|
|
def record(path, drive="/org/freedesktop/UDisks2/drives/usb", mount=""):
|
|
return media.FilesystemRecord(
|
|
path=f"/org/freedesktop/UDisks2/block_devices/{path}",
|
|
drive_path=drive,
|
|
display_name=path,
|
|
label=path.upper(),
|
|
filesystem="vfat",
|
|
size_bytes=1024,
|
|
mount_point=mount,
|
|
removable=True,
|
|
ejectable=True,
|
|
power_off_capable=True,
|
|
)
|
|
|
|
|
|
class FakeBackend:
|
|
def __init__(self, records):
|
|
self.items = records
|
|
self.calls = []
|
|
|
|
def records(self):
|
|
return list(self.items)
|
|
|
|
def _change_mount(self, path, value):
|
|
self.items = [
|
|
replace(item, mount_point=value if item.path == path else item.mount_point)
|
|
for item in self.items
|
|
]
|
|
|
|
def mount(self, path):
|
|
self.calls.append(("mount", path))
|
|
self._change_mount(path, f"/run/media/test/{Path(path).name}")
|
|
return self.mount_points_sync(path)
|
|
|
|
def unmount(self, path):
|
|
self.calls.append(("unmount", path))
|
|
self._change_mount(path, "")
|
|
return []
|
|
|
|
def mount_points_sync(self, path):
|
|
for item in self.items:
|
|
if item.path == path:
|
|
return [item.mount_point.encode() + b"\0"] if item.mount_point else []
|
|
raise media.RequestError("Volume introuvable après la demande")
|
|
|
|
def safe_remove(self, drive):
|
|
self.calls.append(("safe-remove", drive.drive_path))
|
|
|
|
|
|
class FakeInterface:
|
|
def __init__(self, name, **properties):
|
|
self.name = name
|
|
self.properties = properties
|
|
|
|
def get_interface_name(self):
|
|
return self.name
|
|
|
|
def get_cached_property(self, name):
|
|
return self.properties.get(name)
|
|
|
|
|
|
class FakeObject:
|
|
def __init__(self, path, *interfaces):
|
|
self.path = path
|
|
self.interfaces = interfaces
|
|
|
|
def get_object_path(self):
|
|
return self.path
|
|
|
|
def get_interfaces(self):
|
|
return self.interfaces
|
|
|
|
|
|
class MediaDaemonTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.state_path = Path(self.temporary.name) / media.STATE_NAME
|
|
|
|
def tearDown(self):
|
|
self.temporary.cleanup()
|
|
|
|
def test_state_contract_and_private_atomic_file(self):
|
|
backend = FakeBackend([record("sdb1", mount="/run/media/test/A")])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
|
|
state = daemon.state()
|
|
self.assertEqual(state["schema"], "labfy.removable-media")
|
|
self.assertEqual(state["version"], 1)
|
|
self.assertEqual(set(state), {"schema", "version", "devices", "updated_at"})
|
|
self.assertEqual(
|
|
set(state["devices"][0]),
|
|
{
|
|
"runtime_id", "drive_runtime_id", "display_name", "label",
|
|
"filesystem", "size_bytes", "mount_point", "mounted",
|
|
"removable", "ejectable", "power_off_capable", "busy", "error",
|
|
},
|
|
)
|
|
self.assertTrue(state["devices"][0]["mounted"])
|
|
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o600)
|
|
self.assertEqual(json.loads(self.state_path.read_text()), state)
|
|
|
|
def test_mount_and_unmount_only_apply_to_selected_volume(self):
|
|
first, second = record("sdb1"), record("sdb2")
|
|
other = record("sdc1", "/org/freedesktop/UDisks2/drives/other")
|
|
backend = FakeBackend([first, second, other])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
|
|
daemon.handle({"operation": "mount", "runtime_id": first.path})
|
|
self.assertEqual(
|
|
backend.calls,
|
|
[("mount", first.path)],
|
|
)
|
|
daemon.handle({"operation": "unmount", "runtime_id": first.path})
|
|
self.assertEqual(
|
|
backend.calls[-1:],
|
|
[("unmount", first.path)],
|
|
)
|
|
|
|
def test_safe_remove_is_delegated_once_at_drive_scope(self):
|
|
first = record("sdb1", mount="/run/media/test/one")
|
|
second = record("sdb2", mount="/run/media/test/two")
|
|
backend = FakeBackend([first, second])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
|
|
daemon.handle({"operation": "safe-remove", "runtime_id": first.drive_path})
|
|
self.assertEqual(
|
|
backend.calls,
|
|
[("safe-remove", first.drive_path)],
|
|
)
|
|
|
|
def test_stale_runtime_id_is_rejected_after_revalidation(self):
|
|
item = record("sdb1")
|
|
backend = FakeBackend([item])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
backend.items = []
|
|
|
|
with self.assertRaisesRegex(media.RequestError, "introuvable"):
|
|
daemon.handle({"operation": "mount", "runtime_id": item.path})
|
|
self.assertEqual(backend.calls, [])
|
|
|
|
def test_open_mounts_then_launches_selected_mountpoint(self):
|
|
item = record("sdb1")
|
|
backend = FakeBackend([item])
|
|
launched = []
|
|
daemon = media.MediaDaemon(backend, self.state_path, launched.append)
|
|
|
|
daemon.handle({"operation": "open", "runtime_id": item.path})
|
|
self.assertEqual(launched, ["/run/media/test/sdb1"])
|
|
|
|
def test_error_is_bounded_and_published(self):
|
|
class FailingBackend(FakeBackend):
|
|
def mount(self, path):
|
|
raise RuntimeError("x" * 500)
|
|
|
|
item = record("sdb1")
|
|
daemon = media.MediaDaemon(FailingBackend([item]), self.state_path)
|
|
with self.assertRaises(media.RequestError):
|
|
daemon.handle({"operation": "mount", "runtime_id": item.path})
|
|
self.assertEqual(len(daemon.state()["devices"][0]["error"]), media.MAX_ERROR)
|
|
|
|
def test_device_busy_is_translated_and_preserves_mounted_state(self):
|
|
item = record("sda1", mount="/run/media/test/Ventoy")
|
|
|
|
class BusyUnmountBackend(FakeBackend):
|
|
def unmount(self, path):
|
|
self.calls.append(("unmount", path))
|
|
raise RuntimeError(
|
|
"g-io-error-quark: "
|
|
"GDBus.Error:org.freedesktop.UDisks2.Error.DeviceBusy: "
|
|
"Error unmounting /dev/sda1: target is busy (36)"
|
|
)
|
|
|
|
backend = BusyUnmountBackend([item])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
|
|
with self.assertRaisesRegex(
|
|
media.RequestError, "Le périphérique est utilisé par une application\\."
|
|
):
|
|
daemon.handle({"operation": "unmount", "runtime_id": item.path})
|
|
|
|
device = daemon.state()["devices"][0]
|
|
self.assertTrue(device["mounted"])
|
|
self.assertEqual(device["error"], "Le périphérique est utilisé par une application.")
|
|
|
|
def test_start_automounts_each_existing_unmounted_volume_once(self):
|
|
first, second = record("sdb1"), record("sdb2", mount="/media/already")
|
|
backend = FakeBackend([first, second])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
|
|
daemon.start()
|
|
daemon.devices_changed()
|
|
|
|
self.assertEqual(backend.calls, [("mount", first.path)])
|
|
self.assertTrue(daemon.state()["devices"][0]["mounted"])
|
|
|
|
def test_added_object_is_mounted_once_despite_duplicate_property_events(self):
|
|
backend = FakeBackend([])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
daemon.start()
|
|
item = record("sdb1")
|
|
backend.items = [item]
|
|
|
|
daemon.devices_changed()
|
|
daemon.devices_changed()
|
|
|
|
self.assertEqual(backend.calls, [("mount", item.path)])
|
|
|
|
def test_manual_unmount_stays_unmounted_on_later_event(self):
|
|
item = record("sdb1")
|
|
backend = FakeBackend([item])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
daemon.start()
|
|
backend.calls.clear()
|
|
|
|
daemon.handle({"operation": "unmount", "runtime_id": item.path})
|
|
daemon.devices_changed()
|
|
|
|
self.assertEqual(backend.calls, [("unmount", item.path)])
|
|
self.assertFalse(daemon.state()["devices"][0]["mounted"])
|
|
|
|
def test_automount_failure_is_isolated_and_not_retried_until_reinserted(self):
|
|
first, second = record("sdb1"), record("sdb2")
|
|
|
|
class OneFailureBackend(FakeBackend):
|
|
def mount(self, path):
|
|
self.calls.append(("mount", path))
|
|
if path == first.path:
|
|
raise RuntimeError("échec " + "x" * 500)
|
|
self._change_mount(path, "/media/second")
|
|
|
|
backend = OneFailureBackend([first, second])
|
|
daemon = media.MediaDaemon(backend, self.state_path)
|
|
daemon.start()
|
|
daemon.devices_changed()
|
|
|
|
state = {item["runtime_id"]: item for item in daemon.state()["devices"]}
|
|
self.assertEqual(backend.calls, [("mount", first.path), ("mount", second.path)])
|
|
self.assertEqual(len(state[first.path]["error"]), media.MAX_ERROR)
|
|
self.assertTrue(state[second.path]["mounted"])
|
|
self.assertEqual(state[second.path]["error"], "")
|
|
|
|
backend.items = [second]
|
|
daemon.devices_changed()
|
|
backend.items = [first, second]
|
|
daemon.devices_changed()
|
|
self.assertEqual(backend.calls.count(("mount", first.path)), 2)
|
|
|
|
def test_manual_mount_uses_real_mountpoint_and_unmount_verifies_state(self):
|
|
item = record("sdb1")
|
|
|
|
class StickyUnmountBackend(FakeBackend):
|
|
def mount(self, path):
|
|
self.calls.append(("mount", path))
|
|
self._change_mount(path, "/media/udisks-result")
|
|
|
|
def unmount(self, path):
|
|
self.calls.append(("unmount", path))
|
|
|
|
backend = StickyUnmountBackend([item])
|
|
launched = []
|
|
daemon = media.MediaDaemon(backend, self.state_path, launched.append)
|
|
daemon.handle({"operation": "open", "runtime_id": item.path})
|
|
self.assertEqual(launched, ["/media/udisks-result"])
|
|
|
|
with self.assertRaisesRegex(media.RequestError, "reste monté"):
|
|
daemon.handle({"operation": "unmount", "runtime_id": item.path})
|
|
|
|
|
|
class UDisksBackendTests(unittest.TestCase):
|
|
def test_records_keep_usb_filesystems_only_and_exclude_luks(self):
|
|
usb_drive = "/org/freedesktop/UDisks2/drives/usb"
|
|
sata_drive = "/org/freedesktop/UDisks2/drives/sata"
|
|
drive_interface = media.UDisksBackend.IFACE_DRIVE
|
|
block_interface = media.UDisksBackend.IFACE_BLOCK
|
|
filesystem_interface = media.UDisksBackend.IFACE_FILESYSTEM
|
|
objects = [
|
|
FakeObject(
|
|
usb_drive,
|
|
FakeInterface(
|
|
drive_interface,
|
|
ConnectionBus="usb",
|
|
MediaCompatibility=[],
|
|
Model="Clé USB",
|
|
Removable=True,
|
|
Ejectable=False,
|
|
CanPowerOff=True,
|
|
),
|
|
),
|
|
FakeObject(
|
|
sata_drive,
|
|
FakeInterface(
|
|
drive_interface,
|
|
ConnectionBus="ata",
|
|
MediaCompatibility=[],
|
|
Model="Disque interne",
|
|
Removable=True,
|
|
),
|
|
),
|
|
FakeObject(
|
|
"/org/freedesktop/UDisks2/block_devices/sdb1",
|
|
FakeInterface(
|
|
block_interface,
|
|
Drive=usb_drive,
|
|
IdUsage="filesystem",
|
|
IdType="vfat",
|
|
IdLabel="DONNEES",
|
|
Size=4096,
|
|
),
|
|
FakeInterface(filesystem_interface, MountPoints=[b"/run/media/test\0"]),
|
|
),
|
|
FakeObject(
|
|
"/org/freedesktop/UDisks2/block_devices/sdb2",
|
|
FakeInterface(
|
|
block_interface,
|
|
Drive=usb_drive,
|
|
IdUsage="crypto",
|
|
IdType="crypto_LUKS",
|
|
Size=4096,
|
|
),
|
|
FakeInterface(filesystem_interface, MountPoints=[]),
|
|
),
|
|
FakeObject(
|
|
"/org/freedesktop/UDisks2/block_devices/sda1",
|
|
FakeInterface(
|
|
block_interface,
|
|
Drive=sata_drive,
|
|
IdUsage="filesystem",
|
|
IdType="ext4",
|
|
Size=8192,
|
|
),
|
|
FakeInterface(filesystem_interface, MountPoints=[b"/\0"]),
|
|
),
|
|
]
|
|
backend = media.UDisksBackend.__new__(media.UDisksBackend)
|
|
backend.manager = type("Manager", (), {"get_objects": lambda self: objects})()
|
|
|
|
records = backend.records()
|
|
|
|
self.assertEqual(len(records), 1)
|
|
self.assertEqual(records[0].label, "DONNEES")
|
|
self.assertEqual(records[0].mount_point, "/run/media/test")
|
|
self.assertTrue(records[0].power_off_capable)
|
|
|
|
def test_records_exclude_hint_system_and_hint_ignore(self):
|
|
drive_path = "/org/freedesktop/UDisks2/drives/usb"
|
|
drive_interface = media.UDisksBackend.IFACE_DRIVE
|
|
block_interface = media.UDisksBackend.IFACE_BLOCK
|
|
filesystem_interface = media.UDisksBackend.IFACE_FILESYSTEM
|
|
objects = [
|
|
FakeObject(
|
|
drive_path,
|
|
FakeInterface(
|
|
drive_interface,
|
|
ConnectionBus="usb",
|
|
MediaCompatibility=[],
|
|
Removable=True,
|
|
),
|
|
)
|
|
]
|
|
for name, properties in (
|
|
("visible", {}),
|
|
("efi", {"HintSystem": True}),
|
|
("ignored", {"HintIgnore": True}),
|
|
):
|
|
objects.append(
|
|
FakeObject(
|
|
f"/org/freedesktop/UDisks2/block_devices/{name}",
|
|
FakeInterface(
|
|
block_interface,
|
|
Drive=drive_path,
|
|
IdUsage="filesystem",
|
|
IdType="vfat",
|
|
**properties,
|
|
),
|
|
FakeInterface(filesystem_interface, MountPoints=[]),
|
|
)
|
|
)
|
|
backend = media.UDisksBackend.__new__(media.UDisksBackend)
|
|
backend.manager = type("Manager", (), {"get_objects": lambda self: objects})()
|
|
|
|
self.assertEqual(
|
|
[item.path for item in backend.records()],
|
|
["/org/freedesktop/UDisks2/block_devices/visible"],
|
|
)
|
|
|
|
def test_safe_remove_rechecks_every_filesystem_child_before_power_off(self):
|
|
drive = record("visible", mount="/run/media/test/visible")
|
|
|
|
class Backend(media.UDisksBackend):
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.reads = 0
|
|
|
|
def _drive_filesystems(self, drive_path):
|
|
self.reads += 1
|
|
return [
|
|
(drive.path, [b"/run/media/test/visible\0"]),
|
|
("/org/freedesktop/UDisks2/block_devices/hidden", [b"/mnt/hidden\0"]),
|
|
]
|
|
|
|
def _drive_filesystems_sync(self, drive_path):
|
|
return self._drive_filesystems(drive_path)
|
|
|
|
def unmount(self, path):
|
|
self.calls.append(("unmount", path))
|
|
|
|
def _call(self, path, interface, method):
|
|
self.calls.append((method, path))
|
|
|
|
backend = Backend()
|
|
with self.assertRaisesRegex(media.RequestError, "reste monté"):
|
|
backend.safe_remove(drive)
|
|
self.assertEqual(backend.reads, 2)
|
|
self.assertEqual(
|
|
backend.calls,
|
|
[
|
|
("unmount", "/org/freedesktop/UDisks2/block_devices/hidden"),
|
|
("unmount", drive.path),
|
|
],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|