diff --git a/Makefile b/Makefile index 3bea491f0..7a410ade1 100644 --- a/Makefile +++ b/Makefile @@ -60,26 +60,38 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.Shutdown \ admin.vm.Start \ admin.vm.Unpause \ + admin.vm.device.pci.Assign \ + admin.vm.device.pci.Assigned \ admin.vm.device.pci.Attach \ + admin.vm.device.pci.Attached \ admin.vm.device.pci.Available \ admin.vm.device.pci.Detach \ - admin.vm.device.pci.List \ - admin.vm.device.pci.Set.persistent \ + admin.vm.device.pci.Set.required \ + admin.vm.device.pci.Unassign \ + admin.vm.device.block.Assign \ + admin.vm.device.block.Assigned \ admin.vm.device.block.Attach \ + admin.vm.device.block.Attached \ admin.vm.device.block.Available \ admin.vm.device.block.Detach \ - admin.vm.device.block.List \ - admin.vm.device.block.Set.persistent \ + admin.vm.device.block.Set.required \ + admin.vm.device.block.Unassign \ + admin.vm.device.usb.Assign \ + admin.vm.device.usb.Assigned \ admin.vm.device.usb.Attach \ + admin.vm.device.usb.Attached \ admin.vm.device.usb.Available \ admin.vm.device.usb.Detach \ - admin.vm.device.usb.List \ - admin.vm.device.usb.Set.persistent \ + admin.vm.device.usb.Set.required \ + admin.vm.device.usb.Unassign \ + admin.vm.device.mic.Assign \ + admin.vm.device.mic.Assigned \ admin.vm.device.mic.Attach \ + admin.vm.device.mic.Attached \ admin.vm.device.mic.Available \ admin.vm.device.mic.Detach \ - admin.vm.device.mic.List \ - admin.vm.device.mic.Set.persistent \ + admin.vm.device.mic.Set.required \ + admin.vm.device.mic.Unassign \ admin.vm.feature.CheckWithNetvm \ admin.vm.feature.CheckWithTemplate \ admin.vm.feature.CheckWithAdminVM \ @@ -211,8 +223,11 @@ endif admin.vm.CreateInPool.AdminVM \ admin.vm.device.testclass.Attach \ admin.vm.device.testclass.Detach \ - admin.vm.device.testclass.List \ - admin.vm.device.testclass.Set.persistent \ + admin.vm.device.testclass.Assign \ + 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.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 61404c08c..e64811a3e 100644 --- a/doc/qubes-devices.rst +++ b/doc/qubes-devices.rst @@ -1,6 +1,131 @@ :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`). + +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. + + +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: +*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. +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`, +while to check whether an (potential) assignment exists, +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. + + +Actions +------- + +The `assign` action signifies 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, +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 +`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 +need to perform `unassign` (see next section). + +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`. + +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 +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 +(`attached`, `automatically_attached`, `required`) are as follow: +#. `(True, False, False)` -> domain is running, device is manually attached +and could be manually detach any time. +#. `(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. +#. `(True, True, True)` -> domain is running, device is attached +and couldn't be detached. +#. `(False, True, 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. +#. `(False, True, True)` -> domain is halted, device assigned to domain +and required to start domain. + + +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). + +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. + +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 `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. + + .. automodule:: qubes.devices :members: :show-inheritance: diff --git a/qubes-rpc-policy/90-admin-default.policy.header b/qubes-rpc-policy/90-admin-default.policy.header index fefdb207a..864872ba4 100644 --- a/qubes-rpc-policy/90-admin-default.policy.header +++ b/qubes-rpc-policy/90-admin-default.policy.header @@ -20,14 +20,20 @@ !include-service admin.vm.volume.Import * include/admin-local-rwx !include-service admin.vm.volume.ImportWithSize * include/admin-local-rwx +!include-service admin.vm.device.mic.Assign * include/admin-local-rwx +!include-service admin.vm.device.mic.Assigned * include/admin-local-ro !include-service admin.vm.device.mic.Attach * include/admin-local-rwx +!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.List * include/admin-local-ro -!include-service admin.vm.device.mic.Set.persistent * include/admin-local-rwx +!include-service admin.vm.device.mic.Set.required * 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 !include-service admin.vm.device.usb.Attach * include/admin-local-rwx +!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.List * include/admin-local-ro -!include-service admin.vm.device.usb.Set.persistent * include/admin-local-rwx +!include-service admin.vm.device.usb.Set.required * 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 700eca39b..28b0d99d4 100644 --- a/qubes/api/__init__.py +++ b/qubes/api/__init__.py @@ -30,13 +30,7 @@ import traceback import qubes.exc - -class ProtocolError(AssertionError): - '''Raised when something is wrong with data received''' - - -class PermissionDenied(Exception): - '''Raised deliberately by handlers when we decide not to cooperate''' +from qubes.exc import ProtocolError, PermissionDenied def method(name, *, no_payload=False, endpoints=None, **classifiers): @@ -127,8 +121,8 @@ def __init__(self, app, src, method_name, dest, arg, send_event=None): #: destination qube self.dest = self.app.domains[vm] except KeyError: - # normally this should filtered out by qrexec policy, but there are - # two cases it might not be: + # normally this should be filtered out by qrexec policy, but there + # are two cases it might not be: # 1. The call comes from dom0, which bypasses qrexec policy # 2. Domain was removed between checking the policy and here # we inform the client accordingly @@ -213,11 +207,11 @@ def enforce(predicate): def validate_size(self, untrusted_size: bytes) -> int: self.enforce(isinstance(untrusted_size, bytes)) if not untrusted_size.isdigit(): - raise qubes.api.ProtocolError('Size must be ASCII digits (only)') + raise qubes.exc.ProtocolError('Size must be ASCII digits (only)') if len(untrusted_size) >= 20: - raise qubes.api.ProtocolError('Sizes limited to 19 decimal digits') + raise qubes.exc.ProtocolError('Sizes limited to 19 decimal digits') if untrusted_size[0] == 48 and untrusted_size != b'0': - raise qubes.api.ProtocolError('Spurious leading zeros not allowed') + raise qubes.exc.ProtocolError('Spurious leading zeros not allowed') return int(untrusted_size) class QubesDaemonProtocol(asyncio.Protocol): diff --git a/qubes/api/admin.py b/qubes/api/admin.py index cfcc113a6..39b9311e4 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -23,7 +23,6 @@ import asyncio import functools -import itertools import os import string import subprocess @@ -38,12 +37,15 @@ import qubes.backup import qubes.config import qubes.devices +import qubes.exc +import qubes.ext import qubes.firewall import qubes.storage import qubes.utils import qubes.vm import qubes.vm.adminvm import qubes.vm.qubesvm +from qubes.device_protocol import Device class QubesMgmtEventsDispatcher: @@ -501,7 +503,7 @@ async def vm_volume_set_rw(self, untrusted_payload): newvalue = qubes.property.bool(None, None, untrusted_payload.decode('ascii')) except (UnicodeDecodeError, ValueError): - raise qubes.api.ProtocolError('Invalid value') + raise qubes.exc.ProtocolError('Invalid value') del untrusted_payload self.fire_event_for_permission(newvalue=newvalue) @@ -520,7 +522,7 @@ async def vm_volume_set_ephemeral(self, untrusted_payload): newvalue = qubes.property.bool(None, None, untrusted_payload.decode('ascii')) except (UnicodeDecodeError, ValueError): - raise qubes.api.ProtocolError('Invalid value') + raise qubes.exc.ProtocolError('Invalid value') del untrusted_payload self.fire_event_for_permission(newvalue=newvalue) @@ -751,7 +753,7 @@ async def pool_set_ephemeral(self, untrusted_payload): newvalue = qubes.property.bool(None, None, untrusted_payload.decode('ascii', 'strict')) except (UnicodeDecodeError, ValueError): - raise qubes.api.ProtocolError('Invalid value') + raise qubes.exc.ProtocolError('Invalid value') del untrusted_payload self.fire_event_for_permission(newvalue=newvalue) @@ -1076,7 +1078,7 @@ async def _vm_create(self, vm_type, allow_pool=False, errors='strict').split(' '): untrusted_key, untrusted_value = untrusted_param.split('=', 1) if untrusted_key in kwargs: - raise qubes.api.ProtocolError('duplicated parameters') + raise qubes.exc.ProtocolError('duplicated parameters') if untrusted_key == 'name': qubes.vm.validate_name(None, None, untrusted_value) @@ -1094,7 +1096,7 @@ async def _vm_create(self, vm_type, allow_pool=False, elif untrusted_key == 'pool' and allow_pool: if pool is not None: - raise qubes.api.ProtocolError('duplicated pool parameter') + raise qubes.exc.ProtocolError('duplicated pool parameter') pool = self.app.get_pool(untrusted_value) elif untrusted_key.startswith('pool:') and allow_pool: untrusted_volume = untrusted_key.split(':', 1)[1] @@ -1104,19 +1106,19 @@ async def _vm_create(self, vm_type, allow_pool=False, 'root', 'private', 'volatile', 'kernel']) volume = untrusted_volume if volume in pools: - raise qubes.api.ProtocolError( + raise qubes.exc.ProtocolError( 'duplicated pool:{} parameter'.format(volume)) pools[volume] = self.app.get_pool(untrusted_value) else: - raise qubes.api.ProtocolError('Invalid param name') + raise qubes.exc.ProtocolError('Invalid param name') del untrusted_payload if 'name' not in kwargs or 'label' not in kwargs: - raise qubes.api.ProtocolError('Missing name or label') + raise qubes.exc.ProtocolError('Missing name or label') if pool and pools: - raise qubes.api.ProtocolError( + raise qubes.exc.ProtocolError( 'Only one of \'pool=\' and \'pool:volume=\' can be used') if kwargs['name'] in self.app.domains: @@ -1155,8 +1157,8 @@ async def create_disposable(self): return dispvm.name - @qubes.api.method('admin.vm.Remove', no_payload=True, - scope='global', write=True) + @qubes.api.method( + 'admin.vm.Remove', no_payload=True, scope='global', write=True) async def vm_remove(self): self.enforce(not self.arg) @@ -1197,118 +1199,161 @@ async def deviceclass_list(self): scope='local', read=True) async def vm_device_available(self, endpoint): devclass = endpoint - devices = self.dest.devices[devclass].available() + devices = self.dest.devices[devclass].get_exposed_devices() if self.arg: devices = [dev for dev in devices if dev.ident == self.arg] - # no duplicated devices, but device may not exists, in which case + # 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 = {} - for dev in devices: - non_default_attrs = set(attr for attr in dir(dev) if - not attr.startswith('_')).difference(( - 'backend_domain', 'ident', 'frontend_domain', - 'description', 'options', 'regex')) - properties_txt = ' '.join( - '{}={!s}'.format(prop, value) for prop, value - in itertools.chain( - ((key, getattr(dev, key)) for key in non_default_attrs), - # keep description as the last one, according to API - # specification - (('description', dev.description),) - )) - self.enforce('\n' not in properties_txt) - dev_info[dev.ident] = properties_txt - + 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)) + for ident in sorted(dev_info)) - @qubes.api.method('admin.vm.device.{endpoint}.List', endpoints=(ep.name + @qubes.api.method('admin.vm.device.{endpoint}.Assigned', endpoints=(ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), no_payload=True, scope='local', read=True) async def vm_device_list(self, endpoint): devclass = endpoint - device_assignments = self.dest.devices[devclass].assignments() + device_assignments = list( + self.dest.devices[devclass].get_assigned_devices()) 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) == (select_backend, select_ident)] - # no duplicated devices, but device may not exists, in which case + # 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) - - dev_info = {} - for dev in device_assignments: - properties_txt = ' '.join( - '{}={!s}'.format(opt, value) for opt, value - in itertools.chain( - dev.options.items(), - (('persistent', 'yes' if dev.persistent else 'no'),) - )) - self.enforce('\n' not in properties_txt) - ident = '{!s}+{!s}'.format(dev.backend_domain, dev.ident) - dev_info[ident] = properties_txt + device_assignments = self.fire_event_for_filter( + device_assignments, devclass=devclass) + + dev_info = { + f'{assignment.backend_domain}+{assignment.ident}': + 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)) - # Attach/Detach action can both modify persistent state (with - # persistent=True) and volatile state of running VM (with persistent=False). - # For this reason, write=True + execute=True - @qubes.api.method('admin.vm.device.{endpoint}.Attach', endpoints=(ep.name + @qubes.api.method( + 'admin.vm.device.{endpoint}.Attached', + endpoints=( + ep.name + for ep in importlib.metadata.entry_points(group='qubes.devices')), + no_payload=True, scope='local', read=True) + async def vm_device_attached(self, endpoint): + devclass = endpoint + device_assignments = self.dest.devices[devclass].get_attached_devices() + 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) + == (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) + + dev_info = { + f'{assignment.backend_domain}+{assignment.ident}': + 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)) + + # Assign/Unassign action can modify only persistent state of running VM. + # For this reason, write=True + @qubes.api.method('admin.vm.device.{endpoint}.Assign', endpoints=(ep.name + for ep in importlib.metadata.entry_points(group='qubes.devices')), + 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] + + assignment = qubes.device_protocol.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 + ) + + await self.dest.devices[devclass].assign(assignment) + self.app.save() + + # Assign/Unassign action can modify only persistent state of running VM. + # For this reason, write=True + @qubes.api.method( + 'admin.vm.device.{endpoint}.Unassign', + endpoints=( + ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), - scope='local', write=True, execute=True) + no_payload=True, scope='local', write=True) + async def vm_device_unassign(self, endpoint): + devclass = endpoint + + # 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) + + assignment = qubes.device_protocol.DeviceAssignment( + dev.backend_domain, dev.ident, devclass) + await self.dest.devices[devclass].unassign(assignment) + self.app.save() + + # Attach/Detach action can modify only volatile state of running VM. + # For this reason, execute=True + @qubes.api.method( + 'admin.vm.device.{endpoint}.Attach', + endpoints=( + ep.name + for ep in importlib.metadata.entry_points(group='qubes.devices')), + scope='local', execute=True) async def vm_device_attach(self, endpoint, untrusted_payload): devclass = endpoint - options = {} - persistent = False - for untrusted_option in untrusted_payload.decode('ascii', - 'strict').split(): - try: - untrusted_key, untrusted_value = untrusted_option.split('=', 1) - except ValueError: - raise qubes.api.ProtocolError('Invalid options format') - if untrusted_key == 'persistent': - persistent = qubes.property.bool(None, None, untrusted_value) - else: - allowed_chars_key = string.digits + string.ascii_letters + '-_.' - allowed_chars_value = allowed_chars_key + ',+:' - if any(x not in allowed_chars_key for x in untrusted_key): - raise qubes.api.ProtocolError( - 'Invalid chars in option name') - if any(x not in allowed_chars_value for x in untrusted_value): - raise qubes.api.ProtocolError( - 'Invalid chars in option value') - options[untrusted_key] = untrusted_value # 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] - self.fire_event_for_permission(device=dev, - devclass=devclass, persistent=persistent, - options=options) + assignment = qubes.device_protocol.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 + ) - assignment = qubes.devices.DeviceAssignment( - dev.backend_domain, dev.ident, - options=options, persistent=persistent) await self.dest.devices[devclass].attach(assignment) - self.app.save() - # Attach/Detach action can both modify persistent state (with - # persistent=True) and volatile state of running VM (with persistent=False). - # For this reason, write=True + execute=True - @qubes.api.method('admin.vm.device.{endpoint}.Detach', endpoints=(ep.name + # Attach/Detach action can modify only a volatile state of running VM. + # For this reason, execute=True + @qubes.api.method( + 'admin.vm.device.{endpoint}.Detach', + endpoints=( + ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), - no_payload=True, - scope='local', write=True, execute=True) + no_payload=True, scope='local', execute=True) async def vm_device_detach(self, endpoint): devclass = endpoint @@ -1319,41 +1364,42 @@ async def vm_device_detach(self, endpoint): # 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, devclass=devclass) - assignment = qubes.devices.DeviceAssignment( - dev.backend_domain, dev.ident) + assignment = qubes.device_protocol.DeviceAssignment( + dev.backend_domain, dev.ident, devclass) await self.dest.devices[devclass].detach(assignment) - self.app.save() - # Attach/Detach action can both modify persistent state (with - # persistent=True) and volatile state of running VM (with persistent=False). - # For this reason, write=True + execute=True - @qubes.api.method('admin.vm.device.{endpoint}.Set.persistent', + # 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', endpoints=(ep.name for ep in importlib.metadata.entry_points(group='qubes.devices')), - scope='local', write=True, execute=True) - async def vm_device_set_persistent(self, endpoint, untrusted_payload): + scope='local', write=True) + async def vm_device_set_required(self, endpoint, untrusted_payload): + """ + Update `required` flag of an already assigned device. + + Payload: + `False` -> device will be auto-attached to qube + `True` -> device is required to start qube + """ devclass = endpoint self.enforce(untrusted_payload in (b'True', b'False')) - persistent = untrusted_payload == b'True' + # now is safe to eval, since the value of untrusted_payload is trusted + # pylint: disable=eval-used + assignment = eval(untrusted_payload) del untrusted_payload # qrexec already verified that no strange characters are in self.arg - backend_domain, ident = self.arg.split('+', 1) - # device must be already attached - matching_devices = [dev for dev - in self.dest.devices[devclass].attached() - if dev.backend_domain.name == backend_domain and dev.ident == ident] - self.enforce(len(matching_devices) == 1) - dev = matching_devices[0] + backend_domain_name, ident = self.arg.split('+', 1) + backend_domain = self.app.domains[backend_domain_name] + dev = Device(backend_domain, ident, devclass) - self.fire_event_for_permission(device=dev, - persistent=persistent) + self.fire_event_for_permission(device=dev, assignment=assignment) - self.dest.devices[devclass].update_persistent(dev, persistent) + await self.dest.devices[devclass].update_required(dev, assignment) self.app.save() @qubes.api.method('admin.vm.firewall.Get', no_payload=True, @@ -1497,7 +1543,7 @@ async def backup_execute(self): profile_path = os.path.join(qubes.config.backup_profile_dir, self.arg + '.conf') if not os.path.exists(profile_path): - raise qubes.api.PermissionDenied( + raise qubes.exc.PermissionDenied( 'Backup profile {} does not exist'.format(self.arg)) if not hasattr(self.app, 'api_admin_running_backups'): @@ -1549,7 +1595,7 @@ async def backup_info(self): profile_path = os.path.join(qubes.config.backup_profile_dir, self.arg + '.conf') if not os.path.exists(profile_path): - raise qubes.api.PermissionDenied( + raise qubes.exc.PermissionDenied( 'Backup profile {} does not exist'.format(self.arg)) backup = await self._load_backup_profile(self.arg, diff --git a/qubes/api/misc.py b/qubes/api/misc.py index 85591796c..ebc2ff955 100644 --- a/qubes/api/misc.py +++ b/qubes/api/misc.py @@ -18,8 +18,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . -''' Interface for methods not being part of Admin API, but still handled by -qubesd. ''' +""" Interface for methods not being part of Admin API, but still handled by +qubesd. """ import string from datetime import datetime @@ -34,7 +34,7 @@ class QubesMiscAPI(qubes.api.AbstractQubesAPI): @qubes.api.method('qubes.FeaturesRequest', no_payload=True) async def qubes_features_request(self): - ''' qubes.FeaturesRequest handler + """ qubes.FeaturesRequest handler VM (mostly templates) can request some features from dom0 for itself. Then dom0 (qubesd extension) may respect this request or ignore it. @@ -44,7 +44,7 @@ async def qubes_features_request(self): dispatch 'features-request' event, which may be handled by appropriate extensions. Requests not explicitly handled by some extension are ignored. - ''' + """ self.enforce(self.dest.name == 'dom0') self.enforce(not self.arg) @@ -66,9 +66,9 @@ async def qubes_features_request(self): @qubes.api.method('qubes.NotifyTools', no_payload=True) async def qubes_notify_tools(self): - ''' + """ Legacy version of qubes.FeaturesRequest, used by Qubes Windows Tools - ''' + """ self.enforce(self.dest.name == 'dom0') self.enforce(not self.arg) @@ -92,12 +92,12 @@ async def qubes_notify_tools(self): @qubes.api.method('qubes.NotifyUpdates') async def qubes_notify_updates(self, untrusted_payload): - ''' + """ Receive VM notification about updates availability Payload contains a single integer - either 0 (no updates) or some positive value (some updates). - ''' + """ untrusted_update_count = untrusted_payload.strip() self.enforce(untrusted_update_count.isdigit()) diff --git a/qubes/app.py b/qubes/app.py index f85b4848e..df0b98071 100644 --- a/qubes/app.py +++ b/qubes/app.py @@ -1052,7 +1052,7 @@ def load(self, lock=False): vm.fire_event('domain-load') # get a file timestamp (before closing it - still holding the lock!), - # to detect whether anyone else have modified it in the meantime + # to detect whether anyone else has modified it in the meantime self.__load_timestamp = os.path.getmtime(self._store) if not lock: @@ -1509,8 +1509,7 @@ def on_domain_pre_deleted(self, event, vm): assignment.ident for assignment in assignments) raise qubes.exc.QubesVMInUseError( vm, - 'VM has devices attached persistently to other VMs: ' + - desc) + 'VM has devices assigned to other VMs: ' + desc) @qubes.events.handler('domain-delete') def on_domain_deleted(self, event, vm): @@ -1564,7 +1563,7 @@ def on_property_set_default_netvm(self, event, name, newvalue, if hasattr(vm, 'provides_network') and not vm.provides_network and \ hasattr(vm, 'netvm') and vm.property_is_default('netvm'): # fire property-reset:netvm as it is responsible for resetting - # netvm to it's default value + # netvm to its default value vm.fire_event('property-reset:netvm', name='netvm', oldvalue=oldvalue) @@ -1576,6 +1575,6 @@ def on_property_set_default_dispvm(self, event, name, newvalue, if hasattr(vm, 'default_dispvm') and \ vm.property_is_default('default_dispvm'): # fire property-reset:default_dispvm as it is responsible for - # resetting dispvm to it's default value + # resetting dispvm to its default value vm.fire_event('property-reset:default_dispvm', name='default_dispvm', oldvalue=oldvalue) diff --git a/qubes/device_protocol.py b/qubes/device_protocol.py new file mode 100644 index 000000000..3d8b9aaa6 --- /dev/null +++ b/qubes/device_protocol.py @@ -0,0 +1,952 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# 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) 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 +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . + +""" +Common part of device API. + +The same in `qubes-core-admin` and `qubes-core-admin-client`, +should be moved to one place. +""" +import string +import sys +from enum import Enum +from typing import Optional, Dict, Any, List, Union, Tuple + +import qubes.utils + +from qubes.exc import ProtocolError + +QubesVM = 'qubes.vm.BaseVM' + + +class UnexpectedDeviceProperty(qubes.exc.QubesException, ValueError): + """ + Device has unexpected property such as backend_domain, devclass etc. + """ + + +def qbool(value): + return qubes.property.bool(None, None, value) + + +class Device: + """ + 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'). + """ + 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 + + @classmethod + def unpack_properties( + cls, untrusted_serialization: bytes + ) -> Tuple[Dict, Dict]: + """ + Unpacks basic device properties from a serialized encoded string. + + Returns: + tuple: A tuple containing two dictionaries, properties and options, + extracted from the serialization. + + Raises: + ValueError: If unexpected characters are found in property + names or values. + """ + ut_decoded = untrusted_serialization.decode( + 'ascii', errors='strict').strip() + + properties = {} + options = {} + + if not ut_decoded: + return properties, options + + keys = [] + values = [] + ut_key, _, ut_rest = ut_decoded.partition("='") + + key = sanitize_str( + ut_key, 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: ') + values.append(value) + key = sanitize_str( + ut_key, 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: ') + values.append(value) + + for key, value in zip(keys, values): + if key.startswith("_"): + # it's handled in cls.__init__ + options[key[1:]] = value + else: + properties[key] = value + + return properties, options + + @classmethod + def pack_property(cls, key: str, value: 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') + + @staticmethod + def check_device_properties( + expected_device: 'Device', properties: Dict[str, Any]): + """ + Validates properties against an expected device configuration. + + Modifies `properties`. + + Raises: + 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: + raise UnexpectedDeviceProperty( + f"Got device exposed by {properties['backend_domain']}" + f"when expected devices from {exp_vm_name}.") + properties['backend_domain'] = expected.backend_domain + + if properties.get('ident', expected.ident) != expected.ident: + raise UnexpectedDeviceProperty( + f"Got device with id: {properties['ident']} " + f"when expected id: {expected.ident}.") + properties['ident'] = expected.ident + + if expected.devclass_is_set: + if (properties.get('devclass', expected.devclass) + != expected.devclass): + raise UnexpectedDeviceProperty( + f"Got {properties['devclass']} device " + f"when expected {expected.devclass}.") + properties['devclass'] = expected.devclass + + +class DeviceCategory(Enum): + """ + Category of a peripheral device. + + Arbitrarily selected interfaces that are important to users, + thus deserving special recognition such as a custom icon, etc. + """ + # pylint: disable=invalid-name + Other = "*******" + + Communication = ("u02****", "p07****") # eg. modems + Input = ("u03****", "p09****") # HID etc. + Keyboard = ("u03**01", "p0900**") + Mouse = ("u03**02", "p0902**") + Printer = ("u07****",) + Scanner = ("p0903**",) + # Multimedia = Audio, Video, Displays etc. + Microphone = ("m******",) + Multimedia = ("u01****", "u0e****", "u06****", "u10****", "p03****", + "p04****") + Wireless = ("ue0****", "p0d****") + Bluetooth = ("ue00101", "p0d11**") + Mass_Data = ("b******", "u08****", "p01****") + Network = ("p02****",) + Memory = ("p05****",) + PCI_Bridge = ("p06****",) + Docking_Station = ("p0a****",) + Processor = ("p0b****", "p40****") + PCI_Serial_Bus = ("p0c****",) + PCI_USB = ("p0c03**",) + + @staticmethod + def from_str(interface_encoding: str) -> 'DeviceCategory': + """ + Returns `DeviceCategory` from data encoded in string. + """ + result = DeviceCategory.Other + if len(interface_encoding) != len(DeviceCategory.Other.value): + return result + best_score = 0 + + for interface in DeviceCategory: + for pattern in interface.value: + score = 0 + for itf, pat in zip(interface_encoding, pattern): + if itf == pat: + score += 1 + elif pat != "*": + score = -1 # inconsistent with a pattern + break + + if score > best_score: + best_score = score + result = interface + + return result + + +class DeviceInterface: + """ + Peripheral device interface wrapper. + """ + + def __init__(self, interface_encoding: str, devclass: Optional[str] = None): + 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 + ) + ifc_full = devclass[0] + ifc_padded + else: + known_devclasses = { + '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 + ) + ifc_full = ifc_padded + elif len(ifc_padded) == 6: + ifc_full = '?' + ifc_padded + else: + ifc_full = ifc_padded + + self._devclass = devclass + self._interface_encoding = ifc_full + self._category = DeviceCategory.from_str(self._interface_encoding) + + @property + def devclass(self) -> Optional[str]: + """ 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. """ + return self._category + + @classmethod + def unknown(cls) -> 'DeviceInterface': + """ Value for unknown device interface. """ + return cls("?******") + + def __repr__(self): + return self._interface_encoding + + def __hash__(self): + return hash(repr(self)) + + def __eq__(self, other): + if not isinstance(other, DeviceInterface): + return False + return repr(self) == repr(other) + + def __str__(self): + if self.devclass == "block": + 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')): + # 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')): + # 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" + return result + if self.devclass == 'mic': + return "Microphone" + return repr(self) + + @staticmethod + def _load_classes(bus: str): + """ + 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(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) + # 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 + subclass_id = None + + return result + + +class DeviceInfo(Device): + """ 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 + ): + super().__init__(backend_domain, ident, devclass) + + self._vendor = vendor + self._product = product + self._manufacturer = manufacturer + self._name = name + self._serial = serial + self._interfaces = interfaces + self._parent = parent + self._attachment = attachment + self._self_identity = self_identity + + self.data = kwargs + + @property + def vendor(self) -> str: + """ + Device vendor name from local database. + + Could be empty string or "unknown". + + Override this method to return proper name from `/usr/share/hwdata/*`. + """ + if not self._vendor: + return "unknown" + return self._vendor + + @property + def product(self) -> str: + """ + Device name from local database. + + Could be empty string or "unknown". + + Override this method to return proper name from `/usr/share/hwdata/*`. + """ + if not self._product: + return "unknown" + return self._product + + @property + def manufacturer(self) -> str: + """ + The name of the manufacturer of the device introduced by device itself. + + Could be empty string or "unknown". + + Override this method to return proper name directly from device itself. + """ + if not self._manufacturer: + return "unknown" + return self._manufacturer + + @property + def name(self) -> str: + """ + The name of the device it introduced itself with. + + Could be empty string or "unknown". + + Override this method to return proper name directly from device itself. + """ + if not self._name: + return "unknown" + return self._name + + @property + def serial(self) -> str: + """ + The serial number of the device it introduced itself with. + + Could be empty string or "unknown". + + Override this method to return proper name directly from device itself. + """ + if not self._serial: + return "unknown" + return self._serial + + @property + def description(self) -> str: + """ + Short human-readable description. + + For unknown device returns `unknown device (unknown vendor)`. + For unknown USB device returns `unknown usb device (unknown vendor)`. + For unknown USB device with known serial number returns + ` (unknown vendor)`. + """ + if self.product and self.product != "unknown": + prod = self.product + elif self.name and self.name != "unknown": + prod = self.name + elif self.serial and self.serial != "unknown": + prod = self.serial + elif self.parent_device is not None: + return f"sub-device of {self.parent_device}" + else: + prod = f"unknown {self.devclass if self.devclass else ''} device" + + if self.vendor and self.vendor != "unknown": + vendor = self.vendor + elif self.manufacturer and self.manufacturer != "unknown": + vendor = self.manufacturer + else: + vendor = "unknown vendor" + + main_interface = str(self.interfaces[0]) + return f"{main_interface}: {vendor} {prod}" + + @property + def interfaces(self) -> List[DeviceInterface]: + """ + Non-empty list of device interfaces. + + Every device should have at least one interface. + """ + if not self._interfaces: + return [DeviceInterface.unknown()] + return self._interfaces + + @property + def parent_device(self) -> Optional[Device]: + """ + The parent device, if any. + + If the device is part of another device (e.g., it's a single + partition of a USB stick), the parent device id should be here. + """ + return self._parent + + @property + def subdevices(self) -> List['DeviceInfo']: + """ + 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] + + @property + def attachment(self) -> Optional[QubesVM]: + """ + VM to which device is attached (frontend domain). + """ + return self._attachment + + def serialize(self) -> bytes: + """ + Serialize an object to be transmitted via Qubes API. + """ + # 'backend_domain', '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) + + if self.attachment: + properties = self.pack_property( + 'attachment', self.attachment.name) + + properties += b' ' + self.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) + + for key, value in self.data.items(): + properties += b' ' + self.pack_property("_" + key, value) + + return properties + + @classmethod + def deserialize( + 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, + ) + + try: + device = cls._deserialize(rest, device) + # pylint: disable=broad-exception-caught + except Exception as exc: + print(exc, file=sys.stderr) + + return device + + @classmethod + def _deserialize( + cls, + untrusted_serialization: bytes, + expected_device: Device + ) -> 'DeviceInfo': + """ + Actually deserializes the object. + """ + properties, options = cls.unpack_properties(untrusted_serialization) + properties.update(options) + + cls.check_device_properties(expected_device, properties) + + if 'attachment' not in properties or not properties['attachment']: + properties['attachment'] = None + else: + 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}.") + + if 'interfaces' in properties: + interfaces = 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( + backend_domain=expected_device.backend_domain, + ident=properties['parent_ident'], + devclass=properties['parent_devclass'], + ) + del properties['parent_ident'] + del properties['parent_devclass'] + + return cls(**properties) + + @property + def self_identity(self) -> str: + """ + Get additional identification of device presented by device itself. + + For pci/usb we expect: + ::: + For block devices: + : + + In addition to the description returns presented interfaces. + It is used to auto-attach usb devices, so an attacking device needs to + mimic not only a name, but also interfaces of trusted device (and have + 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: + return "0000:0000::?******" + return self._self_identity + + +def serialize_str(value: str): + """ + Serialize python string to ensure consistency. + """ + return "'" + str(value).replace("'", r"\'") + "'" + + +def deserialize_str(value: str): + """ + Deserialize python string to ensure consistency. + """ + return value.replace(r"\'", "'") + + +def sanitize_str( + untrusted_value: str, + allowed_chars: set, + replace_char: 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 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. + """ + + def __init__(self, backend_domain, ident, options=None, + frontend_domain=None, devclass=None, + required=False, attach_automatically=False): + super().__init__(backend_domain, ident, devclass) + self.__options = options or {} + if required: + assert attach_automatically + self.__required = required + self.__attach_automatically = attach_automatically + self.frontend_domain = frontend_domain + + def clone(self, **kwargs): + """ + Clone object and substitute attributes with explicitly given. + """ + attr = { + "backend_domain": self.backend_domain, + "ident": self.ident, + "options": self.options, + "required": self.required, + "attach_automatically": self.attach_automatically, + "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 + ) + + @property + def device(self) -> DeviceInfo: + """Get DeviceInfo object corresponding to this DeviceAssignment""" + return self.backend_domain.devices[self.devclass][self.ident] + + @property + def frontend_domain(self) -> Optional[QubesVM]: + """ 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. """ + if isinstance(frontend_domain, str): + frontend_domain = self.backend_domain.app.domains[frontend_domain] + self.__frontend_domain = frontend_domain + + @property + def attached(self) -> bool: + """ + Is the device attached to the fronted domain? + + Returns False if device is attached to different domain + """ + return self.device.attachment == self.frontend_domain + + @property + 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 + + @property + 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 + + @property + def options(self) -> Dict[str, Any]: + """ 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). """ + 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) + + if self.frontend_domain is not None: + properties += b' ' + self.pack_property( + 'frontend_domain', self.frontend_domain.name) + + for key, value in self.options.items(): + properties += b' ' + self.pack_property("_" + key, value) + + return properties + + @classmethod + def deserialize( + cls, + serialization: bytes, + expected_device: Device, + ) -> 'DeviceAssignment': + """ + Recovers a serialized object, see: :py:meth:`serialize`. + """ + try: + result = cls._deserialize(serialization, expected_device) + except Exception as exc: + raise ProtocolError() from exc + return result + + @classmethod + def _deserialize( + cls, + untrusted_serialization: bytes, + expected_device: Device, + ) -> 'DeviceAssignment': + """ + Actually deserializes the object. + """ + properties, options = cls.unpack_properties(untrusted_serialization) + properties['options'] = options + + cls.check_device_properties(expected_device, properties) + + properties['attach_automatically'] = qbool( + properties.get('attach_automatically', 'no')) + properties['required'] = qbool(properties.get('required', 'no')) + + return cls(**properties) diff --git a/qubes/devices.py b/qubes/devices.py index a277aeeb2..c073ef759 100644 --- a/qubes/devices.py +++ b/qubes/devices.py @@ -4,6 +4,8 @@ # Copyright (C) 2010-2016 Joanna Rutkowska # Copyright (C) 2015-2016 Wojtek Porczyk # Copyright (C) 2016 Bahtiar `kalkin-` Gadimov +# 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 @@ -19,11 +21,11 @@ # License along with this library; if not, see . # -'''API for various types of devices. +"""API for various types of devices. Main concept is that some domain may expose (potentially multiple) devices, which can be attached to other domains. -Devices can be of different buses (like 'pci', 'usb', etc). Each device +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 @@ -31,8 +33,8 @@ Such extension should: - provide `qubes.devices` endpoint - a class descendant from - :py:class:`qubes.devices.DeviceInfo`, designed to hold device description - (including bus-specific properties) + :py:class:`qubes.device_protocol.DeviceInfo`, designed to hold device + description (including bus-specific properties) - handle `device-attach:bus` and `device-detach:bus` events for performing the attach/detach action; events are fired even when domain isn't running and extension should be prepared for this; handlers for those events @@ -41,9 +43,10 @@ domain; it should return a list of appropriate DeviceInfo objects - handle `device-get:bus` event - get one device object exposed by this domain of given identifier - - handle `device-list-attached:class` event - list devices currently attached + - handle `device-list-attached:bus` event - list devices currently attached to this domain - - fire `device-list-change:class` event when a device list change is detected + - fire `device-list-change:bus` and following `device-added:bus` or + `device-removed:bus` events when a device list change is detected (new/removed device) Note that device-listing event handlers cannot be asynchronous. This for @@ -54,110 +57,42 @@ Extension may use QubesDB watch API (QubesVM.watch_qdb_path(path), then handle `domain-qdb-change:path`) to detect changes and fire `device-list-change:class` event. -''' -from typing import Optional +""" +import itertools +from typing import Iterable +import qubes.exc import qubes.utils +from qubes.device_protocol import (Device, DeviceInfo, UnknownDevice, + DeviceAssignment) + + +class DeviceNotAssigned(qubes.exc.QubesException, KeyError): + """ + Trying to unassign not assigned device. + """ -class DeviceNotAttached(qubes.exc.QubesException, KeyError): - '''Trying to detach not attached device''' class DeviceAlreadyAttached(qubes.exc.QubesException, KeyError): - '''Trying to attach already attached device''' - -class DeviceInfo: - ''' Holds all information about a device ''' - # pylint: disable=too-few-public-methods - def __init__(self, backend_domain, ident, description=None, - frontend_domain=None): - #: domain providing this device - self.backend_domain = backend_domain - #: device identifier (unique for given domain and device type) - self.ident = ident - # allow redefining those as dynamic properties in subclasses - try: - #: human readable description/name of the device - self.description = description - except AttributeError: - pass - try: - #: (running) domain to which device is currently attached - self.frontend_domain = frontend_domain - except AttributeError: - pass - - if hasattr(self, 'regex'): - # pylint: disable=no-member - dev_match = self.regex.match(ident) - if not dev_match: - raise ValueError('Invalid device identifier: {!r}'.format( - ident)) - - for group in self.regex.groupindex: - setattr(self, group, dev_match.group(group)) - - def __hash__(self): - return hash((self.backend_domain, self.ident)) - - def __eq__(self, other): - return ( - self.backend_domain == other.backend_domain and - self.ident == other.ident - ) - - def __lt__(self, other): - if isinstance(other, DeviceInfo): - return (self.backend_domain, self.ident) < \ - (other.backend_domain, other.ident) - return NotImplemented - - def __str__(self): - return '{!s}:{!s}'.format(self.backend_domain, self.ident) - - -class DeviceAssignment: # pylint: disable=too-few-public-methods - ''' Maps a device to a frontend_domain. ''' - - def __init__(self, backend_domain, ident, options=None, persistent=False, - bus=None): - self.backend_domain = backend_domain - self.ident = ident - self.options = options or {} - self.persistent = persistent - self.bus = bus - - def __repr__(self): - return "[%s]:%s" % (self.backend_domain, self.ident) - - def __hash__(self): - # it's important to use the same hash as DeviceInfo - return hash((self.backend_domain, self.ident)) - - def __eq__(self, other): - if not isinstance(self, other.__class__): - return NotImplemented - - return self.backend_domain == other.backend_domain \ - and self.ident == other.ident - - def clone(self): - '''Clone object instance''' - return self.__class__( - self.backend_domain, - self.ident, - self.options, - self.persistent, - self.bus, - ) - - @property - def device(self): - '''Get DeviceInfo object corresponding to this DeviceAssignment''' - return self.backend_domain.devices[self.bus][self.ident] + """ + Trying to attach already attached device. + """ + + +class DeviceAlreadyAssigned(qubes.exc.QubesException, KeyError): + """ + Trying to assign already assigned device. + """ + + +class UnrecognizedDevice(qubes.exc.QubesException, ValueError): + """ + Device identity is not as expected. + """ class DeviceCollection: - '''Bag for devices. + """Bag for devices. Used as default value for :py:meth:`DeviceManager.__missing__` factory. @@ -166,6 +101,14 @@ class DeviceCollection: This class emits following events on VM object: + .. event:: device-added: (device) + + Fired when new device is discovered. + + .. event:: device-removed: (device) + + Fired when device is no longer exposed by a backend VM. + .. event:: device-attach: (device, options) Fired when device is attached to a VM. @@ -199,6 +142,23 @@ class DeviceCollection: :param device: :py:class:`DeviceInfo` object to be attached + .. event:: device-assign: (device, options) + + Fired when device is assigned to a VM. + + Handler for this event may be asynchronous. + + :param device: :py:class:`DeviceInfo` object to be assigned + :param options: :py:class:`dict` of assignment options + + .. event:: device-unassign: (device) + + Fired when device is unassigned from a VM. + + Handler for this event can be asynchronous (a coroutine). + + :param device: :py:class:`DeviceInfo` object to be unassigned + .. event:: device-list: Fired to get list of devices exposed by a VM. Handlers of this @@ -212,169 +172,214 @@ class DeviceCollection: :py:class:`DeviceInfo`, or :py:obj:`None`. Especially should not raise :py:class:`exceptions.KeyError`. - .. event:: device-list-attached: (persistent) + .. event:: device-list-attached: Fired to get list of currently attached devices to a VM. Handlers of this event should return list of devices actually attached to a domain, regardless of its settings. - ''' + """ def __init__(self, vm, bus): self._vm = vm self._bus = bus - self._set = PersistentCollection() + self._set = AssignedCollection() self.devclass = qubes.utils.get_entry_point_one( 'qubes.devices', self._bus) - async def attach(self, device_assignment: DeviceAssignment): - '''Attach (add) device to domain. - - :param DeviceInfo device: device object - ''' + async def attach(self, assignment: DeviceAssignment): + """ + Attach device to domain. + """ - if device_assignment.bus is None: - device_assignment.bus = self._bus - elif device_assignment.bus != self._bus: + if not assignment.devclass_is_set: + assignment.devclass = self._bus + elif assignment.devclass != self._bus: raise ValueError( - 'Trying to attach DeviceAssignment of a different device class') + f'Trying to attach {assignment.devclass} device ' + f'when {self._bus} device expected.') - if not device_assignment.persistent and self._vm.is_halted(): - raise qubes.exc.QubesVMNotRunningError(self._vm, - "VM not running, can only attach device with persistent flag") - device = device_assignment.device - if device in self.assignments(): + if self._vm.is_halted(): + raise qubes.exc.QubesVMNotRunningError( + self._vm,"VM not running, cannot attach device," + " do you mean `assign`?") + + device = assignment.device + if device in self.get_attached_devices(): raise DeviceAlreadyAttached( '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=device_assignment.options) - if device_assignment.persistent: - self._set.add(device_assignment) - await self._vm.fire_event_async('device-attach:' + self._bus, - device=device, options=device_assignment.options) - def load_persistent(self, device_assignment: DeviceAssignment): - '''Load DeviceAssignment retrieved from qubes.xml + await self._vm.fire_event_async( + '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) + + 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: + raise ValueError( + f'Trying to attach {assignment.devclass} device ' + f'when {self._bus} device expected.') + + device = assignment.device + if device in self.get_assigned_devices(): + raise DeviceAlreadyAssigned( + 'device {!s} of class {} already assigned to {!s}'.format( + device, self._bus, self._vm)) + + self._set.add(assignment) + + 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 This can be used only for loading qubes.xml, when VM events are not enabled yet. - ''' + """ assert not self._vm.events_enabled - assert device_assignment.persistent - device_assignment.bus = self._bus + assert device_assignment.attach_automatically + device_assignment.devclass = self._bus self._set.add(device_assignment) - def update_persistent(self, device: DeviceInfo, persistent: bool): - '''Update `persistent` flag of already attached device. - ''' + async def update_required(self, device: Device, required: bool): + """ + Update `required` flag of an already attached 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 + """ if self._vm.is_halted(): - raise qubes.exc.QubesVMNotStartedError(self._vm, - 'VM must be running to modify device persistence flag') - assignments = [a for a in self.assignments() if a.device == device] + raise qubes.exc.QubesVMNotStartedError( + self._vm, + 'VM must be running to modify device assignment' + ) + assignments = [a for a in self.get_assigned_devices() + if a == device] if not assignments: - raise qubes.exc.QubesValueError('Device not assigned') + raise qubes.exc.QubesValueError( + f'Device {device} not assigned to {self._vm.name}') assert len(assignments) == 1 assignment = assignments[0] # be careful to use already present assignment, not the provided one # - to not change options as a side effect - if persistent and device not in self._set: - assignment.persistent = True - self._set.add(assignment) - elif not persistent and device in self._set: - self._set.discard(assignment) - - async def detach(self, device_assignment: DeviceAssignment): - '''Detach (remove) device from domain. - - :param DeviceInfo device: device object - ''' - - if device_assignment.bus is None: - device_assignment.bus = self._bus + if assignment.required == required: + return + + assignment.required = required + await self._vm.fire_event_async( + 'device-assignment-changed:' + self._bus, device=device) + + async def detach(self, device: Device): + """ + Detach device from domain. + """ + for assign in self.get_attached_devices(): + if device == assign: + # 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}') + + 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.") + + # use the local object + device = assignment.device + await self._vm.fire_event_async( + 'device-pre-detach:' + self._bus, pre_event=True, device=device) + + await self._vm.fire_event_async( + 'device-detach:' + self._bus, device=device) + + async def unassign(self, device_assignment: DeviceAssignment): + """ + Unassign device from domain. + """ + for assignment in self.get_assigned_devices(): + if device_assignment == assignment: + # load all options + device_assignment = assignment + break else: - assert device_assignment.bus == self._bus, \ - "Trying to attach DeviceAssignment of a different device class" + raise DeviceNotAssigned( + f'device {device_assignment.ident!s} of class {self._bus} not ' + f'assigned to {self._vm!s}') - if device_assignment in self._set and not self._vm.is_halted(): - raise qubes.exc.QubesVMNotHaltedError(self._vm, - "Can not remove a persistent attachment from a non halted vm") - if device_assignment not in self.assignments(): - raise DeviceNotAttached( - 'device {!s} of class {} not attached to {!s}'.format( - device_assignment.ident, self._bus, self._vm)) + self._set.discard(assignment) device = device_assignment.device - await self._vm.fire_event_async('device-pre-detach:' + self._bus, - pre_event=True, device=device) - if device in self._set: - device_assignment.persistent = True - self._set.discard(device_assignment) - - await self._vm.fire_event_async('device-detach:' + self._bus, - device=device) - - def attached(self): - '''List devices which are (or may be) attached to this vm ''' - attached = self._vm.fire_event('device-list-attached:' + self._bus, - persistent=None) - if attached: - return [dev for dev, _ in attached] - - return [] - - def persistent(self): - ''' Devices persistently attached and safe to access before libvirt - bootstrap. - ''' - return [a.device for a in self._set] - - def assignments(self, persistent: Optional[bool]=None): - '''List assignments for devices which are (or may be) attached to the - vm. - - Devices may be attached persistently (so they are included in - :file:`qubes.xml`) or not. Device can also be in :file:`qubes.xml`, - but be temporarily detached. - - :param Optional[bool] persistent: only include devices which are or are - not attached persistently. - ''' - - try: - devices = self._vm.fire_event('device-list-attached:' + self._bus, - persistent=persistent) - except Exception: # pylint: disable=broad-except - self._vm.log.exception('Failed to list {} devices'.format( - self._bus)) - if persistent is True: - # don't break app.save() - return list(self._set) - raise - result = [] - if persistent is not False: # None or True - result.extend(self._set) - if not persistent: # None or False - for dev, options in devices: - if dev not in self._set: - result.append( - DeviceAssignment( - backend_domain=dev.backend_domain, - ident=dev.ident, options=options, - bus=self._bus)) - return result - - def available(self): - '''List devices exposed by this vm''' - devices = self._vm.fire_event('device-list:' + self._bus) - return devices - - def __iter__(self): - return iter(self.available()) + await self._vm.fire_event_async( + 'device-unassign:' + self._bus, device=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()) + + 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) + for dev, options in attached: + for assignment in self._set: + if dev == assignment: + yield assignment + break + else: + yield DeviceAssignment( + backend_domain=dev.backend_domain, + ident=dev.ident, + options=options, + frontend_domain=self._vm, + devclass=dev.devclass, + attach_automatically=False, + required=False, + ) + + def get_assigned_devices( + 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: + continue + yield dev + + def get_exposed_devices(self) -> Iterable[DeviceInfo]: + """ + List devices exposed by this vm. + """ + yield from self._vm.fire_event('device-list:' + self._bus) + + __iter__ = get_exposed_devices def __getitem__(self, ident): '''Get device object with given ident. @@ -394,14 +399,15 @@ def __getitem__(self, ident): assert len(dev) == 1 return dev[0] - return UnknownDevice(self._vm, ident) + return UnknownDevice(self._vm, ident, devclass=self._bus) class DeviceManager(dict): - '''Device manager that hold all devices by their classes. + """ + Device manager that hold all devices by their classes. :param vm: VM for which we manage devices - ''' + """ def __init__(self, vm): super().__init__() @@ -412,29 +418,17 @@ def __missing__(self, key): return self[key] -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, description=None, - frontend_domain=None): - if description is None: - description = "Unknown device" - super().__init__(backend_domain, ident, description, - frontend_domain) - - -class PersistentCollection: - - ''' Helper object managing persistent `DeviceAssignment`s. - ''' +class AssignedCollection: + """ + Helper object managing assigned devices. + """ def __init__(self): self._dict = {} def add(self, assignment: DeviceAssignment): - ''' Add assignment to collection ''' - assert assignment.persistent + """ Add assignment to collection """ + assert assignment.attach_automatically vm = assignment.backend_domain ident = assignment.ident key = (vm, ident) @@ -442,9 +436,11 @@ def add(self, assignment: DeviceAssignment): self._dict[key] = assignment - def discard(self, assignment): - ''' Discard assignment from collection ''' - assert assignment.persistent + def discard(self, assignment: DeviceAssignment): + """ + Discard assignment from a collection. + """ + assert assignment.attach_automatically vm = assignment.backend_domain ident = assignment.ident key = (vm, ident) @@ -456,8 +452,9 @@ def __contains__(self, device) -> bool: return (device.backend_domain, device.ident) in self._dict def get(self, device: DeviceInfo) -> DeviceAssignment: - ''' Returns the corresponding `qubes.devices.DeviceAssignment` for the - device. ''' + """ + Returns the corresponding `DeviceAssignment` for the device. + """ return self._dict[(device.backend_domain, device.ident)] def __iter__(self): diff --git a/qubes/exc.py b/qubes/exc.py index 5139a5724..45a67fab0 100644 --- a/qubes/exc.py +++ b/qubes/exc.py @@ -219,3 +219,11 @@ def __init__(self, label): def __str__(self): # KeyError overrides __str__ method return QubesException.__str__(self) + + +class ProtocolError(AssertionError): + '''Raised when something is wrong with data received''' + + +class PermissionDenied(Exception): + '''Raised deliberately by handlers when we decide not to cooperate''' diff --git a/qubes/ext/__init__.py b/qubes/ext/__init__.py index 0530a3442..4a1949815 100644 --- a/qubes/ext/__init__.py +++ b/qubes/ext/__init__.py @@ -30,8 +30,8 @@ class Extension: - '''Base class for all extensions - ''' # pylint: disable=too-few-public-methods + '''Base class for all extensions''' + # pylint: disable=too-few-public-methods def __new__(cls): if '_instance' not in cls.__dict__: diff --git a/qubes/ext/admin.py b/qubes/ext/admin.py index 39a6e34a6..4abc04418 100644 --- a/qubes/ext/admin.py +++ b/qubes/ext/admin.py @@ -19,6 +19,7 @@ import qubes.api import qubes.api.internal +import qubes.exc import qubes.ext import qubes.vm.adminvm from qrexec.policy import utils, parser @@ -52,7 +53,7 @@ def on_tag_set_or_remove(self, vm, event, arg, **kwargs): # pylint: disable=unused-argument if arg.startswith('created-by-') and \ not isinstance(vm, qubes.vm.adminvm.AdminVM): - raise qubes.api.PermissionDenied( + raise qubes.exc.PermissionDenied( 'changing this tag is prohibited by {}.{}'.format( __name__, type(self).__name__)) diff --git a/qubes/ext/block.py b/qubes/ext/block.py index 4d53c2c13..3a3eb1e58 100644 --- a/qubes/ext/block.py +++ b/qubes/ext/block.py @@ -4,6 +4,7 @@ # # 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 @@ -18,13 +19,20 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . -''' Qubes block devices extensions ''' +""" Qubes block devices extensions """ +import asyncio +import collections import re import string +import sys +from typing import Optional, List + import lxml.etree +import qubes.device_protocol import qubes.devices import qubes.ext +from qubes.ext.utils import device_list_change name_re = re.compile(r"\A[a-z0-9-]{1,12}\Z") device_re = re.compile(r"\A[a-z0-9/-]{1,64}\Z") @@ -42,34 +50,73 @@ SYSTEM_DISKS_DOM0_KERNEL = SYSTEM_DISKS + ('xvdd',) -class BlockDevice(qubes.devices.DeviceInfo): +class BlockDevice(qubes.device_protocol.DeviceInfo): def __init__(self, backend_domain, ident): - super().__init__(backend_domain=backend_domain, - ident=ident) - self._description = None + super().__init__( + backend_domain=backend_domain, ident=ident, devclass="block") + + # lazy loading self._mode = None self._size = None + self._interface_num = None @property - def description(self): - '''Human readable device description''' - if self._description is None: - if not self.backend_domain.is_running(): - return self.ident - safe_set = {ord(c) for c in - string.ascii_letters + string.digits + '()+,-.:=_/ '} - untrusted_desc = self.backend_domain.untrusted_qdb.read( - '/qubes-block-devices/{}/desc'.format(self.ident)) - if not untrusted_desc: - return '' - desc = ''.join((chr(c) if c in safe_set else '_') - for c in untrusted_desc) - self._description = desc - return self._description + def name(self): + """ + The name of the device it introduced itself with. + + Could be empty string or "unknown". + """ + if self._name is None: + name, _ = self._load_lazily_name_and_serial() + return name + return self._name + + @property + def serial(self) -> str: + """ + The serial number of the device it introduced itself with. + + Could be empty string or "unknown". + + Override this method to return proper name directly from device itself. + """ + if self._serial is None: + _, serial = self._load_lazily_name_and_serial() + return serial + return self._serial + + 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') + if not untrusted_desc: + return "unknown", "unknown" + desc = BlockDevice._sanitize( + untrusted_desc, + string.ascii_letters + string.digits + '()+,-.:=_/ ') + model, _, label = desc.partition(' ') + if model: + serial = self._serial = model.replace('_', ' ').strip() + else: + serial = "unknown" + # label: '(EXAMPLE)' or '()' + if label[1:-1]: + 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}" + return f"hosted by {self.backend_domain!s}" @property def mode(self): - '''Device mode, either 'w' for read-write, or 'r' for read-only''' + """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' @@ -87,7 +134,7 @@ def mode(self): @property def size(self): - '''Device size in bytes''' + """Device size in bytes""" if self._size is None: if not self.backend_domain.is_running(): return None @@ -105,29 +152,212 @@ def size(self): @property def device_node(self): - '''Device node in backend domain''' + """Device node in backend domain""" return '/dev/' + self.ident.replace('_', '/') + @property + def interfaces(self) -> List[qubes.device_protocol.DeviceInterface]: + """ + List of device interfaces. + + Every device should have at least one interface. + """ + return [qubes.device_protocol.DeviceInterface("******", "block")] + + @property + def parent_device(self) -> Optional[qubes.device_protocol.Device]: + """ + The parent device, if any. + + If the device is part of another device (e.g., it's a single + partition of an usb stick), the parent device id should be here. + """ + if self._parent is None: + if not self.backend_domain.is_running(): + return None + untrusted_parent_info = self.backend_domain.untrusted_qdb.read( + f'/qubes-block-devices/{self.ident}/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' + if not parent_ident: + return None + try: + self._parent = ( + self.backend_domain.devices)[devclass][parent_ident] + except KeyError: + self._parent = qubes.device_protocol.UnknownDevice( + self.backend_domain, parent_ident, devclass=devclass) + self._interface_num = interface_num + return self._parent + + @property + def attachment(self) -> Optional['qubes.vm.BaseVM']: + """ + Warning: this property is time-consuming, do not run in loop! + """ + if not self.backend_domain.is_running(): + return None + for vm in self.backend_domain.app.domains: + if not vm.is_running(): + continue + if self._is_attached_to(vm): + return vm + + return None + + def _is_attached_to(self, vm): + xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) + + 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 + + if backend_domain.name != self.backend_domain.name: + continue + + if self.ident == ident: + return True + return False + + @property + def self_identity(self) -> str: + """ + Get identification of a device not related to port. + """ + 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 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}' + + def _get_possible_partition_number(self) -> Optional[int]: + """ + If the device is partition return partition number. + + The behavior is undefined for the rest block devices. + """ + # partition number: 'xxxxx12' -> '12' (partition) + numbers = re.findall(r'\d+$', self.ident) + return int(numbers[-1]) if numbers else None + + @staticmethod + def _sanitize( + 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 + ) + + +def _try_get_block_device_info(app, disk): + if disk.get('type') != 'block': + return None + dev_path_node = disk.find('source') + if dev_path_node is None: + return None + + backend_domain_node = disk.find('backenddomain') + if backend_domain_node is not None: + backend_domain = app.domains[ + backend_domain_node.get('name')] + else: + backend_domain = app.domains[0] + + dev_path = dev_path_node.get('dev') + + if dev_path.startswith('/dev/'): + ident = dev_path[len('/dev/'):] + else: + ident = dev_path + + ident = ident.replace('/', '_') + + return backend_domain, ident + class BlockDeviceExtension(qubes.ext.Extension): + def __init__(self): + super().__init__() + self.devices_cache = collections.defaultdict(dict) + @qubes.ext.handler('domain-init', 'domain-load') def on_domain_init_load(self, vm, event): - '''Initialize watching for changes''' + """Initialize watching for changes""" # pylint: disable=unused-argument vm.watch_qdb_path('/qubes-block-devices') + if vm.app.vmm.offline_mode: + self.devices_cache[vm.name] = {} + return + 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)) + self.devices_cache[vm.name] = current_devices + else: + self.devices_cache[vm.name] = {} @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 device list''' + """A change in QubesDB means a change in a device list.""" # pylint: disable=unused-argument - vm.fire_event('device-list-change:block') + 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) + + @staticmethod + def get_device_attachments(vm_): + result = {} + for vm in vm_.app.domains: + if not vm.is_running(): + continue + + if vm.app.vmm.offline_mode: + return result + + xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc()) - def device_get(self, vm, ident): - '''Read information about device from QubesDB + 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 + + result[ident] = vm + return result + + @staticmethod + def device_get(vm, ident): + """ + Read information about a device from QubesDB :param vm: backend VM object :param ident: device identifier - :returns BlockDevice''' + :returns BlockDevice + """ untrusted_qubes_device_attrs = vm.untrusted_qdb.list( '/qubes-block-devices/{}/'.format(ident)) @@ -221,9 +451,11 @@ def on_device_list_attached(self, vm, event, **kwargs): yield (BlockDevice(backend_domain, ident), options) - def find_unused_frontend(self, vm, devtype='disk'): - '''Find unused block frontend device node for - parameter''' + @staticmethod + def find_unused_frontend(vm, devtype='disk'): + """ + Find unused block frontend device node for parameter + """ assert vm.is_running() xml = vm.libvirt_domain.XMLDesc() @@ -242,6 +474,18 @@ def find_unused_frontend(self, vm, devtype='disk'): @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)) + + def pre_attachment_internal( + 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) + raise qubes.devices.UnrecognizedDevice() # validate options for option, value in options.items(): @@ -257,6 +501,15 @@ def on_device_pre_attached_block(self, vm, event, device, options): 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}" + ) else: raise qubes.exc.QubesValueError( 'Unsupported option {}'.format(option)) @@ -268,20 +521,89 @@ def on_device_pre_attached_block(self, vm, event, device, options): '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) return + if not isinstance(device, BlockDevice): + 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) + ) + if not device.backend_domain.is_running(): - raise qubes.exc.QubesVMNotRunningError(device.backend_domain, - 'Domain {} needs to be running to attach device from ' - 'it'.format(device.backend_domain.name)) + raise qubes.exc.QubesVMNotRunningError( + device.backend_domain, + 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 if 'frontend-dev' not in options: options['frontend-dev'] = self.find_unused_frontend( vm, options.get('devtype', 'disk')) - vm.libvirt_domain.attachDevice( - vm.app.env.get_template('libvirt/devices/block.xml').render( - device=device, vm=vm, options=options)) + @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)) + + async def attach_and_notify(self, vm, device, options): + # bypass DeviceCollection logic preventing double attach + # we expected that these devices are already attached to this vm + self.notify_auto_attached(vm, device, options) + + @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. + """ + # pylint: disable=unused-argument + + new_cache = {} + for domain in vm.app.domains.values(): + new_cache[domain.name] = {} + if domain == vm: + 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) + 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)) + else: + new_cache[domain.name][dev_id] = front_vm + self.devices_cache = new_cache.copy() + + async def _detach_and_notify(self, vm, device, options): + # 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) + + @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): @@ -289,10 +611,12 @@ def on_device_pre_detached_block(self, vm, event, device): if not vm.is_running(): return - # need to enumerate attached device to find frontend_dev option (at + # 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 vm.libvirt_domain.detachDevice( vm.app.env.get_template('libvirt/devices/block.xml').render( device=device, vm=vm, options=options)) diff --git a/qubes/ext/pci.py b/qubes/ext/pci.py index 4b1cf32db..1535a7f9d 100644 --- a/qubes/ext/pci.py +++ b/qubes/ext/pci.py @@ -18,16 +18,20 @@ # License along with this library; if not, see . # -''' Qubes PCI Extensions ''' +""" Qubes PCI Extensions """ import functools import os import re +import string import subprocess +from typing import Optional, List, Dict + import libvirt import lxml import lxml.etree +import qubes.device_protocol import qubes.devices import qubes.ext @@ -35,7 +39,7 @@ pci_classes = None -#: emit warning on unspported device only once +#: emit warning on unsupported device only once unsupported_devices_warned = set() @@ -44,14 +48,14 @@ 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: + encoding='utf-8', errors='ignore') as pciids: class_id = None subclass_id = None for line in pciids.readlines(): @@ -83,7 +87,7 @@ def pcidev_class(dev_xmldesc): with open(sysfs_path + '/class', encoding='ascii') as f_class: class_id = f_class.read().strip() except OSError: - return "Unknown" + return "unknown" if not qubes.ext.pci.pci_classes: qubes.ext.pci.pci_classes = load_pci_classes() @@ -93,7 +97,21 @@ def pcidev_class(dev_xmldesc): # ignore prog-if return qubes.ext.pci.pci_classes[class_id[0:4]] except KeyError: - return "Unknown" + return "unknown" + + +def pcidev_interface(dev_xmldesc): + sysfs_path = dev_xmldesc.findtext('path') + assert sysfs_path + try: + 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'): + class_id = class_id[2:] + return class_id def attached_devices(app): @@ -134,7 +152,7 @@ def _device_desc(hostdev_xml): ) -class PCIDevice(qubes.devices.DeviceInfo): +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]+)\.' @@ -150,16 +168,84 @@ def __init__(self, backend_domain, ident, libvirt_name=None): raise UnsupportedDevice(libvirt_name) ident = '{bus}_{device}.{function}'.format(**dev_match.groupdict()) - super().__init__(backend_domain, ident, None) + super().__init__( + backend_domain=backend_domain, ident=ident, devclass="pci") + + dev_match = self.regex.match(ident) + if not dev_match: + raise ValueError('Invalid device identifier: {!r}'.format( + ident)) + + 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 + + @property + def vendor(self) -> str: + """ + Device vendor from local database `/usr/share/hwdata/pci.ids` + + Could be empty string or "unknown". + + Lazy loaded. + """ + if self._vendor is None: + result = self._load_desc()["vendor"] + else: + result = self._vendor + return result + + @property + def product(self) -> str: + """ + Device name from local database `/usr/share/hwdata/usb.ids` + + Could be empty string or "unknown". + + Lazy loaded. + """ + if self._product is None: + result = self._load_desc()["product"] + else: + result = self._product + return result + + @property + def interfaces(self) -> List[qubes.device_protocol.DeviceInterface]: + """ + List of device interfaces. + + Every device should have at least one interface. + """ + if self._interfaces is None: + 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 + + @property + def parent_device(self) -> Optional[qubes.device_protocol.DeviceInfo]: + """ + The parent device, if any. + + PCI device has no parents. + """ + return None @property def libvirt_name(self): # pylint: disable=no-member # noinspection PyUnresolvedReferences - return 'pci_0000_{}_{}_{}'.format(self.bus, self.device, self.function) + return f'pci_0000_{self.bus}_{self.device}_{self.function}' @property def description(self): @@ -172,6 +258,54 @@ def description(self): hostdev_details.XMLDesc())) return self._description + @property + def self_identity(self) -> str: + """ + Get identification of the device not related to port. + """ + 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) + 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) + serial = self._serial if self._serial else "" + 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(): + # don't cache these values + return result + 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] + return result + @property def frontend_domain(self): # TODO: cache this @@ -179,7 +313,6 @@ def frontend_domain(self): return all_attached.get(self.ident, None) - class PCIDeviceExtension(qubes.ext.Extension): def __init__(self): super().__init__() @@ -304,7 +437,7 @@ def on_device_pre_detached_pci(self, vm, event, device): @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'].persistent(): + for assignment in vm.devices['pci'].get_assigned_devices(): device = _cache_get(assignment.backend_domain, assignment.ident) self.bind_pci_to_pciback(vm.app, device) diff --git a/qubes/ext/utils.py b/qubes/ext/utils.py new file mode 100644 index 000000000..1c7831922 --- /dev/null +++ b/qubes/ext/utils.py @@ -0,0 +1,102 @@ +# coding=utf-8 +# +# The Qubes OS Project, https://www.qubes-os.org +# +# Copyright (C) 2023 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 +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. +import asyncio + +import qubes + + +def device_list_change( + ext: qubes.ext.Extension, current_devices, + vm, path, device_class: qubes.device_protocol.DeviceInfo +): + devclass = device_class.__name__[:-len('Device')].lower() + + if path is not None: + vm.fire_event(f'device-list-change:{devclass}') + + 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) + # options are unknown, device already attached + asyncio.ensure_future(front_vm.fire_event_async( + f'device-attach:{devclass}', device=dev, options={})) + + ext.devices_cache[vm.name] = current_devices + + 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)) + + +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) + added = set() + attached = {} + detached = {} + removed = set() + cache = devices_cache[vm.name] + for dev_id, front_vm in current_devices.items(): + if dev_id not in cache: + added.add(dev_id) + if front_vm is not None: + attached[dev_id] = front_vm + elif cache[dev_id] != front_vm: + cached_front = cache[dev_id] + if front_vm is None: + detached[dev_id] = cached_front + elif cached_front is None: + attached[dev_id] = front_vm + else: + # a front changed from one to another, so we signal it as: + # detach from the first one and attach to the second one. + detached[dev_id] = cached_front + attached[dev_id] = front_vm + + for dev_id, cached_front in cache.items(): + if dev_id not in current_devices: + removed.add(dev_id) + if cached_front is not None: + detached[dev_id] = cached_front + return added, attached, detached, removed diff --git a/qubes/tests/api.py b/qubes/tests/api.py index 54ca186d9..c900307c1 100644 --- a/qubes/tests/api.py +++ b/qubes/tests/api.py @@ -23,6 +23,7 @@ import unittest.mock import qubes.api +import qubes.exc import qubes.tests @@ -43,7 +44,7 @@ def __init__(self, app, src, method, dest, arg, send_event=None): 'mgmt.event': self.event, }[self.method.decode()] except KeyError: - raise qubes.api.ProtocolError('Invalid method') + raise qubes.exc.ProtocolError('Invalid method') def execute(self, untrusted_payload): self.task = asyncio.Task(self.function( diff --git a/qubes/tests/api_admin.py b/qubes/tests/api_admin.py index c24d29f12..6b752b392 100644 --- a/qubes/tests/api_admin.py +++ b/qubes/tests/api_admin.py @@ -34,6 +34,7 @@ import qubes import qubes.devices +import qubes.exc import qubes.firewall import qubes.api.admin import qubes.api.internal @@ -124,6 +125,7 @@ def call_internal_mgmt_func(self, method, dest, arg=b'', payload=b''): return response +# noinspection PyUnresolvedReferences class TC_00_VMs(AdminAPITestCase): def test_000_vm_list(self): value = self.call_mgmt_func(b'admin.vm.List', b'dom0') @@ -483,7 +485,7 @@ def test_080_vm_volume_info_invalid_volume(self): 'keys.return_value': ['root', 'private', 'volatile', 'kernel'] } self.vm.volumes.configure_mock(**volumes_conf) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.Info', b'test-vm1', b'no-such-volume') self.assertEqual(self.vm.volumes.mock_calls, @@ -511,7 +513,7 @@ def test_090_vm_volume_listsnapshots_invalid_volume(self): 'keys.return_value': ['root', 'private', 'volatile', 'kernel'] } self.vm.volumes.configure_mock(**volumes_conf) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.ListSnapshots', b'test-vm1', b'no-such-volume') self.assertEqual(self.vm.volumes.mock_calls, @@ -530,7 +532,7 @@ def test_100_vm_volume_snapshot_invalid_volume(self): {'rev2': '2018-02-22T22:22:22', 'rev1': '2018-01-11T11:11:11'}, } self.vm.volumes.configure_mock(**volumes_conf) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.Snapshots', b'test-vm1', b'no-such-volume') self.assertEqual(self.vm.volumes.mock_calls, @@ -543,7 +545,7 @@ def test_100_vm_volume_snapshot_invalid_revision(self): 'keys.return_value': ['root', 'private', 'volatile', 'kernel'] } self.vm.volumes.configure_mock(**volumes_conf) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.Snapshots', b'test-vm1', b'private', b'no-such-rev') self.assertEqual(self.vm.volumes.mock_calls, @@ -582,7 +584,7 @@ def test_110_vm_volume_revert_invalid_rev(self): } self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.Revert', b'test-vm1', b'private', b'no-such-rev') self.assertEqual(self.vm.volumes.mock_calls, @@ -630,7 +632,7 @@ def test_120_vm_volume_resize_invalid_size1(self): self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() self.vm.storage.resize.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Resize', b'test-vm1', b'private', b'no-int-size') self.assertEqual(self.vm.volumes.mock_calls, @@ -645,7 +647,7 @@ def test_120_vm_volume_resize_invalid_size2(self): self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() self.vm.storage.resize.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Resize', b'test-vm1', b'private', b'-1') self.assertEqual(self.vm.volumes.mock_calls, @@ -660,7 +662,7 @@ def test_120_vm_volume_resize_invalid_size3(self): self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() self.vm.storage.resize.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Resize', b'test-vm1', b'private', b'10000000000000000000') self.assertEqual(self.vm.volumes.mock_calls, @@ -675,7 +677,7 @@ def test_120_vm_volume_resize_invalid_size4(self): self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() self.vm.storage.resize.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Resize', b'test-vm1', b'private', b'01') self.assertEqual(self.vm.volumes.mock_calls, @@ -867,7 +869,7 @@ def test_160_pool_add_missing_name(self, mock_parameters, mock_drivers): add_pool_mock, self.app.add_pool = self.coroutine_mock() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.pool.Add', b'dom0', b'driver1', b'param1=value\nparam2=some-value\n') self.assertEqual(mock_drivers.mock_calls, [unittest.mock.call()]) @@ -892,7 +894,7 @@ def test_160_pool_add_existing_pool(self, mock_parameters, mock_drivers): add_pool_mock, self.app.add_pool = self.coroutine_mock() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.pool.Add', b'dom0', b'driver1', b'name=file\nparam1=value\nparam2=some-value\n') self.assertEqual(mock_drivers.mock_calls, [unittest.mock.call()]) @@ -918,7 +920,7 @@ def test_160_pool_add_invalid_config_format(self, mock_parameters, add_pool_mock, self.app.add_pool = self.coroutine_mock() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.pool.Add', b'dom0', b'driver1', b'name=test-pool\nparam 1=value\n_param2\n') self.assertEqual(mock_drivers.mock_calls, [unittest.mock.call()]) @@ -946,7 +948,7 @@ def test_170_pool_remove_invalid_pool(self): 'test-pool': unittest.mock.Mock(), } remove_pool_mock, self.app.remove_pool = self.coroutine_mock() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.pool.Remove', b'dom0', b'no-such-pool') self.assertEqual(remove_pool_mock.mock_calls, []) @@ -1003,7 +1005,7 @@ def test_200_label_create_invalid_color(self): 'keys.return_value': range(1, 9), } self.app.labels.configure_mock(**labels_config) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Create', b'dom0', b'cyan', b'abcd') self.assertEqual(self.app.get_label.mock_calls, @@ -1019,13 +1021,13 @@ def test_200_label_create_invalid_name(self): 'keys.return_value': range(1, 9), } self.app.labels.configure_mock(**labels_config) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Create', b'dom0', b'01', b'0xff0000') - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Create', b'dom0', b'../xxx', b'0xff0000') - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Create', b'dom0', b'strange-name!@#$', b'0xff0000') @@ -1067,7 +1069,7 @@ def test_210_label_remove_default_label(self): self.app.labels = unittest.mock.MagicMock(wraps=self.app.labels) self.app.get_label = unittest.mock.Mock( **{'return_value.index': 6}) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Remove', b'dom0', b'blue') self.assertEqual(self.app.labels.mock_calls, []) @@ -1077,7 +1079,7 @@ def test_210_label_remove_in_use(self): self.app.labels = unittest.mock.MagicMock(wraps=self.app.labels) self.app.get_label = unittest.mock.Mock( **{'return_value.index': 1}) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.label.Remove', b'dom0', b'red') self.assertEqual(self.app.labels.mock_calls, []) @@ -1149,7 +1151,7 @@ def test_234_shutdown_force_wait_invalid(self): async def coroutine_mock(*args, **kwargs): return func_mock(*args, **kwargs) self.vm.shutdown = coroutine_mock - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.Shutdown', b'test-vm1', b'forcewait') func_mock.assert_not_called() @@ -1267,7 +1269,7 @@ async def fire_event(): mgmt_obj.execute(untrusted_payload=b'')) event_task = asyncio.ensure_future(fire_event()) loop.run_until_complete(execute_task) - vm2 = event_task.result() + event_task.result() self.assertIsNone(execute_task.result()) self.assertEqual(send_event.mock_calls, [ @@ -1496,7 +1498,7 @@ def test_334_vm_create_invalid_name(self, storage_mock): @unittest.mock.patch('qubes.storage.Storage.create') def test_335_vm_create_missing_name(self, storage_mock): storage_mock.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.Create.AppVM', b'dom0', b'test-template', b'label=red') @@ -1505,7 +1507,7 @@ def test_335_vm_create_missing_name(self, storage_mock): @unittest.mock.patch('qubes.storage.Storage.create') def test_336_vm_create_spurious_pool(self, storage_mock): storage_mock.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.Create.AppVM', b'dom0', b'test-template', b'name=test-vm2 label=red pool=default') @@ -1526,7 +1528,7 @@ def test_337_vm_create_duplicate_name(self, storage_mock): @unittest.mock.patch('qubes.storage.Storage.create') def test_338_vm_create_name_twice(self, storage_mock): storage_mock.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.Create.AppVM', b'dom0', b'test-template', b'name=test-vm2 name=test-vm3 label=red') @@ -1608,7 +1610,7 @@ def test_343_vm_create_in_pool_invalid_pool2(self, storage_mock): @unittest.mock.patch('qubes.storage.Storage.create') def test_344_vm_create_in_pool_invalid_volume(self, storage_mock): storage_mock.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.CreateInPool.AppVM', b'dom0', b'test-template', b'name=test-vm2 label=red ' b'pool:invalid=test') @@ -1634,7 +1636,7 @@ def test_346_vm_create_in_pool_duplicate_pool(self, storage_mock): # setting custom pool for 'root' volume of AppVM should not be # allowed - this volume belongs to the template storage_mock.side_effect = self.dummy_coro - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.CreateInPool.AppVM', b'dom0', b'test-template', b'name=test-vm2 label=red ' b'pool=test pool:root=test') @@ -1687,30 +1689,52 @@ def test_450_property_reset(self): def device_list_testclass(self, vm, event): if vm is not self.vm: return - dev = qubes.devices.DeviceInfo(self.vm, '1234') - dev.description = 'Some device' + dev = qubes.device_protocol.DeviceInfo( + self.vm, '1234', product='Some device') dev.extra_prop = 'xx' yield dev - dev = qubes.devices.DeviceInfo(self.vm, '4321') - dev.description = 'Some other device' + dev = qubes.device_protocol.DeviceInfo( + self.vm, '4321', product='Some other device') yield dev + def assertSerializedEqual(self, actual, expected): + """ + Works only for props without spaces! + """ + actual = sorted(actual.split(' ')) + expected = sorted(expected.split(' ')) + self.assertEqual(actual, expected) + def test_460_vm_device_available(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) value = self.call_mgmt_func(b'admin.vm.device.testclass.Available', b'test-vm1') - self.assertEqual(value, - '1234 extra_prop=xx description=Some ' - 'device\n' - '4321 description=Some other device\n') + # the only props with sapces + 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' " + "interfaces='?******'\n") self.assertFalse(self.app.save.called) def test_461_vm_device_available_specific(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) value = self.call_mgmt_func(b'admin.vm.device.testclass.Available', b'test-vm1', b'4321') - self.assertEqual(value, - '4321 description=Some other device\n') + 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' " + "interfaces='?******'\n") self.assertFalse(self.app.save.called) def test_462_vm_device_available_invalid(self): @@ -1720,156 +1744,205 @@ def test_462_vm_device_available_invalid(self): self.assertEqual(value, '') self.assertFalse(self.app.save.called) - def test_470_vm_device_list_persistent(self): - assignment = qubes.devices.DeviceAssignment(self.vm, '1234', - persistent=True) + def test_470_vm_device_list_assigned(self): + assignment = qubes.device_protocol.DeviceAssignment(self.vm, '1234', + attach_automatically=True, required=True) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - value = self.call_mgmt_func(b'admin.vm.device.testclass.List', + 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 persistent=yes\n') + "test-vm1+1234 required='yes' attach_automatically='yes' " + "ident='1234' devclass='testclass' backend_domain='test-vm1'\n") self.assertFalse(self.app.save.called) - def test_471_vm_device_list_persistent_options(self): - assignment = qubes.devices.DeviceAssignment(self.vm, '1234', - persistent=True, options={'opt1': 'value'}) + 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'}) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - assignment = qubes.devices.DeviceAssignment(self.vm, '4321', - persistent=True) + self.vm.devices['testclass'].assign(assignment)) + assignment = qubes.device_protocol.DeviceAssignment(self.vm, '4321', + attach_automatically=True, required=True) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - value = self.call_mgmt_func(b'admin.vm.device.testclass.List', + 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 opt1=value persistent=yes\n' - 'test-vm1+4321 persistent=yes\n') + "test-vm1+1234 required='yes' attach_automatically='yes' " + "ident='1234' devclass='testclass' backend_domain='test-vm1' " + "_opt1='value'\n" + "test-vm1+4321 required='yes' attach_automatically='yes' " + "ident='4321' devclass='testclass' backend_domain='test-vm1'\n") self.assertFalse(self.app.save.called) - def device_list_attached_testclass(self, vm, event, **kwargs): + def device_list_single_attached_testclass(self, vm, event, **kwargs): if vm is not self.vm: return - dev = qubes.devices.DeviceInfo(self.vm, '1234') + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234', 'testclass') yield (dev, {'attach_opt': 'value'}) - def test_472_vm_device_list_temporary(self): + def test_472_vm_device_list_attached(self): self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) - value = self.call_mgmt_func(b'admin.vm.device.testclass.List', + self.device_list_single_attached_testclass) + value = self.call_mgmt_func(b'admin.vm.device.testclass.Attached', b'test-vm1') self.assertEqual(value, - 'test-vm1+1234 attach_opt=value persistent=no\n') + "test-vm1+1234 required='no' attach_automatically='no' " + "ident='1234' devclass='testclass' backend_domain='test-vm1' " + "frontend_domain='test-vm1' _attach_opt='value'\n") self.assertFalse(self.app.save.called) - def test_473_vm_device_list_mixed(self): - self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) - assignment = qubes.devices.DeviceAssignment(self.vm, '4321', - persistent=True) + def test_473_vm_device_list_assigned_specific(self): + assignment = qubes.device_protocol.DeviceAssignment( + self.vm, '1234', attach_automatically=True, required=True) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - value = self.call_mgmt_func(b'admin.vm.device.testclass.List', - b'test-vm1') + self.vm.devices['testclass'].assign(assignment)) + assignment = qubes.device_protocol.DeviceAssignment( + self.vm, '4321', attach_automatically=True, required=True) + 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 attach_opt=value persistent=no\n' - 'test-vm1+4321 persistent=yes\n') + "test-vm1+1234 required='yes' attach_automatically='yes' " + "ident='1234' devclass='testclass' backend_domain='test-vm1'\n") self.assertFalse(self.app.save.called) - def test_474_vm_device_list_specific(self): + 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', 'testclass') + yield (dev, {'attach_opt': 'value'}) + dev = qubes.device_protocol.DeviceInfo(self.vm, '4321', 'testclass') + yield (dev, {'attach_opt': 'value'}) + + def test_474_vm_device_list_attached_specific(self): self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) - assignment = qubes.devices.DeviceAssignment(self.vm, '4321', - persistent=True) - self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - value = self.call_mgmt_func(b'admin.vm.device.testclass.List', + self.device_list_multiple_attached_testclass) + value = self.call_mgmt_func(b'admin.vm.device.testclass.Attached', b'test-vm1', b'test-vm1+1234') self.assertEqual(value, - 'test-vm1+1234 attach_opt=value persistent=no\n') + "test-vm1+1234 required='no' attach_automatically='no' " + "ident='1234' devclass='testclass' backend_domain='test-vm1' " + "frontend_domain='test-vm1' _attach_opt='value'\n") self.assertFalse(self.app.save.called) def test_480_vm_device_attach(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) - mock_attach = unittest.mock.Mock() - mock_attach.return_value = None - del mock_attach._is_coroutine - self.vm.add_handler('device-attach:testclass', mock_attach) + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler(f'device-attach: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.Attach', - b'test-vm1', b'test-vm1+1234') + b'test-vm1', b'test-vm1+1234') self.assertIsNone(value) - mock_attach.assert_called_once_with(self.vm, 'device-attach:testclass', + mock_action.assert_called_once_with( + self.vm, f'device-attach:testclass', device=self.vm.devices['testclass']['1234'], options={}) - self.assertEqual(len(self.vm.devices['testclass'].persistent()), 0) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), + 0) + self.assertFalse(self.app.save.called) + + def test_481_vm_device_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): + value = self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234', + b"attach_automatically='yes'") + self.assertIsNone(value) + mock_action.assert_called_once_with( + self.vm, f'device-assign:testclass', + device=self.vm.devices['testclass']['1234'], + options={}) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), + 1) self.app.save.assert_called_once_with() - def test_481_vm_device_attach(self): + def test_483_vm_device_assign_required(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) - mock_attach = unittest.mock.Mock() - mock_attach.return_value = None - del mock_attach._is_coroutine - self.vm.add_handler('device-attach:testclass', mock_attach) + 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): - value = self.call_mgmt_func(b'admin.vm.device.testclass.Attach', - b'test-vm1', b'test-vm1+1234', b'persistent=no') + value = self.call_mgmt_func( + b'admin.vm.device.testclass.Assign', + b'test-vm1', b'test-vm1+1234', + b"attach_automatically='yes' required='yes'") self.assertIsNone(value) - mock_attach.assert_called_once_with(self.vm, 'device-attach:testclass', + mock_action.assert_called_once_with( + self.vm, f'device-assign:testclass', device=self.vm.devices['testclass']['1234'], options={}) - self.assertEqual(len(self.vm.devices['testclass'].persistent()), 0) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), + 1) self.app.save.assert_called_once_with() - def test_482_vm_device_attach_not_running(self): + def test_484_vm_device_attach_not_running(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) - mock_attach = unittest.mock.Mock() - del mock_attach._is_coroutine - self.vm.add_handler('device-attach:testclass', mock_attach) + mock_action = unittest.mock.Mock() + del mock_action._is_coroutine + 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') - self.assertFalse(mock_attach.called) - self.assertEqual(len(self.vm.devices['testclass'].persistent()), 0) + self.assertFalse(mock_action.called) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), 0) self.assertFalse(self.app.save.called) - def test_483_vm_device_attach_persistent(self): + def test_485_vm_device_assign_not_running(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) - mock_attach = unittest.mock.Mock() - mock_attach.return_value = None - del mock_attach._is_coroutine - self.vm.add_handler('device-attach:testclass', mock_attach) + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler('device-assign: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.Attach', - b'test-vm1', b'test-vm1+1234', b'persistent=yes') - self.assertIsNone(value) - dev = self.vm.devices['testclass']['1234'] - mock_attach.assert_called_once_with(self.vm, 'device-attach:testclass', - device=dev, + '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'") + mock_action.assert_called_once_with( + self.vm, f'device-assign:testclass', + device=self.vm.devices['testclass']['1234'], options={}) - self.assertIn(dev, self.vm.devices['testclass'].persistent()) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), 1) self.app.save.assert_called_once_with() - def test_484_vm_device_attach_persistent_not_running(self): + def test_486_vm_device_assign_required_not_running(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) - mock_attach = unittest.mock.Mock() - mock_attach.return_value = None - del mock_attach._is_coroutine - self.vm.add_handler('device-attach:testclass', mock_attach) - value = self.call_mgmt_func(b'admin.vm.device.testclass.Attach', - b'test-vm1', b'test-vm1+1234', b'persistent=yes') - self.assertIsNone(value) - dev = self.vm.devices['testclass']['1234'] - mock_attach.assert_called_once_with(self.vm, 'device-attach:testclass', - device=dev, + mock_action = unittest.mock.Mock() + mock_action.return_value = None + del mock_action._is_coroutine + self.vm.add_handler('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', + b"attach_automatically='yes' required='yes'") + mock_action.assert_called_once_with( + self.vm, f'device-assign:testclass', + device=self.vm.devices['testclass']['1234'], options={}) - self.assertIn(dev, self.vm.devices['testclass'].persistent()) + self.assertEqual( + len(list(self.vm.devices['testclass'].get_assigned_devices())), 1) self.app.save.assert_called_once_with() - def test_485_vm_device_attach_options(self): + def test_487_vm_device_attach_options(self): self.vm.add_handler('device-list:testclass', self.device_list_testclass) mock_attach = unittest.mock.Mock() mock_attach.return_value = None @@ -1878,18 +1951,149 @@ def test_485_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', b"_option1='value2'") self.assertIsNone(value) dev = self.vm.devices['testclass']['1234'] - mock_attach.assert_called_once_with(self.vm, 'device-attach:testclass', - device=dev, + mock_attach.assert_called_once_with( + self.vm, 'device-attach:testclass', device=dev, options={'option1': 'value2'}) + self.assertFalse(self.app.save.called) + + def test_488_vm_device_assign_options(self): + self.vm.add_handler('device-list:testclass', + self.device_list_testclass) + mock_attach = unittest.mock.Mock() + mock_attach.return_value = None + del mock_attach._is_coroutine + self.vm.add_handler('device-assign:testclass', mock_attach) + with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, + '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'") + self.assertIsNone(value) + dev = self.vm.devices['testclass']['1234'] + mock_attach.assert_called_once_with( + self.vm, 'device-assign:testclass', device=dev, + options={'option1': 'value2'}) + self.app.save.assert_called_once_with() + + 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'}) + 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('device-unassign: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.Unassign', + b'test-vm1', b'test-vm1+1234') + self.assertIsNone(value) + mock_action.assert_called_once_with( + self.vm, 'device-unassign:testclass', + device=self.vm.devices['testclass']['1234']) + 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'}) + 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('device-unassign: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.Unassign', + b'test-vm1', b'test-vm1+1234') + self.assertIsNone(value) + mock_action.assert_called_once_with( + self.vm, 'device-unassign:testclass', + device=self.vm.devices['testclass']['1234']) + 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'}) + 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('device-unassign:testclass', mock_action) + self.call_mgmt_func( + b'admin.vm.device.testclass.Unassign', + b'test-vm1', b'test-vm1+1234') + mock_action.assert_called_once_with( + self.vm, 'device-unassign:testclass', + device=self.vm.devices['testclass']['1234']) self.app.save.assert_called_once_with() - def test_490_vm_device_detach(self): + 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'}) + 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('device-unassign:testclass', mock_action) + self.call_mgmt_func( + b'admin.vm.device.testclass.Unassign', + b'test-vm1', b'test-vm1+1234') + mock_action.assert_called_once_with( + self.vm, 'device-unassign:testclass', + device=self.vm.devices['testclass']['1234']) + 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_attached_testclass) + self.device_list_single_attached_testclass) + assignment = qubes.device_protocol.DeviceAssignment( + self.vm, '1234', attach_automatically=True, required=False, + 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('device-unassign:testclass', mock_action) + self.call_mgmt_func( + b'admin.vm.device.testclass.Unassign', + b'test-vm1', b'test-vm1+1234') + mock_action.assert_called_once_with( + self.vm, 'device-unassign:testclass', + device=self.vm.devices['testclass']['1234']) + self.app.save.assert_called_once_with() + + def test_495_vm_device_unassign_not_assigned(self): + mock_detach = unittest.mock.Mock() + mock_detach.return_value = None + del mock_detach._is_coroutine + self.vm.add_handler('device-detach:testclass', mock_detach) + with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, + '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') + self.assertFalse(mock_detach.called) + self.assertFalse(self.app.save.called) + + def test_496_vm_device_detach(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) mock_detach = unittest.mock.Mock() mock_detach.return_value = None del mock_detach._is_coroutine @@ -1901,16 +2105,16 @@ def test_490_vm_device_detach(self): self.assertIsNone(value) mock_detach.assert_called_once_with(self.vm, 'device-detach:testclass', device=self.vm.devices['testclass']['1234']) - self.app.save.assert_called_once_with() + self.assertFalse(self.app.save.called) - def test_491_vm_device_detach_not_attached(self): + def test_497_vm_device_detach_not_attached(self): mock_detach = unittest.mock.Mock() mock_detach.return_value = None del mock_detach._is_coroutine self.vm.add_handler('device-detach:testclass', mock_detach) with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): - with self.assertRaises(qubes.devices.DeviceNotAttached): + with self.assertRaises(qubes.devices.DeviceNotAssigned): self.call_mgmt_func(b'admin.vm.device.testclass.Detach', b'test-vm1', b'test-vm1+1234') self.assertFalse(mock_detach.called) @@ -1943,10 +2147,10 @@ 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.devices.DeviceAssignment( - self.vm, '1234', persistent=True) + assignment = qubes.device_protocol.DeviceAssignment( + self.vm, '1234', attach_automatically=True, required=True) self.loop.run_until_complete( - self.vm2.devices['testclass'].attach(assignment)) + self.vm2.devices['testclass'].assign(assignment)) mock_remove.side_effect = self.dummy_coro with self.assertRaises(qubes.exc.QubesVMInUseError): @@ -2075,7 +2279,7 @@ def test_520_vm_volume_clone(self): def test_521_vm_volume_clone_invalid_volume(self): self.setup_for_clone() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.CloneFrom', b'test-vm1', b'private123', b'') self.assertNotIn('init_volume().import_volume', @@ -2087,7 +2291,7 @@ def test_522_vm_volume_clone_invalid_volume2(self): token = self.call_mgmt_func(b'admin.vm.volume.CloneFrom', b'test-vm1', b'private', b'') - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.CloneTo', b'test-vm1', b'private123', token.encode()) self.assertNotIn('init_volume().import_volume', @@ -2105,7 +2309,7 @@ def get_volume(vid): else: return unittest.mock.DEFAULT self.pool.get_volume.side_effect = get_volume - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.CloneTo', b'test-vm1', b'private', token.encode()) self.assertNotIn('init_volume().import_volume', @@ -2115,7 +2319,7 @@ def get_volume(vid): def test_524_vm_volume_clone_invlid_token(self): self.setup_for_clone() - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'admin.vm.volume.CloneTo', b'test-vm1', b'private', b'no-such-token') self.assertNotIn('init_volume().import_volume', @@ -2631,88 +2835,114 @@ 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_persistent_true(self): - self.vm.add_handler('device-list:testclass', - self.device_list_testclass) - self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) + 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'}) + 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.persistent', + b'admin.vm.device.testclass.Set.required', b'test-vm1', b'test-vm1+1234', b'True') + self.assertIsNone(value) - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertIn(dev, self.vm.devices['testclass'].persistent()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + required = self.vm.devices['testclass'].get_assigned_devices( + required_only=True) + self.assertIn(dev, required) + self.assertEventFired( + self.emitter, + 'admin-permission:admin.vm.device.testclass.Set.required') + mock_action.assert_called_once_with( + self.vm, f'device-assignment-changed:testclass', + device=self.vm.devices['testclass']['1234']) self.app.save.assert_called_once_with() - def test_651_vm_device_set_persistent_false_unchanged(self): - self.vm.add_handler('device-list:testclass', - self.device_list_testclass) - self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) + 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'}) + 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.persistent', + b'admin.vm.device.testclass.Set.required', b'test-vm1', b'test-vm1+1234', b'False') + self.assertIsNone(value) - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertNotIn(dev, self.vm.devices['testclass'].persistent()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + required = self.vm.devices['testclass'].get_assigned_devices( + required_only=True) + self.assertNotIn(dev, required) + self.assertEventFired( + self.emitter, + 'admin-permission:admin.vm.device.testclass.Set.required') + mock_action.assert_called_once_with( + self.vm, f'device-assignment-changed:testclass', + device=self.vm.devices['testclass']['1234']) self.app.save.assert_called_once_with() - def test_652_vm_device_set_persistent_false(self): - self.vm.add_handler('device-list:testclass', - self.device_list_testclass) - assignment = qubes.devices.DeviceAssignment(self.vm, '1234', {}, - True) + 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'}) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertIn(dev, self.vm.devices['testclass'].persistent()) + 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.persistent', - b'test-vm1', b'test-vm1+1234', b'False') + b'admin.vm.device.testclass.Set.required', + b'test-vm1', b'test-vm1+1234', b'True') self.assertIsNone(value) - self.assertNotIn(dev, self.vm.devices['testclass'].persistent()) - self.assertIn(dev, self.vm.devices['testclass'].attached()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + required = self.vm.devices['testclass'].get_assigned_devices( + required_only=True) + self.assertIn(dev, required) self.app.save.assert_called_once_with() - def test_653_vm_device_set_persistent_true_unchanged(self): - self.vm.add_handler('device-list:testclass', - self.device_list_testclass) - assignment = qubes.devices.DeviceAssignment(self.vm, '1234', {}, - True) + 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'}) self.loop.run_until_complete( - self.vm.devices['testclass'].attach(assignment)) - self.vm.add_handler('device-list-attached:testclass', - self.device_list_attached_testclass) + 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.persistent', - b'test-vm1', b'test-vm1+1234', b'True') + b'admin.vm.device.testclass.Set.required', + b'test-vm1', b'test-vm1+1234', b'False') self.assertIsNone(value) - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertIn(dev, self.vm.devices['testclass'].persistent()) - self.assertIn(dev, self.vm.devices['testclass'].attached()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + required = self.vm.devices['testclass'].get_assigned_devices( + required_only=True) + self.assertNotIn(dev, required) self.app.save.assert_called_once_with() - def test_654_vm_device_set_persistent_not_attached(self): + def test_654_vm_device_set_persistent_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.api.PermissionDenied): + with self.assertRaises(qubes.exc.QubesValueError): self.call_mgmt_func( - b'admin.vm.device.testclass.Set.persistent', + b'admin.vm.device.testclass.Set.required', b'test-vm1', b'test-vm1+1234', b'True') - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertNotIn(dev, self.vm.devices['testclass'].persistent()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + 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): @@ -2720,12 +2950,12 @@ def test_655_vm_device_set_persistent_invalid_value(self): self.device_list_testclass) with unittest.mock.patch.object(qubes.vm.qubesvm.QubesVM, 'is_halted', lambda _: False): - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func( - b'admin.vm.device.testclass.Set.persistent', + b'admin.vm.device.testclass.Set.required', b'test-vm1', b'test-vm1+1234', b'maybe') - dev = qubes.devices.DeviceInfo(self.vm, '1234') - self.assertNotIn(dev, self.vm.devices['testclass'].persistent()) + dev = qubes.device_protocol.DeviceInfo(self.vm, '1234') + self.assertNotIn(dev, self.vm.devices['testclass'].get_assigned_devices()) self.assertFalse(self.app.save.called) def test_660_pool_set_revisions_to_keep(self): @@ -2739,7 +2969,7 @@ def test_660_pool_set_revisions_to_keep(self): def test_661_pool_set_revisions_to_keep_negative(self): self.app.pools['test-pool'] = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.pool.Set.revisions_to_keep', b'dom0', b'test-pool', b'-2') self.assertEqual(self.app.pools['test-pool'].mock_calls, []) @@ -2747,7 +2977,7 @@ def test_661_pool_set_revisions_to_keep_negative(self): def test_662_pool_set_revisions_to_keep_not_a_number(self): self.app.pools['test-pool'] = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.pool.Set.revisions_to_keep', b'dom0', b'test-pool', b'abc') self.assertEqual(self.app.pools['test-pool'].mock_calls, []) @@ -2764,7 +2994,7 @@ def test_663_pool_set_ephemeral(self): def test_664_pool_set_ephemeral_not_a_boolean(self): self.app.pools['test-pool'] = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.pool.Set.ephemeral_volatile', b'dom0', b'test-pool', b'abc') self.assertEqual(self.app.pools['test-pool'].mock_calls, []) @@ -2793,7 +3023,7 @@ def test_671_vm_volume_set_revisions_to_keep_negative(self): } self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Set.revisions_to_keep', b'test-vm1', b'private', b'-2') @@ -2804,7 +3034,7 @@ def test_672_vm_volume_set_revisions_to_keep_not_a_number(self): } self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Set.revisions_to_keep', b'test-vm1', b'private', b'abc') @@ -2831,7 +3061,7 @@ def test_681_vm_volume_set_rw_invalid(self): } self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Set.rw', b'test-vm1', b'private', b'abc') self.assertFalse(self.app.save.called) @@ -2859,7 +3089,7 @@ def test_686_vm_volume_set_ephemeral_invalid(self): } self.vm.volumes.configure_mock(**volumes_conf) self.vm.storage = unittest.mock.Mock() - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(b'admin.vm.volume.Set.ephemeral', b'test-vm1', b'volatile', b'abc') self.assertFalse(self.app.save.called) @@ -3014,14 +3244,14 @@ def test_990_vm_unexpected_payload(self): for method in methods_with_no_payload: # should reject payload regardless of having argument or not with self.subTest(method.decode('ascii')): - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(method, b'test-vm1', b'', b'unexpected-payload') self.assertFalse(vm_mock.called) self.assertFalse(self.app.save.called) with self.subTest(method.decode('ascii') + '+arg'): - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(method, b'test-vm1', b'some-arg', b'unexpected-payload') self.assertFalse(vm_mock.called) @@ -3052,11 +3282,11 @@ def test_991_vm_unexpected_argument(self): vm_mock.qid = self.vm.qid vm_mock.__lt__ = (lambda x, y: x.qid < y.qid) self.app.domains._dict[self.vm.qid] = vm_mock - exceptions = (qubes.api.PermissionDenied, qubes.api.ProtocolError) + exceptions = (qubes.exc.PermissionDenied, qubes.exc.ProtocolError) for method in methods_with_no_argument: # should reject argument regardless of having payload or not with self.subTest(method.decode('ascii')): - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(method, b'test-vm1', b'some-arg', b'') self.assertFalse(vm_mock.called) @@ -3099,14 +3329,14 @@ def test_992_dom0_unexpected_payload(self): for method in methods_with_no_payload: # should reject payload regardless of having argument or not with self.subTest(method.decode('ascii')): - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(method, b'dom0', b'', b'unexpected-payload') self.assertFalse(vm_mock.called) self.assertFalse(self.app.save.called) with self.subTest(method.decode('ascii') + '+arg'): - with self.assertRaises(qubes.api.ProtocolError): + with self.assertRaises(qubes.exc.ProtocolError): self.call_mgmt_func(method, b'dom0', b'some-arg', b'unexpected-payload') self.assertFalse(vm_mock.called) @@ -3129,11 +3359,11 @@ def test_993_dom0_unexpected_argument(self): vm_mock.qid = self.vm.qid vm_mock.__lt__ = (lambda x, y: x.qid < y.qid) self.app.domains._dict[self.vm.qid] = vm_mock - exceptions = (qubes.api.PermissionDenied, qubes.api.ProtocolError) + exceptions = (qubes.exc.PermissionDenied, qubes.exc.ProtocolError) for method in methods_with_no_argument: # should reject argument regardless of having payload or not with self.subTest(method.decode('ascii')): - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(method, b'dom0', b'some-arg', b'') self.assertFalse(vm_mock.called) @@ -3185,7 +3415,7 @@ def test_994_dom0_only_calls(self): vm_mock.qid = self.vm.qid vm_mock.__lt__ = (lambda x, y: x.qid < y.qid) self.app.domains._dict[self.vm.qid] = vm_mock - exceptions = (qubes.api.PermissionDenied, qubes.api.ProtocolError) + exceptions = (qubes.exc.PermissionDenied, qubes.exc.ProtocolError) for method in methods_for_dom0_only: # should reject call regardless of having payload or not with self.subTest(method.decode('ascii')): @@ -3272,7 +3502,7 @@ def test_995_vm_only_calls(self): vm_mock.qid = self.vm.qid vm_mock.__lt__ = (lambda x, y: x.qid < y.qid) self.app.domains._dict[self.vm.qid] = vm_mock - exceptions = (qubes.api.PermissionDenied, qubes.api.ProtocolError) + exceptions = (qubes.exc.PermissionDenied, qubes.exc.ProtocolError) for method in methods_for_vm_only: # should reject payload regardless of having argument or not # should reject call regardless of having payload or not diff --git a/qubes/tests/api_misc.py b/qubes/tests/api_misc.py index 7c8776dae..3345cea8c 100644 --- a/qubes/tests/api_misc.py +++ b/qubes/tests/api_misc.py @@ -20,6 +20,8 @@ import asyncio from datetime import datetime from unittest import mock + +import qubes.exc import qubes.tests import qubes.api.misc @@ -98,7 +100,7 @@ def test_002_features_request_invalid1(self): '/features-request/feature1': b'test spaces', } self.configure_qdb(qdb_entries) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'qubes.FeaturesRequest') self.assertEqual(self.app.mock_calls, []) self.assertEqual(self.src.mock_calls, [ @@ -180,7 +182,7 @@ def test_015_notify_tools_invalid_value_qrexec(self): '/qubes-tools/default-user': b'user', } self.configure_qdb(qdb_entries) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'qubes.NotifyTools') self.assertEqual(self.app.mock_calls, []) self.assertEqual(self.src.mock_calls, [ @@ -196,7 +198,7 @@ def test_016_notify_tools_invalid_value_gui(self): '/qubes-tools/default-user': b'user', } self.configure_qdb(qdb_entries) - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'qubes.NotifyTools') self.assertEqual(self.app.mock_calls, []) self.assertEqual(self.src.mock_calls, [ @@ -242,7 +244,7 @@ def test_021_notify_updates_standalone2(self): def test_022_notify_updates_invalid(self): del self.src.template - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'') self.assertEqual(self.src.mock_calls, []) self.assertEqual(self.tpl.mock_calls, []) @@ -250,7 +252,7 @@ def test_022_notify_updates_invalid(self): def test_023_notify_updates_invalid2(self): del self.src.template - with self.assertRaises(qubes.api.PermissionDenied): + with self.assertRaises(qubes.exc.PermissionDenied): self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'no updates') self.assertEqual(self.src.mock_calls, []) self.assertEqual(self.tpl.mock_calls, []) diff --git a/qubes/tests/devices.py b/qubes/tests/devices.py index 50bb6f131..22db41c35 100644 --- a/qubes/tests/devices.py +++ b/qubes/tests/devices.py @@ -21,10 +21,13 @@ # import qubes.devices +from qubes.device_protocol import (Device, DeviceInfo, DeviceAssignment, + DeviceInterface, UnknownDevice) import qubes.tests -class TestDevice(qubes.devices.DeviceInfo): + +class TestDevice(DeviceInfo): # pylint: disable=too-few-public-methods pass @@ -45,7 +48,7 @@ def __init__(self, app, name, *args, **kwargs): super(TestVM, self).__init__(*args, **kwargs) self.app = app self.name = name - self.device = TestDevice(self, 'testdev', 'Description') + self.device = TestDevice(self, 'testdev', 'testclass') self.events_enabled = True self.devices = { 'testclass': qubes.devices.DeviceCollection(self, 'testclass') @@ -58,9 +61,9 @@ def __str__(self): return self.name @qubes.events.handler('device-list-attached:testclass') - def dev_testclass_list_attached(self, event, persistent = False): + def dev_testclass_list_attached(self, event, persistent=False): for vm in self.app.domains: - if vm.device.frontend_domain == self: + if vm.device.data.get('test_frontend_domain', None) == self: yield (vm.device, {}) @qubes.events.handler('device-list:testclass') @@ -73,6 +76,10 @@ def is_halted(self): def is_running(self): return self.running + class log: + @staticmethod + def exception(message): + pass class TC_00_DeviceCollection(qubes.tests.QubesTestCase): @@ -83,121 +90,242 @@ def setUp(self): self.app.domains['vm'] = self.emitter self.device = self.emitter.device self.collection = self.emitter.devices['testclass'] - self.assignment = qubes.devices.DeviceAssignment( + self.assignment = DeviceAssignment( backend_domain=self.device.backend_domain, ident=self.device.ident, - persistent=True + attach_automatically=True, + required=True, ) + def attach(self): + self.emitter.running = True + # device-attach event not implemented, so manipulate object manually + self.device.data['test_frontend_domain'] = self.emitter + + def detach(self): + # device-detach event not implemented, so manipulate object manually + del self.device.data['test_frontend_domain'] + def test_000_init(self): self.assertFalse(self.collection._set) def test_001_attach(self): + self.emitter.running = True self.loop.run_until_complete(self.collection.attach(self.assignment)) self.assertEventFired(self.emitter, 'device-pre-attach:testclass') self.assertEventFired(self.emitter, 'device-attach:testclass') self.assertEventNotFired(self.emitter, 'device-pre-detach:testclass') self.assertEventNotFired(self.emitter, 'device-detach:testclass') - def test_002_detach(self): - self.loop.run_until_complete(self.collection.attach(self.assignment)) + def test_002_attach_to_halted(self): + with self.assertRaises(qubes.exc.QubesVMNotRunningError): + self.loop.run_until_complete( + self.collection.attach(self.assignment)) + + def test_003_detach(self): + self.attach() self.loop.run_until_complete(self.collection.detach(self.assignment)) - self.assertEventFired(self.emitter, 'device-pre-attach:testclass') - self.assertEventFired(self.emitter, 'device-attach:testclass') 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)) + def test_010_empty_detach(self): + self.emitter.running = True with self.assertRaises(LookupError): self.loop.run_until_complete( self.collection.detach(self.assignment)) - def test_011_double_attach(self): - self.loop.run_until_complete(self.collection.attach(self.assignment)) + def test_011_empty_unassign(self): + for _ in range(2): + with self.assertRaises(LookupError): + self.loop.run_until_complete( + self.collection.unassign(self.assignment)) + self.emitter.running = True + def test_012_double_attach(self): + self.attach() with self.assertRaises(qubes.devices.DeviceAlreadyAttached): self.loop.run_until_complete( self.collection.attach(self.assignment)) - def test_012_double_detach(self): - self.loop.run_until_complete(self.collection.attach(self.assignment)) + def test_013_double_detach(self): + self.attach() self.loop.run_until_complete(self.collection.detach(self.assignment)) + self.detach() - with self.assertRaises(qubes.devices.DeviceNotAttached): + with self.assertRaises(qubes.devices.DeviceNotAssigned): self.loop.run_until_complete( self.collection.detach(self.assignment)) - def test_013_list_attached_persistent(self): - self.assertEqual(set([]), set(self.collection.persistent())) - self.loop.run_until_complete(self.collection.attach(self.assignment)) - self.assertEventFired(self.emitter, 'device-list-attached:testclass') - self.assertEqual({self.device}, set(self.collection.persistent())) + def test_014_double_assign(self): + self.loop.run_until_complete(self.collection.assign(self.assignment)) + + with self.assertRaises(qubes.devices.DeviceAlreadyAssigned): + self.loop.run_until_complete( + self.collection.assign(self.assignment)) + + def test_015_double_unassign(self): + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.loop.run_until_complete(self.collection.unassign(self.assignment)) + + with self.assertRaises(qubes.devices.DeviceNotAssigned): + self.loop.run_until_complete( + self.collection.unassign(self.assignment)) + + 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}, + set(self.collection.get_assigned_devices())) self.assertEqual(set([]), - set(self.collection.attached())) + set(self.collection.get_attached_devices())) + self.assertEqual({self.device}, + set(self.collection.get_dedicated_devices())) - def test_014_list_attached_non_persistent(self): - self.assignment.persistent = False - self.emitter.running = True - self.loop.run_until_complete(self.collection.attach(self.assignment)) - # device-attach event not implemented, so manipulate object manually - self.device.frontend_domain = self.emitter + def test_017_list_attached(self): + self.assignment.required = False + self.attach() self.assertEqual({self.device}, - set(self.collection.attached())) + set(self.collection.get_attached_devices())) self.assertEqual(set([]), - set(self.collection.persistent())) + set(self.collection.get_assigned_devices())) self.assertEqual({self.device}, - set(self.collection.attached())) + set(self.collection.get_dedicated_devices())) self.assertEventFired(self.emitter, 'device-list-attached:testclass') - def test_015_list_available(self): + def test_018_list_available(self): self.assertEqual({self.device}, set(self.collection)) self.assertEventFired(self.emitter, 'device-list:testclass') - def test_020_update_persistent_to_false(self): - self.emitter.running = True - self.assertEqual(set([]), set(self.collection.persistent())) - self.loop.run_until_complete(self.collection.attach(self.assignment)) - # device-attach event not implemented, so manipulate object manually - self.device.frontend_domain = self.emitter - self.assertEqual({self.device}, set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - self.assertEqual({self.device}, set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - self.collection.update_persistent(self.device, False) - self.assertEqual(set(), set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - - def test_021_update_persistent_to_true(self): - self.assignment.persistent = False - self.emitter.running = True - self.assertEqual(set([]), set(self.collection.persistent())) - self.loop.run_until_complete(self.collection.attach(self.assignment)) - # device-attach event not implemented, so manipulate object manually - self.device.frontend_domain = self.emitter - self.assertEqual(set(), set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - self.assertEqual(set(), set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - self.collection.update_persistent(self.device, True) - self.assertEqual({self.device}, set(self.collection.persistent())) - self.assertEqual({self.device}, set(self.collection.attached())) - - def test_022_update_persistent_reject_not_running(self): - self.assertEqual(set([]), set(self.collection.persistent())) - self.loop.run_until_complete(self.collection.attach(self.assignment)) - self.assertEqual({self.device}, set(self.collection.persistent())) - self.assertEqual(set(), set(self.collection.attached())) + def test_020_update_required_to_false(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}, + set(self.collection.get_assigned_devices(required_only=True))) + self.assertEqual( + {self.device}, set(self.collection.get_assigned_devices())) + self.loop.run_until_complete( + self.collection.update_required(self.device, False)) + self.assertEqual( + {self.device}, set(self.collection.get_assigned_devices())) + self.assertEqual( + {self.device}, set(self.collection.get_attached_devices())) + + def test_021_update_required_to_true(self): + self.assignment.required = False + self.attach() + self.assertEqual(set(), set(self.collection.get_assigned_devices())) + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.assertEqual( + set(), + 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.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())) + + def test_022_update_required_reject_not_running(self): + 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.collection.update_persistent(self.device, False) + self.loop.run_until_complete( + self.collection.update_required(self.device, False)) - def test_023_update_persistent_reject_not_attached(self): - self.assertEqual(set(), set(self.collection.persistent())) - self.assertEqual(set(), set(self.collection.attached())) + 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.collection.update_persistent(self.device, True) + self.loop.run_until_complete( + self.collection.update_required(self.device, True)) with self.assertRaises(qubes.exc.QubesValueError): - self.collection.update_persistent(self.device, False) + self.loop.run_until_complete( + self.collection.update_required(self.device, False)) + + def test_030_assign(self): + self.emitter.running = True + self.assignment.required = False + 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.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_032_assign_required(self): + self.emitter.running = True + 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_033_assign_required_to_halted(self): + 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_034_unassign_from_halted(self): + self.assignment.required = False + 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_035_unassign(self): + self.emitter.running = True + self.assignment.required = False + 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_040_detach_required(self): + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.attach() + with self.assertRaises(qubes.exc.QubesVMNotHaltedError): + self.loop.run_until_complete( + self.collection.detach(self.assignment)) + + 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)) + + def test_042_unassign_required(self): + self.emitter.running = True + 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_043_detach_assigned(self): + self.assignment.required = False + self.loop.run_until_complete(self.collection.assign(self.assignment)) + self.attach() + self.loop.run_until_complete(self.collection.detach(self.assignment)) + self.assertEventFired(self.emitter, 'device-assign:testclass') + self.assertEventFired(self.emitter, 'device-pre-detach:testclass') + self.assertEventFired(self.emitter, 'device-detach:testclass') class TC_01_DeviceManager(qubes.tests.QubesTestCase): @@ -212,11 +340,311 @@ def test_000_init(self): def test_001_missing(self): device = TestDevice(self.emitter.app.domains['vm'], 'testdev') - assignment = qubes.devices.DeviceAssignment( + assignment = DeviceAssignment( backend_domain=device.backend_domain, ident=device.ident, - persistent=True) + attach_automatically=True, required=True) self.loop.run_until_complete( - self.manager['testclass'].attach(assignment)) - self.assertEventFired(self.emitter, 'device-attach:testclass') + self.manager['testclass'].assign(assignment)) + self.assertEqual( + len(list(self.manager['testclass'].get_assigned_devices())), 1) + +class TC_02_DeviceInfo(qubes.tests.QubesTestCase): + def setUp(self): + super().setUp() + self.app = TestApp() + self.vm = TestVM(self.app, 'vm') + + def test_010_serialize(self): + device = DeviceInfo( + backend_domain=self.vm, + ident="1-1.1.1", + devclass="bus", + vendor="ITL", + product="Qubes", + manufacturer="", + name="Some untrusted garbage", + serial=None, + interfaces=[DeviceInterface(" ******"), + DeviceInterface("u03**01")], + additional_info="", + date="06.12.23", + ) + actual = device.serialize() + expected = ( + b"manufacturer='unknown' self_identity='0000:0000::?******' " + b"serial='unknown' ident='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'") + expected = set(expected.replace(b"Some untrusted garbage", + b"Some_untrusted_garbage").split(b" ")) + actual = set(actual.replace(b"Some untrusted garbage", + b"Some_untrusted_garbage").split(b" ")) + self.assertEqual(actual, expected) + + def test_011_serialize_with_parent(self): + device = DeviceInfo( + backend_domain=self.vm, + ident="1-1.1.1", + devclass="bus", + vendor="ITL", + product="Qubes", + manufacturer="", + name="Some untrusted garbage", + serial=None, + interfaces=[DeviceInterface(" ******"), + DeviceInterface("u03**01")], + additional_info="", + date="06.12.23", + parent=Device(self.vm, '1-1.1', 'pci') + ) + actual = device.serialize() + expected = ( + b"manufacturer='unknown' self_identity='0000:0000::?******' " + b"serial='unknown' ident='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'") + expected = set(expected.replace(b"Some untrusted garbage", + b"Some_untrusted_garbage").split(b" ")) + actual = set(actual.replace(b"Some untrusted garbage", + b"Some_untrusted_garbage").split(b" ")) + self.assertEqual(actual, expected) + + def test_012_invalid_serialize(self): + device = DeviceInfo( + backend_domain=self.vm, + ident="1-1.1.1", + devclass="bus?", + vendor="malicious", + product="suspicious", + manufacturer="", + name="""Some='untrusted' garbage="5%\n" ?""", + ) + with self.assertRaises(qubes.exc.ProtocolError): + _ = device.serialize() + + 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"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", + vendor="ITL", + product="Qubes", + manufacturer="unknown", + name="Some untrusted garbage", + serial=None, + interfaces=[DeviceInterface(" ******"), + DeviceInterface("u03**01")], + additional_info="", + date="06.12.23", + ) + + self.assertEqual(actual.backend_domain, expected.backend_domain) + self.assertEqual(actual.ident, expected.ident) + self.assertEqual(actual.devclass, expected.devclass) + self.assertEqual(actual.vendor, expected.vendor) + self.assertEqual(actual.product, expected.product) + self.assertEqual(actual.manufacturer, expected.manufacturer) + 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.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"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' add'tional='info'") + 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.devclass, 'peripheral') + + def test_030_serialize_and_deserialize(self): + device = DeviceInfo( + backend_domain=self.vm, + ident="1-1.1.1", + devclass="bus?", + vendor="malicious", + product="suspicious", + manufacturer="", + name=r"""Some='untrusted' garbage="5%\n" ?""", + serial=None, + interfaces=[DeviceInterface(" 112233"), + DeviceInterface("&012345")], + **{"additional_info": "and='more'", "date": "06.12.23"} + ) + 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.devclass, device.devclass) + self.assertEqual(deserialized.vendor, device.vendor) + self.assertEqual(deserialized.product, device.product) + self.assertEqual(deserialized.manufacturer, device.manufacturer) + self.assertEqual(deserialized.name, device.name) + self.assertEqual(deserialized.serial, device.serial) + self.assertEqual(deserialized.data, device.data) + self.assertEqual(deserialized.interfaces[0], device.interfaces[0]) + self.assertEqual(deserialized.interfaces[1], device.interfaces[1]) + + +class TC_03_DeviceAssignment(qubes.tests.QubesTestCase): + def setUp(self): + super().setUp() + self.app = TestApp() + self.vm = TestVM(self.app, 'vm') + + def test_010_serialize(self): + assignment = DeviceAssignment( + backend_domain=self.vm, + ident="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'") + 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, + ) + actual = assignment.serialize() + expected = ( + b"ident='1-1.1.1' devclass='bus' " + b"backend_domain='vm' required='yes' attach_automatically='yes'") + 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", + 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'") + 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", + 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'") + 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", + options={"read'only": 'yes'}, + ) + with self.assertRaises(qubes.exc.ProtocolError): + _ = assignment.serialize() + + 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"_read-only='yes'") + expected_device = Device(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", + frontend_domain=self.vm, + attach_automatically=True, + required=False, + options={'read-only': 'yes'}, + ) + + self.assertEqual(actual.backend_domain, expected.backend_domain) + self.assertEqual(actual.ident, expected.ident) + self.assertEqual(actual.devclass, expected.devclass) + self.assertEqual(actual.frontend_domain, expected.frontend_domain) + self.assertEqual(actual.attach_automatically, expected.attach_automatically) + self.assertEqual(actual.required, expected.required) + self.assertEqual(actual.options, expected.options) + + 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"_read'only='yes'") + expected_device = Device(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"read-only='yes'") + expected_device = Device(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 = DeviceAssignment( + backend_domain=self.vm, + ident="1-1.1.1", + devclass="bus", + frontend_domain=self.vm, + attach_automatically=True, + required=False, + 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.devclass, expected.devclass) + self.assertEqual(actual.frontend_domain, expected.frontend_domain) + self.assertEqual(actual.attach_automatically, + expected.attach_automatically) + self.assertEqual(actual.required, expected.required) + self.assertEqual(actual.options, expected.options) diff --git a/qubes/tests/devices_block.py b/qubes/tests/devices_block.py index 07daf211c..0d19eebb4 100644 --- a/qubes/tests/devices_block.py +++ b/qubes/tests/devices_block.py @@ -17,13 +17,15 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . - +import asyncio from unittest import mock import jinja2 import qubes.tests import qubes.ext.block +from qubes.device_protocol import DeviceInterface, Device, DeviceInfo, \ + DeviceAssignment modules_disk = ''' @@ -102,6 +104,13 @@ def list(self, prefix): class TestApp(object): + class Domains(dict): + def __init__(self): + super().__init__() + + def __iter__(self): + return iter(self.values()) + def __init__(self): #: jinja2 environment for libvirt XML templates self.env = jinja2.Environment( @@ -112,11 +121,31 @@ def __init__(self): ]), undefined=jinja2.StrictUndefined, autoescape=True) - self.domains = {} + self.domains = TestApp.Domains() + self.vmm = mock.Mock() + + +class TestDeviceCollection(object): + def __init__(self, backend_vm, devclass): + self._exposed = [] + self._assigned = [] + self.backend_vm = backend_vm + self.devclass = devclass + def get_assigned_devices(self): + return self._assigned -class TestVM(object): - def __init__(self, qdb, domain_xml=None, running=True, name='test-vm'): + def __getitem__(self, ident): + for dev in self._exposed: + if dev.ident == ident: + return dev + + +class TestVM(qubes.tests.TestEmitter): + def __init__( + self, qdb, domain_xml=None, running=True, name='test-vm', + *args, **kwargs): + super(TestVM, self).__init__(*args, **kwargs) self.name = name self.untrusted_qdb = TestQubesDB(qdb) self.libvirt_domain = mock.Mock() @@ -132,11 +161,17 @@ def __init__(self, qdb, domain_xml=None, running=True, name='test-vm'): self.libvirt_domain.configure_mock(**{ 'XMLDesc.return_value': domain_xml }) + self.devices = { + 'testclass': TestDeviceCollection(self, 'testclass') + } def __eq__(self, other): if isinstance(other, TestVM): return self.name == other.name + def __str__(self): + return self.name + class TC_00_Block(qubes.tests.QubesTestCase): @@ -147,25 +182,64 @@ def setUp(self): def test_000_device_get(self): vm = TestVM({ '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', + '/qubes-block-devices/sda/desc': b'Test_ (device)', '/qubes-block-devices/sda/size': b'1024000', '/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') + vm.devices['usb'] = TestDeviceCollection(backend_vm=vm, devclass='usb') + vm.devices['usb']._exposed.append(parent) + vm.is_running = lambda: True + + dom0 = TestVM({}, name='dom0', + domain_xml=domain_xml_template.format("")) + + disk = ''' + + + + + + + + ''' + front = TestVM({}, domain_xml=domain_xml_template.format(disk), name='front-vm') + + vm.app.domains[0] = dom0 + vm.app.domains['test-vm'] = vm + vm.app.domains['front-vm'] = front + front.app.domains = vm.app.domains + dom0.app.domains = vm.app.domains + 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.description, 'Test device') - self.assertEqual(device_info._description, 'Test device') + self.assertEqual(device_info.name, 'device') + self.assertEqual(device_info._name, 'device') + self.assertEqual(device_info.serial, 'Test') + self.assertEqual(device_info._serial, 'Test') self.assertEqual(device_info.size, 1024000) self.assertEqual(device_info.mode, 'w') - self.assertEqual(device_info.frontend_domain, None) + self.assertEqual(device_info.manufacturer, + 'sub-device of test-vm:1-1.1') + 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.attachment, front) + self.assertEqual(device_info.self_identity, + '1-1.1:0000:0000::?******:1.0') + self.assertEqual( + device_info.data.get('test_frontend_domain', None), None) self.assertEqual(device_info.device_node, '/dev/sda') def test_001_device_get_other_node(self): vm = TestVM({ '/qubes-block-devices/mapper_dmroot': b'', - '/qubes-block-devices/mapper_dmroot/desc': b'Test device', + '/qubes-block-devices/mapper_dmroot/desc': b'Test_device', '/qubes-block-devices/mapper_dmroot/size': b'1024000', '/qubes-block-devices/mapper_dmroot/mode': b'w', }) @@ -173,22 +247,26 @@ def test_001_device_get_other_node(self): 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.description, 'Test device') - self.assertEqual(device_info._description, 'Test device') + self.assertEqual(device_info._name, None) + self.assertEqual(device_info.name, 'unknown') + self.assertEqual(device_info.serial, 'Test device') + self.assertEqual(device_info._serial, 'Test device') self.assertEqual(device_info.size, 1024000) self.assertEqual(device_info.mode, 'w') - self.assertEqual(device_info.frontend_domain, None) + self.assertEqual( + device_info.data.get('test_frontend_domain', None), None) self.assertEqual(device_info.device_node, '/dev/mapper/dmroot') def test_002_device_get_invalid_desc(self): vm = TestVM({ '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device<>za\xc4\x87abc', + '/qubes-block-devices/sda/desc': b'Test (device<>za\xc4\x87abc)', '/qubes-block-devices/sda/size': b'1024000', '/qubes-block-devices/sda/mode': b'w', }) device_info = self.ext.device_get(vm, 'sda') - self.assertEqual(device_info.description, 'Test device__za__abc') + self.assertEqual(device_info.serial, 'Test') + self.assertEqual(device_info.name, 'device zaabc') def test_003_device_get_invalid_size(self): vm = TestVM({ @@ -225,11 +303,11 @@ def test_005_device_get_none(self): def test_010_devices_list(self): vm = TestVM({ '/qubes-block-devices/sda': b'', - '/qubes-block-devices/sda/desc': b'Test device', + '/qubes-block-devices/sda/desc': b'Test_device', '/qubes-block-devices/sda/size': b'1024000', '/qubes-block-devices/sda/mode': b'w', '/qubes-block-devices/sdb': b'', - '/qubes-block-devices/sdb/desc': b'Test device2', + '/qubes-block-devices/sdb/desc': b'Test_device (2)', '/qubes-block-devices/sdb/size': b'2048000', '/qubes-block-devices/sdb/mode': b'r', }) @@ -237,12 +315,14 @@ def test_010_devices_list(self): self.assertEqual(len(devices), 2) self.assertEqual(devices[0].backend_domain, vm) self.assertEqual(devices[0].ident, 'sda') - self.assertEqual(devices[0].description, 'Test device') + 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].description, 'Test device2') + self.assertEqual(devices[1].serial, 'Test device') + self.assertEqual(devices[1].name, '2') self.assertEqual(devices[1].size, 2048000) self.assertEqual(devices[1].mode, 'r') @@ -310,6 +390,7 @@ def test_031_list_attached(self): options = devices[0][1] self.assertEqual(dev.backend_domain, vm.app.domains['sys-usb']) self.assertEqual(dev.ident, 'sda') + self.assertEqual(dev.attachment, None) self.assertEqual(options['frontend-dev'], 'xvdi') self.assertEqual(options['read-only'], 'yes') @@ -569,17 +650,241 @@ def test_051_detach_not_attached(self): '/qubes-block-devices/sda/size': b'1024000', '/qubes-block-devices/sda/mode': b'r', }) - device_xml = ( - '\n' - ' \n' - ' \n' - ' \n' - ' \n\n' - ' \n' - '') 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) 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') + + 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(""), + 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 + front.app = back_vm.app + dom0.app = back_vm.app + + back_vm.app.vmm.configure_mock(**{'offline_mode': False}) + fire_event_async = mock.Mock() + front.fire_event_async = fire_event_async + + back_vm.devices['block'] = TestDeviceCollection( + backend_vm=back_vm, devclass='block') + front.devices['block'] = TestDeviceCollection( + backend_vm=front, devclass='block') + 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): + # 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') + + self.ext.devices_cache = {'sys-usb': {'sda': None}} + + # then attached + disk = ''' + + + + + + + + ''' + front = TestVM({}, domain_xml=domain_xml_template.format(disk), + 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 + front.app = back_vm.app + dom0.app = back_vm.app + + back_vm.app.vmm.configure_mock(**{'offline_mode': False}) + fire_event_async = mock.Mock() + front.fire_event_async = fire_event_async + + back_vm.devices['block'] = TestDeviceCollection( + backend_vm=back_vm, devclass='block') + front.devices['block'] = TestDeviceCollection( + backend_vm=front, devclass='block') + dom0.devices['block'] = TestDeviceCollection( + backend_vm=dom0, devclass='block') + + 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={}) + + def test_063_on_qdb_change_changed(self): + # 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') + + front = TestVM({}, name='front-vm') + dom0 = TestVM({}, name='dom0', + domain_xml=domain_xml_template.format("")) + + self.ext.devices_cache = {'sys-usb': {'sda': front}} + + disk = ''' + + + + + + + + ''' + front_2 = TestVM({}, domain_xml=domain_xml_template.format(disk), + name='front-2') + + back_vm.app.vmm.configure_mock(**{'offline_mode': False}) + front.libvirt_domain.configure_mock(**{ + 'XMLDesc.return_value': domain_xml_template.format("") + }) + + back_vm.app.domains['sys-usb'] = back_vm + back_vm.app.domains['front-vm'] = front + back_vm.app.domains['front-2'] = front_2 + back_vm.app.domains[0] = dom0 + + front.app = back_vm.app + front_2.app = back_vm.app + dom0.app = back_vm.app + + fire_event_async = mock.Mock() + front.fire_event_async = fire_event_async + fire_event_async_2 = mock.Mock() + front_2.fire_event_async = fire_event_async_2 + + back_vm.devices['block'] = TestDeviceCollection( + backend_vm=back_vm, devclass='block') + front.devices['block'] = TestDeviceCollection( + backend_vm=front, devclass='block') + dom0.devices['block'] = TestDeviceCollection( + backend_vm=dom0, devclass='block') + front_2.devices['block'] = TestDeviceCollection( + backend_vm=front_2, devclass='block') + + 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_2}}) + fire_event_async.assert_called_with( + 'device-detach:block', device=exp_dev) + fire_event_async_2.assert_called_once_with( + 'device-attach:block', device=exp_dev, options={}) + + def test_064_on_qdb_change_removed_attached(self): + # 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("")) + dom0 = TestVM({}, name='dom0', + domain_xml=domain_xml_template.format("")) + exp_dev = Device(back_vm, 'sda', 'block') + + disk = ''' + + + + + + + + ''' + front = TestVM({}, domain_xml=domain_xml_template.format(disk), + name='front') + self.ext.devices_cache = {'sys-usb': {'sda': front}} + + back_vm.app.vmm.configure_mock(**{'offline_mode': False}) + front.libvirt_domain.configure_mock(**{ + 'XMLDesc.return_value': 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 + + front.app = back_vm.app + dom0.app = back_vm.app + + fire_event_async = mock.Mock() + front.fire_event_async = fire_event_async + + back_vm.devices['block'] = TestDeviceCollection( + backend_vm=back_vm, devclass='block') + front.devices['block'] = TestDeviceCollection( + backend_vm=front, devclass='block') + dom0.devices['block'] = TestDeviceCollection( + backend_vm=dom0, devclass='block') + + back_vm.untrusted_qdb = TestQubesDB({}) + with mock.patch('asyncio.ensure_future'): + 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) + self.assertEqual( + back_vm.fired_events[ + ('device-removed:block', frozenset({('device', exp_dev)}))], + 1) diff --git a/qubes/tests/devices_pci.py b/qubes/tests/devices_pci.py index b653337e2..01011cadd 100644 --- a/qubes/tests/devices_pci.py +++ b/qubes/tests/devices_pci.py @@ -17,13 +17,11 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . - from unittest import mock -import jinja2 - import qubes.tests import qubes.ext.pci +from qubes.device_protocol import DeviceInterface class TestVM(object): @@ -38,38 +36,10 @@ def __eq__(self, other): if isinstance(other, TestVM): return self.name == other.name -class TC_00_Block(qubes.tests.QubesTestCase): - def setUp(self): - super().setUp() - self.ext = qubes.ext.pci.PCIDeviceExtension() - def test_000_unsupported_device(self): - vm = TestVM() - vm.app.configure_mock(**{ - 'vmm.libvirt_conn.listAllDevices.return_value': - [mock.Mock(**{"XMLDesc.return_value": """ - pci_0000_00_14_0 - /sys/devices/pci0000:00/0000:00:14.0 - computer - - pciback - - - 0x0c0330 - 0 - 0 - 20 - 0 - 9 Series Chipset Family USB xHCI Controller - Intel Corporation - - -""", - "listCaps.return_value": ["pci"] - }), - mock.Mock(**{"XMLDesc.return_value": """ - pci_1000_00_14_0 - /sys/devices/pci1000:00/1000:00:14.0 +PCI_XML = """ + pci_{}_00_14_0 + /sys/devices/pci{}:00/{}:00:14.0 computer pciback @@ -84,11 +54,103 @@ def test_000_unsupported_device(self): Intel Corporation -""", - "listCaps.return_value": ["pci"] +""" + + +def mock_file_open(filename: str, *_args, **_kwargs): + if filename == "/usr/share/hwdata/pci.ids": + # short version of pci.ids + content = """ +# +# List of PCI ID's +# +# (...) +# +0001 SafeNet (wrong ID) +0010 Allied Telesis, Inc (Wrong ID) +# This is a relabelled RTL-8139 + 8139 AT-2500TX V3 Ethernet +# 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 + +C 00 Unclassified device +\t00 Non-VGA unclassified device +C 01 Mass storage controller +\t01 IDE interface +\t\t00 ISA Compatibility mode-only controller +C 0c Serial bus controller +\t00 FireWire (IEEE 1394) +\t\t00 Generic +\t\t10 OHCI +\t01 ACCESS Bus +\t02 SSA +\t03 USB controller +\t\t00 UHCI +\t\t10 OHCI +\t\t20 EHCI +\t\t30 XHCI +\t\t40 USB4 Host Interface +\t\t80 Unspecified +\t\tfe USB Device +\t04 Fibre Channel +\t05 SMBus +\t06 InfiniBand +\t07 IPMI Interface +\t\t00 SMIC +\t\t01 KCS +\t\t02 BT (Block Transfer) +\t08 SERCOS interface +\t09 CANBUS +\t80 Serial bus controller +""" + elif filename.startswith("/sys/devices/pci"): + content = "0x0c0330" + else: + raise OSError() + + file_object = mock.mock_open(read_data=content).return_value + file_object.__iter__.return_value = content + return file_object + + +class TC_00_Block(qubes.tests.QubesTestCase): + def setUp(self): + super().setUp() + self.ext = qubes.ext.pci.PCIDeviceExtension() + + @mock.patch('builtins.open', new=mock_file_open) + def test_000_unsupported_device(self): + vm = TestVM() + vm.app.configure_mock(**{ + 'vmm.libvirt_conn.nodeDeviceLookupByName.return_value': + mock.Mock(**{"XMLDesc.return_value": + PCI_XML.format(*["0000"] * 3) }), - ] + 'vmm.libvirt_conn.listAllDevices.return_value': + [mock.Mock(**{"XMLDesc.return_value": + PCI_XML.format(*["0000"] * 3), + "listCaps.return_value": ["pci"] + }), + mock.Mock(**{"XMLDesc.return_value": + PCI_XML.format(*["1000"] * 3), + "listCaps.return_value": ["pci"] + }), + ] }) 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].vendor, "Intel Corporation") + self.assertEqual(devices[0].product, + "9 Series Chipset Family USB xHCI Controller") + self.assertEqual(devices[0].interfaces, [DeviceInterface("p0c0330")]) + self.assertEqual(devices[0].parent_device, None) + self.assertEqual(devices[0].libvirt_name, "pci_0000_00_14_0") + 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") diff --git a/qubes/tests/integ/backup.py b/qubes/tests/integ/backup.py index 42b40f02c..d62c6d3f7 100644 --- a/qubes/tests/integ/backup.py +++ b/qubes/tests/integ/backup.py @@ -303,7 +303,7 @@ 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].assignments(): + for dev_ass in vm.devices[dev_class].get_assigned_devices(): vm_info['devices'][dev_class][str(dev_ass.device)] = \ dev_ass.options vms_info[vm.name] = vm_info @@ -338,7 +338,7 @@ def assertCorrectlyRestored(self, vms_info, orig_hashes): for dev in vm_info['devices'][dev_class]: found = False for restored_dev_ass in restored_vm.devices[ - dev_class].assignments(): + dev_class].get_assigned_devices(): if str(restored_dev_ass.device) == dev: found = True self.assertEqual(vm_info['devices'][dev_class][dev], diff --git a/qubes/tests/integ/devices_block.py b/qubes/tests/integ/devices_block.py index 3897881a2..74e60b4ae 100644 --- a/qubes/tests/integ/devices_block.py +++ b/qubes/tests/integ/devices_block.py @@ -87,7 +87,7 @@ def test_000_list_loop(self): dev_list = list(self.vm.devices['block']) found = False for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.assertTrue(dev.ident.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) @@ -113,7 +113,7 @@ def test_001_list_loop_mounted(self): dev_list = list(self.vm.devices['block']) for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.fail( 'Device {} ({}) should not be listed because is mounted' .format(dev, self.img_path)) @@ -132,11 +132,11 @@ def test_010_list_dm(self): found = False for dev in dev_list: if dev.ident.startswith('loop'): - self.assertNotEquals(dev.description, self.img_path, + self.assertNotEquals(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) ) - elif dev.description == 'test-dm': + elif dev.serial == 'test-dm': self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) found = True @@ -163,12 +163,12 @@ def test_011_list_dm_mounted(self): dev_list = list(self.vm.devices['block']) for dev in dev_list: if dev.ident.startswith('loop'): - self.assertNotEquals(dev.description, self.img_path, + self.assertNotEquals(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) ) else: - self.assertNotEquals(dev.description, 'test-dm', + self.assertNotEquals(dev.serial, 'test-dm', "Device {} ({}) should not be listed as it is " "mounted".format(dev, 'test-dm') ) @@ -188,11 +188,11 @@ def test_012_list_dm_delayed(self): found = False for dev in dev_list: if dev.ident.startswith('loop'): - self.assertNotEquals(dev.description, self.img_path, + self.assertNotEquals(dev.serial, self.img_path, "Device {} ({}) should not be listed as it is used in " "device-mapper".format(dev, self.img_path) ) - elif dev.description == 'test-dm': + elif dev.serial == 'test-dm': self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) found = True @@ -218,7 +218,7 @@ def test_013_list_dm_removed(self): dev_list = list(self.vm.devices['block']) found = False for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.assertTrue(dev.ident.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) @@ -242,7 +242,7 @@ def test_020_list_loop_partition(self): dev_list = list(self.vm.devices['block']) found = False for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.assertTrue(dev.ident.startswith('loop')) self.assertEqual(dev.mode, 'w') self.assertEqual(dev.size, 1024 * 1024 * 128) @@ -271,7 +271,7 @@ def test_021_list_loop_partition_mounted(self): dev_list = list(self.vm.devices['block']) for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.fail( 'Device {} ({}) should not be listed because its ' 'partition is mounted' @@ -318,16 +318,16 @@ def setUp(self): "udevadm settle".format(path=self.img_path), user="root")) dev_list = list(self.backend.devices['block']) for dev in dev_list: - if dev.description == self.img_path: + if dev.serial == self.img_path: self.device = dev self.device_ident = dev.ident break else: self.fail('Device for {} in {} not found'.format( - self.img_path, self.backend.name)) + self.img_path, self.backend.serial)) def test_000_attach_reattach(self): - ass = qubes.devices.DeviceAssignment(self.backend, self.device_ident) + ass = qubes.device_protocol.DeviceAssignment(self.backend, self.device_ident) with self.subTest('attach'): self.loop.run_until_complete( self.frontend.devices['block'].attach(ass)) @@ -346,7 +346,8 @@ def test_000_attach_reattach(self): self.loop.run_until_complete( self.frontend.run_for_stdio('! ls /dev/xvdi')) - self.assertIsNone(self.device.frontend_domain) + self.assertIsNone( + self.device.data.get('test_frontend_domain', None)) with self.subTest('reattach'): self.loop.run_until_complete( diff --git a/qubes/tests/integ/devices_pci.py b/qubes/tests/integ/devices_pci.py index 290607e3b..7b1ea5579 100644 --- a/qubes/tests/integ/devices_pci.py +++ b/qubes/tests/integ/devices_pci.py @@ -37,11 +37,18 @@ 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.devices.DeviceAssignment( + self.assignment = qubes.device_protocol.DeviceAssignment( backend_domain=self.dev.backend_domain, ident=self.dev.ident, - persistent=True) - if isinstance(self.dev, qubes.devices.UnknownDevice): + 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, + ) + if isinstance(self.dev, qubes.device_protocol.UnknownDevice): self.skipTest('Specified device {} does not exists'.format(pcidev)) self.init_default_template() self.vm = self.app.add_new_vm(qubes.vm.appvm.AppVM, @@ -73,27 +80,35 @@ def test_000_list(self): self.fail('Not all devices listed, missing: {}'.format( actual_devices)) - def assertDeviceNotInCollection(self, dev, dev_col): - self.assertNotIn(dev, dev_col.attached()) - self.assertNotIn(dev, dev_col.persistent()) - self.assertNotIn(dev, dev_col.assignments()) - self.assertNotIn(dev, dev_col.assignments(persistent=True)) - - def test_010_attach_offline_persistent(self): + def assertDeviceIs( + self, device, *, attached: bool, assigned: bool, required: bool + ): dev_col = self.vm.devices['pci'] - self.assertDeviceNotInCollection(self.dev, dev_col) - self.loop.run_until_complete( - dev_col.attach(self.assignment)) + if required: + assert assigned + self.assertTrue(attached == device in dev_col.get_attached_devices()) + self.assertTrue(assigned == device in dev_col.get_assigned_devices()) + self.assertTrue( + required == device in dev_col.get_assigned_devices( + required_only=True) + ) + dedicated = assigned or attached + self.assertTrue(dedicated == device in dev_col.get_dedicated_devices()) + + def test_010_assign_offline(self): # TODO required + dev_col = self.vm.devices['pci'] + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) + + self.loop.run_until_complete(dev_col.assign(self.assignment)) self.app.save() - self.assertNotIn(self.dev, dev_col.attached()) - self.assertIn(self.dev, dev_col.persistent()) - self.assertIn(self.dev, dev_col.assignments()) - self.assertIn(self.dev, dev_col.assignments(persistent=True)) - self.assertNotIn(self.dev, dev_col.assignments(persistent=False)) + self.assertDeviceIs( + self.dev, attached=False, assigned=True, required=False) self.loop.run_until_complete(self.vm.start()) + self.assertDeviceIs( + self.dev, attached=True, assigned=False, required=False) - self.assertIn(self.dev, dev_col.attached()) (stdout, _) = self.loop.run_until_complete( self.vm.run_for_stdio('lspci')) self.assertIn(self.dev.description, stdout.decode()) @@ -101,26 +116,24 @@ def test_010_attach_offline_persistent(self): def test_011_attach_offline_temp_fail(self): dev_col = self.vm.devices['pci'] - self.assertDeviceNotInCollection(self.dev, dev_col) - self.assignment.persistent = False + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) + self.assignment.required = False with self.assertRaises(qubes.exc.QubesVMNotRunningError): self.loop.run_until_complete( dev_col.attach(self.assignment)) - - def test_020_attach_online_persistent(self): + def test_020_attach_online_persistent(self): # TODO: required self.loop.run_until_complete( self.vm.start()) dev_col = self.vm.devices['pci'] - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) + self.loop.run_until_complete( dev_col.attach(self.assignment)) - - self.assertIn(self.dev, dev_col.attached()) - self.assertIn(self.dev, dev_col.persistent()) - self.assertIn(self.dev, dev_col.assignments()) - self.assertIn(self.dev, dev_col.assignments(persistent=True)) - self.assertNotIn(self.dev, dev_col.assignments(persistent=False)) + self.assertDeviceIs( + self.dev, attached=True, assigned=True, required=False) # give VM kernel some time to discover new device time.sleep(1) @@ -131,7 +144,8 @@ def test_020_attach_online_persistent(self): def test_021_persist_detach_online_fail(self): dev_col = self.vm.devices['pci'] - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) self.loop.run_until_complete( dev_col.attach(self.assignment)) self.app.save() @@ -141,37 +155,34 @@ 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): + def test_030_persist_attach_detach_offline(self): # TODO: required dev_col = self.vm.devices['pci'] - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) + self.loop.run_until_complete( dev_col.attach(self.assignment)) self.app.save() - self.assertNotIn(self.dev, dev_col.attached()) - self.assertIn(self.dev, dev_col.persistent()) - self.assertIn(self.dev, dev_col.assignments()) - self.assertIn(self.dev, dev_col.assignments(persistent=True)) - self.assertNotIn(self.dev, dev_col.assignments(persistent=False)) + self.assertDeviceIs( + self.dev, attached=False, assigned=True, required=False) + self.loop.run_until_complete( dev_col.detach(self.assignment)) - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) - def test_031_attach_detach_online_temp(self): + def test_031_attach_detach_online_temp(self): # TODO: requiured dev_col = self.vm.devices['pci'] self.loop.run_until_complete( self.vm.start()) - self.assignment.persistent = False - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assignment.assigned = False + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) + self.loop.run_until_complete( dev_col.attach(self.assignment)) - - self.assertIn(self.dev, dev_col.attached()) - self.assertNotIn(self.dev, dev_col.persistent()) - self.assertIn(self.dev, dev_col.assignments()) - self.assertIn(self.dev, dev_col.assignments(persistent=False)) - self.assertNotIn(self.dev, dev_col.assignments(persistent=True)) - self.assertIn(self.dev, dev_col.assignments(persistent=False)) - + self.assertDeviceIs( + self.dev, attached=True, assigned=False, required=False) # give VM kernel some time to discover new device time.sleep(1) @@ -181,7 +192,8 @@ def test_031_attach_detach_online_temp(self): self.assertIn(self.dev.description, stdout.decode()) self.loop.run_until_complete( dev_col.detach(self.assignment)) - self.assertDeviceNotInCollection(self.dev, dev_col) + self.assertDeviceIs( + self.dev, attached=False, assigned=False, required=False) (stdout, _) = self.loop.run_until_complete( self.vm.run_for_stdio('lspci')) diff --git a/qubes/tests/integ/vm_qrexec_gui.py b/qubes/tests/integ/vm_qrexec_gui.py index e55671963..011ca9cff 100644 --- a/qubes/tests/integ/vm_qrexec_gui.py +++ b/qubes/tests/integ/vm_qrexec_gui.py @@ -308,7 +308,7 @@ def common_audio_record_muted(self): self.fail('VM recorded something, even though mic disabled') def common_audio_record_unmuted(self): - deva = qubes.devices.DeviceAssignment(self.app.domains[0], 'mic') + deva = qubes.device_protocol.DeviceAssignment(self.app.domains[0], 'mic') self.loop.run_until_complete( self.testvm1.devices['mic'].attach(deva)) # connect VM's recording source output monitor (instead of mic) diff --git a/qubes/tests/vm/init.py b/qubes/tests/vm/init.py index bf1f9ef21..761ff9953 100644 --- a/qubes/tests/vm/init.py +++ b/qubes/tests/vm/init.py @@ -110,13 +110,13 @@ def test_000_load(self): }) self.assertCountEqual(vm.devices.keys(), ('pci',)) - self.assertCountEqual(list(vm.devices['pci'].persistent()), - [qubes.ext.pci.PCIDevice(vm, '00_11.22')]) + self.assertCountEqual(list(vm.devices['pci'].get_assigned_devices()), + [qubes.ext.pci.PCIDevice(vm, '00_11.22')]) - assignments = list(vm.devices['pci'].assignments()) + assignments = list(vm.devices['pci'].get_assigned_devices()) self.assertEqual(len(assignments), 1) self.assertEqual(assignments[0].options, {'no-strict-reset': 'True'}) - self.assertEqual(assignments[0].persistent, True) + self.assertEqual(assignments[0].attach_automatically, True) self.assertXMLIsValid(vm.__xml__(), 'relaxng/domain.rng') diff --git a/qubes/tests/vm/qubesvm.py b/qubes/tests/vm/qubesvm.py index b07fdb2b0..6821942f7 100644 --- a/qubes/tests/vm/qubesvm.py +++ b/qubes/tests/vm/qubesvm.py @@ -79,9 +79,10 @@ class TestDeviceCollection(object): def __init__(self): self._list = [] - def persistent(self): + def get_assigned_devices(self, required_only=False): return self._list + class TestQubesDB(object): def __init__(self, data=None): self.data = {} @@ -214,7 +215,7 @@ def test_010_default_virt_mode(self): self.vm.template = None self.assertEqual(qubes.vm.qubesvm._default_virt_mode(self.vm), 'pvh') - self.vm.devices['pci'].persistent().append('some-dev') + self.vm.devices['pci'].get_assigned_devices().append('some-dev') self.assertEqual(qubes.vm.qubesvm._default_virt_mode(self.vm), 'hvm') @@ -246,7 +247,7 @@ def test_021_default_maxmem_with_pcidevs(self): self.vm.is_memory_balancing_possible = \ lambda: qubes.vm.qubesvm.QubesVM.is_memory_balancing_possible( self.vm) - self.vm.devices['pci'].persistent().append('00_00.0') + self.vm.devices['pci'].get_assigned_devices().append('00_00.0') self.assertEqual(qubes.vm.qubesvm._default_maxmem(self.vm), 0) def test_022_default_maxmem_linux(self): @@ -1311,8 +1312,10 @@ def test_600_libvirt_xml_hvm_pcidev(self): vm, # this is violation of API, but for PCI the argument # is unused '00_00.0', - bus='pci', - persistent=True) + devclass='pci', + attach_automatically=True, + required=True, + ) vm.devices['pci']._set.add( assignment) libvirt_xml = vm.create_config_file() @@ -1391,12 +1394,12 @@ def test_600_libvirt_xml_hvm_pcidev_s0ix(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 + assignment = qubes.device_protocol.DeviceAssignment( + vm, # this is a violation of API, but for PCI the argument # is unused '00_00.0', - bus='pci', - persistent=True) + devclass='pci', + attach_automatically=True, required=True) vm.devices['pci']._set.add( assignment) libvirt_xml = vm.create_config_file() @@ -1477,10 +1480,11 @@ def test_600_libvirt_xml_hvm_cdrom_boot(self): vm.kernel = None dom0.events_enabled = True self.app.vmm.offline_mode = False - dev = qubes.devices.DeviceAssignment( + dev = qubes.device_protocol.DeviceAssignment( dom0, 'sda', - {'devtype': 'cdrom', 'read-only': 'yes'}, persistent=True) - self.loop.run_until_complete(vm.devices['block'].attach(dev)) + {'devtype': 'cdrom', 'read-only': 'yes'}, + attach_automatically=True, required=True) + self.loop.run_until_complete(vm.devices['block'].assign(dev)) libvirt_xml = vm.create_config_file() self.assertXMLEqual(lxml.etree.XML(libvirt_xml), lxml.etree.XML(expected)) @@ -1581,10 +1585,11 @@ def test_600_libvirt_xml_hvm_cdrom_dom0_kernel_boot(self): }) dom0.events_enabled = True self.app.vmm.offline_mode = False - dev = qubes.devices.DeviceAssignment( + dev = qubes.device_protocol.DeviceAssignment( dom0, 'sda', - {'devtype': 'cdrom', 'read-only': 'yes'}, persistent=True) - self.loop.run_until_complete(vm.devices['block'].attach(dev)) + {'devtype': 'cdrom', 'read-only': 'yes'}, + attach_automatically=True, required=True) + self.loop.run_until_complete(vm.devices['block'].assign(dev)) libvirt_xml = vm.create_config_file() self.assertXMLEqual(lxml.etree.XML(libvirt_xml), lxml.etree.XML(expected)) @@ -1886,10 +1891,11 @@ def test_615_libvirt_xml_block_devices(self): 'device.backend_domain.features.check_with_template.return_value': '4.2', }), ] - vm.devices['block'].assignments = lambda persistent: assignments + vm.devices['block'].get_assigned_devices = \ + lambda required_only: assignments libvirt_xml = vm.create_config_file() - self.assertXMLEqual(lxml.etree.XML(libvirt_xml), - lxml.etree.XML(expected)) + self.assertXMLEqual( + lxml.etree.XML(libvirt_xml), lxml.etree.XML(expected)) @unittest.mock.patch('qubes.utils.get_timezone') @unittest.mock.patch('qubes.utils.urandom') diff --git a/qubes/vm/__init__.py b/qubes/vm/__init__.py index b3be66e92..1b0772b23 100644 --- a/qubes/vm/__init__.py +++ b/qubes/vm/__init__.py @@ -27,6 +27,7 @@ import re import string import uuid +from typing import List import lxml.etree @@ -275,13 +276,16 @@ def load_extras(self): options[option.get('name')] = str(option.text) try: - device_assignment = qubes.devices.DeviceAssignment( + device_assignment = qubes.device_protocol.DeviceAssignment( self.app.domains[node.get('backend-domain')], node.get('id'), options, - persistent=True + attach_automatically=True, + # backward compatibility: persistent~>required=True + required=qubes.property.bool( + None, None, node.get('required', 'yes')), ) - self.devices[devclass].load_persistent(device_assignment) + self.devices[devclass].load_assignment(device_assignment) except KeyError: msg = "{}: Cannot find backend domain '{}' " \ "for device type {} '{}'".format(self.name, node.get( @@ -295,15 +299,19 @@ def load_extras(self): # SEE:1815 firewall, policy. - def get_provided_assignments(self): - '''List of persistent device assignments from this VM.''' + def get_provided_assignments(self, required_only: bool = False)\ + -> List['qubes.device_protocol.DeviceAssignment']: + """ + List device assignments from this VM. + """ assignments = [] for domain in self.app.domains: if domain == self: continue for device_collection in domain.devices.values(): - for assignment in device_collection.persistent(): + for assignment in device_collection.get_assigned_devices( + required_only): if assignment.backend_domain == self: assignments.append(assignment) return assignments @@ -329,10 +337,11 @@ def __xml__(self): for devclass in self.devices: devices = lxml.etree.Element('devices') devices.set('class', devclass) - for device in self.devices[devclass].assignments(persistent=True): + for device 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(): option_node = lxml.etree.Element('option') option_node.set('name', key) diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index bab1d3979..a38d46432 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -132,10 +132,10 @@ def _setter_virt_mode(self, prop, value): raise qubes.exc.QubesPropertyValueError( self, prop, value, 'Invalid virtualization mode, supported values: hvm, pv, pvh') - if value == 'pvh' and list(self.devices['pci'].persistent()): + if value == 'pvh' and list(self.devices['pci'].get_assigned_devices()): raise qubes.exc.QubesPropertyValueError( self, prop, value, - "pvh mode can't be set if pci devices are attached") + "pvh mode can't be set if pci devices are assigned") return value @@ -169,7 +169,7 @@ def _setter_kbd_layout(self, prop, value): def _default_virt_mode(self): - if self.devices['pci'].persistent(): + if list(self.devices['pci'].get_assigned_devices()): return 'hvm' try: return self.template.virt_mode @@ -215,7 +215,7 @@ def _default_maxmem(self): def _default_kernelopts(self): """ Return default kernel options for the given kernel. If kernel directory - contains 'default-kernelopts-{pci,nopci}.txt' file, use that. Otherwise + contains 'default-kernelopts-{pci,nopci}.txt' file, use that. Otherwise, use built-in defaults. For qubes without PCI devices, kernelopts of qube's template are considered (for template-based qubes). @@ -228,9 +228,9 @@ def _default_kernelopts(self): kernels_dir = os.path.join( qubes.config.system_path['qubes_kernels_base_dir'], self.kernel) - pci = bool(list(self.devices['pci'].persistent())) + any_pci_assigned = bool(list(self.devices['pci'].get_assigned_devices())) extra_opts = "" - if pci: + if any_pci_assigned: path = os.path.join(kernels_dir, 'default-kernelopts-pci.txt') if self.app.domains[0].features.get('suspend-s0ix', False): extra_opts = " qubes_exp_pm_use_suspend=1" @@ -244,8 +244,9 @@ def _default_kernelopts(self): with open(path, encoding='ascii') as f_kernelopts: return f_kernelopts.read().strip() + extra_opts else: - return (qubes.config.defaults['kernelopts_pcidevs'] if pci else - qubes.config.defaults['kernelopts']) + extra_opts + return (qubes.config.defaults['kernelopts_pcidevs'] + if any_pci_assigned else qubes.config.defaults['kernelopts'] + ) + extra_opts class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): @@ -1169,11 +1170,15 @@ async def start(self, start_guid=True, notify_function=None, qmemman_client = None try: for devclass in self.devices: - for dev in self.devices[devclass].persistent(): - if isinstance(dev, qubes.devices.UnknownDevice): + for ass in self.devices[devclass].get_assigned_devices(): + if isinstance( + ass.device, + qubes.device_protocol.UnknownDevice) \ + and ass.required: raise qubes.exc.QubesException( - '{} device {} not available'.format( - devclass, dev)) + f'{devclass.capitalize()} device {ass} ' + f'not available' + ) if self.virt_mode == 'pvh' and not self.kernel: raise qubes.exc.QubesException( @@ -1216,8 +1221,9 @@ async def start(self, start_guid=True, notify_function=None, except libvirt.libvirtError as exc: # missing IOMMU? if self.virt_mode == 'hvm' and \ - list(self.devices['pci'].persistent()) and \ - not self.app.host.is_iommu_supported(): + list(self.devices['pci'].get_assigned_devices( + required_only=True) + ) and not self.app.host.is_iommu_supported(): exc = qubes.exc.QubesException( 'Failed to start an HVM qube with PCI devices assigned ' '- hardware does not support IOMMU/VT-d/AMD-Vi') @@ -1623,12 +1629,12 @@ def is_memory_balancing_possible(self): - have PCI devices - balloon driver not present - We don't have reliable way to detect the second point, but good - heuristic is HVM virt_mode (PV and PVH require OS support and it does - include balloon driver) and lack of qrexec/meminfo-writer service + We don't have a reliable way to detect the second point, but good + heuristic is HVM virt_mode (PV and PVH require OS support, and it does + include the balloon driver) and lack of qrexec/meminfo-writer service support (no qubes tools installed). """ - if list(self.devices['pci'].persistent()): + if list(self.devices['pci'].get_assigned_devices()): return False if self.virt_mode == 'hvm': # if VM announce any supported service diff --git a/relaxng/qubes.rng b/relaxng/qubes.rng index 39065e288..b286db21b 100644 --- a/relaxng/qubes.rng +++ b/relaxng/qubes.rng @@ -218,11 +218,21 @@ the parser will complain about missing combine= attribute on the second . - [0-9a-f]{2}_[0-9a-f]{2}.[0-9a-f]{2} + + + + Flag: is the device required to start domain? + If not present assume 'yes'. + + + [yesno]+ + + + diff --git a/rpm_spec/core-dom0.spec.in b/rpm_spec/core-dom0.spec.in index 6ac367dff..3e092681b 100644 --- a/rpm_spec/core-dom0.spec.in +++ b/rpm_spec/core-dom0.spec.in @@ -254,16 +254,22 @@ admin.vm.Shutdown admin.vm.Start admin.vm.Stats admin.vm.Unpause +admin.vm.device.block.Assign +admin.vm.device.block.Assigned admin.vm.device.block.Attach +admin.vm.device.block.Attached admin.vm.device.block.Available admin.vm.device.block.Detach -admin.vm.device.block.List -admin.vm.device.block.Set.persistent +admin.vm.device.block.Set.required +admin.vm.device.block.Unassign +admin.vm.device.pci.Assign +admin.vm.device.pci.Assigned admin.vm.device.pci.Attach +admin.vm.device.pci.Attached admin.vm.device.pci.Available admin.vm.device.pci.Detach -admin.vm.device.pci.List -admin.vm.device.pci.Set.persistent +admin.vm.device.pci.Set.required +admin.vm.device.pci.Unassign admin.vm.feature.CheckWithAdminVM admin.vm.feature.CheckWithNetvm admin.vm.feature.CheckWithTemplate @@ -366,6 +372,7 @@ done %{python3_sitelib}/qubes/app.py %{python3_sitelib}/qubes/backup.py %{python3_sitelib}/qubes/config.py +%{python3_sitelib}/qubes/device_protocol.py %{python3_sitelib}/qubes/devices.py %{python3_sitelib}/qubes/dochelpers.py %{python3_sitelib}/qubes/events.py @@ -438,6 +445,7 @@ done %{python3_sitelib}/qubes/ext/r3compatibility.py %{python3_sitelib}/qubes/ext/services.py %{python3_sitelib}/qubes/ext/supported_features.py +%{python3_sitelib}/qubes/ext/utils.py %{python3_sitelib}/qubes/ext/windows.py %{python3_sitelib}/qubes/ext/vm_config.py diff --git a/templates/libvirt/xen.xml b/templates/libvirt/xen.xml index 40af09f33..a9ce7c1db 100644 --- a/templates/libvirt/xen.xml +++ b/templates/libvirt/xen.xml @@ -7,7 +7,7 @@ {% block basic %} {{ vm.name }} {{ vm.uuid }} - {% if ((vm.virt_mode == 'hvm' and vm.devices['pci'].persistent() | list) + {% if ((vm.virt_mode == 'hvm' and vm.devices['pci'].get_assigned_devices() | list) or vm.maxmem == 0) -%} {{ vm.memory }} {% elif vm.use_memory_hotplug %} @@ -79,7 +79,7 @@ {% endif %} - {% if vm.devices['pci'].persistent() | list + {% if vm.devices['pci'].get_assigned_devices() | list and vm.features.get('pci-e820-host', True) %} @@ -156,7 +156,7 @@ {# start external devices from xvdi #} {% set counter = {'i': 4} %} - {% for assignment in vm.devices.block.assignments(True) %} + {% for assignment in vm.devices.block.get_assigned_devices(False) %} {% set device = assignment.device %} {% set options = assignment.options %} {% include 'libvirt/devices/block.xml' %} @@ -166,7 +166,7 @@ {% include 'libvirt/devices/net.xml' with context %} {% endif %} - {% for assignment in vm.devices.pci.assignments(True) %} + {% for assignment in vm.devices.pci.get_assigned_devices(True) %} {% set device = assignment.device %} {% set options = assignment.options %} {% set power_mgmt =