diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f231d9a13..dde9f1a25 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ checks:pylint: script: - PYTHONPATH=test-packages:~/qubes-core-qrexec python3 -m pylint qubes stage: checks + checks:tests: after_script: - ci/codecov-wrapper -F unittests @@ -36,7 +37,22 @@ checks:tests: - PYTHONPATH=test-packages:~/qubes-core-qrexec ./run-tests stage: checks tags: - - vm-kvm + - vm + +mypy: + stage: checks + image: fedora:40 + tags: + - docker + before_script: + - sudo dnf install -y python3-mypy python3-pip + - sudo python3 -m pip install lxml-stubs types-docutils types-pywin32 + script: + - mypy --install-types --non-interactive --ignore-missing-imports --exclude tests/ --junit-xml mypy.xml qubes + artifacts: + reports: + junit: mypy.xml + include: - file: /r4.3/gitlab-base.yml project: QubesOS/qubes-continuous-integration diff --git a/Makefile b/Makefile index 7a410ade1..a52eb2e29 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.device.pci.Attached \ admin.vm.device.pci.Available \ admin.vm.device.pci.Detach \ - admin.vm.device.pci.Set.required \ + admin.vm.device.pci.Set.assignment \ admin.vm.device.pci.Unassign \ admin.vm.device.block.Assign \ admin.vm.device.block.Assigned \ @@ -74,7 +74,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.device.block.Attached \ admin.vm.device.block.Available \ admin.vm.device.block.Detach \ - admin.vm.device.block.Set.required \ + admin.vm.device.block.Set.assignment \ admin.vm.device.block.Unassign \ admin.vm.device.usb.Assign \ admin.vm.device.usb.Assigned \ @@ -82,7 +82,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.device.usb.Attached \ admin.vm.device.usb.Available \ admin.vm.device.usb.Detach \ - admin.vm.device.usb.Set.required \ + admin.vm.device.usb.Set.assignment \ admin.vm.device.usb.Unassign \ admin.vm.device.mic.Assign \ admin.vm.device.mic.Assigned \ @@ -90,7 +90,7 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.device.mic.Attached \ admin.vm.device.mic.Available \ admin.vm.device.mic.Detach \ - admin.vm.device.mic.Set.required \ + admin.vm.device.mic.Set.assignment \ admin.vm.device.mic.Unassign \ admin.vm.feature.CheckWithNetvm \ admin.vm.feature.CheckWithTemplate \ @@ -227,7 +227,7 @@ endif admin.vm.device.testclass.Unassign \ admin.vm.device.testclass.Attached \ admin.vm.device.testclass.Assigned \ - admin.vm.device.testclass.Set.required \ + admin.vm.device.testclass.Set.assignment \ admin.vm.device.testclass.Available install -d $(DESTDIR)/etc/qubes/policy.d/include install -m 0644 qubes-rpc-policy/admin-local-ro \ diff --git a/doc/qubes-devices.rst b/doc/qubes-devices.rst index e64811a3e..9018fec8d 100644 --- a/doc/qubes-devices.rst +++ b/doc/qubes-devices.rst @@ -1,25 +1,80 @@ :py:mod:`qubes.devices` -- Devices -=================================== +================================== -Main concept is that some domain (backend) may expose (potentially multiple) -devices, which can be attached to other domains (frontend). Devices can be of -different buses (like 'pci', 'usb', etc.). Each device bus is implemented by -an extension (see :py:mod:`qubes.ext`). +The main concept is that a domain (backend) can expose (potentially multiple) +devices, each through a port, where only one device can be in one port at +any given time. Such devices can be connected to other domains (frontends). +Devices can be of different buses (like 'pci', 'usb', etc.). Each device bus +is implemented by an extension (see :py:mod:`qubes.ext`). -Devices are identified by pair of (backend domain, `ident`), where `ident` is -:py:class:`str` and can contain only characters from `[a-zA-Z0-9._-]` set. +Devices are identified by a pair (`port`, `device_id`), where `port` is a pair +(backend domain, `port_id`). Both `port_id` and `device_id` are :py:class:`str`, +and in addition, port_id is unique per backend. More about the requirements +for `port_id` and `device_id` can be found in the sections below. +Classes +------- + +:py:class:`qubes.device_protocol.Port`: a pair `:` +with `devclass` (e.g., `pci`, `usb`). In the previous version (before +QubesOS 4.3), this was referred to as `Device`, and `port_id` was named `ident`. + +:py:class:`qubes.device_protocol.AnyPort`: A class used to handle cases where +any port is accepted. + +:py:class:`qubes.device_protocol.VirtualDevice`: A pair `:`. +This class links a device identified by `device_id` to a specific port. +If both values are specified, the instance represents a device connected to +that particular port. If the port is of type `AnyPort`, it represents a device +identified by `device_id` that can be connected to any port. This is used by +:py:class:`qubes.device_protocol.DeviceInfo`, which describes what to do with +a device identified by `device_id` when connected anywhere. Similarly, +when `device_id` is `*`, the instance represents any potential device +connected to the given port. As a result, the device is considered "virtual" +meaning it may or may not represent an actual device in the system. +A device with `*:*` (any port and any device) is not permitted. + +:py:class:`qubes.device_protocol.DeviceInfo`: Derived from `VirtualDevice`. +Extensions should assume that `Port` is provided, and based on that, +`device_id` should return the same string for the same device, regardless of +which port it is connected to. The `device_id` acts as a device hash +and *should* be "human-readable". It must contain only digits, ASCII letters, +spaces, and the following characters: `!#$%&()*+,-./:;<>?@[\]^_{|}~`. +It cannot be empty or equal to `*`. + +:py:class:`qubes.device_protocol.DeviceAssignment`: Represents the relationship +between a `VirtualDevice` and a `frontend_domain`. There are four modes: +#. `manual` (attachment): The device is manually attached to `frontend_domain`. +This type of assignment does not persist across domain restarts. +#. `auto-attach`: Any device that matches a `VirtualDevice` will be +automatically attached to the `frontend_domain` when discovered +or during domain startup. +#. `ask-to-attach`: Functions like `auto-attach`, but prompts the user for +confirmation before attaching. If no GUI is available, the prompt is ignored. +#. `required`: The device must be available during `frontend_domain` startup and +will be attached before the domain is started. + +:py:class:`qubes.device_protocol.DeviceInterface`: Represents device interfaces +as a 7-character code in the format `BCCSSII`, where `B` indicates the devclass +(e.g., `p` for PCI, `u` for USB, `?` for unknown), `CC` is the class code, +`SS` is the subclass code, and `II` represents the interface code. + +:py:class:`qubes.device_protocol.DeviceCategory`: Provides an easy-to-use, +arbitrary subset of interfaces with names assigned to categories considered as +most relevant to users. When needed, the class should be extended with new +categories. This structure allows for quick identification of the device type +and can be useful when displaying devices to the end user. Device Assignment vs Attachment ------------------------------- -:py:class:`qubes.device_protocol.DeviceAssignment` describes the assignment of a device -to a frontend VM. For clarity let's us introduce two types of assignments: +For clarity let's us introduce two types of assignments: *potential* and *real* (attachment). Attachment indicates that the device has been attached by the Qubes backend to its frontend VM and is visible from its perspective. Potential assignment, on the other hand, -has two additional options: `automatically_attach` and `required`. -For detailed descriptions, refer to the `DeviceAssignment` documentation. +has tree modes: `auto-attach`, `ask-to-attach` and `required`. +For detailed descriptions, take a look at +:py:class:`qubes.device_protocol.DeviceAssignment` documentation. In general we refer to potential assignment as assignment and real assignment as attachment. To check whether the device is currently attached, we check :py:meth:`qubes.device_protocol.DeviceAssignment.attached`, @@ -28,17 +83,35 @@ we check :py:meth:`qubes.device_protocol.DeviceAssignment.attach_automatically`. Potential and real connections may coexist at the same time, in which case both values will be true. +Understanding Device Identity +----------------------------- + +It is important to understand that :py:class:`qubes.device_protocol.Port` does not +correspond to the device itself, but rather to the *port* to which the device +is connected. Therefore, when assigning a device to a VM, such as +`sys-usb:1-1.1`, the port `1-1.1` is actually assigned, and thus +*every* devices connected to it will be automatically attached. +Similarly, when assigning `vm:sda`, every block device with the name `sda` +will be automatically attached. We can limit this using +:py:meth:`qubes.device_protocol.DeviceInfo.device_id`, which returns a string +containing information presented by the device, such as for example +`vendor_id`, `product_id`, `serial_number`, and encoded interfaces. +In the case of block devices, `device_id` consists of the parent's `device_id` +to which the device is connected (if any) and the interface/partition number. +In practice, this means that, a partition on a USB drive will only +be automatically attached to a frontend domain if the parent presents +the correct serial number etc. Actions ------- -The `assign` action signifies that a device will be assigned to the frontend VM +The `assign` action means that a device will be assigned to the frontend VM in a potential form (this does not change the current system state). This will result in an attempt to automatically attach the device -upon the next VM startup. If `required=True`, and the device cannot be attached, +upon the next VM startup. If `mode=required`, and the device cannot be attached, the VM startup will fail. Additionally, upon device detection (`device-added`), an attempt will be made to attach the device. However, at any time -(unless `required=True`), the user can manually modify this state by performing +(unless `mode=required`), the user can manually modify this state by performing `attach` or `detach` on the device, changing the current system state. This will not alter the assignment, and automatic attachment attempts will still be made in the future. To remove the assignment the user @@ -48,23 +121,23 @@ Assignment Management --------------------- Assignments can be edited at any time: regardless of whether the VM is running -or the device is currently attached. An exception is `required=True`, -in which case the VM must be shut down. Removing the assignment does not change the real system state, so if the device is currently attached -and the user remove the assignment, it will not be detached, -but it will not be automatically attached in the future. -Similarly, it works the other way around with `assign`. +or the device is currently attached. Removing the assignment does not change +the real system state, so if the device is currently attached and the user +remove the assignment, it will not be detached, but it will not be +automatically attached in the future. Similarly, it works the other way +around with `assign`. Proper Assignment States ------------------------ In short, we can think of device assignment in terms of three flags: #. `attached` - indicating whether the device is currently assigned, -#. `attach_automatically` - indicating whether the device will be +#. `attach_automatically` - indicating whether the device will be automatically attached by the system daemon, #. `required` - determining whether the failure of automatic attachment should result in the domain startup being interrupted. -Then the proper states of assignment +Then the possible states of assignment (`attached`, `automatically_attached`, `required`) are as follow: #. `(True, False, False)` -> domain is running, device is manually attached and could be manually detach any time. @@ -79,51 +152,52 @@ because either (i) domain is halted, device (ii) manually detached or #. `(False, True, True)` -> domain is halted, device assigned to domain and required to start domain. +Note that if `required=True` then `automatically_attached=True`. + +Conflicted Assignments +---------------------- + +If a connected device has multiple assignments to different `frontend_domain` +instances, the user will be asked to choose which domain connect the device to. +If no GUI client is available, the device will not be connected to any domain. +If multiple assignments exist for a connected device with different options but +to the same `frontend_domain`, the most specific assignment will take +precedence, according to the following order (from highest to lowest priority): +#. Assignment specifies both `port` and `device_id`. +#. Assignment specifies only the `port`. +#. Assignment specifies only the `device_id`. + +It is important to note that only one matching assignment can exist within +each of the categories listed above. + +Port Assignment +--------------- + +It is possible to not assign a specific device but rather a port, +(e.g., we can use the `--port` flag in the client). In this case, +the value `*` will appear in the `identity` field of the `qubes.xml` file. +This indicates that the identity presented by the devices will be ignored, +and all connected devices will be automatically attached. + PCI Devices ----------- PCI devices cannot be manually attached to a VM at any time. We must first create an assignment (`assign`) as required -(in client we can use `--required` flag) while the VM is turned off. -Then, it will be automatically attached upon each VM startup. -However, if a PCI device is currently in use by another VM, -the startup of the second VM will fail. -PCI devices can only be assigned with the `required=True`, which does not -allow for manual modification of the state during VM operation (attach/detach). +(in client we can use `--required` flag). Then, it will be automatically +attached upon each VM startup. However, if a PCI device is currently in use +by another VM, the startup of the second VM will fail. Microphone ---------- -The microphone cannot be assigned (potentially) to any VM (attempting to attach the microphone during VM startup fails). - -Understanding Device Self Identity ----------------------------------- - -It is important to understand that :py:class:`qubes.device_protocol.Device` does not -correspond to the device itself, but rather to the *port* to which the device -is connected. Therefore, when assigning a device to a VM, such as -`sys-usb:1-1.1`, the port `1-1.1` is actually assigned, and thus -*every* devices connected to it will be automatically attached. -Similarly, when assigning `vm:sda`, every block device with the name `sda` -will be automatically attached. We can limit this using :py:meth:`qubes.device_protocol.DeviceInfo.self_identity`, which returns a string containing information -presented by the device, such as, `vendor_id`, `product_id`, `serial_number`, -and encoded interfaces. In the case of block devices, `self_identity` -consists of the parent port to which the device is connected (if any), -the parent's `self_identity`, and the interface/partition number. -In practice, this means that, a partition on a USB drive will only be -automatically attached to a frontend domain if the parent presents -the correct serial number etc., and is connected to a specific port. +The microphone cannot be assigned with the `mode=required` to any VM. -Port Assignment ---------------- +USB Devices +----------- -It is possible to not assign a specific device but rather a port, -(e.g., we can use the `--port` flag in the client). In this case, -the value `any` will appear in the `identity` field of the `qubes.xml` file. -This indicates that the identity presented by the devices will be ignored, -and all connected devices will be automatically attached. Note that to create -an assignment, *any* device must currently be connected to the port. +The USB devices cannot be assigned with the `mode=required` to any VM. .. automodule:: qubes.devices diff --git a/qubes-rpc-policy/90-admin-default.policy.header b/qubes-rpc-policy/90-admin-default.policy.header index 864872ba4..bdd5c7065 100644 --- a/qubes-rpc-policy/90-admin-default.policy.header +++ b/qubes-rpc-policy/90-admin-default.policy.header @@ -26,7 +26,7 @@ !include-service admin.vm.device.mic.Attached * include/admin-local-ro !include-service admin.vm.device.mic.Available * include/admin-local-ro !include-service admin.vm.device.mic.Detach * include/admin-local-rwx -!include-service admin.vm.device.mic.Set.required * include/admin-local-rwx +!include-service admin.vm.device.mic.Set.assignment * include/admin-local-rwx !include-service admin.vm.device.mic.Unassign * include/admin-local-rwx !include-service admin.vm.device.usb.Assign * include/admin-local-rwx !include-service admin.vm.device.usb.Assigned * include/admin-local-ro @@ -34,6 +34,6 @@ !include-service admin.vm.device.usb.Attached * include/admin-local-ro !include-service admin.vm.device.usb.Available * include/admin-local-ro !include-service admin.vm.device.usb.Detach * include/admin-local-rwx -!include-service admin.vm.device.usb.Set.required * include/admin-local-rwx +!include-service admin.vm.device.usb.Set.assignment * include/admin-local-rwx !include-service admin.vm.device.usb.Unassign * include/admin-local-rwx diff --git a/qubes/api/__init__.py b/qubes/api/__init__.py index cc88a268b..e78a93f7d 100644 --- a/qubes/api/__init__.py +++ b/qubes/api/__init__.py @@ -135,7 +135,7 @@ class AbstractQubesAPI: ''' #: the preferred socket location (to be overridden in child's class) - SOCKNAME = None + SOCKNAME = "" app: qubes.Qubes src: qubes.vm.qubesvm.QubesVM @@ -144,7 +144,7 @@ def __init__(self, src: bytes, method_name: bytes, dest: bytes, - arg: qubes.Qubes, + arg: bytes, send_event: Any = None) -> None: #: :py:class:`qubes.Qubes` object self.app = app diff --git a/qubes/api/admin.py b/qubes/api/admin.py index 65e039f3d..de5cf4ea4 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -45,7 +45,8 @@ import qubes.vm import qubes.vm.adminvm import qubes.vm.qubesvm -from qubes.device_protocol import Device +from qubes.device_protocol import ( + VirtualDevice, UnknownDevice, DeviceAssignment, AssignmentMode) class QubesMgmtEventsDispatcher: @@ -1218,14 +1219,15 @@ async def vm_device_available(self, endpoint): raise qubes.exc.QubesException("qubesd shutdown in progress") raise if self.arg: - devices = [dev for dev in devices if dev.ident == self.arg] + devices = [dev for dev in devices if dev.port_id == self.arg] # no duplicated devices, but device may not exist, in which case # the list is empty self.enforce(len(devices) <= 1) devices = self.fire_event_for_filter(devices, devclass=devclass) - dev_info = {dev.ident: dev.serialize().decode() for dev in devices} - return ''.join('{} {}\n'.format(ident, dev_info[ident]) - for ident in sorted(dev_info)) + dev_info = {f'{dev.port_id}:{dev.device_id}': + dev.serialize().decode() for dev in devices} + return ''.join(f'{port_id} {dev_info[port_id]}\n' + for port_id in sorted(dev_info)) @qubes.api.method('admin.vm.device.{endpoint}.Assigned', endpoints=(ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), @@ -1244,7 +1246,7 @@ async def vm_device_list(self, endpoint): if self.arg: select_backend, select_ident = self.arg.split('+', 1) device_assignments = [dev for dev in device_assignments - if (str(dev.backend_domain), dev.ident) + if (str(dev.backend_domain), dev.port_id) == (select_backend, select_ident)] # no duplicated devices, but device may not exist, in which case # the list is empty @@ -1253,12 +1255,13 @@ async def vm_device_list(self, endpoint): device_assignments, devclass=devclass) dev_info = { - f'{assignment.backend_domain}+{assignment.ident}': + (f'{assignment.backend_domain}' + f'+{assignment.port_id}:{assignment.device_id}'): assignment.serialize().decode('ascii', errors="ignore") for assignment in device_assignments} - return ''.join('{} {}\n'.format(ident, dev_info[ident]) - for ident in sorted(dev_info)) + return ''.join('{} {}\n'.format(port_id, dev_info[port_id]) + for port_id in sorted(dev_info)) @qubes.api.method( 'admin.vm.device.{endpoint}.Attached', @@ -1279,21 +1282,24 @@ async def vm_device_attached(self, endpoint): if self.arg: select_backend, select_ident = self.arg.split('+', 1) device_assignments = [dev for dev in device_assignments - if (str(dev.backend_domain), dev.ident) + if (str(dev.backend_domain), dev.port_id) == (select_backend, select_ident)] # no duplicated devices, but device may not exist, in which case # the list is empty self.enforce(len(device_assignments) <= 1) - device_assignments = self.fire_event_for_filter(device_assignments, - devclass=devclass) + device_assignments = [ + a.clone(device=self.app.domains[a.backend_name + ].devices[devclass][a.port_id]) for a in device_assignments] + device_assignments = self.fire_event_for_filter( + device_assignments, devclass=devclass) dev_info = { - f'{assignment.backend_domain}+{assignment.ident}': + (f'{assignment.backend_domain}' + f'+{assignment.port_id}:{assignment.device_id}'): assignment.serialize().decode('ascii', errors="ignore") for assignment in device_assignments} - - return ''.join('{} {}\n'.format(ident, dev_info[ident]) - for ident in sorted(dev_info)) + return ''.join('{} {}\n'.format(port_id, dev_info[port_id]) + for port_id in sorted(dev_info)) # Assign/Unassign action can modify only persistent state of running VM. # For this reason, write=True @@ -1302,26 +1308,34 @@ async def vm_device_attached(self, endpoint): scope='local', write=True) async def vm_device_assign(self, endpoint, untrusted_payload): devclass = endpoint - - # qrexec already verified that no strange characters are in self.arg - backend_domain, ident = self.arg.split('+', 1) - # may raise KeyError, either on domain or ident - dev = self.app.domains[backend_domain].devices[devclass][ident] + dev = self.load_device_info(devclass) assignment = qubes.device_protocol.DeviceAssignment.deserialize( - untrusted_payload, expected_device=dev - ) + untrusted_payload, expected_device=dev) self.fire_event_for_permission( - device=dev, devclass=devclass, - required=assignment.required, - attach_automatically=assignment.attach_automatically, - options=assignment.options - ) + device=dev, mode=assignment.mode, options=assignment.options,) await self.dest.devices[devclass].assign(assignment) self.app.save() + def load_device_info(self, devclass) -> VirtualDevice: + # qrexec already verified that no strange characters are in self.arg + _dev = VirtualDevice.from_qarg(self.arg, devclass, self.app.domains) + if _dev.port_id == '*' or _dev.device_id == '*': + return _dev + # load all info, may raise KeyError, either on domain or port_id + try: + dev = self.app.domains[ + _dev.backend_domain].devices[devclass][_dev.port_id] + if isinstance(dev, UnknownDevice): + return _dev + if _dev.device_id not in ('*', dev.device_id): + return _dev + return dev + except KeyError: + return _dev + # Assign/Unassign action can modify only persistent state of running VM. # For this reason, write=True @qubes.api.method( @@ -1329,21 +1343,14 @@ async def vm_device_assign(self, endpoint, untrusted_payload): endpoints=( ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), - no_payload=True, scope='local', write=True) + no_payload=True, scope='local', write=True) async def vm_device_unassign(self, endpoint): devclass = endpoint + dev = self.load_device_info(devclass) + assignment = DeviceAssignment(dev) - # qrexec already verified that no strange characters are in self.arg - backend_domain, ident = self.arg.split('+', 1) - # may raise KeyError; if a device isn't found, it will be UnknownDevice - # instance - but allow it, otherwise it will be impossible to unassign - # an already removed device - dev = self.app.domains[backend_domain].devices[devclass][ident] - - self.fire_event_for_permission(device=dev, devclass=devclass) + self.fire_event_for_permission(device=dev) - assignment = qubes.device_protocol.DeviceAssignment( - dev.backend_domain, dev.ident, devclass=devclass) await self.dest.devices[devclass].unassign(assignment) self.app.save() @@ -1357,22 +1364,12 @@ async def vm_device_unassign(self, endpoint): scope='local', execute=True) async def vm_device_attach(self, endpoint, untrusted_payload): devclass = endpoint - - # qrexec already verified that no strange characters are in self.arg - backend_domain, ident = self.arg.split('+', 1) - # may raise KeyError, either on domain or ident - dev = self.app.domains[backend_domain].devices[devclass][ident] - - assignment = qubes.device_protocol.DeviceAssignment.deserialize( - untrusted_payload, expected_device=dev - ) + dev = self.load_device_info(devclass) + assignment = DeviceAssignment.deserialize( + untrusted_payload, expected_device=dev) self.fire_event_for_permission( - device=dev, devclass=devclass, - required=assignment.required, - attach_automatically=assignment.attach_automatically, - options=assignment.options - ) + device=dev, mode=assignment.mode.value, options=assignment.options) await self.dest.devices[devclass].attach(assignment) @@ -1386,23 +1383,16 @@ async def vm_device_attach(self, endpoint, untrusted_payload): no_payload=True, scope='local', execute=True) async def vm_device_detach(self, endpoint): devclass = endpoint + dev = self.load_device_info(devclass) - # qrexec already verified that no strange characters are in self.arg - backend_domain, ident = self.arg.split('+', 1) - # may raise KeyError; if device isn't found, it will be UnknownDevice - # instance - but allow it, otherwise it will be impossible to detach - # already removed device - dev = self.app.domains[backend_domain].devices[devclass][ident] - - self.fire_event_for_permission(device=dev, devclass=devclass) + self.fire_event_for_permission(device=dev) - assignment = qubes.device_protocol.DeviceAssignment( - dev.backend_domain, dev.ident, devclass=devclass) + assignment = qubes.device_protocol.DeviceAssignment(dev) await self.dest.devices[devclass].detach(assignment) # Assign/Unassign action can modify only a persistent state of running VM. # For this reason, write=True - @qubes.api.method('admin.vm.device.{endpoint}.Set.required', + @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) @@ -1416,20 +1406,20 @@ async def vm_device_set_required(self, endpoint, untrusted_payload): """ devclass = endpoint - self.enforce(untrusted_payload in (b'True', b'False')) - # now is safe to eval, since the value of untrusted_payload is trusted - # pylint: disable=eval-used - assignment = eval(untrusted_payload) - del untrusted_payload + allowed_values = { + b'required': AssignmentMode.REQUIRED, + b'ask-to-attach': AssignmentMode.ASK, + b'auto-attach': AssignmentMode.AUTO} + try: + mode = allowed_values[untrusted_payload] + except KeyError: + raise qubes.exc.PermissionDenied() - # qrexec already verified that no strange characters are in self.arg - backend_domain_name, ident = self.arg.split('+', 1) - backend_domain = self.app.domains[backend_domain_name] - dev = Device(backend_domain, ident, devclass) + dev = VirtualDevice.from_qarg(self.arg, devclass, self.app.domains) - self.fire_event_for_permission(device=dev, assignment=assignment) + self.fire_event_for_permission(device=dev, mode=mode) - await self.dest.devices[devclass].update_required(dev, assignment) + await self.dest.devices[devclass].update_assignment(dev, mode) self.app.save() @qubes.api.method('admin.vm.firewall.Get', no_payload=True, diff --git a/qubes/app.py b/qubes/app.py index 2051afc44..35d1866ce 100644 --- a/qubes/app.py +++ b/qubes/app.py @@ -354,7 +354,7 @@ def get_vm_stats(self, previous_time=None, previous=None, only_vm=None): """Measure cpu usage for all domains at once. If previous measurements are given, CPU usage will be given in - percents of time. Otherwise only absolute value (seconds). + percents of time. Otherwise, only absolute value (seconds). Return a tuple of (measurements_time, measurements), where measurements is a dictionary with key: domid, value: dict: @@ -404,19 +404,21 @@ def get_vm_stats(self, previous_time=None, previous=None, only_vm=None): domid = vm['domid'] current[domid] = {} current[domid]['memory_kb'] = vm['mem_kb'] - current[domid]['cpu_time'] = int(vm['cpu_time']) + current[domid]['cpu_time'] = round(vm['cpu_time']) vcpus = max(vm['online_vcpus'], 1) if domid in previous: - current[domid]['cpu_usage_raw'] = int( + current[domid]['cpu_usage_raw'] = round( (current[domid]['cpu_time'] - previous[domid]['cpu_time']) - / 1000 ** 3 * 100 / (current_time - previous_time)) + / 1000 ** 3 * 100 / (current_time - previous_time) + ) if current[domid]['cpu_usage_raw'] < 0: # VM has been rebooted current[domid]['cpu_usage_raw'] = 0 else: current[domid]['cpu_usage_raw'] = 0 - current[domid]['cpu_usage'] = \ - int(current[domid]['cpu_usage_raw'] / vcpus) + current[domid]['cpu_usage'] = round( + current[domid]['cpu_usage_raw'] / vcpus + ) return current_time, current @@ -1542,7 +1544,7 @@ def on_domain_pre_deleted(self, event, vm): assignments = vm.get_provided_assignments() if assignments: desc = ', '.join( - assignment.ident for assignment in assignments) + assignment.port_id for assignment in assignments) raise qubes.exc.QubesVMInUseError( vm, 'VM has devices assigned to other VMs: ' + desc) diff --git a/qubes/config.py b/qubes/config.py index d44ff035d..d179f0f22 100644 --- a/qubes/config.py +++ b/qubes/config.py @@ -28,7 +28,26 @@ import os.path +from typing import TypedDict, Dict + +class PoolConfig(TypedDict, total=False): + dir_path: str + name: str + driver: str + +class Defaults(TypedDict): + libvirt_uri: str + memory: int + hvm_memory: int + kernelopts: str + kernelopts_pcidevs: str + kernelopts_common: str + private_img_size: int + root_img_size: int + pool_configs: Dict[str, PoolConfig] + qubes_base_dir = "/var/lib/qubes" + system_path = { 'qrexec_daemon_path': '/usr/sbin/qrexec-daemon', 'qrexec_client_path': '/usr/bin/qrexec-client', @@ -48,7 +67,7 @@ 'dom0_services_dir': '/var/run/qubes-service', } -defaults = { +defaults: Defaults = { 'libvirt_uri': 'xen:///', 'memory': 400, 'hvm_memory': 400, @@ -67,8 +86,9 @@ 'name': 'varlibqubes' }, 'linux-kernel': { - 'dir_path': os.path.join(qubes_base_dir, - system_path['qubes_kernels_base_dir']), + 'dir_path': os.path.join( + qubes_base_dir, system_path['qubes_kernels_base_dir'] + ), 'driver': 'linux-kernel', 'name': 'linux-kernel' } diff --git a/qubes/device_protocol.py b/qubes/device_protocol.py index fc6defdb8..b4ec4117f 100644 --- a/qubes/device_protocol.py +++ b/qubes/device_protocol.py @@ -5,10 +5,10 @@ # Copyright (C) 2010-2016 Joanna Rutkowska # Copyright (C) 2015-2016 Wojtek Porczyk # Copyright (C) 2016 Bahtiar `kalkin-` Gadimov -# Copyright (C) 2017 Marek Marczykowski-Górecki +# Copyright (C) 2017 Marek Marczykowski-Górecki # # Copyright (C) 2024 Piotr Bartman-Szwarc -# +# # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -34,13 +34,16 @@ import string import sys from enum import Enum -from typing import Optional, Dict, Any, List, Union, Tuple +from typing import Optional, Dict, Any, List, Union, Tuple, Callable +from typing import TYPE_CHECKING import qubes.utils - from qubes.exc import ProtocolError -QubesVM = 'qubes.vm.BaseVM' +if TYPE_CHECKING: + from qubes.vm.qubesvm import QubesVM +else: + QubesVM = "qubes.vm.qubesvm.QubesVM" class UnexpectedDeviceProperty(qubes.exc.QubesException, ValueError): @@ -53,101 +56,22 @@ def qbool(value): return qubes.property.bool(None, None, value) -class Device: +class DeviceSerializer: """ - Basic class of a *bus* device with *ident* exposed by a *backend domain*. - - Attributes: - backend_domain (QubesVM): The domain which exposes devices, - e.g.`sys-usb`. - ident (str): A unique identifier for the device within - the backend domain. - devclass (str, optional): The class of the device (e.g., 'usb', 'pci'). + Group of method for serialization of device properties. """ - ALLOWED_CHARS_KEY = set( - string.digits + string.ascii_letters - + r"!#$%&()*+,-./:;<>?@[\]^_{|}~") - ALLOWED_CHARS_PARAM = ALLOWED_CHARS_KEY.union(set(string.punctuation + ' ')) - - def __init__(self, backend_domain, ident, devclass=None): - self.__backend_domain = backend_domain - self.__ident = ident - self.__bus = devclass - - def __hash__(self): - return hash((str(self.backend_domain), self.ident)) - - def __eq__(self, other): - if isinstance(other, Device): - return ( - self.backend_domain == other.backend_domain and - self.ident == other.ident - ) - raise TypeError(f"Comparing instances of 'Device' and '{type(other)}' " - "is not supported") - def __lt__(self, other): - if isinstance(other, Device): - return (self.backend_domain.name, self.ident) < \ - (other.backend_domain.name, other.ident) - raise TypeError(f"Comparing instances of 'Device' and '{type(other)}' " - "is not supported") - - def __repr__(self): - return "[%s]:%s" % (self.backend_domain, self.ident) - - def __str__(self): - return '{!s}:{!s}'.format(self.backend_domain, self.ident) - - @property - def ident(self) -> str: - """ - Immutable device identifier. - - Unique for given domain and device type. - """ - return self.__ident - - @property - def backend_domain(self) -> QubesVM: - """ Which domain provides this device. (immutable)""" - return self.__backend_domain - - @property - def devclass(self) -> str: - """ Immutable* Device class such like: 'usb', 'pci' etc. - - For unknown devices "peripheral" is returned. - - *see `@devclass.setter` - """ - if self.__bus: - return self.__bus - return "peripheral" - - @property - def devclass_is_set(self) -> bool: - """ - Returns true if devclass is already initialised. - """ - return bool(self.__bus) - - @devclass.setter - def devclass(self, devclass: str): - """ Once a value is set, it should not be overridden. - - However, if it has not been set, i.e., the value is `None`, - we can override it.""" - if self.__bus is not None: - raise TypeError("Attribute devclass is immutable") - self.__bus = devclass + ALLOWED_CHARS_KEY = set( + string.digits + string.ascii_letters + r"!#$%&()*+,-./:;<>?@[\]^_{|}~" + ) + ALLOWED_CHARS_PARAM = ALLOWED_CHARS_KEY.union(set(string.punctuation + " ")) @classmethod def unpack_properties( - cls, untrusted_serialization: bytes + cls, untrusted_serialization: bytes ) -> Tuple[Dict, Dict]: """ - Unpacks basic device properties from a serialized encoded string. + Unpacks basic port properties from a serialized encoded string. Returns: tuple: A tuple containing two dictionaries, properties and options, @@ -158,10 +82,11 @@ def unpack_properties( names or values. """ ut_decoded = untrusted_serialization.decode( - 'ascii', errors='strict').strip() + "ascii", errors="strict" + ).strip() - properties = {} - options = {} + properties: Dict[str, str] = {} + options: Dict[str, str] = {} if not ut_decoded: return properties, options @@ -170,25 +95,33 @@ def unpack_properties( values = [] ut_key, _, ut_rest = ut_decoded.partition("='") - key = sanitize_str( - ut_key, cls.ALLOWED_CHARS_KEY, - error_message='Invalid chars in property name: ') + key = cls.sanitize_str( + ut_key.strip(), + cls.ALLOWED_CHARS_KEY, + error_message="Invalid chars in property name: ", + ) keys.append(key) while "='" in ut_rest: ut_value_key, _, ut_rest = ut_rest.partition("='") ut_value, _, ut_key = ut_value_key.rpartition("' ") - value = sanitize_str( - deserialize_str(ut_value), cls.ALLOWED_CHARS_PARAM, - error_message='Invalid chars in property value: ') + value = cls.sanitize_str( + cls.deserialize_str(ut_value), + cls.ALLOWED_CHARS_PARAM, + error_message="Invalid chars in property value: ", + ) values.append(value) - key = sanitize_str( - ut_key, cls.ALLOWED_CHARS_KEY, - error_message='Invalid chars in property name: ') + key = cls.sanitize_str( + ut_key.strip(), + cls.ALLOWED_CHARS_KEY, + error_message="Invalid chars in property name: ", + ) keys.append(key) ut_value = ut_rest[:-1] # ending ' - value = sanitize_str( - deserialize_str(ut_value), cls.ALLOWED_CHARS_PARAM, - error_message='Invalid chars in property value: ') + value = cls.sanitize_str( + cls.deserialize_str(ut_value), + cls.ALLOWED_CHARS_PARAM, + error_message="Invalid chars in property value: ", + ) values.append(value) for key, value in zip(keys, values): @@ -201,23 +134,30 @@ def unpack_properties( return properties, options @classmethod - def pack_property(cls, key: str, value: str): + def pack_property(cls, key: str, value: Optional[str]): """ Add property `key=value` to serialization. """ - key = sanitize_str( - key, cls.ALLOWED_CHARS_KEY, - error_message='Invalid chars in property name: ') - value = sanitize_str( - serialize_str(value), cls.ALLOWED_CHARS_PARAM, - error_message='Invalid chars in property value: ') - return key.encode('ascii') + b'=' + value.encode('ascii') + if value is None: + return b"" + key = cls.sanitize_str( + key, + cls.ALLOWED_CHARS_KEY, + error_message="Invalid chars in property name: ", + ) + value = cls.sanitize_str( + cls.serialize_str(value), + cls.ALLOWED_CHARS_PARAM, + error_message="Invalid chars in property value: ", + ) + return key.encode("ascii") + b"=" + value.encode("ascii") @staticmethod - def check_device_properties( - expected_device: 'Device', properties: Dict[str, Any]): + def parse_basic_device_properties( + expected_device: "VirtualDevice", properties: Dict[str, Any] + ): """ - Validates properties against an expected device configuration. + Validates properties against an expected port configuration. Modifies `properties`. @@ -225,27 +165,451 @@ def check_device_properties( UnexpectedDeviceProperty: If any property does not match the expected values. """ - expected = expected_device - exp_vm_name = expected.backend_domain.name - if properties.get('backend_domain', exp_vm_name) != exp_vm_name: + expected = expected_device.port + exp_vm_name = expected.backend_name + if properties.get("backend_domain", exp_vm_name) != exp_vm_name: raise UnexpectedDeviceProperty( f"Got device exposed by {properties['backend_domain']}" - f"when expected devices from {exp_vm_name}.") - properties['backend_domain'] = expected.backend_domain + f"when expected devices from {exp_vm_name}." + ) + properties.pop("backend_domain", None) - if properties.get('ident', expected.ident) != expected.ident: + if properties.get("port_id", expected.port_id) != expected.port_id: raise UnexpectedDeviceProperty( - f"Got device with id: {properties['ident']} " - f"when expected id: {expected.ident}.") - properties['ident'] = expected.ident + f"Got device from port: {properties['port_id']} " + f"when expected port: {expected.port_id}." + ) + properties.pop("port_id", None) - if expected.devclass_is_set: - if (properties.get('devclass', expected.devclass) - != expected.devclass): + if not expected.has_devclass: + expected = Port( + expected.backend_domain, + expected.port_id, + properties.get("devclass", None), + ) + if properties.get("devclass", expected.devclass) != expected.devclass: + raise UnexpectedDeviceProperty( + f"Got {properties['devclass']} device " + f"when expected {expected.devclass}." + ) + properties.pop("devclass", None) + + expected_devid = expected_device.device_id + # device id is optional + if expected_devid != "*": + if properties.get("device_id", expected_devid) != expected_devid: raise UnexpectedDeviceProperty( - f"Got {properties['devclass']} device " - f"when expected {expected.devclass}.") - properties['devclass'] = expected.devclass + f"Unrecognized device identity '{properties['device_id']}' " + f"expected '{expected_device.device_id}'" + ) + properties["device_id"] = properties.get("device_id", expected_devid) + + properties["port"] = expected + + @staticmethod + def serialize_str(value: str) -> str: + """ + Serialize python string to ensure consistency. + """ + return "'" + str(value).replace("'", r"\'") + "'" + + @staticmethod + def deserialize_str(value: str) -> str: + """ + Deserialize python string to ensure consistency. + """ + return value.replace(r"\'", "'") + + @staticmethod + def sanitize_str( + untrusted_value: str, + allowed_chars: set, + replace_char: Optional[str] = None, + error_message: str = "", + ) -> str: + """ + Sanitize given untrusted string. + + If `replace_char` is not None, ignore `error_message` and replace + invalid characters with the string. + """ + if replace_char is None: + not_allowed_chars = set(untrusted_value) - allowed_chars + if not_allowed_chars: + raise ProtocolError(error_message + repr(not_allowed_chars)) + return untrusted_value + result = "" + for char in untrusted_value: + if char in allowed_chars: + result += char + else: + result += replace_char + return result + + +class Port: + """ + Class of a *bus* device port with *port id* exposed by a *backend domain*. + + Attributes: + backend_domain (QubesVM): The domain which exposes devices, + e.g.`sys-usb`. + port_id (str): A unique (in backend domain) identifier for the port. + devclass (str): The class of the port (e.g., 'usb', 'pci'). + """ + + def __init__( + self, + backend_domain: Optional[QubesVM], + port_id: Optional[str], + devclass: Optional[str], + ): + self.__backend_domain = backend_domain + self.__port_id = port_id + self.__devclass = devclass + + def __hash__(self): + return hash((self.backend_name, self.port_id, self.devclass)) + + def __eq__(self, other): + if isinstance(other, Port): + return ( + self.backend_name == other.backend_name + and self.port_id == other.port_id + and self.devclass == other.devclass + ) + return False + + def __lt__(self, other): + if isinstance(other, Port): + return (self.backend_name, self.devclass, self.port_id) < ( + other.backend_name, + other.devclass, + other.port_id, + ) + raise TypeError( + f"Comparing instances of 'Port' and '{type(other)}' " + "is not supported" + ) + + def __repr__(self): + return f"{self.backend_name}+{self.port_id}" + + def __str__(self): + return f"{self.backend_name}:{self.port_id}" + + @property + def backend_name(self) -> str: + # pylint: disable=missing-function-docstring + if self.backend_domain is not None: + return self.backend_domain.name + return "*" + + @classmethod + def from_qarg( + cls, representation: str, devclass, domains, blind=False + ) -> "Port": + """ + Parse qrexec argument + to retrieve Port. + """ + if blind: + get_domain = domains.get_blind + else: + get_domain = domains.__getitem__ + return cls._parse(representation, devclass, get_domain, "+") + + @classmethod + def from_str( + cls, representation: str, devclass, domains, blind=False + ) -> "Port": + """ + Parse string : to retrieve Port. + """ + if blind: + get_domain = domains.get_blind + else: + get_domain = domains.__getitem__ + return cls._parse(representation, devclass, get_domain, ":") + + @classmethod + def _parse( + cls, representation: str, devclass: str, get_domain: Callable, sep: str + ) -> "Port": + """ + Parse string representation and return instance of Port. + """ + backend_name, port_id = representation.split(sep, 1) + backend = get_domain(backend_name) + return cls(backend_domain=backend, port_id=port_id, devclass=devclass) + + @property + def port_id(self) -> str: + """ + Immutable port identifier. + + Unique for given domain and devclass. + """ + if self.__port_id is not None: + return self.__port_id + return "*" + + @property + def backend_domain(self) -> Optional[QubesVM]: + """Which domain exposed this port. (immutable)""" + return self.__backend_domain + + @property + def devclass(self) -> str: + """Immutable port class such like: 'usb', 'pci' etc. + + For unknown classes "peripheral" is returned. + """ + if self.__devclass: + return self.__devclass + return "peripheral" + + @property + def has_devclass(self): + """Returns True if devclass is set.""" + return self.__devclass is not None + + +class AnyPort(Port): + """Represents any port in virtual devices ("*")""" + + def __init__(self, devclass: str): + super().__init__(None, "*", devclass) + + def __repr__(self): + return "*" + + def __str__(self): + return "*" + + +class VirtualDevice: + """ + Class of a device connected to *port*. + + Attributes: + port (Port): Peripheral device port exposed by vm. + device_id (str): An identifier for the device. + """ + + def __init__( + self, + port: Optional[Port] = None, + device_id: Optional[str] = None, + ): + assert not isinstance(port, AnyPort) or device_id is not None + self.port: Optional[Port] = port # type: ignore + self._device_id = device_id + + def clone(self, **kwargs) -> "VirtualDevice": + """ + Clone object and substitute attributes with explicitly given. + """ + attr: Dict[str, Any] = { + "port": self.port, + "device_id": self.device_id, + } + attr.update(kwargs) + return VirtualDevice(**attr) + + @property + def port(self) -> Port: + # pylint: disable=missing-function-docstring + return self._port + + @port.setter + def port(self, value: Union[Port, str, None]): + # pylint: disable=missing-function-docstring + if isinstance(value, Port): + self._port = value + return + if isinstance(value, str) and value != "*": + raise ValueError("Unsupported value for port") + if self.device_id == "*": + raise ValueError("Cannot set port to '*' if device_is is '*'") + self._port = AnyPort(self.devclass) + + @property + def device_id(self) -> str: + # pylint: disable=missing-function-docstring + if self._device_id is not None and self.is_device_id_set: + return self._device_id + return "*" + + @property + def is_device_id_set(self) -> bool: + """ + Check if `device_id` is explicitly set. + """ + return self._device_id is not None + + @property + def backend_domain(self) -> Optional[QubesVM]: + # pylint: disable=missing-function-docstring + return self.port.backend_domain + + @property + def backend_name(self) -> str: + """ + Return backend domain name if any or `*`. + """ + return self.port.backend_name + + @property + def port_id(self) -> str: + # pylint: disable=missing-function-docstring + return self.port.port_id + + @property + def devclass(self) -> str: + # pylint: disable=missing-function-docstring + return self.port.devclass + + @property + def description(self) -> str: + """ + Return human-readable description of the device identity. + """ + if not self.device_id or self.device_id == "*": + return "any device" + return self.device_id + + def __hash__(self): + return hash((self.port, self.device_id)) + + def __eq__(self, other): + if isinstance(other, (VirtualDevice, DeviceAssignment)): + result = ( + self.port == other.port and self.device_id == other.device_id + ) + return result + if isinstance(other, Port): + return self.port == other and self.device_id == "*" + return super().__eq__(other) + + def __lt__(self, other): + """ + Desired order (important for auto-attachment): + + 1. : + 2. :* + 3. *: + 4. *:* + """ + if isinstance(other, (VirtualDevice, DeviceAssignment)): + if isinstance(self.port, AnyPort) and not isinstance( + other.port, AnyPort + ): + return True + if not isinstance(self.port, AnyPort) and isinstance( + other.port, AnyPort + ): + return False + reprs = {self: [self.port], other: [other.port]} + for obj, obj_repr in reprs.items(): + if obj.device_id != "*": + obj_repr.append(obj.device_id) + return reprs[self] < reprs[other] + if isinstance(other, Port): + _other = VirtualDevice(other, "*") + return self < _other + raise TypeError( + f"Comparing instances of {type(self)} and '{type(other)}' " + "is not supported" + ) + + def __repr__(self): + return f"{self.port!r}:{self.device_id}" + + def __str__(self): + return f"{self.port}:{self.device_id}" + + @classmethod + def from_qarg( + cls, + representation: str, + devclass: Optional[str], + domains, + blind: bool = False, + backend: Optional[QubesVM] = None, + ) -> "VirtualDevice": + """ + Parse qrexec argument +: to get device info + """ + if backend is None: + if blind: + get_domain = domains.get_blind + else: + get_domain = domains.__getitem__ + else: + get_domain = None + return cls._parse(representation, devclass, get_domain, backend, "+") + + @classmethod + def from_str( + cls, + representation: str, + devclass: Optional[str], + domains, + blind: bool = False, + backend: Optional[QubesVM] = None, + ) -> "VirtualDevice": + """ + Parse string +: to get device info + """ + if backend is None: + if blind: + get_domain = domains.get_blind + else: + get_domain = domains.__getitem__ + else: + get_domain = None + return cls._parse(representation, devclass, get_domain, backend, ":") + + @classmethod + def _parse( + cls, + representation: str, + devclass: Optional[str], + get_domain: Callable, + backend: Optional[QubesVM], + sep: str, + ) -> "VirtualDevice": + """ + Parse string representation and return instance of VirtualDevice. + """ + if backend is None: + backend_name, identity = representation.split(sep, 1) + if backend_name != "*": + backend = get_domain(backend_name) + else: + identity = representation + + port_id, _, devid = identity.partition(":") + return cls( + Port(backend_domain=backend, port_id=port_id, devclass=devclass), + device_id=devid or None, + ) + + def serialize(self) -> bytes: + """ + Serialize an object to be transmitted via Qubes API. + """ + properties = b" ".join( + DeviceSerializer.pack_property(key, value) + for key, value in ( + ("device_id", self.device_id), + ("port_id", self.port_id), + ("devclass", self.devclass), + ("backend_domain", self.backend_name), + ) + ) + + return properties class DeviceCategory(Enum): @@ -255,6 +619,7 @@ class DeviceCategory(Enum): Arbitrarily selected interfaces that are important to users, thus deserving special recognition such as a custom icon, etc. """ + # pylint: disable=invalid-name Other = "*******" @@ -264,13 +629,19 @@ class DeviceCategory(Enum): Mouse = ("u03**02", "p0902**") Printer = ("u07****",) Scanner = ("p0903**",) - # Multimedia = Audio, Video, Displays etc. Microphone = ("m******",) - Multimedia = ("u01****", "u0e****", "u06****", "u10****", "p03****", - "p04****") + # Multimedia = Audio, Video, Displays etc. + Multimedia = ( + "u01****", + "u0e****", + "u06****", + "u10****", + "p03****", + "p04****", + ) Wireless = ("ue0****", "p0d****") Bluetooth = ("ue00101", "p0d11**") - Mass_Data = ("b******", "u08****", "p01****") + Storage = ("b******", "u08****", "p01****") Network = ("p02****",) Memory = ("p05****",) PCI_Bridge = ("p06****",) @@ -280,7 +651,7 @@ class DeviceCategory(Enum): PCI_USB = ("p0c03**",) @staticmethod - def from_str(interface_encoding: str) -> 'DeviceCategory': + def from_str(interface_encoding: str) -> "DeviceCategory": """ Returns `DeviceCategory` from data encoded in string. """ @@ -312,29 +683,33 @@ class DeviceInterface: """ def __init__(self, interface_encoding: str, devclass: Optional[str] = None): - ifc_padded = interface_encoding.ljust(6, '*') + ifc_padded = interface_encoding.ljust(6, "*") if devclass: if len(ifc_padded) > 6: print( f"{interface_encoding=} is too long " f"(is {len(interface_encoding)}, expected max. 6) " f"for given {devclass=}", - file=sys.stderr + file=sys.stderr, ) ifc_full = devclass[0] + ifc_padded else: known_devclasses = { - 'p': 'pci', 'u': 'usb', 'b': 'block', 'm': 'mic'} + "p": "pci", + "u": "usb", + "b": "block", + "m": "mic", + } devclass = known_devclasses.get(interface_encoding[0], None) if len(ifc_padded) > 7: print( f"{interface_encoding=} is too long " f"(is {len(interface_encoding)}, expected max. 7)", - file=sys.stderr + file=sys.stderr, ) ifc_full = ifc_padded elif len(ifc_padded) == 6: - ifc_full = '?' + ifc_padded + ifc_full = "?" + ifc_padded else: ifc_full = ifc_padded @@ -344,17 +719,17 @@ def __init__(self, interface_encoding: str, devclass: Optional[str] = None): @property def devclass(self) -> Optional[str]: - """ Immutable Device class such like: 'usb', 'pci' etc. """ + """Immutable Device class such like: 'usb', 'pci' etc.""" return self._devclass @property def category(self) -> DeviceCategory: - """ Immutable Device category such like: 'Mouse', 'Mass_Data' etc. """ + """Immutable Device category such like: 'Mouse', 'Mass_Data' etc.""" return self._category @classmethod - def unknown(cls) -> 'DeviceInterface': - """ Value for unknown device interface. """ + def unknown(cls) -> "DeviceInterface": + """Value for unknown device interface.""" return cls("?******") def __repr__(self): @@ -370,25 +745,41 @@ def __eq__(self, other): def __str__(self): if self.devclass == "block": - return "Block device" + return "Block Device" if self.devclass in ("usb", "pci"): # try subclass first as in `lspci` result = self._load_classes(self.devclass).get( - self._interface_encoding[1:-2] + '**', None) - if (result is None or result.lower() - in ('none', 'no subclass', 'unused', 'undefined')): + self._interface_encoding[1:-2] + "**", None + ) + if result is None or result.lower() in ( + "none", + "no subclass", + "unused", + "undefined", + "vendor specific subclass", + ): # if not, try interface result = self._load_classes(self.devclass).get( - self._interface_encoding[1:], None) - if (result is None or result.lower() - in ('none', 'no subclass', 'unused', 'undefined')): + self._interface_encoding[1:], None + ) + if result is None or result.lower() in ( + "none", + "unused", + "undefined", + ): # if not, try class result = self._load_classes(self.devclass).get( - self._interface_encoding[1:-4] + '****', None) - if result is None: - result = f"Unclassified {self.devclass} device" + self._interface_encoding[1:-4] + "****", None + ) + if result is None or result.lower() in ( + "none", + "unused", + "undefined", + "vendor specific class", + ): + result = f"{self.devclass.upper()} device" return result - if self.devclass == 'mic': + if self.devclass == "mic": return "Microphone" return repr(self) @@ -402,53 +793,74 @@ def _load_classes(bus: str): # subclass subclass_name <-- single tab # prog-if prog-if_name <-- two tabs result = {} - with open(f'/usr/share/hwdata/{bus}.ids', - encoding='utf-8', errors='ignore') as pciids: + with open( + f"/usr/share/hwdata/{bus}.ids", encoding="utf-8", errors="ignore" + ) as pciids: # for `class_name` and `subclass_name` # pylint: disable=used-before-assignment class_id = None subclass_id = None for line in pciids.readlines(): line = line.rstrip() - if line.startswith('\t\t') \ - and class_id is not None and subclass_id is not None: - (progif_id, _, progif_name) = line[2:].split(' ', 2) - result[class_id + subclass_id + progif_id] = \ - progif_name - elif line.startswith('\t') and class_id: - (subclass_id, _, subclass_name) = line[1:].split(' ', 2) + if ( + line.startswith("\t\t") + and class_id is not None + and subclass_id is not None + ): + (progif_id, _, progif_name) = line[2:].split(" ", 2) + result[class_id + subclass_id + progif_id] = progif_name + elif line.startswith("\t") and class_id: + (subclass_id, _, subclass_name) = line[1:].split(" ", 2) # store both prog-if specific entry and generic one - result[class_id + subclass_id + '**'] = \ - subclass_name - elif line.startswith('C '): - (_, class_id, _, class_name) = line.split(' ', 3) - result[class_id + '****'] = class_name + result[class_id + subclass_id + "**"] = subclass_name + elif line.startswith("C "): + (_, class_id, _, class_name) = line.split(" ", 3) + result[class_id + "****"] = class_name subclass_id = None return result + def matches(self, other: "DeviceInterface") -> bool: + """ + Check if this `DeviceInterface` (pattern) matches given one. + + The matching is done character by character using the string + representation (`repr`) of both objects. A wildcard character (`'*'`) + in the pattern (i.e., `self`) can match any character in the candidate + (i.e., `other`). + The two representations must be of the same length. + """ + pattern = repr(self) + candidate = repr(other) + if len(pattern) != len(candidate): + return False + for patt, cand in zip(pattern, candidate): + if patt == "*": + continue + if patt != cand: + return False + return True + -class DeviceInfo(Device): - """ Holds all information about a device """ +class DeviceInfo(VirtualDevice): + """Holds all information about a device""" def __init__( - self, - backend_domain: QubesVM, - ident: str, - *, - devclass: Optional[str] = None, - vendor: Optional[str] = None, - product: Optional[str] = None, - manufacturer: Optional[str] = None, - name: Optional[str] = None, - serial: Optional[str] = None, - interfaces: Optional[List[DeviceInterface]] = None, - parent: Optional[Device] = None, - attachment: Optional[QubesVM] = None, - self_identity: Optional[str] = None, - **kwargs + self, + port: Port, + *, + vendor: Optional[str] = None, + product: Optional[str] = None, + manufacturer: Optional[str] = None, + name: Optional[str] = None, + serial: Optional[str] = None, + interfaces: Optional[List[DeviceInterface]] = None, + parent: Optional["DeviceInfo"] = None, + attachment: Optional[QubesVM] = None, + device_id: Optional[str] = None, + **kwargs, ): - super().__init__(backend_domain, ident, devclass) + super().__init__(port, device_id) self._vendor = vendor self._product = product @@ -458,7 +870,6 @@ def __init__( self._interfaces = interfaces self._parent = parent self._attachment = attachment - self._self_identity = self_identity self.data = kwargs @@ -555,8 +966,18 @@ def description(self) -> str: else: vendor = "unknown vendor" - main_interface = str(self.interfaces[0]) - return f"{main_interface}: {vendor} {prod}" + for interface in self.interfaces: + if interface.category.name != "Other": + cat = interface.category.name + break + else: + for interface in self.interfaces: + if str(interface) != f"{self.devclass.upper()} device": + cat = str(interface) + break + else: + cat = f"{self.devclass.upper()} device" + return f"{cat}: {vendor} {prod}" @property def interfaces(self) -> List[DeviceInterface]: @@ -570,7 +991,7 @@ def interfaces(self) -> List[DeviceInterface]: return self._interfaces @property - def parent_device(self) -> Optional[Device]: + def parent_device(self) -> Optional[VirtualDevice]: """ The parent device, if any. @@ -580,15 +1001,21 @@ def parent_device(self) -> Optional[Device]: return self._parent @property - def subdevices(self) -> List['DeviceInfo']: + def subdevices(self) -> List[VirtualDevice]: """ The list of children devices if any. If the device has subdevices (e.g., partitions of a USB stick), the subdevices id should be here. """ - return [dev for dev in self.backend_domain.devices[self.devclass] - if dev.parent_device.ident == self.ident] + if not self.backend_domain: + return [] + return [ + dev + for devclass in self.backend_domain.devices.keys() + for dev in self.backend_domain.devices[devclass] + if dev.parent_device.port.port_id == self.port_id + ] @property def attachment(self) -> Optional[QubesVM]: @@ -601,111 +1028,116 @@ def serialize(self) -> bytes: """ Serialize an object to be transmitted via Qubes API. """ - # 'backend_domain', 'attachment', 'interfaces', 'data', 'parent_device' + properties = super().serialize() + # 'attachment', 'interfaces', 'data', 'parent_device' # are not string, so they need special treatment - default_attrs = { - 'ident', 'devclass', 'vendor', 'product', 'manufacturer', 'name', - 'serial', 'self_identity'} - properties = b' '.join( - self.pack_property(key, value) - for key, value in ((key, getattr(self, key)) - for key in default_attrs)) - - properties += b' ' + self.pack_property( - 'backend_domain', self.backend_domain.name) + default = DeviceInfo(self.port) + default_attrs = {"vendor", "product", "manufacturer", "name", "serial"} + properties += b" " + b" ".join( + DeviceSerializer.pack_property(key, value) + for key, value in ( + (key, getattr(self, key)) + for key in default_attrs + if getattr(self, key) != getattr(default, key) + ) + ) if self.attachment: - properties = self.pack_property( - 'attachment', self.attachment.name) + properties += b" " + DeviceSerializer.pack_property( + "attachment", self.attachment.name + ) - properties += b' ' + self.pack_property( - 'interfaces', - ''.join(repr(ifc) for ifc in self.interfaces)) + properties += b" " + DeviceSerializer.pack_property( + "interfaces", "".join(repr(ifc) for ifc in self.interfaces) + ) if self.parent_device is not None: - properties += b' ' + self.pack_property( - 'parent_ident', self.parent_device.ident) - properties += b' ' + self.pack_property( - 'parent_devclass', self.parent_device.devclass) + properties += b" " + DeviceSerializer.pack_property( + "parent_port_id", self.parent_device.port_id + ) + properties += b" " + DeviceSerializer.pack_property( + "parent_devclass", self.parent_device.devclass + ) for key, value in self.data.items(): - properties += b' ' + self.pack_property("_" + key, value) + properties += b" " + DeviceSerializer.pack_property( + "_" + key, value + ) return properties @classmethod def deserialize( - cls, - serialization: bytes, - expected_backend_domain: QubesVM, - expected_devclass: Optional[str] = None, - ) -> 'DeviceInfo': + cls, + serialization: bytes, + expected_backend_domain: QubesVM, + expected_devclass: Optional[str] = None, + ) -> "DeviceInfo": """ Recovers a serialized object, see: :py:meth:`serialize`. """ - ident, _, rest = serialization.partition(b' ') - ident = ident.decode('ascii', errors='ignore') - device = UnknownDevice( - backend_domain=expected_backend_domain, - ident=ident, - devclass=expected_devclass, + head, _, rest = serialization.partition(b" ") + device = VirtualDevice.from_str( + head.decode("ascii", errors="ignore"), + expected_devclass, + domains=None, + backend=expected_backend_domain, ) try: device = cls._deserialize(rest, device) # pylint: disable=broad-exception-caught except Exception as exc: - print(exc, file=sys.stderr) + print(str(exc), file=sys.stderr) + device = UnknownDevice.from_device(device) return device @classmethod def _deserialize( - cls, - untrusted_serialization: bytes, - expected_device: Device - ) -> 'DeviceInfo': + cls, untrusted_serialization: bytes, expected_device: VirtualDevice + ) -> "DeviceInfo": """ Actually deserializes the object. """ - properties, options = cls.unpack_properties(untrusted_serialization) + properties, options = DeviceSerializer.unpack_properties( + untrusted_serialization + ) properties.update(options) - cls.check_device_properties(expected_device, properties) + DeviceSerializer.parse_basic_device_properties( + expected_device, properties + ) - if 'attachment' not in properties or not properties['attachment']: - properties['attachment'] = None - else: + if "attachment" not in properties or not properties["attachment"]: + properties["attachment"] = None + elif expected_device.backend_domain: app = expected_device.backend_domain.app - properties['attachment'] = app.domains.get_blind( - properties['attachment']) - - if (expected_device.devclass_is_set - and properties['devclass'] != expected_device.devclass): - raise UnexpectedDeviceProperty( - f"Got {properties['devclass']} device " - f"when expected {expected_device.devclass}.") + properties["attachment"] = app.domains.get_blind( + properties["attachment"] + ) - if 'interfaces' in properties: - interfaces = properties['interfaces'] + if "interfaces" in properties: + interfaces = properties["interfaces"] interfaces = [ - DeviceInterface(interfaces[i:i + 7]) - for i in range(0, len(interfaces), 7)] - properties['interfaces'] = interfaces + DeviceInterface(interfaces[i : i + 7]) + for i in range(0, len(interfaces), 7) + ] + properties["interfaces"] = interfaces - if 'parent_ident' in properties: - properties['parent'] = Device( + if "parent_ident" in properties: + properties["parent"] = Port( backend_domain=expected_device.backend_domain, - ident=properties['parent_ident'], - devclass=properties['parent_devclass'], + port_id=properties["parent_ident"], + devclass=properties["parent_devclass"], ) - del properties['parent_ident'] - del properties['parent_devclass'] + del properties["parent_ident"] + del properties["parent_devclass"] return cls(**properties) @property - def self_identity(self) -> str: + def device_id(self) -> str: """ Get additional identification of device presented by device itself. @@ -720,136 +1152,205 @@ def self_identity(self) -> str: to be plugged to the same port). For a common user it is all the data she uses to recognize the device. """ - if not self._self_identity: + if not self._device_id: return "0000:0000::?******" - return self._self_identity + return self._device_id + @device_id.setter + def device_id(self, value): + # Do not auto-override value like in super class + self._device_id = value -def serialize_str(value: str): - """ - Serialize python string to ensure consistency. - """ - return "'" + str(value).replace("'", r"\'") + "'" +class UnknownDevice(DeviceInfo): + # pylint: disable=too-few-public-methods + """Unknown device - for example, exposed by domain not running currently""" -def deserialize_str(value: str): - """ - Deserialize python string to ensure consistency. - """ - return value.replace(r"\'", "'") + @staticmethod + def from_device(device: VirtualDevice) -> "UnknownDevice": + """ + Return `UnknownDevice` based on any virtual device. + """ + return UnknownDevice(device.port, device_id=device.device_id) -def sanitize_str( - untrusted_value: str, - allowed_chars: set, - replace_char: str = None, - error_message: str = "" -) -> str: +class AssignmentMode(Enum): """ - Sanitize given untrusted string. - - If `replace_char` is not None, ignore `error_message` and replace invalid - characters with the string. + Device assignment modes """ - if replace_char is None: - not_allowed_chars = set(untrusted_value) - allowed_chars - if not_allowed_chars: - raise ProtocolError(error_message + repr(not_allowed_chars)) - return untrusted_value - result = "" - for char in untrusted_value: - if char in allowed_chars: - result += char - else: - result += replace_char - return result + MANUAL = "manual" + ASK = "ask-to-attach" + AUTO = "auto-attach" + REQUIRED = "required" -class UnknownDevice(DeviceInfo): - # pylint: disable=too-few-public-methods - """Unknown device - for example, exposed by domain not running currently""" - def __init__(self, backend_domain, ident, *, devclass, **kwargs): - super().__init__(backend_domain, ident, devclass=devclass, **kwargs) - - -class DeviceAssignment(Device): - """ Maps a device to a frontend_domain. - - There are 3 flags `attached`, `automatically_attached` and `required`. - The meaning of valid combinations is as follows: - 1. (True, False, False) -> domain is running, device is manually attached - and could be manually detach any time. - 2. (True, True, False) -> domain is running, device is attached - and could be manually detach any time (see 4.), - but in the future will be auto-attached again. - 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 (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. +class DeviceAssignment: + """ + Maps a device to a frontend_domain. """ - def __init__(self, backend_domain, ident, *, options=None, - frontend_domain=None, devclass=None, - required=False, attach_automatically=False): - super().__init__(backend_domain, ident, devclass) + def __init__( + self, + device: VirtualDevice, + frontend_domain=None, + options=None, + mode: Union[str, AssignmentMode] = "manual", + ): + if isinstance(device, DeviceInfo): + device = VirtualDevice(device.port, device.device_id) + self.virtual_device = device self.__options = options or {} - if required: - assert attach_automatically - self.__required = required - self.__attach_automatically = attach_automatically + if isinstance(mode, AssignmentMode): + self.mode = mode + else: + self.mode = AssignmentMode(mode) self.frontend_domain = frontend_domain + @classmethod + def new( + cls, + backend_domain: QubesVM, + port_id: str, + devclass: str, + device_id: Optional[str] = None, + *, + frontend_domain: Optional[QubesVM] = None, + options=None, + mode: Union[str, AssignmentMode] = "manual", + ) -> "DeviceAssignment": + """Helper method to create a DeviceAssignment object.""" + return cls( + VirtualDevice(Port(backend_domain, port_id, devclass), device_id), + frontend_domain, + options, + mode, + ) + def clone(self, **kwargs): """ Clone object and substitute attributes with explicitly given. """ attr = { - "backend_domain": self.backend_domain, - "ident": self.ident, + "device": self.virtual_device, "options": self.options, - "required": self.required, - "attach_automatically": self.attach_automatically, + "mode": self.mode, "frontend_domain": self.frontend_domain, - "devclass": self.devclass, } attr.update(kwargs) return self.__class__(**attr) - @classmethod - def from_device(cls, device: Device, **kwargs) -> 'DeviceAssignment': - """ - Get assignment of the device. - """ - return cls( - backend_domain=device.backend_domain, - ident=device.ident, - devclass=device.devclass, - **kwargs + def __repr__(self): + return f"{self.virtual_device!r}" + + def __str__(self): + return f"{self.virtual_device}" + + def __hash__(self): + return hash(self.virtual_device) + + def __eq__(self, other): + if isinstance(other, (VirtualDevice, DeviceAssignment)): + result = ( + self.port == other.port and self.device_id == other.device_id + ) + return result + return False + + def __lt__(self, other): + if isinstance(other, DeviceAssignment): + return self.virtual_device < other.virtual_device + if isinstance(other, VirtualDevice): + return self.virtual_device < other + raise TypeError( + f"Comparing instances of {type(self)} and '{type(other)}' " + "is not supported" ) + @property + def backend_domain(self) -> Optional[QubesVM]: + # pylint: disable=missing-function-docstring + return self.virtual_device.backend_domain + + @property + def backend_name(self) -> str: + # pylint: disable=missing-function-docstring + return self.virtual_device.backend_name + + @property + def port_id(self) -> str: + # pylint: disable=missing-function-docstring + return self.virtual_device.port_id + + @property + def devclass(self) -> str: + # pylint: disable=missing-function-docstring + return self.virtual_device.devclass + + @property + def device_id(self) -> str: + # pylint: disable=missing-function-docstring + return self.virtual_device.device_id + + @property + def devices(self) -> List[DeviceInfo]: + """Get DeviceInfo objects corresponding to this DeviceAssignment""" + result: List[DeviceInfo] = [] + if not self.backend_domain: + return result + if self.port_id != "*": + dev = self.backend_domain.devices[self.devclass][self.port_id] + if ( + isinstance(dev, UnknownDevice) + or (dev and self.device_id in (dev.device_id, "*")) + ): + return [dev] + if self.device_id == "0000:0000::?******": + return result + for dev in self.backend_domain.devices[self.devclass]: + if self.matches(dev): + result.append(dev) + return result + @property def device(self) -> DeviceInfo: - """Get DeviceInfo object corresponding to this DeviceAssignment""" - return self.backend_domain.devices[self.devclass][self.ident] + """ + Get single DeviceInfo object or raise an error. + + If port id is set we have exactly one device + since we can attach ony one device to one port. + If assignment is more general we can get 0 or many devices. + """ + devices = self.devices + if len(devices) == 1: + return devices[0] + if len(devices) > 1: + raise ProtocolError("Too many devices matches to assignment") + raise ProtocolError("No devices matches to assignment") + + @property + def port(self) -> Port: + """ + Device port visible in Qubes. + """ + return Port(self.backend_domain, self.port_id, self.devclass) @property def frontend_domain(self) -> Optional[QubesVM]: - """ Which domain the device is attached/assigned to. """ + """Which domain the device is attached/assigned to.""" return self.__frontend_domain @frontend_domain.setter - def frontend_domain( - self, frontend_domain: Optional[Union[str, QubesVM]] - ): - """ Which domain the device is attached/assigned to. """ + def frontend_domain(self, frontend_domain: Optional[Union[str, QubesVM]]): + """Which domain the device is attached/assigned to.""" if isinstance(frontend_domain, str): - frontend_domain = self.backend_domain.app.domains[frontend_domain] - self.__frontend_domain = frontend_domain + if not self.backend_domain: + raise ProtocolError("Cannot determine backend domain") + self.__frontend_domain: Optional[QubesVM] = ( + self.backend_domain.app.domains[frontend_domain] + ) + else: + self.__frontend_domain = frontend_domain @property def attached(self) -> bool: @@ -858,7 +1359,10 @@ def attached(self) -> bool: Returns False if device is attached to different domain """ - return self.device.attachment == self.frontend_domain + for device in self.devices: + if device.attachment and device.attachment == self.frontend_domain: + return True + return False @property def required(self) -> bool: @@ -866,11 +1370,7 @@ def required(self) -> bool: Is the presence of this device required for the domain to start? If yes, it will be attached automatically. """ - return self.__required - - @required.setter - def required(self, required: bool): - self.__required = required + return self.mode == AssignmentMode.REQUIRED @property def attach_automatically(self) -> bool: @@ -878,78 +1378,95 @@ def attach_automatically(self) -> bool: Should this device automatically connect to the frontend domain when available and not connected to other qubes? """ - return self.__attach_automatically - - @attach_automatically.setter - def attach_automatically(self, attach_automatically: bool): - self.__attach_automatically = attach_automatically + return self.mode in ( + AssignmentMode.AUTO, + AssignmentMode.ASK, + AssignmentMode.REQUIRED, + ) @property def options(self) -> Dict[str, Any]: - """ Device options (same as in the legacy API). """ + """Device options (same as in the legacy API).""" return self.__options @options.setter def options(self, options: Optional[Dict[str, Any]]): - """ Device options (same as in the legacy API). """ + """Device options (same as in the legacy API).""" self.__options = options or {} def serialize(self) -> bytes: """ Serialize an object to be transmitted via Qubes API. """ - properties = b' '.join( - self.pack_property(key, value) - for key, value in ( - ('required', 'yes' if self.required else 'no'), - ('attach_automatically', - 'yes' if self.attach_automatically else 'no'), - ('ident', self.ident), - ('devclass', self.devclass))) - - properties += b' ' + self.pack_property( - 'backend_domain', self.backend_domain.name) - + properties = self.virtual_device.serialize() + properties += b" " + DeviceSerializer.pack_property( + "mode", self.mode.value + ) if self.frontend_domain is not None: - properties += b' ' + self.pack_property( - 'frontend_domain', self.frontend_domain.name) + properties += b" " + DeviceSerializer.pack_property( + "frontend_domain", self.frontend_domain.name + ) for key, value in self.options.items(): - properties += b' ' + self.pack_property("_" + key, value) + properties += b" " + DeviceSerializer.pack_property( + "_" + key, value + ) return properties @classmethod def deserialize( - cls, - serialization: bytes, - expected_device: Device, - ) -> 'DeviceAssignment': + cls, + serialization: bytes, + expected_device: VirtualDevice, + ) -> "DeviceAssignment": """ Recovers a serialized object, see: :py:meth:`serialize`. """ try: result = cls._deserialize(serialization, expected_device) except Exception as exc: - raise ProtocolError() from exc + raise ProtocolError(str(exc)) from exc return result @classmethod def _deserialize( - cls, - untrusted_serialization: bytes, - expected_device: Device, - ) -> 'DeviceAssignment': + cls, + untrusted_serialization: bytes, + expected_device: VirtualDevice, + ) -> "DeviceAssignment": """ Actually deserializes the object. """ - properties, options = cls.unpack_properties(untrusted_serialization) - properties['options'] = options + properties, options = DeviceSerializer.unpack_properties( + untrusted_serialization + ) + properties["options"] = options - cls.check_device_properties(expected_device, properties) + DeviceSerializer.parse_basic_device_properties( + expected_device, properties + ) - properties['attach_automatically'] = qbool( - properties.get('attach_automatically', 'no')) - properties['required'] = qbool(properties.get('required', 'no')) + expected_device = expected_device.clone( + device_id=properties["device_id"] + ) + # we do not need port, we need device + del properties["port"] + del properties["device_id"] + properties["device"] = expected_device return cls(**properties) + + def matches(self, device: VirtualDevice) -> bool: + """ + Checks if the given device matches the assignment. + """ + if self.devclass != device.devclass: + return False + if self.backend_domain != device.backend_domain: + return False + if self.port_id not in ("*", device.port_id): + return False + if self.device_id not in ("*", device.device_id): + return False + return True diff --git a/qubes/devices.py b/qubes/devices.py index c073ef759..bbdd55f82 100644 --- a/qubes/devices.py +++ b/qubes/devices.py @@ -28,8 +28,8 @@ Devices can be of different buses (like 'pci', 'usb', etc.). Each device bus is implemented by an extension. -Devices are identified by pair of (backend domain, `ident`), where `ident` is -:py:class:`str` and can contain only characters from `[a-zA-Z0-9._-]` set. +Devices are identified by pair of (backend domain, `port_id`), where `port_id` +is :py:class:`str` and can contain only characters from `[a-zA-Z0-9._-]` set. Such extension should: - provide `qubes.devices` endpoint - a class descendant from @@ -63,8 +63,17 @@ import qubes.exc import qubes.utils -from qubes.device_protocol import (Device, DeviceInfo, UnknownDevice, - DeviceAssignment) +from qubes.device_protocol import ( + Port, + DeviceInfo, + UnknownDevice, + DeviceAssignment, + VirtualDevice, + AssignmentMode, +) +from qubes.exc import ProtocolError + +DEVICE_DENY_LIST = "/etc/qubes/device-deny.list" class DeviceNotAssigned(qubes.exc.QubesException, KeyError): @@ -126,7 +135,7 @@ class DeviceCollection: :param device: :py:class:`DeviceInfo` object to be attached - .. event:: device-detach: (device) + .. event:: device-detach: (port) Fired when device is detached from a VM. @@ -134,13 +143,13 @@ class DeviceCollection: :param device: :py:class:`DeviceInfo` object to be attached - .. event:: device-pre-detach: (device) + .. event:: device-pre-detach: (port) Fired before device is detached from a VM Handler for this event can be asynchronous (a coroutine). - :param device: :py:class:`DeviceInfo` object to be attached + :param port: :py:class:`Port` object from which device be detached .. event:: device-assign: (device, options) @@ -165,9 +174,9 @@ class DeviceCollection: event should return a list of py:class:`DeviceInfo` objects (or appropriate class specific descendant) - .. event:: device-get: (ident) + .. event:: device-get: (port_id) - Fired to get a single device, given by the `ident` parameter. + Fired to get a single device, given by the `port_id` parameter. Handlers of this event should either return appropriate object of :py:class:`DeviceInfo`, or :py:obj:`None`. Especially should not raise :py:class:`exceptions.KeyError`. @@ -186,61 +195,95 @@ def __init__(self, vm, bus): self._set = AssignedCollection() self.devclass = qubes.utils.get_entry_point_one( - 'qubes.devices', self._bus) + "qubes.devices", self._bus + ) async def attach(self, assignment: DeviceAssignment): """ Attach device to domain. """ - if not assignment.devclass_is_set: - assignment.devclass = self._bus - elif assignment.devclass != self._bus: - raise ValueError( - f'Trying to attach {assignment.devclass} device ' - f'when {self._bus} device expected.') + if assignment.devclass != self._bus: + raise ProtocolError( + f"Trying to attach {assignment.devclass} device " + f"when {self._bus} device expected." + ) if self._vm.is_halted(): raise qubes.exc.QubesVMNotRunningError( - self._vm,"VM not running, cannot attach device," - " do you mean `assign`?") + self._vm, + "VM not running, cannot attach device," + " do you mean `assign`?", + ) + + try: + device = assignment.device + except ProtocolError: + # assignment matches no or top many devices + raise ProtocolError( + f"Cannot attach ambiguous {assignment.devclass} device." + ) + + if isinstance(device, UnknownDevice): + raise ProtocolError( + f"{device.devclass} device not recognized " + f"in {device.port_id} port." + ) - device = assignment.device - if device in self.get_attached_devices(): + if device in [ass.device for ass in self.get_attached_devices()]: raise DeviceAlreadyAttached( - 'device {!s} of class {} already attached to {!s}'.format( - device, self._bus, self._vm)) + "device {!s} of class {} already attached to {!s}".format( + device, self._bus, self._vm + ) + ) await self._vm.fire_event_async( - 'device-pre-attach:' + self._bus, - pre_event=True, device=device, options=assignment.options) + "device-pre-attach:" + self._bus, + pre_event=True, + device=device, + options=assignment.options, + ) await self._vm.fire_event_async( - 'device-attach:' + self._bus, - device=device, options=assignment.options) + "device-attach:" + self._bus, + device=device, + options=assignment.options, + ) async def assign(self, assignment: DeviceAssignment): """ Assign device to domain. """ - if not assignment.devclass_is_set: - assignment.devclass = self._bus - elif assignment.devclass != self._bus: + if assignment.devclass != self._bus: raise ValueError( - f'Trying to attach {assignment.devclass} device ' - f'when {self._bus} device expected.') + f"Trying to assign {assignment.devclass} device " + f"when {self._bus} device expected." + ) - device = assignment.device - if device in self.get_assigned_devices(): + device = assignment.virtual_device + if assignment in self.get_assigned_devices(): raise DeviceAlreadyAssigned( - 'device {!s} of class {} already assigned to {!s}'.format( - device, self._bus, self._vm)) + f"{self._bus} device {device!s} " + f"already assigned to {self._vm!s}" + ) + + if not assignment.attach_automatically: + raise ValueError("Only auto-attachable devices can be assigned.") self._set.add(assignment) await self._vm.fire_event_async( - 'device-assign:' + self._bus, - device=device, options=assignment.options) + "device-pre-assign:" + self._bus, + pre_event=True, + device=device, + options=assignment.options, + ) + + await self._vm.fire_event_async( + "device-assign:" + self._bus, + device=device, + options=assignment.options, + ) def load_assignment(self, device_assignment: DeviceAssignment): """Load DeviceAssignment retrieved from qubes.xml @@ -250,100 +293,118 @@ def load_assignment(self, device_assignment: DeviceAssignment): """ assert not self._vm.events_enabled assert device_assignment.attach_automatically - device_assignment.devclass = self._bus self._set.add(device_assignment) - async def update_required(self, device: Device, required: bool): + async def update_assignment( + self, device: VirtualDevice, mode: AssignmentMode + ): """ - Update `required` flag of an already attached device. + Update assignment mode of an already assigned device. - :param Device device: device for which change required flag - :param bool required: new assignment: - `False` -> device will be auto-attached to qube - `True` -> device is required to start qube + :param VirtualDevice device: device for which change required flag + :param AssignmentMode mode: new assignment mode """ - if self._vm.is_halted(): - raise qubes.exc.QubesVMNotStartedError( - self._vm, - 'VM must be running to modify device assignment' + if mode == AssignmentMode.MANUAL: + raise qubes.exc.QubesValueError( + "Cannot change assignment mode to 'manual'" ) - assignments = [a for a in self.get_assigned_devices() - if a == device] + assignments = [ + a for a in self.get_assigned_devices() if a.virtual_device == device + ] if not assignments: raise qubes.exc.QubesValueError( - f'Device {device} not assigned to {self._vm.name}') + f"Device {device} not assigned to {self._vm.name}" + ) assert len(assignments) == 1 assignment = assignments[0] + if assignment.mode == mode: + return + # be careful to use already present assignment, not the provided one # - to not change options as a side effect - if assignment.required == required: - return + new_assignment = assignment.clone(mode=mode) - assignment.required = required await self._vm.fire_event_async( - 'device-assignment-changed:' + self._bus, device=device) + "device-pre-assign:" + self._bus, + pre_event=True, + device=device, + options=new_assignment.options, + ) - async def detach(self, device: Device): + self._set.discard(assignment) + self._set.add(new_assignment) + await self._vm.fire_event_async( + "device-assignment-changed:" + self._bus, device=device + ) + + async def detach(self, port: Port): """ Detach device from domain. """ - for assign in self.get_attached_devices(): - if device == assign: + for attached in self.get_attached_devices(): + if port.port_id == attached.port_id: # load all options - assignment = assign break else: raise DeviceNotAssigned( - f'device {device.ident!s} of class {self._bus} not ' - f'attached to {self._vm!s}') + f"{self._bus} device {port.port_id!s} not " + f"attached to {self._vm!s}" + ) - if assignment.required and not self._vm.is_halted(): - raise qubes.exc.QubesVMNotHaltedError( - self._vm, - "Can not detach a required device from a non halted qube. " - "You need to unassign device first.") + for assign in self.get_assigned_devices(): + if ( + assign.required + and not self._vm.is_halted() + and assign.matches(attached.device) + ): + raise qubes.exc.QubesVMNotHaltedError( + self._vm, + "Can not detach a required device from a non halted qube. " + "You need to unassign device first.", + ) - # use the local object - device = assignment.device + # use the local object, only one device can match + port = attached.device.port await self._vm.fire_event_async( - 'device-pre-detach:' + self._bus, pre_event=True, device=device) + "device-pre-detach:" + self._bus, pre_event=True, port=port + ) - await self._vm.fire_event_async( - 'device-detach:' + self._bus, device=device) + await self._vm.fire_event_async("device-detach:" + self._bus, port=port) - async def unassign(self, device_assignment: DeviceAssignment): + async def unassign(self, assignment: DeviceAssignment): """ Unassign device from domain. """ - for assignment in self.get_assigned_devices(): - if device_assignment == assignment: + for assign in self.get_assigned_devices(): + if assignment == assign: # load all options - device_assignment = assignment + assignment = assign break else: raise DeviceNotAssigned( - f'device {device_assignment.ident!s} of class {self._bus} not ' - f'assigned to {self._vm!s}') + f"{self._bus} device {assignment} not assigned to {self._vm!s}" + ) self._set.discard(assignment) - device = device_assignment.device await self._vm.fire_event_async( - 'device-unassign:' + self._bus, device=device) + "device-unassign:" + self._bus, device=assignment.virtual_device + ) def get_dedicated_devices(self) -> Iterable[DeviceAssignment]: """ List devices which are attached or assigned to this vm. """ yield from itertools.chain( - self.get_attached_devices(), self.get_assigned_devices()) + self.get_attached_devices(), self.get_assigned_devices() + ) def get_attached_devices(self) -> Iterable[DeviceAssignment]: """ List devices which are attached to this vm. """ - attached = self._vm.fire_event('device-list-attached:' + self._bus) + attached = self._vm.fire_event("device-list-attached:" + self._bus) for dev, options in attached: for assignment in self._set: if dev == assignment: @@ -351,38 +412,35 @@ def get_attached_devices(self) -> Iterable[DeviceAssignment]: break else: yield DeviceAssignment( - backend_domain=dev.backend_domain, - ident=dev.ident, - options=options, + dev, frontend_domain=self._vm, - devclass=dev.devclass, - attach_automatically=False, - required=False, + options=options, + mode="manual", ) def get_assigned_devices( - self, required_only: bool = False + self, required_only: bool = False ) -> Iterable[DeviceAssignment]: """ Devices assigned to this vm (included in :file:`qubes.xml`). Safe to access before libvirt bootstrap. """ - for dev in self._set: - if required_only and not dev.required: + for ass in self._set: + if required_only and not ass.required: continue - yield dev + yield ass def get_exposed_devices(self) -> Iterable[DeviceInfo]: """ List devices exposed by this vm. """ - yield from self._vm.fire_event('device-list:' + self._bus) + yield from self._vm.fire_event("device-list:" + self._bus) __iter__ = get_exposed_devices - def __getitem__(self, ident): - '''Get device object with given ident. + def __getitem__(self, port_id): + """Get device object with given port id. :returns: py:class:`DeviceInfo` @@ -391,15 +449,15 @@ def __getitem__(self, ident): devices - otherwise it will be impossible to detach already disconnected device. - :raises AssertionError: when multiple devices with the same ident are + :raises AssertionError: when multiple devices with the same port_id are found - ''' - dev = self._vm.fire_event('device-get:' + self._bus, ident=ident) + """ + dev = self._vm.fire_event("device-get:" + self._bus, port_id=port_id) if dev: assert len(dev) == 1 return dev[0] - return UnknownDevice(self._vm, ident, devclass=self._bus) + return UnknownDevice(Port(self._vm, port_id, devclass=self._bus)) class DeviceManager(dict): @@ -427,11 +485,12 @@ def __init__(self): self._dict = {} def add(self, assignment: DeviceAssignment): - """ Add assignment to collection """ + """Add assignment to collection""" assert assignment.attach_automatically vm = assignment.backend_domain - ident = assignment.ident - key = (vm, ident) + port_id = assignment.port_id + dev_id = assignment.device_id + key = (vm, port_id, dev_id) assert key not in self._dict self._dict[key] = assignment @@ -442,20 +501,23 @@ def discard(self, assignment: DeviceAssignment): """ assert assignment.attach_automatically vm = assignment.backend_domain - ident = assignment.ident - key = (vm, ident) + port_id = assignment.port_id + dev_id = assignment.device_id + key = (vm, port_id, dev_id) if key not in self._dict: raise KeyError del self._dict[key] def __contains__(self, device) -> bool: - return (device.backend_domain, device.ident) in self._dict + key = (device.backend_domain, device.port_id, device.device_id) + return key in self._dict def get(self, device: DeviceInfo) -> DeviceAssignment: """ Returns the corresponding `DeviceAssignment` for the device. """ - return self._dict[(device.backend_domain, device.ident)] + key = (device.backend_domain, device.port_id, device.device_id) + return self._dict[key] def __iter__(self): return self._dict.values().__iter__() diff --git a/qubes/dochelpers.py b/qubes/dochelpers.py index c9bb10daf..f7e033802 100644 --- a/qubes/dochelpers.py +++ b/qubes/dochelpers.py @@ -51,7 +51,7 @@ try: log = logging.getLogger(__name__) except AttributeError: - log = None + log = None # type: ignore class GithubTicket: # pylint: disable=too-few-public-methods @@ -89,7 +89,8 @@ def ticket(name, rawtext, text, lineno, inliner, options=None, content=None): that called this function :param options: Directive options for customisation :param content: The directive content for customisation - """ # pylint: disable=unused-argument,too-many-positional-arguments + """ + # pylint: disable=unused-argument,too-many-positional-arguments if options is None: options = {} diff --git a/qubes/ext/__init__.py b/qubes/ext/__init__.py index 4a1949815..f2b673155 100644 --- a/qubes/ext/__init__.py +++ b/qubes/ext/__init__.py @@ -24,7 +24,7 @@ some systems. They may be OS- or architecture-dependent or custom-developed for particular customer. ''' - +import collections import importlib.metadata import qubes.events @@ -53,6 +53,13 @@ def __new__(cls): return cls._instance + def __init__(self): + #: This is to be implemented in extension handling devices + self.devices_cache = collections.defaultdict(dict) + + #: This is to be implemented in extension handling devices + def ensure_detach(self, vm, port): + pass def get_extensions(): return set(ext.load()() diff --git a/qubes/ext/admin.py b/qubes/ext/admin.py index 4abc04418..209a1f132 100644 --- a/qubes/ext/admin.py +++ b/qubes/ext/admin.py @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . +import importlib +import os import qubes.api import qubes.api.internal @@ -24,6 +26,9 @@ import qubes.vm.adminvm from qrexec.policy import utils, parser +from qubes.device_protocol import DeviceInterface +from qubes.devices import DEVICE_DENY_LIST + class JustEvaluateAskResolution(parser.AskResolution): async def execute(self): @@ -160,3 +165,54 @@ def on_tag_add(self, vm, event, tag, **kwargs): tag_with = created_by.features.get('tag-created-vm-with', '') for tag_with_single in tag_with.split(): vm.tags.add(tag_with_single) + + @qubes.ext.handler(*(f'admin-permission:admin.vm.device.{ep.name}.Attach' + for ep in importlib.metadata.entry_points(group='qubes.devices'))) + def on_device_attach( + self, vm, event, dest, arg, device, mode, **kwargs + ): + # pylint: disable=unused-argument,too-many-positional-arguments + # ignore auto-attachment + if mode != 'manual': + return + + # load device deny list + deny = {} + AdminExtension._load_deny_list(deny, DEVICE_DENY_LIST) + + # load drop ins + drop_in_path = DEVICE_DENY_LIST + '.d' + if os.path.isdir(drop_in_path): + for deny_list_name in os.listdir(drop_in_path): + deny_list_path = os.path.join(drop_in_path, deny_list_name) + + if os.path.isfile(deny_list_path): + AdminExtension._load_deny_list(deny, deny_list_path) + + # check if any presented interface is on deny list + for interface in deny.get(dest.name, set()): + pattern = DeviceInterface(interface) + for devint in device.interfaces: + if pattern.matches(devint): + raise qubes.exc.PermissionDenied( + f"Device exposes a banned interface: {devint}") + + @staticmethod + def _load_deny_list(deny: dict, path: str) -> None: + try: + with open(path, 'r', encoding="utf-8") as file: + for line in file: + line = line.strip() + + # skip comments + if line.startswith('#'): + continue + + if line: + name, *values = line.split() + values = ' '.join(values).replace(',', ' ').split() + values = [v for v in values if len(v) > 0] + + deny[name] = deny.get(name, set()).union(set(values)) + except IOError: + pass diff --git a/qubes/ext/block.py b/qubes/ext/block.py index 268e6ddcd..244e62371 100644 --- a/qubes/ext/block.py +++ b/qubes/ext/block.py @@ -21,7 +21,6 @@ """ Qubes block devices extensions """ import asyncio -import collections import re import string import sys @@ -32,8 +31,10 @@ import qubes.device_protocol import qubes.devices import qubes.ext -from qubes.ext.utils import device_list_change +from qubes.ext import utils from qubes.storage import Storage +from qubes.vm.qubesvm import QubesVM +from qubes.devices import Port name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") @@ -41,20 +42,26 @@ desc_re = re.compile(r"\A.{1,255}\Z") mode_re = re.compile(r"\A[rw]\Z") -SYSTEM_DISKS = ('xvda', 'xvdb', 'xvdc') +SYSTEM_DISKS = ("xvda", "xvdb", "xvdc") # xvdd is considered system disk only if vm.kernel is set -SYSTEM_DISKS_DOM0_KERNEL = SYSTEM_DISKS + ('xvdd',) +SYSTEM_DISKS_DOM0_KERNEL = SYSTEM_DISKS + ("xvdd",) class BlockDevice(qubes.device_protocol.DeviceInfo): - def __init__(self, backend_domain, ident): - super().__init__( - backend_domain=backend_domain, ident=ident, devclass="block") + + def __init__(self, port: qubes.device_protocol.Port): + if port.devclass != "block": + raise qubes.exc.QubesValueError( + f"Incompatible device class for input port: {port.devclass}" + ) + + # init parent class + super().__init__(port) # lazy loading - self._mode = None - self._size = None - self._interface_num = None + self._mode: Optional[str] = None + self._size: Optional[int] = None + self._interface_num: Optional[str] = None @property def name(self): @@ -86,28 +93,29 @@ def _load_lazily_name_and_serial(self): if not self.backend_domain.is_running(): return "unknown", "unknown" untrusted_desc = self.backend_domain.untrusted_qdb.read( - f'/qubes-block-devices/{self.ident}/desc') + f"/qubes-block-devices/{self.port_id}/desc" + ) if not untrusted_desc: return "unknown", "unknown" desc = BlockDevice._sanitize( - untrusted_desc, - string.ascii_letters + string.digits + '()+,-.:=_/ ') - model, _, label = desc.partition(' ') + untrusted_desc, string.ascii_letters + string.digits + "()+,-.:=_/ " + ) + model, _, label = desc.partition(" ") if model: - serial = self._serial = model.replace('_', ' ').strip() + serial = self._serial = model.replace("_", " ").strip() else: serial = "unknown" # label: '(EXAMPLE)' or '()' if label[1:-1]: - name = self._name = label.replace('_', ' ')[1:-1].strip() + name = self._name = label.replace("_", " ")[1:-1].strip() else: name = "unknown" return name, serial @property def manufacturer(self) -> str: - if self.parent_device: - return f"sub-device of {self.parent_device}" + if self.parent_device is not None: + return f"sub-device of {self.parent_device.port}" return f"hosted by {self.backend_domain!s}" @property @@ -115,15 +123,17 @@ def mode(self): """Device mode, either 'w' for read-write, or 'r' for read-only""" if self._mode is None: if not self.backend_domain.is_running(): - return 'w' + return "w" untrusted_mode = self.backend_domain.untrusted_qdb.read( - '/qubes-block-devices/{}/mode'.format(self.ident)) + "/qubes-block-devices/{}/mode".format(self.port_id) + ) if untrusted_mode is None: - self._mode = 'w' - elif untrusted_mode not in (b'w', b'r'): + self._mode = "w" + elif untrusted_mode not in (b"w", b"r"): self.backend_domain.log.warning( - 'Device {} has invalid mode'.format(self.ident)) - self._mode = 'w' + "Device {} has invalid mode".format(self.port_id) + ) + self._mode = "w" else: self._mode = untrusted_mode.decode() return self._mode @@ -135,12 +145,14 @@ def size(self): if not self.backend_domain.is_running(): return None untrusted_size = self.backend_domain.untrusted_qdb.read( - '/qubes-block-devices/{}/size'.format(self.ident)) + "/qubes-block-devices/{}/size".format(self.port_id) + ) if untrusted_size is None: self._size = 0 elif not untrusted_size.isdigit(): self.backend_domain.log.warning( - 'Device {} has invalid size'.format(self.ident)) + "Device {} has invalid size".format(self.port_id) + ) self._size = 0 else: self._size = int(untrusted_size) @@ -149,7 +161,7 @@ def size(self): @property def device_node(self): """Device node in backend domain""" - return '/dev/' + self.ident.replace('_', '/') + return "/dev/" + self.port_id.replace("_", "/") @property def interfaces(self) -> List[qubes.device_protocol.DeviceInterface]: @@ -161,7 +173,7 @@ def interfaces(self) -> List[qubes.device_protocol.DeviceInterface]: return [qubes.device_protocol.DeviceInterface("******", "block")] @property - def parent_device(self) -> Optional[qubes.device_protocol.Device]: + def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: """ The parent device, if any. @@ -169,34 +181,40 @@ def parent_device(self) -> Optional[qubes.device_protocol.Device]: partition of an usb stick), the parent device id should be here. """ if self._parent is None: - if not self.backend_domain.is_running(): + if not self.backend_domain or not self.backend_domain.is_running(): return None untrusted_parent_info = self.backend_domain.untrusted_qdb.read( - f'/qubes-block-devices/{self.ident}/parent') + f"/qubes-block-devices/{self.port_id}/parent" + ) if untrusted_parent_info is None: return None # '4-4.1:1.0' -> parent_ident='4-4.1', interface_num='1.0' # 'sda' -> parent_ident='sda', interface_num='' parent_ident, sep, interface_num = self._sanitize( - untrusted_parent_info).partition(":") - devclass = 'usb' if sep == ':' else 'block' + untrusted_parent_info + ).partition(":") + devclass = "usb" if sep == ":" else "block" if not parent_ident: return None try: - self._parent = ( - self.backend_domain.devices)[devclass][parent_ident] + self._parent = self.backend_domain.devices[devclass][ + parent_ident + ] except KeyError: self._parent = qubes.device_protocol.UnknownDevice( - self.backend_domain, parent_ident, devclass=devclass) + qubes.device_protocol.Port( + self.backend_domain, parent_ident, devclass=devclass + ) + ) self._interface_num = interface_num return self._parent @property - def attachment(self) -> Optional['qubes.vm.BaseVM']: + def attachment(self) -> Optional[QubesVM]: """ Warning: this property is time-consuming, do not run in loop! """ - if not self.backend_domain.is_running(): + if not self.backend_domain or not self.backend_domain.is_running(): return None for vm in self.backend_domain.app.domains: if not vm.is_running(): @@ -209,37 +227,37 @@ def attachment(self) -> Optional['qubes.vm.BaseVM']: def _is_attached_to(self, vm): xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) - for disk in xml_desc.findall('devices/disk'): + for disk in xml_desc.findall("devices/disk"): info = _try_get_block_device_info(vm.app, disk) if not info: continue - backend_domain, ident = info + backend_domain, port_id = info if backend_domain.name != self.backend_domain.name: continue - if self.ident == ident: + if self.port_id == port_id: return True return False - @property - def self_identity(self) -> str: + @property # type: ignore[misc] + def device_id(self) -> str: """ Get identification of a device not related to port. """ - parent_identity = '' + parent_identity = "" p = self.parent_device - if p is not None: - p_info = p.backend_domain.devices[p.devclass][p.ident] - parent_identity = p_info.self_identity - if p.devclass == 'usb': - parent_identity = f'{p.ident}:{parent_identity}' + if p is not None and p.backend_domain: + p_info = p.backend_domain.devices[p.devclass][p.port_id] + parent_identity = p_info.device_id + if p.devclass == "usb": + parent_identity = f"{parent_identity}" if self._interface_num: # device interface number (not partition) self_id = self._interface_num else: - self_id = self._get_possible_partition_number() - return f'{parent_identity}:{self_id}' + self_id = str(self._get_possible_partition_number()) + return f"{parent_identity}:{self_id}" def _get_possible_partition_number(self) -> Optional[int]: """ @@ -248,81 +266,81 @@ def _get_possible_partition_number(self) -> Optional[int]: The behavior is undefined for the rest block devices. """ # partition number: 'xxxxx12' -> '12' (partition) - numbers = re.findall(r'\d+$', self.ident) + numbers = re.findall(r"\d+$", self.port_id) return int(numbers[-1]) if numbers else None @staticmethod def _sanitize( - untrusted_parent: bytes, - safe_chars: str = - string.ascii_letters + string.digits + string.punctuation + untrusted_parent: bytes, + safe_chars: str = string.ascii_letters + + string.digits + + string.punctuation, ) -> str: untrusted_device_desc = untrusted_parent.decode( - 'ascii', errors='ignore') - return ''.join( - c if c in set(safe_chars) else '_' for c in untrusted_device_desc + "ascii", errors="ignore" + ) + return "".join( + c if c in set(safe_chars) else "_" for c in untrusted_device_desc ) def _try_get_block_device_info(app, disk): - if disk.get('type') != 'block': + if disk.get("type") != "block": return None - dev_path_node = disk.find('source') + dev_path_node = disk.find("source") if dev_path_node is None: return None - backend_domain_node = disk.find('backenddomain') + backend_domain_node = disk.find("backenddomain") if backend_domain_node is not None: - backend_domain = app.domains[ - backend_domain_node.get('name')] + backend_domain = app.domains[backend_domain_node.get("name")] else: backend_domain = app.domains[0] - dev_path = dev_path_node.get('dev') + dev_path = dev_path_node.get("dev") - if dev_path.startswith('/dev/'): - ident = dev_path[len('/dev/'):] + if dev_path.startswith("/dev/"): + port_id = dev_path[len("/dev/") :] else: - ident = dev_path + port_id = dev_path - ident = ident.replace('/', '_') + port_id = port_id.replace("/", "_") - return backend_domain, ident + return backend_domain, port_id class BlockDeviceExtension(qubes.ext.Extension): - def __init__(self): - super().__init__() - self.devices_cache = collections.defaultdict(dict) - @qubes.ext.handler('domain-init', 'domain-load') + @qubes.ext.handler("domain-init", "domain-load") def on_domain_init_load(self, vm, event): """Initialize watching for changes""" # pylint: disable=unused-argument - vm.watch_qdb_path('/qubes-block-devices') + vm.watch_qdb_path("/qubes-block-devices") if vm.app.vmm.offline_mode: self.devices_cache[vm.name] = {} return - if event == 'domain-load': + if event == "domain-load": # avoid building a cache on domain-init, as it isn't fully set yet, # and definitely isn't running yet device_attachments = self.get_device_attachments(vm) current_devices = dict( - (dev.ident, device_attachments.get(dev.ident, None)) - for dev in self.on_device_list_block(vm, None)) + (dev.port_id, device_attachments.get(dev.port_id, None)) + for dev in self.on_device_list_block(vm, None) + ) self.devices_cache[vm.name] = current_devices else: self.devices_cache[vm.name] = {} - @qubes.ext.handler('domain-qdb-change:/qubes-block-devices') + @qubes.ext.handler("domain-qdb-change:/qubes-block-devices") def on_qdb_change(self, vm, event, path): """A change in QubesDB means a change in a device list.""" # pylint: disable=unused-argument device_attachments = self.get_device_attachments(vm) current_devices = dict( - (dev.ident, device_attachments.get(dev.ident, None)) - for dev in self.on_device_list_block(vm, None)) - device_list_change(self, current_devices, vm, path, BlockDevice) + (dev.port_id, device_attachments.get(dev.port_id, None)) + for dev in self.on_device_list_block(vm, None) + ) + utils.device_list_change(self, current_devices, vm, path, BlockDevice) @staticmethod def get_device_attachments(vm_): @@ -336,85 +354,92 @@ def get_device_attachments(vm_): xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) - for disk in xml_desc.findall('devices/disk'): + for disk in xml_desc.findall("devices/disk"): info = _try_get_block_device_info(vm.app, disk) if not info: continue - _backend_domain, ident = info + _backend_domain, port_id = info - result[ident] = vm + result[port_id] = vm return result @staticmethod - def device_get(vm, ident): + def device_get(vm, port_id): """ Read information about a device from QubesDB :param vm: backend VM object - :param ident: device identifier + :param port_id: port identifier :returns BlockDevice """ untrusted_qubes_device_attrs = vm.untrusted_qdb.list( - '/qubes-block-devices/{}/'.format(ident)) + "/qubes-block-devices/{}/".format(port_id) + ) if not untrusted_qubes_device_attrs: return None - return BlockDevice(vm, ident) + return BlockDevice( + Port(backend_domain=vm, port_id=port_id, devclass="block") + ) - @qubes.ext.handler('device-list:block') + @qubes.ext.handler("device-list:block") def on_device_list_block(self, vm, event): # pylint: disable=unused-argument if not vm.is_running(): return - untrusted_qubes_devices = vm.untrusted_qdb.list('/qubes-block-devices/') - untrusted_idents = set(untrusted_path.split('/', 3)[2] - for untrusted_path in untrusted_qubes_devices) + untrusted_qubes_devices = vm.untrusted_qdb.list("/qubes-block-devices/") + untrusted_idents = set( + untrusted_path.split("/", 3)[2] + for untrusted_path in untrusted_qubes_devices + ) for untrusted_ident in untrusted_idents: if not name_re.match(untrusted_ident): - msg = ("%s vm's device path name contains unsafe characters. " - "Skipping it.") + msg = ( + "%s vm's device path name contains unsafe characters. " + "Skipping it." + ) vm.log.warning(msg % vm.name) continue - ident = untrusted_ident + port_id = untrusted_ident - device_info = self.device_get(vm, ident) + device_info = self.device_get(vm, port_id) if device_info: yield device_info - @qubes.ext.handler('device-get:block') - def on_device_get_block(self, vm, event, ident): + @qubes.ext.handler("device-get:block") + def on_device_get_block(self, vm, event, port_id): # pylint: disable=unused-argument if not vm.is_running(): return if not vm.app.vmm.offline_mode: - device_info = self.device_get(vm, ident) + device_info = self.device_get(vm, port_id) if device_info: yield device_info - @qubes.ext.handler('device-list-attached:block') + @qubes.ext.handler("device-list-attached:block") def on_device_list_attached(self, vm, event, **kwargs): # pylint: disable=unused-argument if not vm.is_running(): return system_disks = SYSTEM_DISKS - if getattr(vm, 'kernel', None): + if getattr(vm, "kernel", None): system_disks = SYSTEM_DISKS_DOM0_KERNEL xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) - for disk in xml_desc.findall('devices/disk'): - if disk.get('type') != 'block': + for disk in xml_desc.findall("devices/disk"): + if disk.get("type") != "block": continue - dev_path_node = disk.find('source') + dev_path_node = disk.find("source") if dev_path_node is None: continue - dev_path = dev_path_node.get('dev') + dev_path = dev_path_node.get("dev") - target_node = disk.find('target') + target_node = disk.find("target") if target_node is not None: - frontend_dev = target_node.get('dev') + frontend_dev = target_node.get("dev") if not frontend_dev: continue if frontend_dev in system_disks: @@ -422,33 +447,33 @@ def on_device_list_attached(self, vm, event, **kwargs): else: continue - backend_domain_node = disk.find('backenddomain') + backend_domain_node = disk.find("backenddomain") if backend_domain_node is not None: - backend_domain = vm.app.domains[backend_domain_node.get('name')] + backend_domain = vm.app.domains[backend_domain_node.get("name")] else: backend_domain = vm.app.domains[0] options = {} - read_only_node = disk.find('readonly') + read_only_node = disk.find("readonly") if read_only_node is not None: - options['read-only'] = 'yes' + options["read-only"] = "yes" else: - options['read-only'] = 'no' - options['frontend-dev'] = frontend_dev - if disk.get('device') != 'disk': - options['devtype'] = disk.get('device') + options["read-only"] = "no" + options["frontend-dev"] = frontend_dev + if disk.get("device") != "disk": + options["devtype"] = disk.get("device") - if dev_path.startswith('/dev/'): - ident = dev_path[len('/dev/'):] + if dev_path.startswith("/dev/"): + port_id = dev_path[len("/dev/") :] else: - ident = dev_path + port_id = dev_path - ident = ident.replace('/', '_') + port_id = port_id.replace("/", "_") - yield (BlockDevice(backend_domain, ident), options) + yield BlockDevice(Port(backend_domain, port_id, "block")), options @staticmethod - def find_unused_frontend(vm, devtype='disk'): + def find_unused_frontend(vm, devtype="disk"): """ Find unused block frontend device node for parameter """ @@ -456,114 +481,158 @@ def find_unused_frontend(vm, devtype='disk'): xml = vm.libvirt_domain.XMLDesc() parsed_xml = lxml.etree.fromstring(xml) - used = [target.get('dev', None) for target in - parsed_xml.xpath("//domain/devices/disk/target")] - if devtype == 'cdrom' and 'xvdd' not in used: + used = [ + target.get("dev", None) + for target in parsed_xml.xpath("//domain/devices/disk/target") + ] + if devtype == "cdrom" and "xvdd" not in used: # prefer 'xvdd' for CDROM if available; only first 4 disks are # emulated in HVM, which means only those are bootable - return 'xvdd' + return "xvdd" for dev in Storage.AVAILABLE_FRONTENDS: if dev not in used: return dev return None - @qubes.ext.handler('device-pre-attach:block') + @qubes.ext.handler("device-pre-attach:block") def on_device_pre_attached_block(self, vm, event, device, options): # pylint: disable=unused-argument self.pre_attachment_internal(vm, device, options) vm.libvirt_domain.attachDevice( - vm.app.env.get_template('libvirt/devices/block.xml').render( - device=device, vm=vm, options=options)) + vm.app.env.get_template("libvirt/devices/block.xml").render( + device=device, vm=vm, options=options + ) + ) def pre_attachment_internal( - self, vm, device, options, expected_attachment=None): + self, vm, device, options, expected_attachment=None + ): if isinstance(device, qubes.device_protocol.UnknownDevice): - print(f'{device.devclass.capitalize()} device {device} ' - 'not available, skipping.', file=sys.stderr) + print( + f"{device.devclass.capitalize()} device {device} " + "not available, skipping.", + file=sys.stderr, + ) raise qubes.devices.UnrecognizedDevice() # validate options for option, value in options.items(): - if option == 'frontend-dev': - if not value.startswith('xvd') and not value.startswith('sd'): + if option == "frontend-dev": + if not value.startswith("xvd") and not value.startswith("sd"): raise qubes.exc.QubesValueError( - 'Invalid frontend-dev option value: ' + value) - elif option == 'read-only': + "Invalid frontend-dev option value: " + value + ) + elif option == "read-only": options[option] = ( - 'yes' if qubes.property.bool(None, None, value) else 'no') - elif option == 'devtype': - if value not in ('disk', 'cdrom'): + "yes" if qubes.property.bool(None, None, value) else "no" + ) + elif option == "devtype": + if value not in ("disk", "cdrom"): raise qubes.exc.QubesValueError( - 'devtype option can only have ' - '\'disk\' or \'cdrom\' value') - elif option == 'identity': - identity = value - if identity not in ('any', device.self_identity): - print("Unrecognized identity, skipping attachment of" - f" {device}", file=sys.stderr) - raise qubes.devices.UnrecognizedDevice( - f"Device presented identity {device.self_identity} " - f"does not match expected {identity}" + "devtype option can only have " + "'disk' or 'cdrom' value" ) else: raise qubes.exc.QubesValueError( - 'Unsupported option {}'.format(option)) + "Unsupported option {}".format(option) + ) - if 'read-only' not in options: - options['read-only'] = 'yes' if device.mode == 'r' else 'no' - if options.get('read-only', 'no') == 'no' and device.mode == 'r': + if "read-only" not in options: + options["read-only"] = "yes" if device.mode == "r" else "no" + if options.get("read-only", "no") == "no" and device.mode == "r": raise qubes.exc.QubesValueError( - 'This device can be attached only read-only') + "This device can be attached only read-only" + ) if not vm.is_running(): - print(f"Can not attach device, qube {vm.name} is not running." - , file=sys.stderr) + print( + f"Can not attach device, qube {vm.name} is not running.", + file=sys.stderr, + ) return if not isinstance(device, BlockDevice): - print("The device is not recognized as block device, " - f"skipping attachment of {device}", - file=sys.stderr) + print( + "The device is not recognized as block device, " + f"skipping attachment of {device}", + file=sys.stderr, + ) return if device.attachment and device.attachment != expected_attachment: raise qubes.devices.DeviceAlreadyAttached( - 'Device {!s} already attached to {!s}'.format( - device, device.attachment) + "Device {!s} already attached to {!s}".format( + device, device.attachment + ) ) if not device.backend_domain.is_running(): raise qubes.exc.QubesVMNotRunningError( device.backend_domain, - f'Domain {device.backend_domain.name} needs to be running ' - f'to attach device from it') + f"Domain {device.backend_domain.name} needs to be running " + f"to attach device from it", + ) - self.devices_cache[device.backend_domain.name][device.ident] = vm + self.devices_cache[device.backend_domain.name][device.port_id] = vm - if 'frontend-dev' not in options: - options['frontend-dev'] = self.find_unused_frontend( - vm, options.get('devtype', 'disk')) + if "frontend-dev" not in options: + options["frontend-dev"] = self.find_unused_frontend( + vm, options.get("devtype", "disk") + ) - @qubes.ext.handler('domain-start') + @qubes.ext.handler("domain-start") async def on_domain_start(self, vm, _event, **_kwargs): # pylint: disable=unused-argument - for assignment in vm.devices['block'].get_assigned_devices(): - self.notify_auto_attached( - vm, assignment.device, assignment.options) - - def notify_auto_attached(self, vm, device, options): - self.pre_attachment_internal( - vm, device, options, expected_attachment=vm) - asyncio.ensure_future(vm.fire_event_async( - 'device-attach:block', device=device, options=options)) + to_attach = {} + assignments = vm.devices["block"].get_assigned_devices() + # the most specific assignments first + for assignment in reversed(sorted(assignments)): + if assignment.required: + # already attached + continue + for device in assignment.devices: + if isinstance(device, qubes.device_protocol.UnknownDevice): + continue + if device.attachment: + continue + if not assignment.matches(device): + print( + "Unrecognized identity, skipping attachment of device " + f"from the port {assignment}", + file=sys.stderr, + ) + continue + # chose first assignment (the most specific) and ignore rest + if device not in to_attach: + # make it unique + to_attach[device] = assignment.clone(device=device) + in_progress = set() + for assignment in to_attach.values(): + in_progress.add( + asyncio.ensure_future(self.attach_and_notify(vm, assignment)) + ) + if in_progress: + await asyncio.wait(in_progress) - async def attach_and_notify(self, vm, device, options): + async def attach_and_notify(self, vm, assignment): # bypass DeviceCollection logic preventing double attach - # we expected that these devices are already attached to this vm - self.notify_auto_attached(vm, device, options) + device = assignment.device + if assignment.mode.value == "ask-to-attach": + allowed = await utils.confirm_device_attachment( + device, {vm: assignment} + ) + allowed = allowed.strip() + if vm.name != allowed: + return + self.on_device_pre_attached_block( + vm, "device-pre-attach:block", device, assignment.options + ) + await vm.fire_event_async( + "device-attach:block", device=device, options=assignment.options + ) - @qubes.ext.handler('domain-shutdown') + @qubes.ext.handler("domain-shutdown") async def on_domain_shutdown(self, vm, event, **_kwargs): """ Remove from cache devices attached to or exposed by the vm. @@ -577,32 +646,43 @@ async def on_domain_shutdown(self, vm, event, **_kwargs): for dev_id, front_vm in self.devices_cache[domain.name].items(): if front_vm is None: continue - dev = BlockDevice(vm, dev_id) - await self._detach_and_notify(vm, dev, options=None) + dev = BlockDevice(Port(vm, dev_id, "block")) + vm.fire_event("device-removed:block", port=dev.port) + await self.detach_and_notify(front_vm, dev.port) continue for dev_id, front_vm in self.devices_cache[domain.name].items(): if front_vm == vm: - dev = BlockDevice(vm, dev_id) - asyncio.ensure_future(front_vm.fire_event_async( - 'device-detach:block', device=dev)) + dev = BlockDevice(Port(vm, dev_id, "block")) + asyncio.ensure_future( + front_vm.fire_event_async( + "device-detach:block", port=dev.port + ) + ) else: new_cache[domain.name][dev_id] = front_vm self.devices_cache = new_cache.copy() - async def _detach_and_notify(self, vm, device, options): + def ensure_detach(self, vm, port): + """ + Run this method if device is no longer detected. + + If usb device which exposes block device is removed a zombie block + device may remain in vm xml, so we ensure that it will be removed too. + """ + self.on_device_pre_detached_block(vm, "device-pre-detach:block", port) + + async def detach_and_notify(self, vm, port): # bypass DeviceCollection logic preventing double attach - self.on_device_pre_detached_block( - vm, 'device-pre-detach:block', device) - await vm.fire_event_async( - 'device-detach:block', device=device, options=options) + self.on_device_pre_detached_block(vm, "device-pre-detach:block", port) + await vm.fire_event_async("device-detach:block", port=port) - @qubes.ext.handler('qubes-close', system=True) + @qubes.ext.handler("qubes-close", system=True) def on_qubes_close(self, app, event): # pylint: disable=unused-argument self.devices_cache.clear() - @qubes.ext.handler('device-pre-detach:block') - def on_device_pre_detached_block(self, vm, event, device): + @qubes.ext.handler("device-pre-detach:block") + def on_device_pre_detached_block(self, vm, event, port): # pylint: disable=unused-argument if not vm.is_running(): return @@ -610,10 +690,13 @@ def on_device_pre_detached_block(self, vm, event, device): # need to enumerate attached devices to find frontend_dev option (at # least) for attached_device, options in self.on_device_list_attached(vm, event): - if attached_device == device: - self.devices_cache[device.backend_domain.name][ - device.ident] = None + if attached_device.port == port: + self.devices_cache[port.backend_domain.name][ + port.port_id + ] = None vm.libvirt_domain.detachDevice( - vm.app.env.get_template('libvirt/devices/block.xml').render( - device=device, vm=vm, options=options)) + vm.app.env.get_template("libvirt/devices/block.xml").render( + device=attached_device, vm=vm, options=options + ) + ) break diff --git a/qubes/ext/pci.py b/qubes/ext/pci.py index 1535a7f9d..934d0fabe 100644 --- a/qubes/ext/pci.py +++ b/qubes/ext/pci.py @@ -34,6 +34,7 @@ import qubes.device_protocol import qubes.devices import qubes.ext +from qubes.device_protocol import Port #: cache of PCI device classes pci_classes = None @@ -48,50 +49,48 @@ class UnsupportedDevice(Exception): def load_pci_classes(): - """ List of known device classes, subclasses and programming interfaces. """ + """List of known device classes, subclasses and programming interfaces.""" # Syntax: # C class class_name # subclass subclass_name <-- single tab # prog-if prog-if_name <-- two tabs result = {} - with open('/usr/share/hwdata/pci.ids', - encoding='utf-8', errors='ignore') as pciids: + with open( + "/usr/share/hwdata/pci.ids", encoding="utf-8", errors="ignore" + ) as pciids: class_id = None subclass_id = None for line in pciids.readlines(): line = line.rstrip() - if line.startswith('\t\t') and class_id and subclass_id: - (progif_id, _, class_name) = line[2:].split(' ', 2) - result[class_id + subclass_id + progif_id] = \ - class_name - elif line.startswith('\t') and class_id: - (subclass_id, _, class_name) = line[1:].split(' ', 2) + if line.startswith("\t\t") and class_id and subclass_id: + (progif_id, _, class_name) = line[2:].split(" ", 2) + result[class_id + subclass_id + progif_id] = class_name + elif line.startswith("\t") and class_id: + (subclass_id, _, class_name) = line[1:].split(" ", 2) # store both prog-if specific entry and generic one - result[class_id + subclass_id + '00'] = \ - class_name - result[class_id + subclass_id] = \ - class_name - elif line.startswith('C '): - (_, class_id, _, class_name) = line.split(' ', 3) - result[class_id + '0000'] = class_name - result[class_id + '00'] = class_name + result[class_id + subclass_id + "00"] = class_name + result[class_id + subclass_id] = class_name + elif line.startswith("C "): + (_, class_id, _, class_name) = line.split(" ", 3) + result[class_id + "0000"] = class_name + result[class_id + "00"] = class_name subclass_id = None return result def pcidev_class(dev_xmldesc): - sysfs_path = dev_xmldesc.findtext('path') + sysfs_path = dev_xmldesc.findtext("path") assert sysfs_path try: - with open(sysfs_path + '/class', encoding='ascii') as f_class: + with open(sysfs_path + "/class", encoding="ascii") as f_class: class_id = f_class.read().strip() except OSError: return "unknown" if not qubes.ext.pci.pci_classes: qubes.ext.pci.pci_classes = load_pci_classes() - if class_id.startswith('0x'): + if class_id.startswith("0x"): class_id = class_id[2:] try: # ignore prog-if @@ -101,15 +100,15 @@ def pcidev_class(dev_xmldesc): def pcidev_interface(dev_xmldesc): - sysfs_path = dev_xmldesc.findtext('path') + sysfs_path = dev_xmldesc.findtext("path") assert sysfs_path try: - with open(sysfs_path + '/class', encoding='ascii') as f_class: + with open(sysfs_path + "/class", encoding="ascii") as f_class: class_id = f_class.read().strip() except OSError: return "000000" - if class_id.startswith('0x'): + if class_id.startswith("0x"): class_id = class_id[2:] return class_id @@ -126,63 +125,72 @@ def attached_devices(app): xs = app.vmm.xs devices = {} - for domid in xs.ls('', 'backend/pci') or []: - for devid in xs.ls('', 'backend/pci/' + domid) or []: - devpath = 'backend/pci/' + domid + '/' + devid - domain_name = xs.read('', devpath + '/domain') + for domid in xs.ls("", "backend/pci") or []: + for devid in xs.ls("", "backend/pci/" + domid) or []: + devpath = "backend/pci/" + domid + "/" + devid + domain_name = xs.read("", devpath + "/domain") try: domain = app.domains[domain_name] except KeyError: # unknown domain - maybe from another qubes.xml? continue - devnum = xs.read('', devpath + '/num_devs') + devnum = xs.read("", devpath + "/num_devs") for dev in range(int(devnum)): - dbdf = xs.read('', devpath + '/dev-' + str(dev)) - bdf = dbdf[len('0000:'):] - devices[bdf.replace(':', '_')] = domain + dbdf = xs.read("", devpath + "/dev-" + str(dev)) + bdf = dbdf[len("0000:") :] + devices[bdf.replace(":", "_")] = domain return devices def _device_desc(hostdev_xml): - return '{devclass}: {vendor} {product}'.format( + return "{devclass}: {vendor} {product}".format( devclass=pcidev_class(hostdev_xml), - vendor=hostdev_xml.findtext('capability/vendor'), - product=hostdev_xml.findtext('capability/product'), + vendor=hostdev_xml.findtext("capability/vendor"), + product=hostdev_xml.findtext("capability/product"), ) class PCIDevice(qubes.device_protocol.DeviceInfo): # pylint: disable=too-few-public-methods regex = re.compile( - r'\A(?P[0-9a-f]+)_(?P[0-9a-f]+)\.' - r'(?P[0-9a-f]+)\Z') + r"\A(?P[0-9a-f]+)_(?P[0-9a-f]+)\." + r"(?P[0-9a-f]+)\Z" + ) _libvirt_regex = re.compile( - r'\Apci_0000_(?P[0-9a-f]+)_(?P[0-9a-f]+)_' - r'(?P[0-9a-f]+)\Z') + r"\Apci_0000_(?P[0-9a-f]+)_(?P[0-9a-f]+)_" + r"(?P[0-9a-f]+)\Z" + ) - def __init__(self, backend_domain, ident, libvirt_name=None): + def __init__(self, port: Port, libvirt_name=None): if libvirt_name: dev_match = self._libvirt_regex.match(libvirt_name) if not dev_match: raise UnsupportedDevice(libvirt_name) - ident = '{bus}_{device}.{function}'.format(**dev_match.groupdict()) + port_id = "{bus}_{device}.{function}".format( + **dev_match.groupdict() + ) + port = Port( + backend_domain=port.backend_domain, + port_id=port_id, + devclass="pci", + ) - super().__init__( - backend_domain=backend_domain, ident=ident, devclass="pci") + super().__init__(port) - dev_match = self.regex.match(ident) + dev_match = self.regex.match(port.port_id) if not dev_match: - raise ValueError('Invalid device identifier: {!r}'.format( - ident)) + raise ValueError( + "Invalid device identifier: {!r}".format(port.port_id) + ) for group in self.regex.groupindex: setattr(self, group, dev_match.group(group)) # lazy loading - self._description = None - self._vendor_id = None - self._product_id = None + self._description: Optional[str] = None + self._vendor_id: Optional[str] = None + self._product_id: Optional[str] = None @property def vendor(self) -> str: @@ -221,16 +229,28 @@ def interfaces(self) -> List[qubes.device_protocol.DeviceInterface]: Every device should have at least one interface. """ - if self._interfaces is None: - hostdev_details = \ + if self._interfaces is None and self.backend_domain: + if self.backend_domain.app.vmm.offline_mode: + # don't cache this value + return [ + qubes.device_protocol.DeviceInterface( + "******", devclass="pci" + ) + ] + hostdev_details = ( self.backend_domain.app.vmm.libvirt_conn.nodeDeviceLookupByName( self.libvirt_name ) - interface_encoding = pcidev_interface(lxml.etree.fromstring( - hostdev_details.XMLDesc())) - self._interfaces = [qubes.device_protocol.DeviceInterface( - interface_encoding, devclass='pci')] - return self._interfaces + ) + interface_encoding = pcidev_interface( + lxml.etree.fromstring(hostdev_details.XMLDesc()) + ) + self._interfaces = [ + qubes.device_protocol.DeviceInterface( + interface_encoding, devclass="pci" + ) + ] + return self._interfaces or [] @property def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: @@ -245,72 +265,89 @@ def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: def libvirt_name(self): # pylint: disable=no-member # noinspection PyUnresolvedReferences - return f'pci_0000_{self.bus}_{self.device}_{self.function}' + return f"pci_0000_{self.bus}_{self.device}_{self.function}" @property def description(self): if self._description is None: - hostdev_details = \ + hostdev_details = ( self.backend_domain.app.vmm.libvirt_conn.nodeDeviceLookupByName( self.libvirt_name ) - self._description = _device_desc(lxml.etree.fromstring( - hostdev_details.XMLDesc())) + ) + self._description = _device_desc( + lxml.etree.fromstring(hostdev_details.XMLDesc()) + ) return self._description - @property - def self_identity(self) -> str: + @property # type: ignore[misc] + def device_id(self) -> str: """ Get identification of the device not related to port. """ - allowed_chars = string.digits + string.ascii_letters + '-_.' + allowed_chars = string.digits + string.ascii_letters + "-_." if self._vendor_id is None: vendor_id = self._load_desc()["vendor ID"] - self._vendor_id = ''.join( - c if c in set(allowed_chars) else '_' for c in vendor_id) + self._vendor_id = "".join( + c if c in set(allowed_chars) else "_" for c in vendor_id + ) if self._product_id is None: product_id = self._load_desc()["product ID"] - self._product_id = ''.join( - c if c in set(allowed_chars) else '_' for c in product_id) - interfaces = ''.join(repr(ifc) for ifc in self.interfaces) + self._product_id = "".join( + c if c in set(allowed_chars) else "_" for c in product_id + ) + interfaces = "".join(repr(ifc) for ifc in self.interfaces) serial = self._serial if self._serial else "" - return \ - f'{self._vendor_id}:{self._product_id}:{serial}:{interfaces}' + return f"{self._vendor_id}:{self._product_id}:{serial}:{interfaces}" def _load_desc(self) -> Dict[str, str]: unknown = "unknown" - result = {"vendor": unknown, - "vendor ID": "0000", - "product": unknown, - "product ID": "0000", - "manufacturer": unknown, - "name": unknown, - "serial": unknown} - if not self.backend_domain.is_running(): + result = { + "vendor": unknown, + "vendor ID": "0000", + "product": unknown, + "product ID": "0000", + "manufacturer": unknown, + "name": unknown, + "serial": unknown, + } + + if ( + not self.backend_domain + or not self.backend_domain.is_running() + or self.backend_domain.app.vmm.offline_mode + ): # don't cache these values return result - hostdev_details = \ + hostdev_details = ( self.backend_domain.app.vmm.libvirt_conn.nodeDeviceLookupByName( self.libvirt_name ) + ) # Data successfully loaded, cache these values hostdev_xml = lxml.etree.fromstring(hostdev_details.XMLDesc()) - self._vendor = result["vendor"] = hostdev_xml.findtext( - 'capability/vendor') - self._vendor_id = result["vendor ID"] = hostdev_xml.xpath( - "//vendor/@id")[0] - self._product = result["product"] = hostdev_xml.findtext( - 'capability/product') - self._product_id = result["product ID"] = hostdev_xml.xpath( - "//product/@id")[0] + + self._vendor = result["vendor"] = ( + hostdev_xml.findtext("capability/vendor") or unknown + ) + self._product = result["product"] = ( + hostdev_xml.findtext("capability/product") or unknown + ) + + vendor = hostdev_xml.xpath("//vendor/@id") or [] + if vendor and isinstance(vendor, List): + self._vendor_id = result["vendor ID"] = str(vendor[0]) + product = hostdev_xml.xpath("//product/@id") or [] + if product and isinstance(product, List): + self._product_id = result["product ID"] = str(product[0]) return result @property def frontend_domain(self): # TODO: cache this all_attached = attached_devices(self.backend_domain.app) - return all_attached.get(self.ident, None) + return all_attached.get(self.port_id, None) class PCIDeviceExtension(qubes.ext.Extension): @@ -319,7 +356,7 @@ def __init__(self): # lazy load this self.pci_classes = {} - @qubes.ext.handler('device-list:pci') + @qubes.ext.handler("device-list:pci") def on_device_list_pci(self, vm, event): # pylint: disable=unused-argument # only dom0 expose PCI devices @@ -327,79 +364,102 @@ def on_device_list_pci(self, vm, event): return for dev in vm.app.vmm.libvirt_conn.listAllDevices(): - if 'pci' not in dev.listCaps(): + if "pci" not in dev.listCaps(): continue xml_desc = lxml.etree.fromstring(dev.XMLDesc()) - libvirt_name = xml_desc.findtext('name') + libvirt_name = xml_desc.findtext("name") try: - yield PCIDevice(vm, None, libvirt_name=libvirt_name) + yield PCIDevice( + Port(backend_domain=vm, port_id=None, devclass="pci"), + libvirt_name=libvirt_name, + ) except UnsupportedDevice: if libvirt_name not in unsupported_devices_warned: vm.log.warning("Unsupported device: %s", libvirt_name) unsupported_devices_warned.add(libvirt_name) - @qubes.ext.handler('device-get:pci') - def on_device_get_pci(self, vm, event, ident): + @qubes.ext.handler("device-get:pci") + def on_device_get_pci(self, vm, event, port_id): # pylint: disable=unused-argument if not vm.app.vmm.offline_mode: - yield _cache_get(vm, ident) + yield _cache_get(vm, port_id) - @qubes.ext.handler('device-list-attached:pci') + @qubes.ext.handler("device-list-attached:pci") def on_device_list_attached(self, vm, event, **kwargs): # pylint: disable=unused-argument if not vm.is_running() or isinstance(vm, qubes.vm.adminvm.AdminVM): return xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) - for hostdev in xml_desc.findall('devices/hostdev'): - if hostdev.get('type') != 'pci': + for hostdev in xml_desc.findall("devices/hostdev"): + if hostdev.get("type") != "pci": continue - address = hostdev.find('source/address') - bus = address.get('bus')[2:] - device = address.get('slot')[2:] - function = address.get('function')[2:] + address = hostdev.find("source/address") + bus = address.get("bus")[2:] + device = address.get("slot")[2:] + function = address.get("function")[2:] - ident = '{bus}_{device}.{function}'.format( + port_id = "{bus}_{device}.{function}".format( bus=bus, device=device, function=function, ) - yield (PCIDevice(vm.app.domains[0], ident), {}) + yield PCIDevice( + Port( + backend_domain=vm.app.domains[0], + port_id=port_id, + devclass="pci", + ) + ), {} - @qubes.ext.handler('device-pre-attach:pci') + @qubes.ext.handler("device-pre-attach:pci") def on_device_pre_attached_pci(self, vm, event, device, options): # pylint: disable=unused-argument - if not os.path.exists('/sys/bus/pci/devices/0000:{}'.format( - device.ident.replace('_', ':'))): + if not os.path.exists( + "/sys/bus/pci/devices/0000:{}".format( + device.port_id.replace("_", ":") + ) + ): raise qubes.exc.QubesException( - 'Invalid PCI device: {}'.format(device.ident)) + "Invalid PCI device: {}".format(device.port_id) + ) if isinstance(vm, qubes.vm.adminvm.AdminVM): raise qubes.exc.QubesException("Can't attach PCI device to dom0") - if vm.virt_mode == 'pvh': + if vm.virt_mode == "pvh": raise qubes.exc.QubesException( - "Can't attach PCI device to VM in pvh mode") + "Can't attach PCI device to VM in pvh mode" + ) if not vm.is_running(): return try: - device = _cache_get(device.backend_domain, device.ident) + device = _cache_get(device.backend_domain, device.port_id) self.bind_pci_to_pciback(vm.app, device) vm.libvirt_domain.attachDevice( - vm.app.env.get_template('libvirt/devices/pci.xml').render( - device=device, vm=vm, options=options, + vm.app.env.get_template("libvirt/devices/pci.xml").render( + device=device, + vm=vm, + options=options, power_mgmt=vm.app.domains[0].features.get( - 'suspend-s0ix', False))) + "suspend-s0ix", False + ), + ) + ) except subprocess.CalledProcessError as e: - vm.log.exception('Failed to attach PCI device {!r} on the fly,' - ' changes will be seen after VM restart.'.format( - device.ident), e) + vm.log.exception( + "Failed to attach PCI device {!r} on the fly," + " changes will be seen after VM restart.".format( + device.port_id + ), + e, + ) - @qubes.ext.handler('device-pre-detach:pci') - def on_device_pre_detached_pci(self, vm, event, device): + @qubes.ext.handler("device-pre-detach:pci") + def on_device_pre_detached_pci(self, vm, event, port): # pylint: disable=unused-argument if not vm.is_running(): return @@ -408,56 +468,70 @@ def on_device_pre_detached_pci(self, vm, event, device): # provision in libvirt for extracting device-side BDF; we need it for # qubes.DetachPciDevice, which unbinds driver, not to oops the kernel - device = _cache_get(device.backend_domain, device.ident) - with subprocess.Popen(['xl', 'pci-list', str(vm.xid)], - stdout=subprocess.PIPE) as p: + device = _cache_get(port.backend_domain, port.port_id) + with subprocess.Popen( + ["xl", "pci-list", str(vm.xid)], stdout=subprocess.PIPE + ) as p: result = p.communicate()[0].decode() - m = re.search(r'^(\d+.\d+)\s+0000:{}$'.format(device.ident.replace( - '_', ':')), + m = re.search( + r"^(\d+.\d+)\s+0000:{}$".format(device.port_id.replace("_", ":")), result, - flags=re.MULTILINE) + flags=re.MULTILINE, + ) if not m: - vm.log.error('Device %s already detached', device.ident) + vm.log.error("Device %s already detached", device.port_id) return vmdev = m.group(1) try: - vm.run_service('qubes.DetachPciDevice', - user='root', input='00:{}'.format(vmdev)) + vm.run_service( + "qubes.DetachPciDevice", + user="root", + input="00:{}".format(vmdev), + ) vm.libvirt_domain.detachDevice( - vm.app.env.get_template('libvirt/devices/pci.xml').render( - device=device, vm=vm, + vm.app.env.get_template("libvirt/devices/pci.xml").render( + device=device, + vm=vm, power_mgmt=vm.app.domains[0].features.get( - 'suspend-s0ix', False))) + "suspend-s0ix", False + ), + ) + ) except (subprocess.CalledProcessError, libvirt.libvirtError) as e: - vm.log.exception('Failed to detach PCI device {!r} on the fly,' - ' changes will be seen after VM restart.'.format( - device.ident), e) + vm.log.exception( + "Failed to detach PCI device {!r} on the fly," + " changes will be seen after VM restart.".format( + device.port_id + ), + e, + ) raise - @qubes.ext.handler('domain-pre-start') + @qubes.ext.handler("domain-pre-start") def on_domain_pre_start(self, vm, _event, **_kwargs): # Bind pci devices to pciback driver - for assignment in vm.devices['pci'].get_assigned_devices(): - device = _cache_get(assignment.backend_domain, assignment.ident) + for assignment in vm.devices["pci"].get_assigned_devices(): + device = _cache_get(assignment.backend_domain, assignment.port_id) self.bind_pci_to_pciback(vm.app, device) @staticmethod def bind_pci_to_pciback(app, device): - '''Bind PCI device to pciback driver. + """Bind PCI device to pciback driver. :param qubes.devices.PCIDevice device: device to attach Devices should be unbound from their normal kernel drivers and bound to the dummy driver, which allows for attaching them to a domain. - ''' + """ try: node = app.vmm.libvirt_conn.nodeDeviceLookupByName( - device.libvirt_name) + device.libvirt_name + ) except libvirt.libvirtError as e: if e.get_error_code() == libvirt.VIR_ERR_NO_NODE_DEVICE: raise qubes.exc.QubesException( - 'PCI device {!s} does not exist'.format( - device)) + "PCI device {!s} does not exist".format(device) + ) raise try: @@ -469,13 +543,13 @@ def bind_pci_to_pciback(app, device): else: raise - @qubes.ext.handler('qubes-close', system=True) + @qubes.ext.handler("qubes-close", system=True) def on_app_close(self, app, event): # pylint: disable=unused-argument _cache_get.cache_clear() @functools.lru_cache(maxsize=None) -def _cache_get(vm, ident): - ''' Caching wrapper around `PCIDevice(vm, ident)`. ''' - return PCIDevice(vm, ident) +def _cache_get(vm, port_id): + """Caching wrapper around `PCIDevice(vm, port_id)`.""" + return PCIDevice(Port(vm, port_id, "pci")) diff --git a/qubes/ext/utils.py b/qubes/ext/utils.py index 1c7831922..c0c89d993 100644 --- a/qubes/ext/utils.py +++ b/qubes/ext/utils.py @@ -2,7 +2,7 @@ # # The Qubes OS Project, https://www.qubes-os.org # -# Copyright (C) 2023 Piotr Bartman-Szwarc +# Copyright (C) 2024 Piotr Bartman-Szwarc # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -19,59 +19,123 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. import asyncio +import sys +from typing import Type, Dict -import qubes +import qubes.ext +from qrexec.server import call_socket_service +from qubes import device_protocol +from qubes.device_protocol import VirtualDevice, Port + +SOCKET_PATH = "/var/run/qubes" def device_list_change( - ext: qubes.ext.Extension, current_devices, - vm, path, device_class: qubes.device_protocol.DeviceInfo + ext: qubes.ext.Extension, + current_devices, + vm, + path, + device_class: Type[qubes.device_protocol.DeviceInfo], ): - devclass = device_class.__name__[:-len('Device')].lower() + devclass = device_class.__name__[: -len("Device")].lower() if path is not None: - vm.fire_event(f'device-list-change:{devclass}') + vm.fire_event(f"device-list-change:{devclass}") - added, attached, detached, removed = ( - compare_device_cache(vm, ext.devices_cache, current_devices)) + added, attached, detached, removed = compare_device_cache( + vm, ext.devices_cache, current_devices + ) # send events about devices detached/attached outside by themselves - for dev_id, front_vm in detached.items(): - dev = device_class(vm, dev_id) - asyncio.ensure_future(front_vm.fire_event_async( - f'device-detach:{devclass}', device=dev)) - for dev_id in removed: - device = device_class(vm, dev_id) - vm.fire_event(f'device-removed:{devclass}', device=device) - for dev_id in added: - device = device_class(vm, dev_id) - vm.fire_event(f'device-added:{devclass}', device=device) - for dev_ident, front_vm in attached.items(): - dev = device_class(vm, dev_ident) + for port_id, front_vm in detached.items(): + device = device_class( + Port(backend_domain=vm, port_id=port_id, devclass=devclass) + ) + ext.ensure_detach(front_vm, device.port) + asyncio.ensure_future( + front_vm.fire_event_async( + f"device-detach:{devclass}", port=device.port + ) + ) + for port_id in removed: + device = device_class( + Port(backend_domain=vm, port_id=port_id, devclass=devclass) + ) + vm.fire_event(f"device-removed:{devclass}", port=device.port) + for port_id in added: + device = device_class( + Port(backend_domain=vm, port_id=port_id, devclass=devclass) + ) + vm.fire_event(f"device-added:{devclass}", device=device) + for port_id, front_vm in attached.items(): + device = device_class( + Port(backend_domain=vm, port_id=port_id, devclass=devclass) + ) # options are unknown, device already attached - asyncio.ensure_future(front_vm.fire_event_async( - f'device-attach:{devclass}', device=dev, options={})) + asyncio.ensure_future( + front_vm.fire_event_async( + f"device-attach:{devclass}", device=device, options={} + ) + ) ext.devices_cache[vm.name] = current_devices + to_attach: Dict[str, Dict] = {} for front_vm in vm.app.domains: if not front_vm.is_running(): continue - for assignment in front_vm.devices[devclass].get_assigned_devices(): - if (assignment.backend_domain == vm - and assignment.ident in added - and assignment.ident not in attached - ): - asyncio.ensure_future(ext.attach_and_notify( - front_vm, assignment.device, assignment.options)) + for assignment in reversed( + sorted(front_vm.devices[devclass].get_assigned_devices()) + ): + for device in assignment.devices: + if ( + assignment.matches(device) + and device.port_id in added + and device.port_id not in attached + ): + frontends = to_attach.get(device.port_id, {}) + # make it unique + ass = assignment.clone( + device=VirtualDevice(device.port, device.device_id) + ) + curr = frontends.get(front_vm, None) + if curr is None or curr < ass: + # chose the most specific assignment + frontends[front_vm] = ass + to_attach[device.port_id] = frontends + + asyncio.ensure_future(resolve_conflicts_and_attach(ext, to_attach)) + + +async def resolve_conflicts_and_attach(ext, to_attach): + for _, frontends in to_attach.items(): + if len(frontends) > 1: + # unique + device = tuple(frontends.values())[0].device + target_name = await confirm_device_attachment(device, frontends) + for front in frontends: + if front.name == target_name: + target = front + assignment = frontends[front] + # already asked + if assignment.mode.value == "ask-to-attach": + assignment.mode = device_protocol.AssignmentMode.AUTO + break + else: + return + else: + target = tuple(frontends.keys())[0] + assignment = frontends[target] + + await ext.attach_and_notify(target, assignment) def compare_device_cache(vm, devices_cache, current_devices): # compare cached devices and current devices, collect: - # - newly appeared devices (ident) - # - devices attached from a vm to frontend vm (ident: frontend_vm) - # - devices detached from frontend vm (ident: frontend_vm) - # - disappeared devices, e.g., plugged out (ident) + # - newly appeared devices (port_id) + # - devices attached from a vm to frontend vm (port_id: frontend_vm) + # - devices detached from frontend vm (port_id: frontend_vm) + # - disappeared devices, e.g., plugged out (port_id) added = set() attached = {} detached = {} @@ -100,3 +164,56 @@ def compare_device_cache(vm, devices_cache, current_devices): if cached_front is not None: detached[dev_id] = cached_front return added, attached, detached, removed + + +async def confirm_device_attachment(device, frontends) -> str: + try: + return await _do_confirm_device_attachment(device, frontends) + except Exception as exc: + print(str(exc.__class__.__name__) + ":", str(exc), file=sys.stderr) + return "" + + +async def _do_confirm_device_attachment(device, frontends): + socket = "device-agent.GUI" + + app = tuple(frontends.keys())[0].app + doms = app.domains + + front_names = [f.name for f in frontends.keys()] + + try: + guivm = doms["dom0"].guivm.name + except AttributeError: + guivm = "dom0" + + number_of_targets = len(front_names) + + params = { + "source": device.backend_domain.name, + "device_name": device.description, + "argument": device.port_id, + "targets": front_names, + "default_target": front_names[0] if number_of_targets == 1 else "", + "icons": { + ( + dom.name if dom.klass != "DispVM" else f"@dispvm:{dom.name}" + ): dom.icon + for dom in doms.values() + }, + } + + socked_call = asyncio.create_task( + call_socket_service(guivm, socket, "dom0", params, SOCKET_PATH) + ) + + while not socked_call.done(): + await asyncio.sleep(0.1) + + ask_response = await socked_call + + if ask_response.startswith("allow:"): + chosen = ask_response[len("allow:") :] + if chosen in front_names: + return chosen + return "" diff --git a/qubes/storage/__init__.py b/qubes/storage/__init__.py index e32489657..159dc666b 100644 --- a/qubes/storage/__init__.py +++ b/qubes/storage/__init__.py @@ -28,10 +28,11 @@ import os.path import string import subprocess -import time -from datetime import datetime +from datetime import datetime, timezone import asyncio +from typing import Dict, Tuple, Union + import lxml.etree import importlib.metadata import qubes @@ -422,10 +423,7 @@ def size(self): ''' Volume size in bytes ''' return self._size - @size.setter - def size(self, size): - # pylint: disable=attribute-defined-outside-init - self._size = int(size) + def encrypted_volume_path(self, qube_name, device_name): """Find the name of the encrypted volatile volume""" @@ -954,10 +952,12 @@ def included_in(self, app): @property def size(self): ''' Storage pool size in bytes, or None if unknown ''' + return @property def usage(self): ''' Space used in the pool in bytes, or None if unknown ''' + return @property def usage_details(self): @@ -1012,7 +1012,9 @@ def driver_parameters(name): def isodate(seconds): ''' Helper method which returns an iso date ''' - return datetime.utcfromtimestamp(seconds).isoformat("T") + return datetime.fromtimestamp( + seconds, tz=timezone.utc + ).isoformat().replace("+00:00", "") def search_pool_containing_dir(pools, dir_path): ''' Helper function looking for a pool containing given directory. @@ -1061,7 +1063,7 @@ def __exit__(self, type, value, tb): # pylint: disable=redefined-builtin # pylint: disable=too-few-public-methods class DirectoryThinPool: '''The thin pool containing the device of given filesystem''' - _thin_pool = {} + _thin_pool: Dict[str, Tuple[Union[str, None], Union[str, None]]] = {} @classmethod def _init(cls, dir_path): diff --git a/qubes/storage/kernels.py b/qubes/storage/kernels.py index 1942ac172..eb5cc46e8 100644 --- a/qubes/storage/kernels.py +++ b/qubes/storage/kernels.py @@ -89,8 +89,10 @@ async def import_volume(self, src_volume): if isinstance(src_volume, LinuxModules): # do nothing return self - raise StoragePoolException('clone of LinuxModules volume from ' - 'different volume type is not supported') + raise StoragePoolException( + 'clone of LinuxModules volume from different' + ' volume type is not supported' + ) async def create(self): return self @@ -99,6 +101,11 @@ async def create(self): def ephemeral(self): return False + @ephemeral.setter + def ephemeral(self, value): + raise qubes.exc.QubesValueError( + 'LinuxModules does not support setting ephemeral value') + async def remove(self): pass diff --git a/qubes/tests/api_admin.py b/qubes/tests/api_admin.py index 357104109..8bb6ae286 100644 --- a/qubes/tests/api_admin.py +++ b/qubes/tests/api_admin.py @@ -43,6 +43,9 @@ import qubes.tests import qubes.storage +from qubes.device_protocol import (DeviceInfo, VirtualDevice, Port, + DeviceAssignment) + # properties defined in API volume_properties = [ 'pool', 'vid', 'size', 'usage', 'rw', 'source', 'path', @@ -1684,7 +1687,6 @@ def test_346_vm_create_in_pool_duplicate_pool(self, storage_mock): self.assertNotIn('test-vm2', self.app.domains) self.assertFalse(self.app.save.called) - def test_400_property_list(self): # actual function tested for admin.vm.property.* already # this test is kind of stupid, but at least check if appropriate @@ -1729,12 +1731,12 @@ def test_450_property_reset(self): def device_list_testclass(self, vm, event): if vm is not self.vm: return - dev = qubes.device_protocol.DeviceInfo( - self.vm, '1234', product='Some device') + dev = DeviceInfo(Port( + self.vm, '1234', 'testclass'), product='Some device') dev.extra_prop = 'xx' yield dev - dev = qubes.device_protocol.DeviceInfo( - self.vm, '4321', product='Some other device') + dev = DeviceInfo(Port( + self.vm, '4321', 'testclass'), product='Some other device') yield dev def assertSerializedEqual(self, actual, expected): @@ -1753,14 +1755,14 @@ def test_460_vm_device_available(self): value = value.replace("'Some device'", "'Some_device'") value = value.replace("'Some other device'", "'Some_other_device'") self.assertSerializedEqual(value, - "1234 serial='unknown' manufacturer='unknown' " - "self_identity='0000:0000::?******' vendor='unknown' " - "devclass='peripheral' product='Some_device' ident='1234' " - "name='unknown' backend_domain='test-vm1' interfaces='?******'\n" - "4321 serial='unknown' manufacturer='unknown' " - "self_identity='0000:0000::?******' vendor='unknown' " - "devclass='peripheral' product='Some_other_device' " - "ident='4321' name='unknown' backend_domain='test-vm1' " + "1234:0000:0000::?****** " + "device_id='0000:0000::?******' " + "devclass='testclass' product='Some_device' port_id='1234' " + "backend_domain='test-vm1' interfaces='?******'\n" + "4321:0000:0000::?****** " + "device_id='0000:0000::?******' " + "devclass='testclass' product='Some_other_device' " + "port_id='4321' backend_domain='test-vm1' " "interfaces='?******'\n") self.assertFalse(self.app.save.called) @@ -1770,10 +1772,10 @@ def test_461_vm_device_available_specific(self): b'test-vm1', b'4321') value = value.replace("'Some other device'", "'Some_other_device'") self.assertSerializedEqual(value, - "4321 serial='unknown' manufacturer='unknown' " - "self_identity='0000:0000::?******' vendor='unknown' " - "devclass='peripheral' product='Some_other_device' " - "ident='4321' name='unknown' backend_domain='test-vm1' " + "4321:0000:0000::?****** " + "device_id='0000:0000::?******' " + "devclass='testclass' product='Some_other_device' " + "port_id='4321' backend_domain='test-vm1' " "interfaces='?******'\n") self.assertFalse(self.app.save.called) @@ -1785,40 +1787,41 @@ def test_462_vm_device_available_invalid(self): self.assertFalse(self.app.save.called) def test_470_vm_device_list_assigned(self): - assignment = qubes.device_protocol.DeviceAssignment(self.vm, '1234', - attach_automatically=True, required=True) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass')), mode='required') self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) value = self.call_mgmt_func(b'admin.vm.device.testclass.Assigned', b'test-vm1') self.assertEqual(value, - "test-vm1+1234 required='yes' attach_automatically='yes' " - "ident='1234' devclass='testclass' backend_domain='test-vm1'\n") + "test-vm1+1234:* device_id='*' port_id='1234' " + "devclass='testclass' backend_domain='test-vm1' mode='required'\n") self.assertFalse(self.app.save.called) def test_471_vm_device_list_assigned_options(self): - assignment = qubes.device_protocol.DeviceAssignment(self.vm, '1234', - attach_automatically=True, required=True, options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass')), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) - assignment = qubes.device_protocol.DeviceAssignment(self.vm, '4321', - attach_automatically=True, required=True) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '4321', 'testclass')), mode='required') self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) value = self.call_mgmt_func(b'admin.vm.device.testclass.Assigned', b'test-vm1') self.assertEqual(value, - "test-vm1+1234 required='yes' attach_automatically='yes' " - "ident='1234' devclass='testclass' backend_domain='test-vm1' " + "test-vm1+1234:* device_id='*' port_id='1234' " + "devclass='testclass' backend_domain='test-vm1' mode='required' " "_opt1='value'\n" - "test-vm1+4321 required='yes' attach_automatically='yes' " - "ident='4321' devclass='testclass' backend_domain='test-vm1'\n") + "test-vm1+4321:* device_id='*' port_id='4321' " + "devclass='testclass' backend_domain='test-vm1' mode='required'\n") self.assertFalse(self.app.save.called) def device_list_single_attached_testclass(self, vm, event, **kwargs): if vm is not self.vm: return - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234', devclass='testclass') + dev = qubes.device_protocol.DeviceInfo(Port(self.vm, '1234', devclass='testclass')) yield (dev, {'attach_opt': 'value'}) def test_472_vm_device_list_attached(self): @@ -1827,33 +1830,36 @@ def test_472_vm_device_list_attached(self): value = self.call_mgmt_func(b'admin.vm.device.testclass.Attached', b'test-vm1') self.assertEqual(value, - "test-vm1+1234 required='no' attach_automatically='no' " - "ident='1234' devclass='testclass' backend_domain='test-vm1' " + "test-vm1+1234:0000:0000::?****** " + "device_id='0000:0000::?******' port_id='1234' " + "devclass='testclass' backend_domain='test-vm1' mode='manual' " "frontend_domain='test-vm1' _attach_opt='value'\n") self.assertFalse(self.app.save.called) def test_473_vm_device_list_assigned_specific(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass')), mode='required') self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '4321', attach_automatically=True, required=True) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '4321', 'testclass')), mode='required') self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) value = self.call_mgmt_func(b'admin.vm.device.testclass.Assigned', b'test-vm1', b'test-vm1+1234') self.assertEqual(value, - "test-vm1+1234 required='yes' attach_automatically='yes' " - "ident='1234' devclass='testclass' backend_domain='test-vm1'\n") + "test-vm1+1234:* device_id='*' port_id='1234' " + "devclass='testclass' backend_domain='test-vm1' mode='required'\n") self.assertFalse(self.app.save.called) def device_list_multiple_attached_testclass(self, vm, event, **kwargs): if vm is not self.vm: return - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234', devclass='testclass') + dev = qubes.device_protocol.DeviceInfo( + Port(self.vm, '1234', devclass='testclass')) yield (dev, {'attach_opt': 'value'}) - dev = qubes.device_protocol.DeviceInfo(self.vm, '4321', devclass='testclass') + dev = qubes.device_protocol.DeviceInfo( + Port(self.vm, '4321', devclass='testclass')) yield (dev, {'attach_opt': 'value'}) def test_474_vm_device_list_attached_specific(self): @@ -1862,13 +1868,20 @@ def test_474_vm_device_list_attached_specific(self): value = self.call_mgmt_func(b'admin.vm.device.testclass.Attached', b'test-vm1', b'test-vm1+1234') self.assertEqual(value, - "test-vm1+1234 required='no' attach_automatically='no' " - "ident='1234' devclass='testclass' backend_domain='test-vm1' " - "frontend_domain='test-vm1' _attach_opt='value'\n") + "test-vm1+1234:0000:0000::?****** " + "device_id='0000:0000::?******' port_id='1234' " + "devclass='testclass' backend_domain='test-vm1' " + "mode='manual' frontend_domain='test-vm1' _attach_opt='value'\n") self.assertFalse(self.app.save.called) + def get_dev(self, *args, **kwargs): + yield DeviceInfo(Port( + self.vm, '1234', 'testclass'), + device_id='0000:0000::t000000',) + def test_480_vm_device_attach(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) + self.vm.add_handler('device-get:testclass', self.get_dev) mock_action = unittest.mock.Mock() mock_action.return_value = None del mock_action._is_coroutine @@ -1876,7 +1889,7 @@ def test_480_vm_device_attach(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func(b'admin.vm.device.testclass.Attach', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:0000:0000::t000000') self.assertIsNone(value) mock_action.assert_called_once_with( self.vm, f'device-attach:testclass', @@ -1897,8 +1910,8 @@ def test_481_vm_device_assign(self): 'is_halted', lambda _: False): value = self.call_mgmt_func( b'admin.vm.device.testclass.Assign', - b'test-vm1', b'test-vm1+1234', - b"attach_automatically='yes'") + b'test-vm1', b'test-vm1+1234:0000:0000::?******', + b"mode='auto-attach'") self.assertIsNone(value) mock_action.assert_called_once_with( self.vm, f'device-assign:testclass', @@ -1919,8 +1932,8 @@ def test_483_vm_device_assign_required(self): 'is_halted', lambda _: False): value = self.call_mgmt_func( b'admin.vm.device.testclass.Assign', - b'test-vm1', b'test-vm1+1234', - b"attach_automatically='yes' required='yes'") + b'test-vm1', b'test-vm1+1234:0000:0000::?******', + b"mode='required'") self.assertIsNone(value) mock_action.assert_called_once_with( self.vm, f'device-assign:testclass', @@ -1938,7 +1951,7 @@ def test_484_vm_device_attach_not_running(self): self.vm.add_handler('device-attach:testclass', mock_action) with self.assertRaises(qubes.exc.QubesVMNotRunningError): self.call_mgmt_func(b'admin.vm.device.testclass.Attach', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:0000:0000::?******') self.assertFalse(mock_action.called) self.assertEqual( len(list(self.vm.devices['testclass'].get_assigned_devices())), 0) @@ -1953,8 +1966,8 @@ def test_485_vm_device_assign_not_running(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): self.call_mgmt_func(b'admin.vm.device.testclass.Assign', - b'test-vm1', b'test-vm1+1234', - b"attach_automatically='yes'") + b'test-vm1', b'test-vm1+1234:0000:0000::?******', + b"mode='auto-attach'") mock_action.assert_called_once_with( self.vm, f'device-assign:testclass', device=self.vm.devices['testclass']['1234'], @@ -1972,8 +1985,8 @@ def test_486_vm_device_assign_required_not_running(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): self.call_mgmt_func(b'admin.vm.device.testclass.Assign', - b'test-vm1', b'test-vm1+1234', - b"attach_automatically='yes' required='yes'") + b'test-vm1', b'test-vm1+1234:0000:0000::?******', + b"mode='required'") mock_action.assert_called_once_with( self.vm, f'device-assign:testclass', device=self.vm.devices['testclass']['1234'], @@ -1984,6 +1997,7 @@ def test_486_vm_device_assign_required_not_running(self): def test_487_vm_device_attach_options(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) + self.vm.add_handler('device-get:testclass', self.get_dev) mock_attach = unittest.mock.Mock() mock_attach.return_value = None del mock_attach._is_coroutine @@ -1991,7 +2005,8 @@ def test_487_vm_device_attach_options(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func(b'admin.vm.device.testclass.Attach', - b'test-vm1', b'test-vm1+1234', b"_option1='value2'") + b'test-vm1', b'test-vm1+1234:0000:0000::t000000', + b"_option1='value2'") self.assertIsNone(value) dev = self.vm.devices['testclass']['1234'] mock_attach.assert_called_once_with( @@ -2010,8 +2025,8 @@ def test_488_vm_device_assign_options(self): 'is_halted', lambda _: False): value = self.call_mgmt_func( b'admin.vm.device.testclass.Assign', - b'test-vm1', b'test-vm1+1234', - b"attach_automatically='yes' _option1='value2'") + b'test-vm1', b'test-vm1+1234:0000:0000::?******', + b"mode='auto-attach' _option1='value2'") self.assertIsNone(value) dev = self.vm.devices['testclass']['1234'] mock_attach.assert_called_once_with( @@ -2019,10 +2034,84 @@ def test_488_vm_device_assign_options(self): options={'option1': 'value2'}) self.app.save.assert_called_once_with() + def test_489_vm_multiple_device_one_port_assign(self): + self.vm.add_handler('device-list:testclass', self.device_list_testclass) + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler(f'device-assign:testclass', mock_action) + with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, + 'is_halted', lambda _: False): + self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234:dead', + b"mode='auto-attach'") + mock_action.assert_called_with( + self.vm, f'device-assign:testclass', + device=VirtualDevice(Port(self.vm, '1234', 'testclass'), + device_id='dead'), + options={}) + self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234:beef', + b"mode='auto-attach'") + mock_action.assert_called_with( + self.vm, f'device-assign:testclass', + device=VirtualDevice(Port(self.vm, '1234', 'testclass'), + device_id='beef'), + options={}) + + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), + 2) + self.app.save.assert_called() + + def test_4890_vm_overlapping_assignments(self): + self.vm.add_handler('device-list:testclass', self.device_list_testclass) + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler(f'device-assign:testclass', mock_action) + with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, + 'is_halted', lambda _: False): + self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234:dead', + b"mode='auto-attach'") + mock_action.assert_called_with( + self.vm, f'device-assign:testclass', + device=VirtualDevice(Port(self.vm, '1234', 'testclass'), + device_id='dead'), + options={}) + self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234:*', + b"mode='auto-attach'") + mock_action.assert_called_with( + self.vm, f'device-assign:testclass', + device=VirtualDevice(Port(self.vm, '1234', 'testclass'), + device_id='*'), + options={}) + self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+*:dead', + b"mode='auto-attach'") + mock_action.assert_called_with( + self.vm, f'device-assign:testclass', + device=VirtualDevice(Port(self.vm, '*', 'testclass'), + device_id='dead'), + options={}) + + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), + 3) + self.app.save.assert_called() + def test_490_vm_device_unassign_from_running(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=False, - options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), + device_id='dead:beef:cafe'), + mode='auto-attach', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2032,17 +2121,18 @@ def test_490_vm_device_unassign_from_running(self): with unittest.mock.patch.object( qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func(b'admin.vm.device.testclass.Unassign', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:dead:beef:cafe') self.assertIsNone(value) mock_action.assert_called_once_with( self.vm, 'device-unassign:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() def test_491_vm_device_unassign_required_from_running(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True, - options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), + device_id='dead:beef:cafe'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2053,17 +2143,18 @@ def test_491_vm_device_unassign_required_from_running(self): qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func( b'admin.vm.device.testclass.Unassign', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:dead:beef:cafe') self.assertIsNone(value) mock_action.assert_called_once_with( self.vm, 'device-unassign:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() def test_492_vm_device_unassign_from_halted(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=False, - options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), + device_id='dead:beef:cafe'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2072,16 +2163,17 @@ def test_492_vm_device_unassign_from_halted(self): self.vm.add_handler('device-unassign:testclass', mock_action) self.call_mgmt_func( b'admin.vm.device.testclass.Unassign', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:dead:beef:cafe') mock_action.assert_called_once_with( self.vm, 'device-unassign:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() def test_493_vm_device_unassign_required_from_halted(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True, - options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), + device_id='dead:beef:cafe'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2090,19 +2182,20 @@ def test_493_vm_device_unassign_required_from_halted(self): self.vm.add_handler('device-unassign:testclass', mock_action) self.call_mgmt_func( b'admin.vm.device.testclass.Unassign', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:dead:beef:cafe') mock_action.assert_called_once_with( self.vm, 'device-unassign:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() def test_494_vm_device_unassign_attached(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) self.vm.add_handler('device-list-attached:testclass', self.device_list_single_attached_testclass) - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=False, - options={'opt1': 'value'}) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), + device_id='dead:beef:cafe'), + mode='auto-attach', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2111,10 +2204,10 @@ def test_494_vm_device_unassign_attached(self): self.vm.add_handler('device-unassign:testclass', mock_action) self.call_mgmt_func( b'admin.vm.device.testclass.Unassign', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:dead:beef:cafe') mock_action.assert_called_once_with( self.vm, 'device-unassign:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() def test_495_vm_device_unassign_not_assigned(self): @@ -2126,7 +2219,7 @@ def test_495_vm_device_unassign_not_assigned(self): 'is_halted', lambda _: False): with self.assertRaises(qubes.devices.DeviceNotAssigned): self.call_mgmt_func(b'admin.vm.device.testclass.Detach', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:*') self.assertFalse(mock_detach.called) self.assertFalse(self.app.save.called) @@ -2141,10 +2234,10 @@ def test_496_vm_device_detach(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func(b'admin.vm.device.testclass.Detach', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234:*') self.assertIsNone(value) mock_detach.assert_called_once_with(self.vm, 'device-detach:testclass', - device=self.vm.devices['testclass']['1234']) + port=self.vm.devices['testclass']['1234'].port) self.assertFalse(self.app.save.called) def test_497_vm_device_detach_not_attached(self): @@ -2187,8 +2280,9 @@ def test_501_vm_remove_running(self, mock_rmtree, mock_remove): @unittest.mock.patch('shutil.rmtree') def test_502_vm_remove_attached(self, mock_rmtree, mock_remove): self.setup_for_clone() - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True) + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass')), + mode='required') self.loop.run_until_complete( self.vm2.devices['testclass'].assign(assignment)) @@ -2905,10 +2999,10 @@ def test_642_vm_create_disposable_not_allowed(self, storage_mock): b'test-vm1') self.assertFalse(self.app.save.called) - def test_650_vm_device_set_required_true(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=False, - options={'opt1': 'value'}) + def test_650_vm_device_set_mode_required(self): + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), device_id='bee'), + mode='auto-attach', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2919,26 +3013,28 @@ def test_650_vm_device_set_required_true(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'True') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234:bee', b'required') self.assertIsNone(value) - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') - required = self.vm.devices['testclass'].get_assigned_devices( - required_only=True) + dev = DeviceInfo(Port( + self.vm, '1234', 'testclass'), device_id='bee') + required = list(self.vm.devices['testclass'].get_assigned_devices( + required_only=True)) self.assertIn(dev, required) + self.assertEqual(required[0].mode.value, "required") self.assertEventFired( self.emitter, - 'admin-permission:admin.vm.device.testclass.Set.required') + 'admin-permission:admin.vm.device.testclass.Set.assignment') mock_action.assert_called_once_with( self.vm, f'device-assignment-changed:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() - def test_651_vm_device_set_required_false(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True, - options={'opt1': 'value'}) + def test_651_vm_device_set_mode_ask(self): + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), device_id='bee'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) mock_action = unittest.mock.Mock() @@ -2949,82 +3045,101 @@ def test_651_vm_device_set_required_false(self): with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'False') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234:bee', b'ask-to-attach') self.assertIsNone(value) - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + dev = DeviceInfo(Port(self.vm, '1234', 'testclass'), + device_id='bee') required = self.vm.devices['testclass'].get_assigned_devices( required_only=True) self.assertNotIn(dev, required) + assignments = list(self.vm.devices['testclass'].get_assigned_devices()) + self.assertEqual(assignments[0].mode.value, "ask-to-attach") self.assertEventFired( self.emitter, - 'admin-permission:admin.vm.device.testclass.Set.required') + 'admin-permission:admin.vm.device.testclass.Set.assignment') mock_action.assert_called_once_with( self.vm, f'device-assignment-changed:testclass', - device=self.vm.devices['testclass']['1234']) + device=assignment.virtual_device) self.app.save.assert_called_once_with() - def test_652_vm_device_set_required_true_unchanged(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=True, - options={'opt1': 'value'}) + def test_652_vm_device_set_mode_auto(self): + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), device_id='bee'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler(f'device-assignment-changed:testclass', mock_action) + with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'True') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234:bee', b'auto-attach') + self.assertIsNone(value) - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + dev = DeviceInfo(Port(self.vm, '1234', 'testclass'), + device_id='bee') required = self.vm.devices['testclass'].get_assigned_devices( required_only=True) - self.assertIn(dev, required) + self.assertNotIn(dev, required) + assignments = list(self.vm.devices['testclass'].get_assigned_devices()) + self.assertEqual(assignments[0].mode.value, "auto-attach") + self.assertEventFired( + self.emitter, + 'admin-permission:admin.vm.device.testclass.Set.assignment') + mock_action.assert_called_once_with( + self.vm, f'device-assignment-changed:testclass', + device=assignment.virtual_device) self.app.save.assert_called_once_with() - def test_653_vm_device_set_required_false_unchanged(self): - assignment = qubes.device_protocol.DeviceAssignment( - self.vm, '1234', attach_automatically=True, required=False, - options={'opt1': 'value'}) + def test_653_vm_device_set_mode_unchanged(self): + assignment = DeviceAssignment(VirtualDevice(Port( + self.vm, '1234', 'testclass'), device_id='bee'), + mode='required', options={'opt1': 'value'}) self.loop.run_until_complete( self.vm.devices['testclass'].assign(assignment)) with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): value = self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'False') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234:bee', b'required') self.assertIsNone(value) - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + dev = DeviceInfo(Port(self.vm, '1234', 'testclass'), + device_id='bee') required = self.vm.devices['testclass'].get_assigned_devices( required_only=True) - self.assertNotIn(dev, required) + self.assertIn(dev, required) self.app.save.assert_called_once_with() - def test_654_vm_device_set_persistent_not_assigned(self): + def test_654_vm_device_set_mode_not_assigned(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): with self.assertRaises(qubes.exc.QubesValueError): self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'True') - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234', b'required') + dev = qubes.device_protocol.DeviceInfo(Port(self.vm, '1234', 'testclass')) self.assertNotIn( dev, self.vm.devices['testclass'].get_assigned_devices()) self.assertFalse(self.app.save.called) - def test_655_vm_device_set_persistent_invalid_value(self): + def test_655_vm_device_set_mode_invalid_value(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func( - b'admin.vm.device.testclass.Set.required', - b'test-vm1', b'test-vm1+1234', b'maybe') - dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + b'admin.vm.device.testclass.Set.assignment', + b'test-vm1', b'test-vm1+1234', b'True') + dev = qubes.device_protocol.DeviceInfo(Port(self.vm, '1234', 'testclass')) self.assertNotIn(dev, self.vm.devices['testclass'].get_assigned_devices()) self.assertFalse(self.app.save.called) diff --git a/qubes/tests/app.py b/qubes/tests/app.py index c9f9ec8c6..50146a810 100644 --- a/qubes/tests/app.py +++ b/qubes/tests/app.py @@ -70,6 +70,7 @@ def setUp(self): self.app = TestApp() self.app.vmm = mock.Mock() self.qubes_host = qubes.app.QubesHost(self.app) + self.maxDiff = None def test_000_get_vm_stats_single(self): self.app.vmm.configure_mock(**{ @@ -118,20 +119,20 @@ def test_001_get_vm_stats_twice(self): expected_info = { 0: { 'cpu_time': 243951379111104, - 'cpu_usage': 9, - 'cpu_usage_raw': 79, + 'cpu_usage': 10, + 'cpu_usage_raw': 80, 'memory_kb': 3733212, }, 1: { 'cpu_time': 2849496569205, - 'cpu_usage': 99, - 'cpu_usage_raw': 99, + 'cpu_usage': 100, + 'cpu_usage_raw': 100, 'memory_kb': 303916, }, 11: { 'cpu_time': 249658663079978, 'cpu_usage': 12, - 'cpu_usage_raw': 99, + 'cpu_usage_raw': 100, 'memory_kb': 3782668, }, } @@ -190,7 +191,7 @@ def test_011_iommu_supported(self): }) self.assertEqual(self.qubes_host.is_iommu_supported(), True) - def test_010_iommu_supported(self): + def test_012_iommu_supported(self): self.app.vmm.configure_mock(**{ 'xc.physinfo.return_value': { 'hw_caps': '...', @@ -815,7 +816,7 @@ def test_206_remove_attached(self): # See also qubes.tests.api_admin. vm = self.app.add_new_vm( 'AppVM', name='test-vm', template=self.template, label='red') - assignment = mock.Mock(ident='1234') + assignment = mock.Mock(port_id='1234') vm.get_provided_assignments = lambda: [assignment] with self.assertRaises(qubes.exc.QubesVMInUseError): del self.app.domains[vm] diff --git a/qubes/tests/devices.py b/qubes/tests/devices.py index e7c81b9ba..e32eaaee1 100644 --- a/qubes/tests/devices.py +++ b/qubes/tests/devices.py @@ -1,4 +1,5 @@ # pylint: disable=protected-access,pointless-statement +import sys # # The Qubes OS Project, https://www.qubes-os.org/ @@ -21,8 +22,9 @@ # import qubes.devices -from qubes.device_protocol import (Device, DeviceInfo, DeviceAssignment, - DeviceInterface, UnknownDevice) +from qubes.device_protocol import (Port, DeviceInfo, DeviceAssignment, + DeviceInterface, UnknownDevice, + VirtualDevice, AssignmentMode) import qubes.tests @@ -48,7 +50,8 @@ def __init__(self, app, name, *args, **kwargs): super(TestVM, self).__init__(*args, **kwargs) self.app = app self.name = name - self.device = TestDevice(self, 'testdev', devclass='testclass') + self.device = TestDevice( + Port(self, 'testport', devclass='testclass'), device_id='testdev') self.events_enabled = True self.devices = { 'testclass': qubes.devices.DeviceCollection(self, 'testclass') @@ -70,6 +73,10 @@ def dev_testclass_list_attached(self, event, persistent=False): def dev_testclass_list(self, event): yield self.device + @qubes.events.handler('device-get:testclass') + def dev_testclass_get(self, event, **kwargs): + yield self.device + def is_halted(self): return not self.running @@ -90,12 +97,7 @@ def setUp(self): self.app.domains['vm'] = self.emitter self.device = self.emitter.device self.collection = self.emitter.devices['testclass'] - self.assignment = DeviceAssignment( - backend_domain=self.device.backend_domain, - ident=self.device.ident, - attach_automatically=True, - required=True, - ) + self.assignment = DeviceAssignment(self.device, mode='required') def attach(self): self.emitter.running = True @@ -124,20 +126,21 @@ def test_002_attach_to_halted(self): def test_003_detach(self): self.attach() - self.loop.run_until_complete(self.collection.detach(self.assignment)) + self.loop.run_until_complete(self.collection.detach( + self.assignment.port)) self.assertEventFired(self.emitter, 'device-pre-detach:testclass') self.assertEventFired(self.emitter, 'device-detach:testclass') def test_004_detach_from_halted(self): with self.assertRaises(LookupError): self.loop.run_until_complete( - self.collection.detach(self.assignment)) + self.collection.detach(self.assignment.port)) def test_010_empty_detach(self): self.emitter.running = True with self.assertRaises(LookupError): self.loop.run_until_complete( - self.collection.detach(self.assignment)) + self.collection.detach(self.assignment.port)) def test_011_empty_unassign(self): for _ in range(2): @@ -154,12 +157,13 @@ def test_012_double_attach(self): def test_013_double_detach(self): self.attach() - self.loop.run_until_complete(self.collection.detach(self.assignment)) + self.loop.run_until_complete(self.collection.detach( + self.assignment.port)) self.detach() with self.assertRaises(qubes.devices.DeviceNotAssigned): self.loop.run_until_complete( - self.collection.detach(self.assignment)) + self.collection.detach(self.assignment.port)) def test_014_double_assign(self): self.loop.run_until_complete(self.collection.assign(self.assignment)) @@ -179,95 +183,92 @@ def test_015_double_unassign(self): def test_016_list_assigned(self): self.assertEqual(set([]), set(self.collection.get_assigned_devices())) self.loop.run_until_complete(self.collection.assign(self.assignment)) - self.assertEqual({self.device}, + self.assertEqual({self.assignment}, set(self.collection.get_assigned_devices())) self.assertEqual(set([]), set(self.collection.get_attached_devices())) - self.assertEqual({self.device}, + self.assertEqual({self.assignment}, set(self.collection.get_dedicated_devices())) def test_017_list_attached(self): - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') self.attach() - self.assertEqual({self.device}, + self.assertEqual({self.assignment}, set(self.collection.get_attached_devices())) self.assertEqual(set([]), set(self.collection.get_assigned_devices())) - self.assertEqual({self.device}, + self.assertEqual({self.assignment}, set(self.collection.get_dedicated_devices())) self.assertEventFired(self.emitter, 'device-list-attached:testclass') def test_018_list_available(self): - self.assertEqual({self.device}, set(self.collection)) + self.assertEqual({self.assignment}, set(self.collection)) self.assertEventFired(self.emitter, 'device-list:testclass') - def test_020_update_required_to_false(self): + def test_020_update_mode_to_auto(self): self.assertEqual(set([]), set(self.collection.get_assigned_devices())) self.loop.run_until_complete(self.collection.assign(self.assignment)) - self.attach() self.assertEqual( - {self.device}, + {self.assignment}, set(self.collection.get_assigned_devices(required_only=True))) self.assertEqual( - {self.device}, set(self.collection.get_assigned_devices())) + {self.assignment}, set(self.collection.get_assigned_devices())) self.loop.run_until_complete( - self.collection.update_required(self.device, False)) + self.collection.update_assignment(self.device, AssignmentMode.AUTO)) self.assertEqual( - {self.device}, set(self.collection.get_assigned_devices())) + set(), + set(self.collection.get_assigned_devices(required_only=True))) self.assertEqual( - {self.device}, set(self.collection.get_attached_devices())) + {self.assignment}, set(self.collection.get_assigned_devices())) - def test_021_update_required_to_true(self): - self.assignment.required = False - self.attach() - self.assertEqual(set(), set(self.collection.get_assigned_devices())) + def test_021_update_mode_to_ask(self): + self.assertEqual(set([]), set(self.collection.get_assigned_devices())) self.loop.run_until_complete(self.collection.assign(self.assignment)) self.assertEqual( - set(), + {self.assignment}, set(self.collection.get_assigned_devices(required_only=True))) - self.assertEqual({self.device}, - set(self.collection.get_attached_devices())) - self.assertEqual({self.device} - , set(self.collection.get_assigned_devices())) - self.assertEqual({self.device}, - set(self.collection.get_attached_devices())) + self.assertEqual( + {self.assignment}, set(self.collection.get_assigned_devices())) self.loop.run_until_complete( - self.collection.update_required(self.device, True)) - self.assertEqual({self.device}, - set(self.collection.get_assigned_devices())) - self.assertEqual({self.device}, - set(self.collection.get_attached_devices())) + self.collection.update_assignment(self.device, AssignmentMode.ASK)) + self.assertEqual( + set(), + set(self.collection.get_assigned_devices(required_only=True))) + self.assertEqual( + {self.assignment}, set(self.collection.get_assigned_devices())) - def test_022_update_required_reject_not_running(self): - self.assertEqual(set([]), set(self.collection.get_assigned_devices())) + def test_022_update_mode_to_required(self): + self.assignment = self.assignment.clone(mode='auto-attach') + self.assertEqual(set(), set(self.collection.get_assigned_devices())) self.loop.run_until_complete(self.collection.assign(self.assignment)) - self.assertEqual({self.device}, - set(self.collection.get_assigned_devices())) - self.assertEqual(set(), set(self.collection.get_attached_devices())) - with self.assertRaises(qubes.exc.QubesVMNotStartedError): - self.loop.run_until_complete( - self.collection.update_required(self.device, False)) - def test_023_update_required_reject_not_attached(self): - self.assertEqual(set(), set(self.collection.get_assigned_devices())) - self.assertEqual(set(), set(self.collection.get_attached_devices())) - self.emitter.running = True - with self.assertRaises(qubes.exc.QubesValueError): - self.loop.run_until_complete( - self.collection.update_required(self.device, True)) - with self.assertRaises(qubes.exc.QubesValueError): - self.loop.run_until_complete( - self.collection.update_required(self.device, False)) + self.assertEqual( + set(), + set(self.collection.get_assigned_devices(required_only=True))) + self.assertEqual( + {self.assignment}, + set(self.collection.get_assigned_devices())) + + self.loop.run_until_complete( + self.collection.update_assignment( + self.device, AssignmentMode.REQUIRED)) + + self.assertEqual( + {self.assignment}, + set(self.collection.get_assigned_devices(required_only=True))) + self.assertEqual( + {self.assignment}, + set(self.collection.get_assigned_devices())) def test_030_assign(self): self.emitter.running = True - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') self.loop.run_until_complete(self.collection.assign(self.assignment)) self.assertEventFired(self.emitter, 'device-assign:testclass') self.assertEventNotFired(self.emitter, 'device-unassign:testclass') def test_031_assign_to_halted(self): - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') self.loop.run_until_complete(self.collection.assign(self.assignment)) self.assertEventFired(self.emitter, 'device-assign:testclass') self.assertEventNotFired(self.emitter, 'device-unassign:testclass') @@ -284,7 +285,7 @@ def test_033_assign_required_to_halted(self): self.assertEventNotFired(self.emitter, 'device-unassign:testclass') def test_034_unassign_from_halted(self): - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') self.loop.run_until_complete(self.collection.assign(self.assignment)) self.loop.run_until_complete(self.collection.unassign(self.assignment)) self.assertEventFired(self.emitter, 'device-assign:testclass') @@ -292,7 +293,30 @@ def test_034_unassign_from_halted(self): def test_035_unassign(self): self.emitter.running = True - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.loop.run_until_complete(self.collection.unassign(self.assignment)) + self.assertEventFired(self.emitter, 'device-assign:testclass') + self.assertEventFired(self.emitter, 'device-unassign:testclass') + + def test_036_assign_unassign_port(self): + self.emitter.running = True + device = self.assignment.virtual_device + device = device.clone(port=Port( + device.backend_domain, '*', device.devclass)) + self.assignment = self.assignment.clone( + mode='ask-to-attach', device=device) + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.loop.run_until_complete(self.collection.unassign(self.assignment)) + self.assertEventFired(self.emitter, 'device-assign:testclass') + self.assertEventFired(self.emitter, 'device-unassign:testclass') + + def test_037_assign_unassign_device(self): + self.emitter.running = True + device = self.assignment.virtual_device + device = device.clone(device_id="*") + self.assignment = self.assignment.clone( + mode='ask-to-attach', device=device) self.loop.run_until_complete(self.collection.assign(self.assignment)) self.loop.run_until_complete(self.collection.unassign(self.assignment)) self.assertEventFired(self.emitter, 'device-assign:testclass') @@ -303,13 +327,13 @@ def test_040_detach_required(self): self.attach() with self.assertRaises(qubes.exc.QubesVMNotHaltedError): self.loop.run_until_complete( - self.collection.detach(self.assignment)) + self.collection.detach(self.assignment.port)) def test_041_detach_required_from_halted(self): self.loop.run_until_complete(self.collection.assign(self.assignment)) with self.assertRaises(LookupError): self.loop.run_until_complete( - self.collection.detach(self.assignment)) + self.collection.detach(self.assignment.port)) def test_042_unassign_required(self): self.emitter.running = True @@ -319,10 +343,11 @@ def test_042_unassign_required(self): self.assertEventFired(self.emitter, 'device-unassign:testclass') def test_043_detach_assigned(self): - self.assignment.required = False + self.assignment = self.assignment.clone(mode='auto-attach') self.loop.run_until_complete(self.collection.assign(self.assignment)) self.attach() - self.loop.run_until_complete(self.collection.detach(self.assignment)) + self.loop.run_until_complete(self.collection.detach( + self.assignment.port)) self.assertEventFired(self.emitter, 'device-assign:testclass') self.assertEventFired(self.emitter, 'device-pre-detach:testclass') self.assertEventFired(self.emitter, 'device-detach:testclass') @@ -339,11 +364,9 @@ def test_000_init(self): self.assertEqual(self.manager, {}) def test_001_missing(self): - device = TestDevice(self.emitter.app.domains['vm'], 'testdev') - assignment = DeviceAssignment( - backend_domain=device.backend_domain, - ident=device.ident, - attach_automatically=True, required=True) + device = TestDevice( + Port(self.emitter.app.domains['vm'], 'testdev', 'testclass')) + assignment = DeviceAssignment(device, mode='required') self.loop.run_until_complete( self.manager['testclass'].assign(assignment)) self.assertEqual( @@ -358,9 +381,9 @@ def setUp(self): def test_010_serialize(self): device = DeviceInfo( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + Port(backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus"), vendor="ITL", product="Qubes", manufacturer="", @@ -370,11 +393,11 @@ def test_010_serialize(self): DeviceInterface("u03**01")], additional_info="", date="06.12.23", + device_id='0000:0000::?******', ) actual = device.serialize() expected = ( - b"manufacturer='unknown' self_identity='0000:0000::?******' " - b"serial='unknown' ident='1-1.1.1' product='Qubes' " + b"device_id='0000:0000::?******' port_id='1-1.1.1' product='Qubes' " b"vendor='ITL' name='Some untrusted garbage' devclass='bus' " b"backend_domain='vm' interfaces=' ******u03**01' " b"_additional_info='' _date='06.12.23'") @@ -386,9 +409,9 @@ def test_010_serialize(self): def test_011_serialize_with_parent(self): device = DeviceInfo( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + Port(backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus"), vendor="ITL", product="Qubes", manufacturer="", @@ -398,16 +421,16 @@ def test_011_serialize_with_parent(self): DeviceInterface("u03**01")], additional_info="", date="06.12.23", - parent=Device(self.vm, '1-1.1', 'pci') + parent=Port(self.vm, '1-1.1', 'pci'), + device_id='0000:0000::?******', ) actual = device.serialize() expected = ( - b"manufacturer='unknown' self_identity='0000:0000::?******' " - b"serial='unknown' ident='1-1.1.1' product='Qubes' " + b"device_id='0000:0000::?******' port_id='1-1.1.1' product='Qubes' " b"vendor='ITL' name='Some untrusted garbage' devclass='bus' " b"backend_domain='vm' interfaces=' ******u03**01' " b"_additional_info='' _date='06.12.23' " - b"parent_ident='1-1.1' parent_devclass='pci'") + b"parent_port_id='1-1.1' parent_devclass='pci'") expected = set(expected.replace(b"Some untrusted garbage", b"Some_untrusted_garbage").split(b" ")) actual = set(actual.replace(b"Some untrusted garbage", @@ -416,9 +439,9 @@ def test_011_serialize_with_parent(self): def test_012_invalid_serialize(self): device = DeviceInfo( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus?", + Port(backend_domain=self.vm, + port_id="1-1.1.1", + devclass="testclass"), vendor="malicious", product="suspicious", manufacturer="", @@ -430,17 +453,16 @@ def test_012_invalid_serialize(self): def test_020_deserialize(self): serialized = ( b"1-1.1.1 " - b"manufacturer='unknown' self_identity='0000:0000::?******' " - b"serial='unknown' ident='1-1.1.1' product='Qubes' " + b"device_id='0000:0000::?******' port_id='1-1.1.1' product='Qubes' " b"vendor='ITL' name='Some untrusted garbage' devclass='bus' " b"backend_domain='vm' interfaces=' ******u03**01' " b"_additional_info='' _date='06.12.23' " b"parent_ident='1-1.1' parent_devclass='None'") actual = DeviceInfo.deserialize(serialized, self.vm) expected = DeviceInfo( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + Port(backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus"), vendor="ITL", product="Qubes", manufacturer="unknown", @@ -450,10 +472,11 @@ def test_020_deserialize(self): DeviceInterface("u03**01")], additional_info="", date="06.12.23", + device_id='0000:0000::?******', ) self.assertEqual(actual.backend_domain, expected.backend_domain) - self.assertEqual(actual.ident, expected.ident) + self.assertEqual(actual.port_id, expected.port_id) self.assertEqual(actual.devclass, expected.devclass) self.assertEqual(actual.vendor, expected.vendor) self.assertEqual(actual.product, expected.product) @@ -461,14 +484,14 @@ def test_020_deserialize(self): self.assertEqual(actual.name, expected.name) self.assertEqual(actual.serial, expected.serial) self.assertEqual(repr(actual.interfaces), repr(expected.interfaces)) - self.assertEqual(actual.self_identity, expected.self_identity) + self.assertEqual(actual.device_id, expected.device_id) self.assertEqual(actual.data, expected.data) def test_021_invalid_deserialize(self): serialized = ( b"1-1.1.1 " - b"manufacturer='unknown' self_identity='0000:0000::?******' " - b"serial='unknown' ident='1-1.1.1' product='Qubes' " + b"manufacturer='unknown' device_id='0000:0000::?******' " + b"serial='unknown' port_id='1-1.1.1' product='Qubes' " b"vendor='ITL' name='Some untrusted garbage' devclass='bus' " b"backend_domain='vm' interfaces=' ******u03**01' " b"_additional_info='' _date='06.12.23' " @@ -476,14 +499,14 @@ def test_021_invalid_deserialize(self): actual = DeviceInfo.deserialize(serialized, self.vm) self.assertIsInstance(actual, UnknownDevice) self.assertEqual(actual.backend_domain, self.vm) - self.assertEqual(actual.ident, '1-1.1.1') + self.assertEqual(actual.port_id, '1-1.1.1') self.assertEqual(actual.devclass, 'peripheral') def test_030_serialize_and_deserialize(self): device = DeviceInfo( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus?", + Port(backend_domain=self.vm, + port_id="1-1.1.1", + devclass="testclass"), vendor="malicious", product="suspicious", manufacturer="", @@ -496,7 +519,7 @@ def test_030_serialize_and_deserialize(self): serialized = device.serialize() deserialized = DeviceInfo.deserialize(b'1-1.1.1 ' + serialized, self.vm) self.assertEqual(deserialized.backend_domain, device.backend_domain) - self.assertEqual(deserialized.ident, device.ident) + self.assertEqual(deserialized.port_id, device.port_id) self.assertEqual(deserialized.devclass, device.devclass) self.assertEqual(deserialized.vendor, device.vendor) self.assertEqual(deserialized.product, device.product) @@ -515,70 +538,77 @@ def setUp(self): self.vm = TestVM(self.app, 'vm') def test_010_serialize(self): - assignment = DeviceAssignment( + assignment = DeviceAssignment(VirtualDevice(Port( backend_domain=self.vm, - ident="1-1.1.1", + port_id="1-1.1.1", devclass="bus", - ) + ))) actual = assignment.serialize() expected = ( - b"ident='1-1.1.1' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='no'") + b"device_id='*' port_id='1-1.1.1' devclass='bus' " + b"backend_domain='vm' mode='manual'") expected = set(expected.split(b" ")) actual = set(actual.split(b" ")) self.assertEqual(actual, expected) def test_011_serialize_required(self): assignment = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", - attach_automatically=True, - required=True, + VirtualDevice(Port( + backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus", + )), + mode='required', ) actual = assignment.serialize() expected = ( - b"ident='1-1.1.1' devclass='bus' " - b"backend_domain='vm' required='yes' attach_automatically='yes'") + b"device_id='*' port_id='1-1.1.1' devclass='bus' " + b"backend_domain='vm' mode='required'") expected = set(expected.split(b" ")) actual = set(actual.split(b" ")) self.assertEqual(actual, expected) def test_012_serialize_fronted(self): assignment = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + VirtualDevice(Port( + backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus", + )), frontend_domain=self.vm, ) actual = assignment.serialize() expected = ( - b"ident='1-1.1.1' frontend_domain='vm' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='no'") + b"device_id='*' port_id='1-1.1.1' frontend_domain='vm' " + b"devclass='bus' backend_domain='vm' mode='manual'") expected = set(expected.split(b" ")) actual = set(actual.split(b" ")) self.assertEqual(actual, expected) def test_013_serialize_options(self): assignment = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + VirtualDevice(Port( + backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus", + )), options={'read-only': 'yes'}, ) actual = assignment.serialize() expected = ( - b"ident='1-1.1.1' _read-only='yes' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='no'") + b"device_id='*' port_id='1-1.1.1' _read-only='yes' devclass='bus' " + b"backend_domain='vm' mode='manual'") expected = set(expected.split(b" ")) actual = set(actual.split(b" ")) self.assertEqual(actual, expected) def test_014_invalid_serialize(self): assignment = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + VirtualDevice(Port( + backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus", + )), options={"read'only": 'yes'}, ) with self.assertRaises(qubes.exc.ProtocolError): @@ -586,23 +616,24 @@ def test_014_invalid_serialize(self): def test_020_deserialize(self): serialized = ( - b"ident='1-1.1.1' frontend_domain='vm' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='yes' " + b"device_id='*' port_id='1-1.1.1' frontend_domain='vm' " + b"devclass='bus' backend_domain='vm' mode='auto-attach' " b"_read-only='yes'") - expected_device = Device(self.vm, '1-1.1.1', 'bus') + expected_device = VirtualDevice(Port(self.vm, '1-1.1.1', 'bus')) actual = DeviceAssignment.deserialize(serialized, expected_device) expected = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + VirtualDevice(Port( + backend_domain=self.vm, + port_id="1-1.1.1", + devclass="bus", + )), frontend_domain=self.vm, - attach_automatically=True, - required=False, + mode='auto-attach', options={'read-only': 'yes'}, ) self.assertEqual(actual.backend_domain, expected.backend_domain) - self.assertEqual(actual.ident, expected.ident) + self.assertEqual(actual.port_id, expected.port_id) self.assertEqual(actual.devclass, expected.devclass) self.assertEqual(actual.frontend_domain, expected.frontend_domain) self.assertEqual(actual.attach_automatically, expected.attach_automatically) @@ -611,37 +642,34 @@ def test_020_deserialize(self): def test_021_invalid_deserialize(self): serialized = ( - b"ident='1-1.1.1' frontend_domain='vm' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='yes' " + b"device_id='*' port_id='1-1.1.1' frontend_domain='vm' " + b"devclass='bus' backend_domain='vm' mode='auto-attach' " b"_read'only='yes'") - expected_device = Device(self.vm, '1-1.1.1', 'bus') + expected_device = VirtualDevice(Port(self.vm, '1-1.1.1', 'bus')) with self.assertRaises(qubes.exc.ProtocolError): _ = DeviceAssignment.deserialize(serialized, expected_device) def test_022_invalid_deserialize_2(self): serialized = ( - b"ident='1-1.1.1' frontend_domain='vm' devclass='bus' " - b"backend_domain='vm' required='no' attach_automatically='yes' " + b"device_id='*' port_id='1-1.1.1' frontend_domain='vm' " + b"devclass='bus' backend_domain='vm' mode='auto-attach' " b"read-only='yes'") - expected_device = Device(self.vm, '1-1.1.1', 'bus') + expected_device = VirtualDevice(Port(self.vm, '1-1.1.1', 'bus')) with self.assertRaises(qubes.exc.ProtocolError): _ = DeviceAssignment.deserialize(serialized, expected_device) def test_030_serialize_and_deserialize(self): + expected_device = VirtualDevice(Port(self.vm, '1-1.1.1', 'bus')) expected = DeviceAssignment( - backend_domain=self.vm, - ident="1-1.1.1", - devclass="bus", + expected_device, frontend_domain=self.vm, - attach_automatically=True, - required=False, + mode='auto-attach', options={'read-only': 'yes'}, ) serialized = expected.serialize() - expected_device = Device(self.vm, '1-1.1.1', 'bus') actual = DeviceAssignment.deserialize(serialized, expected_device) self.assertEqual(actual.backend_domain, expected.backend_domain) - self.assertEqual(actual.ident, expected.ident) + self.assertEqual(actual.port_id, expected.port_id) self.assertEqual(actual.devclass, expected.devclass) self.assertEqual(actual.frontend_domain, expected.frontend_domain) self.assertEqual(actual.attach_automatically, diff --git a/qubes/tests/devices_block.py b/qubes/tests/devices_block.py index 0d19eebb4..751b67eaf 100644 --- a/qubes/tests/devices_block.py +++ b/qubes/tests/devices_block.py @@ -18,14 +18,25 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . import asyncio +import unittest from unittest import mock +from unittest.mock import Mock, AsyncMock import jinja2 import qubes.tests import qubes.ext.block -from qubes.device_protocol import DeviceInterface, Device, DeviceInfo, \ - DeviceAssignment +from qubes.device_protocol import DeviceInterface, Port, DeviceInfo, \ + DeviceAssignment, VirtualDevice + + +def async_test(f): + def wrapper(*args, **kwargs): + coro = asyncio.coroutine(f) + future = coro(*args, **kwargs) + loop = asyncio.get_event_loop() + loop.run_until_complete(future) + return wrapper modules_disk = ''' @@ -135,9 +146,14 @@ def __init__(self, backend_vm, devclass): def get_assigned_devices(self): return self._assigned - def __getitem__(self, ident): + def get_exposed_devices(self): + yield from self._exposed + + __iter__ = get_exposed_devices + + def __getitem__(self, port_id): for dev in self._exposed: - if dev.ident == ident: + if dev.port_id == port_id: return dev @@ -147,6 +163,8 @@ def __init__( *args, **kwargs): super(TestVM, self).__init__(*args, **kwargs) self.name = name + self.klass = "AdminVM" if name == "dom0" else "AppVM" + self.icon = "red" self.untrusted_qdb = TestQubesDB(qdb) self.libvirt_domain = mock.Mock() self.features = mock.Mock() @@ -165,6 +183,9 @@ def __init__( 'testclass': TestDeviceCollection(self, 'testclass') } + def __hash__(self): + return hash(self.name) + def __eq__(self, other): if isinstance(other, TestVM): return self.name == other.name @@ -173,6 +194,16 @@ def __str__(self): return self.name +def get_qdb(mode): + result = { + '/qubes-block-devices/sda': b'', + '/qubes-block-devices/sda/desc': b'Test device', + '/qubes-block-devices/sda/size': b'1024000', + '/qubes-block-devices/sda/mode': mode.encode(), + } + return result + + class TC_00_Block(qubes.tests.QubesTestCase): def setUp(self): @@ -187,7 +218,8 @@ def test_000_device_get(self): '/qubes-block-devices/sda/mode': b'w', '/qubes-block-devices/sda/parent': b'1-1.1:1.0', }, domain_xml=domain_xml_template.format("")) - parent = DeviceInfo(vm, '1-1.1', devclass='usb') + parent = DeviceInfo(Port(vm, '1-1.1', devclass='usb'), + device_id='0000:0000::?******') vm.devices['usb'] = TestDeviceCollection(backend_vm=vm, devclass='usb') vm.devices['usb']._exposed.append(parent) vm.is_running = lambda: True @@ -215,7 +247,7 @@ def test_000_device_get(self): device_info = self.ext.device_get(vm, 'sda') self.assertIsInstance(device_info, qubes.ext.block.BlockDevice) self.assertEqual(device_info.backend_domain, vm) - self.assertEqual(device_info.ident, 'sda') + self.assertEqual(device_info.port_id, 'sda') self.assertEqual(device_info.name, 'device') self.assertEqual(device_info._name, 'device') self.assertEqual(device_info.serial, 'Test') @@ -227,11 +259,10 @@ def test_000_device_get(self): self.assertEqual(device_info.device_node, '/dev/sda') self.assertEqual(device_info.interfaces, [DeviceInterface("b******")]) - self.assertEqual(device_info.parent_device, - Device(vm, '1-1.1', devclass='usb')) + self.assertEqual(device_info.parent_device, parent) self.assertEqual(device_info.attachment, front) - self.assertEqual(device_info.self_identity, - '1-1.1:0000:0000::?******:1.0') + self.assertEqual(device_info.device_id, + '0000:0000::?******:1.0') self.assertEqual( device_info.data.get('test_frontend_domain', None), None) self.assertEqual(device_info.device_node, '/dev/sda') @@ -246,7 +277,7 @@ def test_001_device_get_other_node(self): device_info = self.ext.device_get(vm, 'mapper_dmroot') self.assertIsInstance(device_info, qubes.ext.block.BlockDevice) self.assertEqual(device_info.backend_domain, vm) - self.assertEqual(device_info.ident, 'mapper_dmroot') + self.assertEqual(device_info.port_id, 'mapper_dmroot') self.assertEqual(device_info._name, None) self.assertEqual(device_info.name, 'unknown') self.assertEqual(device_info.serial, 'Test device') @@ -314,13 +345,13 @@ def test_010_devices_list(self): devices = sorted(list(self.ext.on_device_list_block(vm, ''))) self.assertEqual(len(devices), 2) self.assertEqual(devices[0].backend_domain, vm) - self.assertEqual(devices[0].ident, 'sda') + self.assertEqual(devices[0].port_id, 'sda') self.assertEqual(devices[0].serial, 'Test device') self.assertEqual(devices[0].name, 'unknown') self.assertEqual(devices[0].size, 1024000) self.assertEqual(devices[0].mode, 'w') self.assertEqual(devices[1].backend_domain, vm) - self.assertEqual(devices[1].ident, 'sdb') + self.assertEqual(devices[1].port_id, 'sdb') self.assertEqual(devices[1].serial, 'Test device') self.assertEqual(devices[1].name, '2') self.assertEqual(devices[1].size, 2048000) @@ -333,8 +364,8 @@ def test_011_devices_list_empty(self): def test_012_devices_list_invalid_ident(self): vm = TestVM({ - '/qubes-block-devices/invalid ident': b'', - '/qubes-block-devices/invalid+ident': b'', + '/qubes-block-devices/invalid port_id': b'', + '/qubes-block-devices/invalid+port_id': b'', '/qubes-block-devices/invalid#': b'', }) devices = sorted(list(self.ext.on_device_list_block(vm, ''))) @@ -389,7 +420,7 @@ def test_031_list_attached(self): dev = devices[0][0] options = devices[0][1] self.assertEqual(dev.backend_domain, vm.app.domains['sys-usb']) - self.assertEqual(dev.ident, 'sda') + self.assertEqual(dev.port_id, 'sda') self.assertEqual(dev.attachment, None) self.assertEqual(options['frontend-dev'], 'xvdi') self.assertEqual(options['read-only'], 'yes') @@ -412,7 +443,7 @@ def test_032_list_attached_dom0(self): dev = devices[0][0] options = devices[0][1] self.assertEqual(dev.backend_domain, vm.app.domains['dom0']) - self.assertEqual(dev.ident, 'sda') + self.assertEqual(dev.port_id, 'sda') self.assertEqual(options['frontend-dev'], 'xvdi') self.assertEqual(options['read-only'], 'no') @@ -434,20 +465,15 @@ def test_033_list_attached_cdrom(self): dev = devices[0][0] options = devices[0][1] self.assertEqual(dev.backend_domain, vm.app.domains['sys-usb']) - self.assertEqual(dev.ident, 'sr0') + self.assertEqual(dev.port_id, 'sr0') self.assertEqual(options['frontend-dev'], 'xvdi') self.assertEqual(options['read-only'], 'yes') self.assertEqual(options['devtype'], 'cdrom') def test_040_attach(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', "block")) self.ext.on_device_pre_attached_block(vm, '', dev, {}) device_xml = ( '\n' @@ -460,14 +486,9 @@ def test_040_attach(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_041_attach_frontend(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_device_pre_attached_block(vm, '', dev, {'frontend-dev': 'xvdj'}) device_xml = ( @@ -481,14 +502,9 @@ def test_041_attach_frontend(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_042_attach_read_only(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_device_pre_attached_block(vm, '', dev, {'read-only': 'yes'}) device_xml = ( @@ -503,69 +519,44 @@ def test_042_attach_read_only(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_043_attach_invalid_option(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) with self.assertRaises(qubes.exc.QubesValueError): self.ext.on_device_pre_attached_block(vm, '', dev, {'no-such-option': '123'}) self.assertFalse(vm.libvirt_domain.attachDevice.called) def test_044_attach_invalid_option2(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) with self.assertRaises(qubes.exc.QubesValueError): self.ext.on_device_pre_attached_block(vm, '', dev, {'read-only': 'maybe'}) self.assertFalse(vm.libvirt_domain.attachDevice.called) def test_045_attach_backend_not_running(self): - back_vm = TestVM(name='sys-usb', running=False, qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'w', - }) + back_vm = TestVM(name='sys-usb', running=False, qdb=get_qdb(mode='w')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) with self.assertRaises(qubes.exc.QubesVMNotRunningError): self.ext.on_device_pre_attached_block(vm, '', dev, {}) self.assertFalse(vm.libvirt_domain.attachDevice.called) def test_046_attach_ro_dev_rw(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) with self.assertRaises(qubes.exc.QubesValueError): self.ext.on_device_pre_attached_block(vm, '', dev, {'read-only': 'no'}) self.assertFalse(vm.libvirt_domain.attachDevice.called) def test_047_attach_read_only_auto(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_device_pre_attached_block(vm, '', dev, {}) device_xml = ( '\n' @@ -579,14 +570,9 @@ def test_047_attach_read_only_auto(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_048_attach_cdrom_xvdi(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) vm = TestVM({}, domain_xml=domain_xml_template.format(modules_disk)) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_device_pre_attached_block(vm, '', dev, {'devtype': 'cdrom'}) device_xml = ( '\n' @@ -600,14 +586,9 @@ def test_048_attach_cdrom_xvdi(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_048_attach_cdrom_xvdd(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_device_pre_attached_block(vm, '', dev, {'devtype': 'cdrom'}) device_xml = ( '\n' @@ -621,12 +602,7 @@ def test_048_attach_cdrom_xvdd(self): vm.libvirt_domain.attachDevice.assert_called_once_with(device_xml) def test_050_detach(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) device_xml = ( '\n' ' \n' @@ -639,55 +615,44 @@ def test_050_detach(self): vm = TestVM({}, domain_xml=domain_xml_template.format(device_xml)) vm.app.domains['test-vm'] = vm vm.app.domains['sys-usb'] = TestVM({}, name='sys-usb') - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') - self.ext.on_device_pre_detached_block(vm, '', dev) + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) + self.ext.on_device_pre_detached_block(vm, '', dev.port) vm.libvirt_domain.detachDevice.assert_called_once_with(device_xml) def test_051_detach_not_attached(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r')) vm = TestVM({}, domain_xml=domain_xml_template.format('')) vm.app.domains['test-vm'] = vm vm.app.domains['sys-usb'] = TestVM({}, name='sys-usb') - dev = qubes.ext.block.BlockDevice(back_vm, 'sda') - self.ext.on_device_pre_detached_block(vm, '', dev) + dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) + self.ext.on_device_pre_detached_block(vm, '', dev.port) self.assertFalse(vm.libvirt_domain.detachDevice.called) def test_060_on_qdb_change_added(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }, domain_xml=domain_xml_template.format("")) - exp_dev = Device(back_vm, 'sda', 'block') + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r'), + domain_xml=domain_xml_template.format("")) + exp_dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.on_qdb_change(back_vm, None, None) self.assertEqual(self.ext.devices_cache, {'sys-usb': {'sda': None}}) self.assertEqual( back_vm.fired_events[ - ('device-added:block', frozenset({('device', exp_dev)}))],1) - - def test_061_on_qdb_change_auto_attached(self): - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }, domain_xml=domain_xml_template.format("")) - exp_dev = Device(back_vm, 'sda', 'block') - front = TestVM({}, domain_xml=domain_xml_template.format(""), + ('device-added:block', frozenset({('device', exp_dev)}))], 1) + + @staticmethod + def added_assign_setup(attached_device=""): + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r'), + domain_xml=domain_xml_template.format("")) + front = TestVM({}, domain_xml=domain_xml_template.format( + attached_device), name='front-vm') dom0 = TestVM({}, name='dom0', domain_xml=domain_xml_template.format("")) back_vm.app.domains['sys-usb'] = back_vm back_vm.app.domains['front-vm'] = front back_vm.app.domains[0] = dom0 + back_vm.app.domains['dom0'] = dom0 front.app = back_vm.app dom0.app = back_vm.app @@ -702,30 +667,142 @@ def test_061_on_qdb_change_auto_attached(self): dom0.devices['block'] = TestDeviceCollection( backend_vm=dom0, devclass='block') - front.devices['block']._assigned.append( - DeviceAssignment.from_device(exp_dev)) - back_vm.devices['block']._exposed.append( - qubes.ext.block.BlockDevice(back_vm, 'sda')) - - # In the case of block devices it is the same, - # but notify_auto_attached is synchronous - self.ext.attach_and_notify = self.ext.notify_auto_attached - with mock.patch('asyncio.ensure_future'): - self.ext.on_qdb_change(back_vm, None, None) - self.assertEqual(self.ext.devices_cache, {'sys-usb': {'sda': front}}) - fire_event_async.assert_called_once_with( - 'device-attach:block', device=exp_dev, - options={'read-only': 'yes', 'frontend-dev': 'xvdi'}) - - def test_062_on_qdb_change_attached(self): + return back_vm, front + + def test_061_on_qdb_change_required(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assignment = DeviceAssignment(exp_dev, mode='required') + front.devices['block']._assigned.append(assignment) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + resolver.assert_called_once_with( + self.ext, {'sda': {front: assignment}}) + + def test_062_on_qdb_change_auto_attached(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assignment = DeviceAssignment(exp_dev) + front.devices['block']._assigned.append(assignment) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + resolver.assert_called_once_with( + self.ext, {'sda': {front: assignment}}) + + def test_063_on_qdb_change_ask_to_attached(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assignment = DeviceAssignment(exp_dev, mode='ask-to-attach') + front.devices['block']._assigned.append(assignment) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + resolver.assert_called_once_with( + self.ext, {'sda': {front: assignment}}) + + def test_064_on_qdb_change_multiple_assignments_including_full(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + full_assig = DeviceAssignment(VirtualDevice( + exp_dev.port, exp_dev.device_id), mode='auto-attach', + options={'pid': 'did'}) + port_assign = DeviceAssignment(VirtualDevice( + exp_dev.port, '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', + options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + front.devices['block']._assigned.append(full_assig) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + self.assertEqual( + resolver.call_args[0][1]['sda'][front].options,{'pid': 'did'}) + + def test_065_on_qdb_change_multiple_assignments_port_vs_dev(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + port_assign = DeviceAssignment(VirtualDevice( + exp_dev.port, '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', + options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + self.assertEqual( + resolver.call_args[0][1]['sda'][front].options, {'pid': 'any'}) + + def test_066_on_qdb_change_multiple_assignments_dev(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + port_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, 'other', 'block'), + '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + ) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'other', 'block')) + ) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + self.assertEqual( + resolver.call_args[0][1]['sda'][front].options, {'any': 'did'}) + + @unittest.mock.patch( + 'qubes.ext.utils.resolve_conflicts_and_attach', new_callable=Mock) + def test_067_on_qdb_change_attached(self, _confirm): # added - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }, domain_xml=domain_xml_template.format("")) - exp_dev = Device(back_vm, 'sda', 'block') + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r'), domain_xml=domain_xml_template.format("")) + exp_dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) self.ext.devices_cache = {'sys-usb': {'sda': None}} @@ -766,15 +843,12 @@ def test_062_on_qdb_change_attached(self): fire_event_async.assert_called_once_with( 'device-attach:block', device=exp_dev, options={}) - def test_063_on_qdb_change_changed(self): + @unittest.mock.patch( + 'qubes.ext.utils.resolve_conflicts_and_attach', new_callable=Mock) + def test_068_on_qdb_change_changed(self, _confirm): # attached to front-vm - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }, domain_xml=domain_xml_template.format("")) - exp_dev = Device(back_vm, 'sda', 'block') + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r'), domain_xml=domain_xml_template.format("")) + exp_dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) front = TestVM({}, name='front-vm') dom0 = TestVM({}, name='dom0', @@ -827,21 +901,18 @@ def test_063_on_qdb_change_changed(self): self.assertEqual(self.ext.devices_cache, {'sys-usb': {'sda': front_2}}) fire_event_async.assert_called_with( - 'device-detach:block', device=exp_dev) + 'device-detach:block', port=exp_dev.port) fire_event_async_2.assert_called_once_with( 'device-attach:block', device=exp_dev, options={}) - def test_064_on_qdb_change_removed_attached(self): + @unittest.mock.patch( + 'qubes.ext.utils.resolve_conflicts_and_attach', new_callable=Mock) + def test_069_on_qdb_change_removed_attached(self, _confirm): # attached to front-vm - back_vm = TestVM(name='sys-usb', qdb={ - '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', - '/qubes-block-devices/sda/size': b'1024000', - '/qubes-block-devices/sda/mode': b'r', - }, domain_xml=domain_xml_template.format("")) + back_vm = TestVM(name='sys-usb', qdb=get_qdb(mode='r'), domain_xml=domain_xml_template.format("")) dom0 = TestVM({}, name='dom0', domain_xml=domain_xml_template.format("")) - exp_dev = Device(back_vm, 'sda', 'block') + exp_dev = qubes.ext.block.BlockDevice(Port(back_vm, 'sda', 'block')) disk = ''' @@ -853,7 +924,7 @@ def test_064_on_qdb_change_removed_attached(self): ''' front = TestVM({}, domain_xml=domain_xml_template.format(disk), - name='front') + name='front') self.ext.devices_cache = {'sys-usb': {'sda': front}} back_vm.app.vmm.configure_mock(**{'offline_mode': False}) @@ -883,8 +954,189 @@ def test_064_on_qdb_change_removed_attached(self): self.ext.on_qdb_change(back_vm, None, None) self.assertEqual(self.ext.devices_cache, {'sys-usb': {}}) fire_event_async.assert_called_with( - 'device-detach:block', device=exp_dev) + 'device-detach:block', port=exp_dev.port) self.assertEqual( back_vm.fired_events[ - ('device-removed:block', frozenset({('device', exp_dev)}))], + ('device-removed:block', frozenset({('port', exp_dev.port)}))], 1) + + def test_070_on_qdb_change_two_fronts(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assign = DeviceAssignment(exp_dev, mode='auto-attach') + + front.devices['block']._assigned.append(assign) + back.devices['block']._assigned.append(assign) + back.devices['block']._exposed.append(exp_dev) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + resolver.assert_called_once_with( + self.ext, {'sda': {front: assign, back: assign}}) + + # call_socket_service returns coroutine + @unittest.mock.patch( + 'qubes.ext.utils.call_socket_service', new_callable=AsyncMock) + def test_071_failed_confirmation(self, socket): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assign = DeviceAssignment(exp_dev, mode='auto-attach') + + front.devices['block']._assigned.append(assign) + back.devices['block']._assigned.append(assign) + back.devices['block']._exposed.append(exp_dev) + + socket.return_value = "allow:nonsense" + + loop = asyncio.get_event_loop() + self.ext.attach_and_notify = AsyncMock() + loop.run_until_complete(qubes.ext.utils.resolve_conflicts_and_attach( + self.ext, {'sda': {front: assign, back: assign}})) + self.ext.attach_and_notify.assert_not_called() + + # call_socket_service returns coroutine + @unittest.mock.patch( + 'qubes.ext.utils.call_socket_service', new_callable=AsyncMock) + def test_072_successful_confirmation(self, socket): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assign = DeviceAssignment(exp_dev, mode='auto-attach') + + front.devices['block']._assigned.append(assign) + back.devices['block']._assigned.append(assign) + back.devices['block']._exposed.append(exp_dev) + + socket.return_value = "allow:front-vm" + + loop = asyncio.get_event_loop() + self.ext.attach_and_notify = AsyncMock() + loop.run_until_complete(qubes.ext.utils.resolve_conflicts_and_attach( + self.ext, {'sda': {front: assign, back: assign}})) + self.ext.attach_and_notify.assert_called_once_with(front, assign) + + def test_073_on_qdb_change_ask(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assign = DeviceAssignment(exp_dev, mode='ask-to-attach') + + front.devices['block']._assigned.append(assign) + back.devices['block']._exposed.append(exp_dev) + + resolver_path = 'qubes.ext.utils.resolve_conflicts_and_attach' + with mock.patch(resolver_path, new_callable=Mock) as resolver: + with mock.patch('asyncio.ensure_future'): + self.ext.on_qdb_change(back, None, None) + resolver.assert_called_once_with( + self.ext, {'sda': {front: assign}}) + + def test_080_on_startup_multiple_assignments_including_full(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + full_assig = DeviceAssignment(VirtualDevice( + exp_dev.port, exp_dev.device_id), mode='auto-attach', + options={'pid': 'did'}) + port_assign = DeviceAssignment(VirtualDevice( + exp_dev.port, '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', + options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + front.devices['block']._assigned.append(full_assig) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + self.ext.attach_and_notify = Mock() + loop = asyncio.get_event_loop() + with mock.patch('asyncio.wait'): + with mock.patch('asyncio.ensure_future'): + loop.run_until_complete(self.ext.on_domain_start(front, None)) + self.assertEqual(self.ext.attach_and_notify.call_args[0][1].options, + {'pid': 'did'}) + + def test_081_on_startup_multiple_assignments_port_vs_dev(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + port_assign = DeviceAssignment(VirtualDevice( + exp_dev.port, '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', + options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + + self.ext.attach_and_notify = Mock() + loop = asyncio.get_event_loop() + with mock.patch('asyncio.wait'): + with mock.patch('asyncio.ensure_future'): + loop.run_until_complete(self.ext.on_domain_start(front, None)) + self.assertEqual(self.ext.attach_and_notify.call_args[0][1].options, + {'pid': 'any'}) + + def test_082_on_startup_multiple_assignments_dev(self): + back, front = self.added_assign_setup() + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + port_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, 'other', 'block'), + '*'), mode='auto-attach', + options={'pid': 'any'}) + dev_assign = DeviceAssignment(VirtualDevice(Port( + exp_dev.backend_domain, '*', 'block'), + exp_dev.device_id), mode='auto-attach', options={'any': 'did'}) + + front.devices['block']._assigned.append(dev_assign) + front.devices['block']._assigned.append(port_assign) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'sda', 'block'))) + back.devices['block']._exposed.append( + qubes.ext.block.BlockDevice(Port(back, 'other', 'block'))) + + self.ext.attach_and_notify = Mock() + loop = asyncio.get_event_loop() + with mock.patch('asyncio.wait'): + with mock.patch('asyncio.ensure_future'): + loop.run_until_complete(self.ext.on_domain_start(front, None)) + self.assertEqual(self.ext.attach_and_notify.call_args[0][1].options, + {'any': 'did'}) + + def test_083_on_startup_already_attached(self): + disk = ''' + + + + + + + + ''' + back, front = self.added_assign_setup(disk) + + exp_dev = qubes.ext.block.BlockDevice(Port(back, 'sda', 'block')) + assign = DeviceAssignment(VirtualDevice( + exp_dev.port, exp_dev.device_id), mode='auto-attach') + + front.devices['block']._assigned.append(assign) + back.devices['block']._exposed.append(exp_dev) + + self.ext.attach_and_notify = Mock() + loop = asyncio.get_event_loop() + with mock.patch('asyncio.ensure_future'): + loop.run_until_complete(self.ext.on_domain_start(front, None)) + self.ext.attach_and_notify.assert_not_called() diff --git a/qubes/tests/devices_pci.py b/qubes/tests/devices_pci.py index 01011cadd..57344d87d 100644 --- a/qubes/tests/devices_pci.py +++ b/qubes/tests/devices_pci.py @@ -126,6 +126,7 @@ def setUp(self): def test_000_unsupported_device(self): vm = TestVM() vm.app.configure_mock(**{ + 'vmm.offline_mode': False, 'vmm.libvirt_conn.nodeDeviceLookupByName.return_value': mock.Mock(**{"XMLDesc.return_value": PCI_XML.format(*["0000"] * 3) @@ -143,7 +144,7 @@ def test_000_unsupported_device(self): }) devices = list(self.ext.on_device_list_pci(vm, 'device-list:pci')) self.assertEqual(len(devices), 1) - self.assertEqual(devices[0].ident, "00_14.0") + self.assertEqual(devices[0].port_id, "00_14.0") self.assertEqual(devices[0].vendor, "Intel Corporation") self.assertEqual(devices[0].product, "9 Series Chipset Family USB xHCI Controller") @@ -153,4 +154,4 @@ def test_000_unsupported_device(self): self.assertEqual(devices[0].description, "USB controller: Intel Corporation 9 Series " "Chipset Family USB xHCI Controller") - self.assertEqual(devices[0].self_identity, "0x8086:0x8cb1::p0c0330") + self.assertEqual(devices[0].device_id, "0x8086:0x8cb1::p0c0330") diff --git a/qubes/tests/integ/audio.py b/qubes/tests/integ/audio.py index 63f03b1b7..336de9f33 100644 --- a/qubes/tests/integ/audio.py +++ b/qubes/tests/integ/audio.py @@ -271,14 +271,18 @@ async def _check_audio_input_status(vm, status): await asyncio.sleep(0.5) def attach_mic(self): - deva = qubes.device_protocol.DeviceAssignment(self.app.domains[0], 'mic') + deva = qubes.device_protocol.DeviceAssignment( + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port(self.app.domains[0], 'mic', 'mic'))) self.loop.run_until_complete( self.testvm1.devices['mic'].attach(deva) ) self.loop.run_until_complete(self.retrieve_audio_input(self.testvm1, b"1")) def detach_mic(self): - deva = qubes.device_protocol.DeviceAssignment(self.app.domains[0], 'mic') + deva = qubes.device_protocol.DeviceAssignment( + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port(self.app.domains[0], 'mic', 'mic'))) self.loop.run_until_complete( self.testvm1.devices['mic'].detach(deva) ) diff --git a/qubes/tests/integ/backup.py b/qubes/tests/integ/backup.py index d62c6d3f7..ef32b1c71 100644 --- a/qubes/tests/integ/backup.py +++ b/qubes/tests/integ/backup.py @@ -303,9 +303,8 @@ def get_vms_info(self, vms): vm_info['default'][prop] = vm.property_is_default(prop) for dev_class in vm.devices.keys(): vm_info['devices'][dev_class] = {} - for dev_ass in vm.devices[dev_class].get_assigned_devices(): - vm_info['devices'][dev_class][str(dev_ass.device)] = \ - dev_ass.options + for ass in vm.devices[dev_class].get_assigned_devices(): + vm_info['devices'][dev_class][str(ass)] = ass.options vms_info[vm.name] = vm_info return vms_info @@ -339,7 +338,7 @@ def assertCorrectlyRestored(self, vms_info, orig_hashes): found = False for restored_dev_ass in restored_vm.devices[ dev_class].get_assigned_devices(): - if str(restored_dev_ass.device) == dev: + if str(restored_dev_ass) == dev: found = True self.assertEqual(vm_info['devices'][dev_class][dev], restored_dev_ass.options, diff --git a/qubes/tests/integ/devices_block.py b/qubes/tests/integ/devices_block.py index e3d17b73b..1642278b5 100644 --- a/qubes/tests/integ/devices_block.py +++ b/qubes/tests/integ/devices_block.py @@ -88,7 +88,7 @@ def test_000_list_loop(self): found = False for dev in dev_list: if dev.serial == self.img_path: - self.assertTrue(dev.ident.startswith('loop')) + self.assertTrue(dev.port_id.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) found = True @@ -131,7 +131,7 @@ def test_010_list_dm(self): dev_list = list(self.vm.devices['block']) found = False for dev in dev_list: - if dev.ident.startswith('loop'): + if dev.port_id.startswith('loop'): self.assertNotEqual(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) @@ -162,7 +162,7 @@ def test_011_list_dm_mounted(self): dev_list = list(self.vm.devices['block']) for dev in dev_list: - if dev.ident.startswith('loop'): + if dev.port_id.startswith('loop'): self.assertNotEqual(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) @@ -187,7 +187,7 @@ def test_012_list_dm_delayed(self): dev_list = list(self.vm.devices['block']) found = False for dev in dev_list: - if dev.ident.startswith('loop'): + if dev.port_id.startswith('loop'): self.assertNotEqual(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) @@ -219,7 +219,7 @@ def test_013_list_dm_removed(self): found = False for dev in dev_list: if dev.serial == self.img_path: - self.assertTrue(dev.ident.startswith('loop')) + self.assertTrue(dev.port_id.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) found = True @@ -243,10 +243,10 @@ def test_020_list_loop_partition(self): found = False for dev in dev_list: if dev.serial == self.img_path: - self.assertTrue(dev.ident.startswith('loop')) + self.assertTrue(dev.port_id.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) - self.assertIn(dev.ident + 'p1', [d.ident for d in dev_list]) + self.assertIn(dev.port_id + 'p1', [d.port_id for d in dev_list]) found = True if not found: @@ -276,7 +276,7 @@ def test_021_list_loop_partition_mounted(self): 'Device {} ({}) should not be listed because its ' 'partition is mounted' .format(dev, self.img_path)) - elif dev.ident.startswith('loop') and dev.ident.endswith('p1'): + elif dev.port_id.startswith('loop') and dev.port_id.endswith('p1'): # FIXME: risky assumption that only tests create partitioned # loop devices self.fail( @@ -320,14 +320,18 @@ def setUp(self): for dev in dev_list: if dev.serial == self.img_path: self.device = dev - self.device_ident = dev.ident + self.device_ident = dev.port_id break else: self.fail('Device for {} in {} not found'.format( self.img_path, self.backend.serial)) def test_000_attach_reattach(self): - ass = qubes.device_protocol.DeviceAssignment(self.backend, self.device_ident) + ass = qubes.device_protocol.DeviceAssignment( + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + self.backend, self.device_ident, 'block') + )) with self.subTest('attach'): self.loop.run_until_complete( self.frontend.devices['block'].attach(ass)) diff --git a/qubes/tests/integ/devices_pci.py b/qubes/tests/integ/devices_pci.py index 7b1ea5579..459123d5d 100644 --- a/qubes/tests/integ/devices_pci.py +++ b/qubes/tests/integ/devices_pci.py @@ -28,6 +28,7 @@ import qubes.devices import qubes.ext.pci import qubes.tests +from qubes.device_protocol import DeviceAssignment @qubes.tests.skipUnlessEnv('QUBES_TEST_PCIDEV') @@ -37,16 +38,8 @@ def setUp(self): if self._testMethodName not in ['test_000_list']: pcidev = os.environ['QUBES_TEST_PCIDEV'] self.dev = self.app.domains[0].devices['pci'][pcidev] - self.assignment = qubes.device_protocol.DeviceAssignment( - backend_domain=self.dev.backend_domain, - ident=self.dev.ident, - attach_automatically=True, - ) - self.required_assignment = qubes.device_protocol.DeviceAssignment( - backend_domain=self.dev.backend_domain, - ident=self.dev.ident, - attach_automatically=True, - required=True, + self.assignment = DeviceAssignment( + self.dev, mode='required' ) if isinstance(self.dev, qubes.device_protocol.UnknownDevice): self.skipTest('Specified device {} does not exists'.format(pcidev)) @@ -69,7 +62,7 @@ def test_000_list(self): l.split(' (')[0].split(' ', 1) for l in p.communicate()[0].decode().splitlines()) for dev in self.app.domains[0].devices['pci']: - lspci_ident = dev.ident.replace('_', ':') + lspci_ident = dev.port_id.replace('_', ':') self.assertIsInstance(dev, qubes.ext.pci.PCIDevice) self.assertEqual(dev.backend_domain, self.app.domains[0]) self.assertIn(lspci_ident, actual_devices) @@ -95,7 +88,7 @@ def assertDeviceIs( dedicated = assigned or attached self.assertTrue(dedicated == device in dev_col.get_dedicated_devices()) - def test_010_assign_offline(self): # TODO required + def test_010_assign_offline(self): dev_col = self.vm.devices['pci'] self.assertDeviceIs( self.dev, attached=False, assigned=False, required=False) @@ -103,11 +96,11 @@ def test_010_assign_offline(self): # TODO required self.loop.run_until_complete(dev_col.assign(self.assignment)) self.app.save() self.assertDeviceIs( - self.dev, attached=False, assigned=True, required=False) + self.dev, attached=False, assigned=True, required=True) self.loop.run_until_complete(self.vm.start()) self.assertDeviceIs( - self.dev, attached=True, assigned=False, required=False) + self.dev, attached=True, assigned=False, required=True) (stdout, _) = self.loop.run_until_complete( self.vm.run_for_stdio('lspci')) @@ -123,7 +116,7 @@ def test_011_attach_offline_temp_fail(self): self.loop.run_until_complete( dev_col.attach(self.assignment)) - def test_020_attach_online_persistent(self): # TODO: required + def test_020_attach_online_persistent(self): self.loop.run_until_complete( self.vm.start()) dev_col = self.vm.devices['pci'] @@ -133,7 +126,7 @@ def test_020_attach_online_persistent(self): # TODO: required self.loop.run_until_complete( dev_col.attach(self.assignment)) self.assertDeviceIs( - self.dev, attached=True, assigned=True, required=False) + self.dev, attached=True, assigned=True, required=True) # give VM kernel some time to discover new device time.sleep(1) @@ -155,7 +148,7 @@ def test_021_persist_detach_online_fail(self): self.loop.run_until_complete( self.vm.devices['pci'].detach(self.assignment)) - def test_030_persist_attach_detach_offline(self): # TODO: required + def test_030_persist_attach_detach_offline(self): dev_col = self.vm.devices['pci'] self.assertDeviceIs( self.dev, attached=False, assigned=False, required=False) @@ -164,14 +157,14 @@ def test_030_persist_attach_detach_offline(self): # TODO: required dev_col.attach(self.assignment)) self.app.save() self.assertDeviceIs( - self.dev, attached=False, assigned=True, required=False) + self.dev, attached=False, assigned=True, required=True) self.loop.run_until_complete( dev_col.detach(self.assignment)) self.assertDeviceIs( self.dev, attached=False, assigned=False, required=False) - def test_031_attach_detach_online_temp(self): # TODO: requiured + def test_031_attach_detach_online_temp(self): dev_col = self.vm.devices['pci'] self.loop.run_until_complete( self.vm.start()) diff --git a/qubes/tests/vm/__init__.py b/qubes/tests/vm/__init__.py index 6a7f273d6..69f553edd 100644 --- a/qubes/tests/vm/__init__.py +++ b/qubes/tests/vm/__init__.py @@ -29,6 +29,7 @@ class TestVMM(object): def __init__(self, offline_mode=False): self.offline_mode = offline_mode self.xs = unittest.mock.Mock() + self.libvirt_mock = unittest.mock.Mock() @property def libvirt_conn(self): @@ -36,10 +37,9 @@ def libvirt_conn(self): import libvirt raise libvirt.libvirtError('phony error') else: - libvirt_mock = unittest.mock.Mock() - vm_mock = libvirt_mock.lookupByUUID.return_value + vm_mock = self.libvirt_mock.lookupByUUID.return_value vm_mock.isActive.return_value = False - return libvirt_mock + return self.libvirt_mock class TestHost(object): # pylint: disable=too-few-public-methods diff --git a/qubes/tests/vm/init.py b/qubes/tests/vm/init.py index 761ff9953..711a53e80 100644 --- a/qubes/tests/vm/init.py +++ b/qubes/tests/vm/init.py @@ -1,4 +1,5 @@ # pylint: disable=protected-access +import sys # # The Qubes OS Project, https://www.qubes-os.org/ @@ -27,6 +28,8 @@ import qubes.vm import qubes.tests +from qubes.device_protocol import Port + class TestVMM(object): def __init__(self): @@ -48,6 +51,10 @@ class TestVM(qubes.vm.BaseVM): testlabel = qubes.property('testlabel') defaultprop = qubes.property('defaultprop', default='defaultvalue') + def is_running(self): + return False + + class TC_10_BaseVM(qubes.tests.QubesTestCase): def setUp(self): super().setUp() @@ -110,8 +117,12 @@ def test_000_load(self): }) self.assertCountEqual(vm.devices.keys(), ('pci',)) - self.assertCountEqual(list(vm.devices['pci'].get_assigned_devices()), - [qubes.ext.pci.PCIDevice(vm, '00_11.22')]) + + self.assertTrue( + list(vm.devices['pci'].get_assigned_devices())[0].matches( + qubes.ext.pci.PCIDevice(Port(vm, '00_11.22', "pci")) + ) + ) assignments = list(vm.devices['pci'].get_assigned_devices()) self.assertEqual(len(assignments), 1) diff --git a/qubes/tests/vm/qubesvm.py b/qubes/tests/vm/qubesvm.py index fcb4fbd47..11f67a9c9 100644 --- a/qubes/tests/vm/qubesvm.py +++ b/qubes/tests/vm/qubesvm.py @@ -92,6 +92,9 @@ def __init__(self, data=None): def write(self, path, value): self.data[path] = value + def read(self, path): + return self.data[path] + def rm(self, path): if path.endswith('/'): for key in [x for x in self.data if x.startswith(path)]: @@ -1303,6 +1306,26 @@ def test_600_libvirt_xml_hvm_pcidev(self): my_uuid = '7db78950-c467-4863-94d1-af59806384ea' # required for PCI devices listing self.app.vmm.offline_mode = False + hostdev_details = unittest.mock.Mock(**{ + 'XMLDesc.return_value': """ + + pci_0000_00_00_0 + /sys/devices/pci0000:00/0000:00:00.0 + computer + + 0x060000 + 0 + 0 + 0 + 0 + Unknown + Intel Corporation + +""", + }) + self.app.vmm.libvirt_mock = unittest.mock.Mock(**{ + 'nodeDeviceLookupByName.return_value': hostdev_details + }) dom0 = self.get_vm(name='dom0', qid=0) vm = self.get_vm(uuid=my_uuid) vm.netvm = None @@ -1310,16 +1333,18 @@ def test_600_libvirt_xml_hvm_pcidev(self): vm.kernel = None # even with meminfo-writer enabled, should have memory==maxmem vm.features['service.meminfo-writer'] = True - assignment = qubes.devices.DeviceAssignment( - vm, # this is violation of API, but for PCI the argument - # is unused - '00_00.0', - devclass='pci', - attach_automatically=True, - required=True, + assignment = qubes.device_protocol.DeviceAssignment( + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + backend_domain=vm, # this is violation of API, + # but for PCI the argument is unused + port_id='00_00.0', + devclass="pci", + ) + ), + mode='required', ) - vm.devices['pci']._set.add( - assignment) + vm.devices['pci']._set.add(assignment) libvirt_xml = vm.create_config_file() self.assertXMLEqual(lxml.etree.XML(libvirt_xml), lxml.etree.XML(expected)) @@ -1388,6 +1413,26 @@ def test_600_libvirt_xml_hvm_pcidev_s0ix(self): my_uuid = '7db78950-c467-4863-94d1-af59806384ea' # required for PCI devices listing self.app.vmm.offline_mode = False + hostdev_details = unittest.mock.Mock(**{ + 'XMLDesc.return_value': """ + + pci_0000_00_00_0 + /sys/devices/pci0000:00/0000:00:00.0 + computer + + 0x060000 + 0 + 0 + 0 + 0 + Unknown + Intel Corporation + + """, + }) + self.app.vmm.libvirt_mock = unittest.mock.Mock(**{ + 'nodeDeviceLookupByName.return_value': hostdev_details + }) dom0 = self.get_vm(name='dom0', qid=0) dom0.features['suspend-s0ix'] = True vm = self.get_vm(uuid=my_uuid) @@ -1397,11 +1442,15 @@ def test_600_libvirt_xml_hvm_pcidev_s0ix(self): # even with meminfo-writer enabled, should have memory==maxmem vm.features['service.meminfo-writer'] = True assignment = qubes.device_protocol.DeviceAssignment( - vm, # this is a violation of API, but for PCI the argument - # is unused - '00_00.0', - devclass='pci', - attach_automatically=True, required=True) + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + backend_domain=vm, # this is violation of API, + # but for PCI the argument is unused + port_id='00_00.0', + devclass="pci", + ), + ), + mode='required') vm.devices['pci']._set.add( assignment) libvirt_xml = vm.create_config_file() @@ -1471,6 +1520,7 @@ def test_600_libvirt_xml_hvm_cdrom_boot(self): '/qubes-block-devices/sda/desc': b'Test device', '/qubes-block-devices/sda/size': b'1024000', '/qubes-block-devices/sda/mode': b'r', + '/qubes-block-devices/sda/parent': b'', } test_qdb = TestQubesDB(qdb) dom0 = qubes.vm.adminvm.AdminVM(self.app, None) @@ -1483,9 +1533,15 @@ def test_600_libvirt_xml_hvm_cdrom_boot(self): dom0.events_enabled = True self.app.vmm.offline_mode = False dev = qubes.device_protocol.DeviceAssignment( - dom0, 'sda', + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + backend_domain=dom0, + port_id='sda', + devclass="block", + ) + ), options={'devtype': 'cdrom', 'read-only': 'yes'}, - attach_automatically=True, required=True) + mode='required') self.loop.run_until_complete(vm.devices['block'].assign(dev)) libvirt_xml = vm.create_config_file() self.assertXMLEqual(lxml.etree.XML(libvirt_xml), @@ -1560,6 +1616,7 @@ def test_600_libvirt_xml_hvm_cdrom_dom0_kernel_boot(self): '/qubes-block-devices/sda/desc': b'Test device', '/qubes-block-devices/sda/size': b'1024000', '/qubes-block-devices/sda/mode': b'r', + '/qubes-block-devices/sda/parent': b'', } test_qdb = TestQubesDB(qdb) dom0 = qubes.vm.adminvm.AdminVM(self.app, None) @@ -1588,9 +1645,15 @@ def test_600_libvirt_xml_hvm_cdrom_dom0_kernel_boot(self): dom0.events_enabled = True self.app.vmm.offline_mode = False dev = qubes.device_protocol.DeviceAssignment( - dom0, 'sda', + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + backend_domain=dom0, + port_id='sda', + devclass="block", + ) + ), options={'devtype': 'cdrom', 'read-only': 'yes'}, - attach_automatically=True, required=True) + mode='required') self.loop.run_until_complete(vm.devices['block'].assign(dev)) libvirt_xml = vm.create_config_file() self.assertXMLEqual(lxml.etree.XML(libvirt_xml), @@ -1874,23 +1937,37 @@ def test_615_libvirt_xml_block_devices(self): 'options': {'frontend-dev': 'xvdl'}, 'device.device_node': '/dev/sdb', 'device.backend_domain.name': 'dom0', + 'devices': [unittest.mock.Mock(**{ + 'device_node': '/dev/sdb', + 'backend_domain.name': 'dom0',})] }), unittest.mock.Mock(**{ 'options': {'devtype': 'cdrom'}, 'device.device_node': '/dev/sda', 'device.backend_domain.name': 'dom0', + 'devices': [unittest.mock.Mock(**{ + 'device_node': '/dev/sda', + 'backend_domain.name': 'dom0', })] }), unittest.mock.Mock(**{ 'options': {'read-only': True}, 'device.device_node': '/dev/loop0', 'device.backend_domain.name': 'backend0', 'device.backend_domain.features.check_with_template.return_value': '4.2', + 'devices': [unittest.mock.Mock(**{ + 'device_node': '/dev/loop0', + 'backend_domain.name': 'backend0', + 'backend_domain.features.check_with_template.return_value': '4.2'})] }), unittest.mock.Mock(**{ 'options': {}, 'device.device_node': '/dev/loop0', 'device.backend_domain.name': 'backend1', 'device.backend_domain.features.check_with_template.return_value': '4.2', + 'devices': [unittest.mock.Mock(**{ + 'device_node': '/dev/loop0', + 'backend_domain.name': 'backend1', + 'backend_domain.features.check_with_template.return_value': '4.2'})] }), ] vm.devices['block'].get_assigned_devices = \ diff --git a/qubes/vm/__init__.py b/qubes/vm/__init__.py index ad52fe735..87e3fa389 100644 --- a/qubes/vm/__init__.py +++ b/qubes/vm/__init__.py @@ -33,6 +33,7 @@ import qubes import qubes.devices +import qubes.device_protocol import qubes.events import qubes.features import qubes.log @@ -284,14 +285,45 @@ def load_extras(self): options[option.get('name')] = str(option.text) try: + # backward compatibility: persistent~>required=True + legacy_required = node.get('required', 'absent') + if legacy_required == 'absent': + mode_str = node.get('mode', 'required') + try: + mode = (qubes.device_protocol. + AssignmentMode(mode_str)) + except ValueError: + self.log.error( + "Unrecognized assignment mode, ignoring.") + continue + else: + required = qubes.property.bool( + None, None, legacy_required) + if required: + mode = (qubes.device_protocol. + AssignmentMode.REQUIRED) + else: + mode = (qubes.device_protocol. + AssignmentMode.AUTO) + if 'identity' in options: + identity = options.get('identity') + del options['identity'] + else: + identity = node.get('identity', '*') + backend_name = node.get('backend-domain', None) + backend = self.app.domains[backend_name] \ + if backend_name else None device_assignment = qubes.device_protocol.DeviceAssignment( - self.app.domains[node.get('backend-domain')], - node.get('id'), + qubes.device_protocol.VirtualDevice( + qubes.device_protocol.Port( + backend_domain=backend, + port_id=node.get('id', '*'), + devclass=devclass, + ), + device_id=identity, + ), options=options, - attach_automatically=True, - # backward compatibility: persistent~>required=True - required=qubes.property.bool( - None, None, node.get('required', 'yes')), + mode=mode, ) self.devices[devclass].load_assignment(device_assignment) except KeyError: @@ -345,12 +377,14 @@ def __xml__(self): for devclass in self.devices: devices = lxml.etree.Element('devices') devices.set('class', devclass) - for device in self.devices[devclass].get_assigned_devices(): + for assignment in self.devices[devclass].get_assigned_devices(): node = lxml.etree.Element('device') - node.set('backend-domain', device.backend_domain.name) - node.set('id', device.ident) - node.set('required', 'yes' if device.required else 'no') - for key, val in device.options.items(): + node.set('backend-domain', str(assignment.backend_name)) + node.set('id', assignment.port_id) + node.set('mode', assignment.mode.value) + identity = assignment.device_id or '*' + node.set('identity', identity) + for key, val in assignment.options.items(): option_node = lxml.etree.Element('option') option_node.set('name', key) option_node.text = val @@ -425,8 +459,8 @@ def _qdb_watch_reader(self, loop): if watched_path == path or ( watched_path.endswith('/') and path.startswith(watched_path)): - self.fire_event('domain-qdb-change:' + watched_path, - path=path) + self.fire_event( + 'domain-qdb-change:' + watched_path, path=path) except qubesdb.DisconnectedError: loop.remove_reader(self._qdb_connection_watch.watch_fd()) self._qdb_connection_watch.close() diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index 451610144..5782c4267 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -63,6 +63,7 @@ _vm_uuid_re = re.compile(rb"\A/vm/[0-9a-f]{8}(?:-[0-9a-f]{4}){4}[0-9a-f]{8}\Z") + def _setter_kernel(self, prop, value): """ Helper for setting the domain kernel and running sanity checks on it. """ # pylint: disable=unused-argument @@ -639,7 +640,9 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): 'memory', lambda self: qubes.config.defaults[ - 'hvm_memory' if self.virt_mode == 'hvm' else 'memory']), + 'hvm_memory' # type: ignore + if self.virt_mode == 'hvm' else 'memory' + ]), doc='Memory currently available for this VM. TemplateBasedVMs use its ' 'template\'s value by default.') @@ -1178,11 +1181,14 @@ async def start(self, start_guid=True, notify_function=None, qmemman_client = None try: for devclass in self.devices: - for ass in self.devices[devclass].get_assigned_devices(): - if isinstance( - ass.device, - qubes.device_protocol.UnknownDevice) \ - and ass.required: + for ass in self.devices[devclass].get_assigned_devices( + required_only=True): + for device in ass.devices: + if not isinstance( + device, qubes.device_protocol.UnknownDevice + ): + break + else: raise qubes.exc.QubesException( f'{devclass.capitalize()} device {ass} ' f'not available' diff --git a/relaxng/qubes.rng b/relaxng/qubes.rng index b286db21b..edf54b8a2 100644 --- a/relaxng/qubes.rng +++ b/relaxng/qubes.rng @@ -205,7 +205,7 @@ the parser will complain about missing combine= attribute on the second . - One device. It's identified by by a pair of + One device. It's identified by a pair of backend domain and some identifier (device class dependant). @@ -222,6 +222,27 @@ the parser will complain about missing combine= attribute on the second . [0-9a-f]{2}_[0-9a-f]{2}.[0-9a-f]{2} + + + + Device presented identity. + + + [A-Za-z0-9\*\:\-]+ + + + + + + + Available values: 'required', 'auto-attach', 'ask-to-attach'. + If not present: 'required' is assumed. + + + [a-z_-]+ + + + diff --git a/rpm_spec/core-dom0.spec.in b/rpm_spec/core-dom0.spec.in index ab2ea5369..521c0c32d 100644 --- a/rpm_spec/core-dom0.spec.in +++ b/rpm_spec/core-dom0.spec.in @@ -260,7 +260,7 @@ admin.vm.device.block.Attach admin.vm.device.block.Attached admin.vm.device.block.Available admin.vm.device.block.Detach -admin.vm.device.block.Set.required +admin.vm.device.block.Set.assignment admin.vm.device.block.Unassign admin.vm.device.pci.Assign admin.vm.device.pci.Assigned @@ -268,7 +268,7 @@ admin.vm.device.pci.Attach admin.vm.device.pci.Attached admin.vm.device.pci.Available admin.vm.device.pci.Detach -admin.vm.device.pci.Set.required +admin.vm.device.pci.Set.assignment admin.vm.device.pci.Unassign admin.vm.feature.CheckWithAdminVM admin.vm.feature.CheckWithNetvm diff --git a/templates/libvirt/xen.xml b/templates/libvirt/xen.xml index 5204e8a35..7d8974cdd 100644 --- a/templates/libvirt/xen.xml +++ b/templates/libvirt/xen.xml @@ -160,10 +160,11 @@ {# start external devices from xvdi #} {% set counter = {'i': 4} %} - {% for assignment in vm.devices.block.get_assigned_devices(False) %} - {% set device = assignment.device %} - {% set options = assignment.options %} - {% include 'libvirt/devices/block.xml' %} + {% for assignment in vm.devices.block.get_assigned_devices(True) %} + {% for device in assignment.devices %} + {% set options = assignment.options %} + {% include 'libvirt/devices/block.xml' %} + {% endfor %} {% endfor %} {% if vm.netvm %} @@ -171,11 +172,12 @@ {% endif %} {% for assignment in vm.devices.pci.get_assigned_devices(True) %} - {% set device = assignment.device %} - {% set options = assignment.options %} - {% set power_mgmt = - vm.app.domains[0].features.get('suspend-s0ix', False) %} - {% include 'libvirt/devices/pci.xml' %} + {% for device in assignment.devices %} + {% set options = assignment.options %} + {% set power_mgmt = + vm.app.domains[0].features.get('suspend-s0ix', False) %} + {% include 'libvirt/devices/pci.xml' %} + {% endfor %} {% endfor %} {% if vm.virt_mode == 'hvm' %}