From 4a426e6d8596d3b2196865445018456670d7d06d Mon Sep 17 00:00:00 2001
From: Piotr Bartman <prbartman@invisiblethingslab.com>
Date: Thu, 1 Feb 2024 09:38:26 +0100
Subject: [PATCH] q-dev: implementation of DeviceInfo.attachment

---
 qubes/api/admin.py | 10 ++----
 qubes/devices.py   | 87 +++++++++++++++++++++++++++++-----------------
 qubes/ext/block.py |  4 +--
 3 files changed, 61 insertions(+), 40 deletions(-)

diff --git a/qubes/api/admin.py b/qubes/api/admin.py
index d6b4842bf..c796dbd4a 100644
--- a/qubes/api/admin.py
+++ b/qubes/api/admin.py
@@ -1300,7 +1300,6 @@ async def vm_device_assign(self, endpoint, untrusted_payload):
             ep.name 
             for ep in importlib.metadata.entry_points(group='qubes.devices')), no_payload=True, scope='local', write=True)
     async def vm_device_unassign(self, endpoint):
-        # TODO DeviceAssignment.deserialize() ? Device
         devclass = endpoint
 
         # qrexec already verified that no strange characters are in self.arg
@@ -1359,7 +1358,6 @@ async def vm_device_attach(self, endpoint, untrusted_payload):
             for ep in importlib.metadata.entry_points(group='qubes.devices')),
         no_payload=True, scope='local', execute=True)
     async def vm_device_detach(self, endpoint):
-        # TODO DeviceAssignment.deserialize() ? Device
         devclass = endpoint
 
         # qrexec already verified that no strange characters are in self.arg
@@ -1374,15 +1372,13 @@ async def vm_device_detach(self, endpoint):
         assignment = qubes.devices.DeviceAssignment(
             dev.backend_domain, dev.ident)
         await self.dest.devices[devclass].detach(assignment)
-        self.app.save()  # TODO: not needed
 
-    # Attach/Detach action can both modify persistent state (with
-    # required=True or required=False) and volatile state of running VM
-    # (with required=None). For this reason, write=True + execute=True
+    # Assign/Unassign action can modify only persistent state of running VM.
+    # For this reason, write=True
     @qubes.api.method('admin.vm.device.{endpoint}.Set.assignment',
         endpoints=(ep.name
             for ep in importlib.metadata.entry_points(group='qubes.devices')),
-        scope='local', write=True, execute=True)
+        scope='local', write=True)
     async def vm_device_set_assignment(self, endpoint, untrusted_payload):
         """
         Update assignment of already attached device.
diff --git a/qubes/devices.py b/qubes/devices.py
index f0d865811..18aa46ded 100644
--- a/qubes/devices.py
+++ b/qubes/devices.py
@@ -318,7 +318,6 @@ def _load_classes(bus: str):
 class DeviceInfo(Device):
     """ Holds all information about a device """
 
-    # pylint: disable=too-few-public-methods
     def __init__(
             self,
             backend_domain: 'qubes.vm.BaseVM',
@@ -331,6 +330,7 @@ def __init__(
             serial: Optional[str] = None,
             interfaces: Optional[List[DeviceInterface]] = None,
             parent: Optional[Device] = None,
+            attachment: Optional['qubes.vm.BaseVM'] = None,
             **kwargs
     ):
         super().__init__(backend_domain, ident, devclass)
@@ -342,6 +342,7 @@ def __init__(
         self._serial = serial
         self._interfaces = interfaces
         self._parent = parent
+        self._attachment = attachment
 
         self.data = kwargs
 
@@ -473,17 +474,17 @@ def subdevices(self) -> List['DeviceInfo']:
                 if dev.parent_device.ident == self.ident]
 
     @property
-    def attachments(self) -> List['DeviceAssignment']:
+    def attachment(self) -> Optional['qubes.vm.BaseVM']:
         """
-        Device attachments
+        VM to which device is attached (frontend domain).
         """
-        return []  # TODO
+        return self._attachment
 
     def serialize(self) -> bytes:
         """
         Serialize object to be transmitted via Qubes API.
         """
-        # 'backend_domain', 'interfaces', 'data', 'parent_device'
+        # 'backend_domain', 'attachment', 'interfaces', 'data', 'parent_device'
         # are not string, so they need special treatment
         default_attrs = {
             'ident', 'devclass', 'vendor', 'product', 'manufacturer', 'name',
@@ -494,9 +495,14 @@ def serialize(self) -> bytes:
                 (key, getattr(self, key)) for key in default_attrs)
         )
 
-        back_name = serialize_str(self.backend_domain.name)
-        backend_domain_prop = (b"backend_domain=" + back_name.encode('ascii'))
-        properties += b' ' + backend_domain_prop
+        qname = serialize_str(self.backend_domain.name)
+        backend_prop = (b"backend_domain=" + qname.encode('ascii'))
+        properties += b' ' + backend_prop
+
+        if self.attachment:
+            qname = serialize_str(self.attachment.name)
+            attachment_prop = (b"attachment=" + qname.encode('ascii'))
+            properties += b' ' + attachment_prop
 
         interfaces = serialize_str(
             ''.join(repr(ifc) for ifc in self.interfaces))
@@ -573,6 +579,13 @@ def _deserialize(
                 f"when expected devices from {expected_backend_domain.name}.")
         properties['backend_domain'] = expected_backend_domain
 
+        if 'attachment' not in properties or not properties['attachment']:
+            properties['attachment'] = None
+        else:
+            app = expected_backend_domain.app
+            properties['attachment'] = app.domains.get_blind(
+                properties['attachment'])
+
         if expected_devclass and properties['devclass'] != expected_devclass:
             raise UnexpectedDeviceProperty(
                 f"Got {properties['devclass']} device "
@@ -597,10 +610,6 @@ def _deserialize(
 
         return cls(**properties)
 
-    @property
-    def frontend_domain(self):
-        return self.data.get("frontend_domain", None)
-
     @property
     def full_identity(self) -> str:
         """
@@ -645,7 +654,6 @@ def sanitize_str(
     """
     if replace_char is None:
         if any(x not in allowed_chars for x in untrusted_value):
-            print(untrusted_value, file=sys.stderr)  # TODO
             raise qubes.api.ProtocolError(error_message)
         return untrusted_value
     result = ""
@@ -678,8 +686,9 @@ class DeviceAssignment(Device):
     3. (True, True, True)   -> domain is running, device is attached
                                and couldn't be detached.
     4. (False, Ture, False) -> device is assigned to domain, but not attached
-                               because either domain is halted
-                               or device manually detached.
+                               because either (i) domain is halted,
+                               device (ii) manually detached or
+                               (iii) attach to different domain.
     5. (False, True, True)  -> domain is halted, device assigned to domain
                                and required to start domain.
     """
@@ -725,10 +734,11 @@ def frontend_domain(
     @property
     def attached(self) -> bool:
         """
-        Is the device already attached to the fronted domain?
+        Is the device attached to the fronted domain?
+
+        Returns False if device is attached to different domain
         """
-        return (self.frontend_domain is not None
-                and self.frontend_domain.is_running())
+        return self.device.attachment == self.frontend_domain
 
     @property
     def required(self) -> bool:
@@ -738,6 +748,10 @@ def required(self) -> bool:
         """
         return self.__required
 
+    @required.setter
+    def required(self, required: bool):
+        self.__required = required
+
     @property
     def attach_automatically(self) -> bool:
         """
@@ -746,6 +760,10 @@ def attach_automatically(self) -> bool:
         """
         return self.__attach_automatically
 
+    @attach_automatically.setter
+    def attach_automatically(self, attach_automatically: bool):
+        self.__attach_automatically = attach_automatically
+
     @property
     def options(self) -> Dict[str, Any]:
         """ Device options (same as in the legacy API). """
@@ -773,10 +791,7 @@ def serialize(self) -> bytes:
         properties += b' ' + backend_domain_prop
 
         if self.frontend_domain is not None:
-            if isinstance(self.frontend_domain, str):
-                front_name = serialize_str(self.frontend_domain)  # TODO
-            else:
-                front_name = serialize_str(self.frontend_domain.name)
+            front_name = serialize_str(self.frontend_domain.name)
             frontend_domain_prop = (
                     b"frontend_domain=" + front_name.encode('ascii'))
             properties += b' ' + frontend_domain_prop
@@ -925,6 +940,23 @@ class DeviceCollection:
 
             :param device: :py:class:`DeviceInfo` object to be attached
 
+        .. event:: device-assign:<class> (device, options)
+
+            Fired when device is assigned to a VM.
+
+            Handler for this event may be asynchronous.
+
+            :param device: :py:class:`DeviceInfo` object to be assigned
+            :param options: :py:class:`dict` of assignment options
+
+        .. event:: device-unassign:<class> (device)
+
+            Fired when device is unassigned from a VM.
+
+            Handler for this event can be asynchronous (a coroutine).
+
+            :param device: :py:class:`DeviceInfo` object to be unassigned
+
         .. event:: device-list:<class>
 
             Fired to get list of devices exposed by a VM. Handlers of this
@@ -1008,10 +1040,6 @@ async def assign(self, assignment: DeviceAssignment):
                 "Only pci and usb devices can be assigned "
                 "to be automatically attached.")
 
-        await self._vm.fire_event_async(
-            'device-pre-assign:' + self._bus,
-            pre_event=True, device=device, options=assignment.options)
-
         self._set.add(assignment)
 
         await self._vm.fire_event_async(
@@ -1112,12 +1140,9 @@ async def unassign(self, device_assignment: DeviceAssignment):
                 "Can not remove an required assignment from "
                 "a non halted qube.")
 
-        device = device_assignment.device
-        await self._vm.fire_event_async(
-            'device-pre-unassign:' + self._bus, pre_event=True, device=device)
-
         self._set.discard(device_assignment)
 
+        device = device_assignment.device
         await self._vm.fire_event_async(
             'device-unassign:' + self._bus, device=device)
 
@@ -1145,7 +1170,7 @@ def get_attached_devices(self) -> Iterable[DeviceAssignment]:
                     backend_domain=dev.backend_domain,
                     ident=dev.ident,
                     options=options,
-                    frontend_domain=dev.frontend_domain,
+                    frontend_domain=self._vm,
                     devclass=dev.devclass,
                     attach_automatically=False,
                     required=False,
diff --git a/qubes/ext/block.py b/qubes/ext/block.py
index 34a86f3db..d56809cb0 100644
--- a/qubes/ext/block.py
+++ b/qubes/ext/block.py
@@ -169,7 +169,7 @@ def on_domain_init_load(self, vm, event):
             # avoid building a cache on domain-init, as it isn't fully set yet,
             # and definitely isn't running yet
             current_devices = {
-                dev.ident: dev.frontend_domain
+                dev.ident: dev.attachment
                 for dev in self.on_device_list_block(vm, None)
             }
             self.devices_cache[vm.name] = current_devices
@@ -181,7 +181,7 @@ def on_qdb_change(self, vm, event, path):
         """A change in QubesDB means a change in device list."""
         # pylint: disable=unused-argument
         vm.fire_event('device-list-change:block')
-        current_devices = dict((dev.ident, dev.frontend_domain)
+        current_devices = dict((dev.ident, dev.attachment)
            for dev in self.on_device_list_block(vm, None))
 
         # send events about devices detached/attached outside by themselves