347 lines
12 KiB
Python
347 lines
12 KiB
Python
import importlib.util
|
|
from importlib.machinery import SourceFileLoader
|
|
import json
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
SCRIPT = Path(__file__).parents[2] / "bin/.local/bin/labfy-removable-media"
|
|
SPEC = importlib.util.spec_from_loader(
|
|
"labfy_removable_media_mtp", SourceFileLoader("labfy_removable_media_mtp", str(SCRIPT))
|
|
)
|
|
assert SPEC and SPEC.loader
|
|
media = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = media
|
|
SPEC.loader.exec_module(media)
|
|
|
|
|
|
class FakeRoot:
|
|
def __init__(self, scheme="mtp", path=None, uri="mtp://fake-device/"):
|
|
self.scheme = scheme
|
|
self.path = path
|
|
self.uri = uri
|
|
|
|
def get_uri_scheme(self):
|
|
return self.scheme
|
|
|
|
def get_path(self):
|
|
return self.path
|
|
|
|
def get_uri(self):
|
|
return self.uri
|
|
|
|
def query_filesystem_info(self, _attributes, _cancellable):
|
|
return type("Info", (), {"get_attribute_uint64": lambda self, _name: 1234})()
|
|
|
|
|
|
class FakeMount:
|
|
def __init__(self, volume, path):
|
|
self.volume = volume
|
|
self.root = FakeRoot(path=path)
|
|
|
|
def get_root(self):
|
|
return self.root
|
|
|
|
def get_name(self):
|
|
return "Stockage du téléphone"
|
|
|
|
def unmount_with_operation(self, _flags, _operation, _cancellable, callback):
|
|
self.volume.pending = lambda: setattr(self.volume, "current_mount", None)
|
|
callback(self, object())
|
|
|
|
def unmount_with_operation_finish(self, _result):
|
|
self.volume.pending()
|
|
|
|
|
|
class FakeVolume:
|
|
def __init__(self, scheme="mtp", name="Téléphone"):
|
|
self.activation = FakeRoot(scheme=scheme)
|
|
self.name = name
|
|
self.current_mount = None
|
|
self.pending = None
|
|
self.mount_calls = 0
|
|
|
|
def get_activation_root(self):
|
|
return self.activation
|
|
|
|
def get_name(self):
|
|
return self.name
|
|
|
|
def get_mount(self):
|
|
return self.current_mount
|
|
|
|
def mount(self, _flags, _operation, _cancellable, callback):
|
|
self.mount_calls += 1
|
|
self.pending = lambda: setattr(
|
|
self, "current_mount", FakeMount(self, "/run/user/1000/gvfs/mtp:host=fake")
|
|
)
|
|
callback(self, object())
|
|
|
|
def mount_finish(self, _result):
|
|
self.pending()
|
|
|
|
|
|
class FakeMonitor:
|
|
def __init__(self, volumes):
|
|
self.volumes = volumes
|
|
self.callbacks = {}
|
|
|
|
def get_volumes(self):
|
|
return list(self.volumes)
|
|
|
|
def connect(self, signal, callback):
|
|
self.callbacks[signal] = callback
|
|
|
|
|
|
class FakeCancellable:
|
|
def __init__(self):
|
|
self.cancelled = False
|
|
|
|
def cancel(self):
|
|
self.cancelled = True
|
|
|
|
|
|
class FakeGio:
|
|
class Cancellable:
|
|
@staticmethod
|
|
def new():
|
|
return FakeCancellable()
|
|
|
|
class MountMountFlags:
|
|
NONE = 0
|
|
|
|
class MountUnmountFlags:
|
|
NONE = 0
|
|
|
|
|
|
class FakeGLib:
|
|
def __init__(self):
|
|
self.sources = {}
|
|
self.next_source = 0
|
|
|
|
def timeout_add_seconds(self, _delay, callback):
|
|
self.next_source += 1
|
|
self.sources[self.next_source] = callback
|
|
return self.next_source
|
|
|
|
def timeout_add(self, _delay, callback, *args):
|
|
self.next_source += 1
|
|
self.sources[self.next_source] = lambda: callback(*args)
|
|
return self.next_source
|
|
|
|
def source_remove(self, source):
|
|
self.sources.pop(source, None)
|
|
|
|
|
|
class MTPBackendTests(unittest.TestCase):
|
|
def backend(self, volumes):
|
|
return media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor(volumes))
|
|
|
|
def test_classifies_only_activation_root_mtp_and_uses_opaque_ids(self):
|
|
mtp = FakeVolume("mtp")
|
|
backend = self.backend([mtp, FakeVolume("gphoto2"), FakeVolume(None)])
|
|
|
|
records = backend.records()
|
|
|
|
self.assertEqual(len(records), 1)
|
|
self.assertEqual(records[0].kind, "mtp")
|
|
self.assertTrue(records[0].path.startswith("mtp:"))
|
|
self.assertNotIn("Téléphone", records[0].path)
|
|
self.assertNotIn("mtp://", records[0].path)
|
|
self.assertEqual(records[0].actions(), ["mount"])
|
|
|
|
def test_mount_and_safe_remove_complete_async_with_real_root_path(self):
|
|
volume = FakeVolume()
|
|
backend = self.backend([volume])
|
|
runtime_id = backend.records()[0].path
|
|
completed = []
|
|
|
|
backend.operate_async(runtime_id, "mount", lambda path, error: completed.append((path, error)))
|
|
self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)])
|
|
mounted = backend.records()[0]
|
|
self.assertEqual(mounted.mount_point, "/run/user/1000/gvfs/mtp:host=fake")
|
|
self.assertEqual(mounted.display_name, "Stockage du téléphone")
|
|
self.assertEqual(mounted.actions(), ["unmount", "open", "safe-remove"])
|
|
|
|
completed.clear()
|
|
backend.operate_async(runtime_id, "safe-remove", lambda path, error: completed.append((path, error)))
|
|
self.assertEqual(completed, [("", None)])
|
|
self.assertIsNone(volume.get_mount())
|
|
|
|
def test_mounted_without_local_path_omits_only_open(self):
|
|
volume = FakeVolume()
|
|
volume.current_mount = FakeMount(volume, None)
|
|
record = self.backend([volume]).records()[0]
|
|
|
|
self.assertTrue(record.public()["mounted"])
|
|
self.assertEqual(record.mount_point, "")
|
|
self.assertEqual(record.actions(), ["unmount", "safe-remove"])
|
|
|
|
def test_disappearance_rejects_stale_runtime_id(self):
|
|
volume = FakeVolume()
|
|
backend = self.backend([volume])
|
|
runtime_id = backend.records()[0].path
|
|
backend.monitor.volumes = []
|
|
|
|
with self.assertRaisesRegex(media.RequestError, "introuvable"):
|
|
backend.operate_async(runtime_id, "mount", lambda *_args: None)
|
|
|
|
def test_new_wrappers_keep_id_and_live_wrapper_is_used_for_action(self):
|
|
shared = {"mount": None}
|
|
|
|
class RewrappedVolume(FakeVolume):
|
|
def get_mount(self):
|
|
return shared["mount"]
|
|
|
|
def mount(self, _flags, _operation, _cancellable, callback):
|
|
shared["mount"] = FakeMount(
|
|
self, "/run/user/1000/gvfs/mtp:host=fake"
|
|
)
|
|
callback(self, object())
|
|
|
|
def mount_finish(self, _result):
|
|
pass
|
|
|
|
class RewrappingMonitor(FakeMonitor):
|
|
def __init__(self):
|
|
super().__init__([])
|
|
self.calls = 0
|
|
self.last_wrapper = None
|
|
|
|
def get_volumes(self):
|
|
self.calls += 1
|
|
self.last_wrapper = RewrappedVolume(name=f"Wrapper {self.calls}")
|
|
return [self.last_wrapper]
|
|
|
|
monitor = RewrappingMonitor()
|
|
backend = media.MTPBackend(FakeGio, FakeGLib(), monitor)
|
|
|
|
first_id = backend.records()[0].path
|
|
second_id = backend.records()[0].path
|
|
completed = []
|
|
backend.operate_async(
|
|
first_id, "mount", lambda path, error: completed.append((path, error))
|
|
)
|
|
|
|
self.assertEqual(second_id, first_id)
|
|
self.assertGreaterEqual(monitor.calls, 3)
|
|
self.assertIsNotNone(monitor.last_wrapper.get_mount())
|
|
self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)])
|
|
|
|
def test_observed_removal_invalidates_id_before_same_uri_reinsertion(self):
|
|
first = FakeVolume()
|
|
monitor = FakeMonitor([first])
|
|
backend = media.MTPBackend(FakeGio, FakeGLib(), monitor)
|
|
backend.connect_changed(lambda: None)
|
|
first_id = backend.records()[0].path
|
|
|
|
monitor.callbacks["volume-removed"](monitor, first)
|
|
monitor.volumes = [FakeVolume()]
|
|
|
|
self.assertNotEqual(backend.records()[0].path, first_id)
|
|
|
|
def test_timeout_cancels_pending_mount_and_returns_bounded_french_error(self):
|
|
class PendingVolume(FakeVolume):
|
|
def mount(self, _flags, _operation, cancellable, _callback):
|
|
self.cancellable = cancellable
|
|
|
|
volume = PendingVolume()
|
|
backend = self.backend([volume])
|
|
runtime_id = backend.records()[0].path
|
|
completed = []
|
|
backend.operate_async(
|
|
runtime_id, "mount", lambda path, error: completed.append((path, error))
|
|
)
|
|
timeout = next(iter(backend.GLib.sources.values()))
|
|
|
|
self.assertFalse(timeout())
|
|
self.assertTrue(volume.cancellable.cancelled)
|
|
self.assertIsInstance(completed[0][1], media.RequestError)
|
|
self.assertIn("Délai d'opération MTP dépassé", str(completed[0][1]))
|
|
|
|
|
|
class CompositeBackend:
|
|
def __init__(self, mtp_backend):
|
|
self.mtp = mtp_backend
|
|
|
|
def records(self):
|
|
return self.mtp.records()
|
|
|
|
def operate_mtp_async(self, runtime_id, operation, callback):
|
|
return self.mtp.operate_async(runtime_id, operation, callback)
|
|
|
|
|
|
class MTPDaemonTests(unittest.TestCase):
|
|
def test_public_v2_snapshot_uses_opaque_id_and_authoritative_mount_path(self):
|
|
volume = FakeVolume(name="Téléphone de test")
|
|
raw_uri = "mtp://[usb:001,007]/raw-serial-ABC-123/"
|
|
volume.activation.uri = raw_uri
|
|
# CONTRACT: GVFS peut inclure une identité dans son chemin local ; ce
|
|
# chemin exact reste nécessaire à Yazi et le snapshot est privé (0600).
|
|
mount_path = "/run/user/1000/gvfs/mtp:host=raw-serial-ABC-123"
|
|
volume.current_mount = FakeMount(volume, mount_path)
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
state_path = Path(directory) / media.STATE_NAME
|
|
daemon = media.MediaDaemon(
|
|
CompositeBackend(media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor([volume]))),
|
|
state_path,
|
|
)
|
|
|
|
state = daemon.state()
|
|
device = state["devices"][0]
|
|
snapshot = state_path.read_text()
|
|
snapshot_mode = stat.S_IMODE(state_path.stat().st_mode)
|
|
|
|
self.assertEqual(state["version"], 2)
|
|
self.assertEqual(
|
|
set(device),
|
|
{
|
|
"runtime_id", "safe_remove_runtime_id", "kind", "actions",
|
|
"display_name", "label", "filesystem", "size_bytes",
|
|
"mount_point", "mounted", "removable", "ejectable",
|
|
"power_off_capable", "busy", "error",
|
|
},
|
|
)
|
|
self.assertEqual(device["kind"], "mtp")
|
|
self.assertEqual(device["actions"], ["unmount", "open", "safe-remove"])
|
|
self.assertTrue(device["mounted"])
|
|
self.assertTrue(device["runtime_id"].startswith("mtp:"))
|
|
self.assertEqual(device["runtime_id"], device["safe_remove_runtime_id"])
|
|
self.assertEqual(device["mount_point"], mount_path)
|
|
self.assertNotIn("mtp://", device["runtime_id"])
|
|
self.assertNotIn("raw-serial-ABC-123", device["runtime_id"])
|
|
self.assertNotIn(raw_uri, json.dumps(state, ensure_ascii=False))
|
|
self.assertIn(mount_path, snapshot)
|
|
self.assertEqual(snapshot_mode, 0o600)
|
|
|
|
def test_manual_unmount_is_not_automounted_again(self):
|
|
volume = FakeVolume()
|
|
monitor = FakeMonitor([volume])
|
|
mtp = media.MTPBackend(FakeGio, FakeGLib(), monitor)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
daemon = media.MediaDaemon(
|
|
CompositeBackend(mtp), Path(directory) / media.STATE_NAME
|
|
)
|
|
mtp.connect_changed(daemon.devices_changed)
|
|
daemon.start()
|
|
runtime_id = daemon.state()["devices"][0]["runtime_id"]
|
|
self.assertEqual(volume.mount_calls, 1)
|
|
replies = []
|
|
daemon.handle_async(
|
|
{"operation": "unmount", "runtime_id": runtime_id}, replies.append
|
|
)
|
|
monitor.callbacks["mount-changed"](monitor, None)
|
|
|
|
self.assertEqual(len(replies), 1)
|
|
self.assertFalse(daemon.state()["devices"][0]["mounted"])
|
|
self.assertEqual(volume.mount_calls, 1)
|
|
self.assertIn("volume-changed", monitor.callbacks)
|
|
self.assertIn("mount-changed", monitor.callbacks)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|