919841635b
Do this for all standard property types - even if other types do additional validation, do not expose them to non-ASCII characters. QubesOS/qubes-issues#2622
699 lines
23 KiB
Python
699 lines
23 KiB
Python
#
|
|
# The Qubes OS Project, https://www.qubes-os.org/
|
|
#
|
|
# Copyright (C) 2010-2015 Joanna Rutkowska <joanna@invisiblethingslab.com>
|
|
# Copyright (C) 2011-2015 Marek Marczykowski-Górecki
|
|
# <marmarek@invisiblethingslab.com>
|
|
# Copyright (C) 2014-2015 Wojtek Porczyk <woju@invisiblethingslab.com>
|
|
#
|
|
# 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.
|
|
#
|
|
|
|
'''
|
|
Qubes OS
|
|
|
|
:copyright: © 2010-2015 Invisible Things Lab
|
|
'''
|
|
|
|
import builtins
|
|
import collections
|
|
import os
|
|
import os.path
|
|
import string
|
|
|
|
import lxml.etree
|
|
import qubes.config
|
|
import qubes.events
|
|
import qubes.exc
|
|
|
|
__author__ = 'Invisible Things Lab'
|
|
__license__ = 'GPLv2 or later'
|
|
__version__ = 'R3'
|
|
|
|
|
|
class Label(object):
|
|
'''Label definition for virtual machines
|
|
|
|
Label specifies colour of the padlock displayed next to VM's name.
|
|
When this is a :py:class:`qubes.vm.dispvm.DispVM`, padlock is overlayed
|
|
with recycling pictogram.
|
|
|
|
:param int index: numeric identificator of label
|
|
:param str color: colour specification as in HTML (``#abcdef``)
|
|
:param str name: label's name like "red" or "green"
|
|
'''
|
|
|
|
def __init__(self, index, color, name):
|
|
#: numeric identificator of label
|
|
self.index = index
|
|
|
|
#: colour specification as in HTML (``#abcdef``)
|
|
self.color = color
|
|
|
|
#: label's name like "red" or "green"
|
|
self.name = name
|
|
|
|
#: freedesktop icon name, suitable for use in
|
|
#: :py:meth:`PyQt4.QtGui.QIcon.fromTheme`
|
|
self.icon = 'appvm-' + name
|
|
|
|
#: freedesktop icon name, suitable for use in
|
|
#: :py:meth:`PyQt4.QtGui.QIcon.fromTheme` on DispVMs
|
|
self.icon_dispvm = 'dispvm-' + name
|
|
|
|
|
|
@classmethod
|
|
def fromxml(cls, xml):
|
|
'''Create label definition from XML node
|
|
|
|
:param lxml.etree._Element xml: XML node reference
|
|
:rtype: :py:class:`qubes.Label`
|
|
'''
|
|
|
|
index = int(xml.get('id').split('-', 1)[1])
|
|
color = xml.get('color')
|
|
name = xml.text
|
|
|
|
return cls(index, color, name)
|
|
|
|
|
|
def __xml__(self):
|
|
element = lxml.etree.Element(
|
|
'label', id='label-{}'.format(self.index), color=self.color)
|
|
element.text = self.name
|
|
return element
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def __repr__(self):
|
|
return '{}({!r}, {!r}, {!r})'.format(
|
|
self.__class__.__name__,
|
|
self.index,
|
|
self.color,
|
|
self.name)
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, Label):
|
|
return self.name == other.name
|
|
return NotImplemented
|
|
|
|
def __hash__(self):
|
|
return hash(self.name)
|
|
|
|
@builtins.property
|
|
def icon_path(self):
|
|
'''Icon path
|
|
|
|
.. deprecated:: 2.0
|
|
use :py:meth:`PyQt4.QtGui.QIcon.fromTheme` and :py:attr:`icon`
|
|
'''
|
|
return os.path.join(qubes.config.system_path['qubes_icon_dir'],
|
|
self.icon) + ".png"
|
|
|
|
|
|
@builtins.property
|
|
def icon_path_dispvm(self):
|
|
'''Icon path
|
|
|
|
.. deprecated:: 2.0
|
|
use :py:meth:`PyQt4.QtGui.QIcon.fromTheme` and :py:attr:`icon_dispvm`
|
|
'''
|
|
return os.path.join(qubes.config.system_path['qubes_icon_dir'],
|
|
self.icon_dispvm) + ".png"
|
|
|
|
|
|
class property(object): # pylint: disable=redefined-builtin,invalid-name
|
|
'''Qubes property.
|
|
|
|
This class holds one property that can be saved to and loaded from
|
|
:file:`qubes.xml`. It is used for both global and per-VM properties.
|
|
|
|
Property can be unset by ordinary ``del`` statement or assigning
|
|
:py:attr:`DEFAULT` special value to it. After deletion (or before first
|
|
assignment/load) attempting to read a property will get its default value
|
|
or, when no default, py:class:`exceptions.AttributeError`.
|
|
|
|
:param str name: name of the property
|
|
:param collections.Callable setter: if not :py:obj:`None`, this is used to \
|
|
initialise value; first parameter to the function is holder instance \
|
|
and the second is value; this is called before ``type``
|
|
:param collections.Callable saver: function to coerce value to something \
|
|
readable by setter
|
|
:param type type: if not :py:obj:`None`, value is coerced to this type
|
|
:param object default: default value; if callable, will be called with \
|
|
holder as first argument
|
|
:param int load_stage: stage when property should be loaded (see \
|
|
:py:class:`Qubes` for description of stages)
|
|
:param int order: order of evaluation (bigger order values are later)
|
|
:param bool clone: :py:meth:`PropertyHolder.clone_properties` will not \
|
|
include this property by default if :py:obj:`False`
|
|
:param str doc: docstring; this should be one paragraph of plain RST, no \
|
|
sphinx-specific features
|
|
|
|
Setters and savers have following signatures:
|
|
|
|
.. :py:function:: setter(self, prop, value)
|
|
:noindex:
|
|
|
|
:param self: instance of object that is holding property
|
|
:param prop: property object
|
|
:param value: value being assigned
|
|
|
|
.. :py:function:: saver(self, prop, value)
|
|
:noindex:
|
|
|
|
:param self: instance of object that is holding property
|
|
:param prop: property object
|
|
:param value: value being saved
|
|
:rtype: str
|
|
:raises property.DontSave: when property should not be saved at all
|
|
|
|
'''
|
|
|
|
#: Assigning this value to property means setting it to its default value.
|
|
#: If property has no default value, this will unset it.
|
|
DEFAULT = object()
|
|
|
|
# internal use only
|
|
_NO_DEFAULT = object()
|
|
|
|
def __init__(self, name, setter=None, saver=None, type=None,
|
|
default=_NO_DEFAULT, write_once=False, load_stage=2, order=0,
|
|
save_via_ref=False, clone=True,
|
|
doc=None):
|
|
# pylint: disable=redefined-builtin
|
|
self.__name__ = name
|
|
self._setter = setter
|
|
self._saver = saver if saver is not None else (
|
|
lambda self, prop, value: str(value))
|
|
self.type = type
|
|
self._default = default
|
|
self._write_once = write_once
|
|
self.order = order
|
|
self.load_stage = load_stage
|
|
self.save_via_ref = save_via_ref
|
|
self.clone = clone
|
|
self.__doc__ = doc
|
|
self._attr_name = '_qubesprop_' + name
|
|
|
|
def __get__(self, instance, owner):
|
|
if instance is None:
|
|
return self
|
|
|
|
# XXX this violates duck typing, shall we keep it?
|
|
if not isinstance(instance, PropertyHolder):
|
|
raise AttributeError('qubes.property should be used on '
|
|
'qubes.PropertyHolder instances only')
|
|
|
|
try:
|
|
return getattr(instance, self._attr_name)
|
|
|
|
except AttributeError:
|
|
if self._default is self._NO_DEFAULT:
|
|
raise AttributeError(
|
|
'property {!r} not set'.format(self.__name__))
|
|
elif isinstance(self._default, collections.Callable):
|
|
return self._default(instance)
|
|
else:
|
|
return self._default
|
|
|
|
|
|
def __set__(self, instance, value):
|
|
self._enforce_write_once(instance)
|
|
|
|
if value is self.__class__.DEFAULT:
|
|
self.__delete__(instance)
|
|
return
|
|
|
|
try:
|
|
oldvalue = getattr(instance, self.__name__)
|
|
has_oldvalue = True
|
|
except AttributeError:
|
|
has_oldvalue = False
|
|
|
|
if self._setter is not None:
|
|
value = self._setter(instance, self, value)
|
|
if self.type not in (None, type(value)):
|
|
value = self.type(value)
|
|
|
|
if has_oldvalue:
|
|
instance.fire_event_pre('property-pre-set:' + self.__name__,
|
|
name=self.__name__, newvalue=value, oldvalue=oldvalue)
|
|
else:
|
|
instance.fire_event_pre('property-pre-set:' + self.__name__,
|
|
name=self.__name__, newvalue=value)
|
|
|
|
instance._property_init(self, value) # pylint: disable=protected-access
|
|
|
|
if has_oldvalue:
|
|
instance.fire_event('property-set:' + self.__name__,
|
|
name=self.__name__, newvalue=value, oldvalue=oldvalue)
|
|
else:
|
|
instance.fire_event('property-set:' + self.__name__,
|
|
name=self.__name__, newvalue=value)
|
|
|
|
|
|
def __delete__(self, instance):
|
|
self._enforce_write_once(instance)
|
|
|
|
try:
|
|
oldvalue = getattr(instance, self._attr_name)
|
|
has_oldvalue = True
|
|
except AttributeError:
|
|
has_oldvalue = False
|
|
|
|
if has_oldvalue:
|
|
instance.fire_event_pre('property-pre-del:' + self.__name__,
|
|
name=self.__name__, oldvalue=oldvalue)
|
|
delattr(instance, self._attr_name)
|
|
instance.fire_event('property-del:' + self.__name__,
|
|
name=self.__name__, oldvalue=oldvalue)
|
|
|
|
else:
|
|
instance.fire_event_pre('property-pre-del:' + self.__name__,
|
|
name=self.__name__)
|
|
instance.fire_event('property-del:' + self.__name__,
|
|
name=self.__name__)
|
|
|
|
|
|
def __repr__(self):
|
|
default = ' default={!r}'.format(self._default) \
|
|
if self._default is not self._NO_DEFAULT \
|
|
else ''
|
|
return '<{} object at {:#x} name={!r}{}>'.format(
|
|
self.__class__.__name__, id(self), self.__name__, default)
|
|
|
|
def __str__(self):
|
|
return self.__name__
|
|
|
|
def __hash__(self):
|
|
return hash(self.__name__)
|
|
|
|
def __lt__(self, other):
|
|
if isinstance(other, property):
|
|
return (self.load_stage, self.order, self.__name__) <\
|
|
(other.load_stage, other.order, other.__name__)
|
|
return NotImplemented
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, str):
|
|
return self.__name__ == other
|
|
return isinstance(other, property) and self.__name__ == other.__name__
|
|
|
|
|
|
def _enforce_write_once(self, instance):
|
|
if self._write_once and not instance.property_is_default(self):
|
|
raise AttributeError(
|
|
'property {!r} is write-once and already set'.format(
|
|
self.__name__))
|
|
|
|
def sanitize(self, *, untrusted_newvalue):
|
|
'''Coarse sanitization of value to be set, before sending it to a
|
|
setter. Can raise QubesValueError if the value is invalid.
|
|
|
|
:param untrusted_newvalue: value to be validated
|
|
:return sanitized value
|
|
:raises qubes.exc.QubesValueError
|
|
'''
|
|
# do not treat type='str' as sufficient validation
|
|
if self.type is not None and self.type is not str:
|
|
# assume specific type will preform enough validation
|
|
try:
|
|
untrusted_newvalue = untrusted_newvalue.decode('ascii',
|
|
errors='strict')
|
|
except UnicodeDecodeError:
|
|
raise qubes.exc.QubesValueError
|
|
if self.type is bool:
|
|
return self.bool(None, None, untrusted_newvalue)
|
|
else:
|
|
try:
|
|
return self.type(untrusted_newvalue)
|
|
except ValueError:
|
|
raise qubes.exc.QubesValueError
|
|
else:
|
|
# 'str' or not specified type
|
|
try:
|
|
untrusted_newvalue = untrusted_newvalue.decode('ascii',
|
|
errors='strict')
|
|
except UnicodeDecodeError:
|
|
raise qubes.exc.QubesValueError
|
|
allowed_set = string.printable
|
|
if not all(x in allowed_set for x in untrusted_newvalue):
|
|
raise qubes.exc.QubesValueError(
|
|
'Invalid characters in property value')
|
|
return untrusted_newvalue
|
|
|
|
|
|
#
|
|
# exceptions
|
|
#
|
|
|
|
class DontSave(Exception):
|
|
'''This exception may be raised from saver to sign that property should
|
|
not be saved.
|
|
'''
|
|
pass
|
|
|
|
@staticmethod
|
|
def dontsave(self, prop, value):
|
|
'''Dummy saver that never saves anything.'''
|
|
# pylint: disable=bad-staticmethod-argument,unused-argument
|
|
raise property.DontSave()
|
|
|
|
#
|
|
# some setters provided
|
|
#
|
|
|
|
@staticmethod
|
|
def forbidden(self, prop, value):
|
|
'''Property setter that forbids loading a property.
|
|
|
|
This is used to effectively disable property in classes which inherit
|
|
unwanted property. When someone attempts to load such a property, it
|
|
|
|
:throws AttributeError: always
|
|
''' # pylint: disable=bad-staticmethod-argument,unused-argument
|
|
|
|
raise AttributeError(
|
|
'setting {} property on {} instance is forbidden'.format(
|
|
prop.__name__, self.__class__.__name__))
|
|
|
|
|
|
@staticmethod
|
|
def bool(self, prop, value):
|
|
'''Property setter for boolean properties.
|
|
|
|
It accepts (case-insensitive) ``'0'``, ``'no'`` and ``false`` as
|
|
:py:obj:`False` and ``'1'``, ``'yes'`` and ``'true'`` as
|
|
:py:obj:`True`.
|
|
''' # pylint: disable=bad-staticmethod-argument,unused-argument
|
|
|
|
if isinstance(value, str):
|
|
lcvalue = value.lower()
|
|
if lcvalue in ('0', 'no', 'false', 'off'):
|
|
return False
|
|
if lcvalue in ('1', 'yes', 'true', 'on'):
|
|
return True
|
|
raise qubes.exc.QubesValueError(
|
|
'Invalid literal for boolean property: {!r}'.format(value))
|
|
|
|
return bool(value)
|
|
|
|
|
|
def stateless_property(func):
|
|
'''Decorator similar to :py:class:`builtins.property`, but for properties
|
|
exposed through management API (including qvm-prefs etc)'''
|
|
return property(func.__name__,
|
|
setter=property.forbidden,
|
|
saver=property.DontSave,
|
|
default=func,
|
|
doc=func.__doc__)
|
|
|
|
|
|
class PropertyHolder(qubes.events.Emitter):
|
|
'''Abstract class for holding :py:class:`qubes.property`
|
|
|
|
Events fired by instances of this class:
|
|
|
|
.. event:: property-load (subject, event)
|
|
|
|
Fired once after all properties are loaded from XML. Individual
|
|
``property-set`` events are not fired.
|
|
|
|
.. event:: property-set:<propname> \
|
|
(subject, event, name, newvalue[, oldvalue])
|
|
|
|
Fired when property changes state. Signature is variable,
|
|
*oldvalue* is present only if there was an old value.
|
|
|
|
:param name: Property name
|
|
:param newvalue: New value of the property
|
|
:param oldvalue: Old value of the property
|
|
|
|
.. event:: property-pre-set:<propname> \
|
|
(subject, event, name, newvalue[, oldvalue])
|
|
|
|
Fired before property changes state. Signature is variable,
|
|
*oldvalue* is present only if there was an old value.
|
|
|
|
:param name: Property name
|
|
:param newvalue: New value of the property
|
|
:param oldvalue: Old value of the property
|
|
|
|
.. event:: property-del:<propname> \
|
|
(subject, event, name[, oldvalue])
|
|
|
|
Fired when property gets deleted (is set to default). Signature is
|
|
variable, *oldvalue* is present only if there was an old value.
|
|
|
|
:param name: Property name
|
|
:param oldvalue: Old value of the property
|
|
|
|
.. event:: property-pre-del:<propname> \
|
|
(subject, event, name[, oldvalue])
|
|
|
|
Fired before property gets deleted (is set to default). Signature
|
|
is variable, *oldvalue* is present only if there was an old value.
|
|
|
|
:param name: Property name
|
|
:param oldvalue: Old value of the property
|
|
|
|
.. event:: clone-properties (subject, event, src, proplist)
|
|
|
|
:param src: object, from which we are cloning
|
|
:param proplist: list of properties
|
|
|
|
Members:
|
|
'''
|
|
|
|
def __init__(self, xml, **kwargs):
|
|
self.xml = xml
|
|
|
|
propvalues = {}
|
|
|
|
all_names = set(prop.__name__ for prop in self.property_list())
|
|
for key in list(kwargs):
|
|
if not key in all_names:
|
|
continue
|
|
propvalues[key] = kwargs.pop(key)
|
|
|
|
super(PropertyHolder, self).__init__(**kwargs)
|
|
|
|
for key, value in propvalues.items():
|
|
setattr(self, key, value)
|
|
|
|
if self.xml is not None:
|
|
# check if properties are appropriate
|
|
all_names = set(prop.__name__ for prop in self.property_list())
|
|
|
|
for node in self.xml.xpath('./properties/property'):
|
|
name = node.get('name')
|
|
if name not in all_names:
|
|
raise TypeError(
|
|
'property {!r} not applicable to {!r}'.format(
|
|
name, self.__class__.__name__))
|
|
|
|
@classmethod
|
|
def property_list(cls, load_stage=None):
|
|
'''List all properties attached to this VM's class
|
|
|
|
:param load_stage: Filter by load stage
|
|
:type load_stage: :py:func:`int` or :py:obj:`None`
|
|
'''
|
|
|
|
props = set()
|
|
for class_ in cls.__mro__:
|
|
props.update(prop for prop in class_.__dict__.values()
|
|
if isinstance(prop, property))
|
|
if load_stage is not None:
|
|
props = set(prop for prop in props
|
|
if prop.load_stage == load_stage)
|
|
return sorted(props)
|
|
|
|
def _property_init(self, prop, value):
|
|
'''Initialise property to a given value, without side effects.
|
|
|
|
:param qubes.property prop: property object of particular interest
|
|
:param value: value
|
|
'''
|
|
|
|
# pylint: disable=protected-access
|
|
setattr(self, self.property_get_def(prop)._attr_name, value)
|
|
|
|
|
|
def property_is_default(self, prop):
|
|
'''Check whether property is in it's default value.
|
|
|
|
Properties when unset may return some default value, so
|
|
``hasattr(vm, prop.__name__)`` is wrong in some circumstances. This
|
|
method allows for checking if the value returned is in fact it's
|
|
default value.
|
|
|
|
:param qubes.property prop: property object of particular interest
|
|
:rtype: bool
|
|
''' # pylint: disable=protected-access
|
|
|
|
# both property_get_def() and ._attr_name may throw AttributeError,
|
|
# which we don't want to catch
|
|
attrname = self.property_get_def(prop)._attr_name
|
|
return not hasattr(self, attrname)
|
|
|
|
|
|
@classmethod
|
|
def property_get_def(cls, prop):
|
|
'''Return property definition object.
|
|
|
|
If prop is already :py:class:`qubes.property` instance, return the same
|
|
object.
|
|
|
|
:param prop: property object or name
|
|
:type prop: qubes.property or str
|
|
:rtype: qubes.property
|
|
'''
|
|
|
|
if isinstance(prop, qubes.property):
|
|
return prop
|
|
|
|
for p in cls.property_list():
|
|
if p.__name__ == prop:
|
|
return p
|
|
|
|
raise AttributeError('No property {!r} found in {!r}'.format(
|
|
prop, cls))
|
|
|
|
|
|
def load_properties(self, load_stage=None):
|
|
'''Load properties from immediate children of XML node.
|
|
|
|
``property-set`` events are not fired for each individual property.
|
|
|
|
:param int load_stage: Stage of loading.
|
|
'''
|
|
|
|
if self.xml is None:
|
|
return
|
|
all_names = set(
|
|
prop.__name__ for prop in self.property_list(load_stage))
|
|
for node in self.xml.xpath('./properties/property'):
|
|
name = node.get('name')
|
|
value = node.get('ref') or node.text
|
|
|
|
if not name in all_names:
|
|
continue
|
|
|
|
setattr(self, name, value)
|
|
|
|
|
|
def xml_properties(self, with_defaults=False):
|
|
'''Iterator that yields XML nodes representing set properties.
|
|
|
|
:param bool with_defaults: If :py:obj:`True`, then it also includes \
|
|
properties which were not set explicite, but have default values \
|
|
filled.
|
|
'''
|
|
|
|
|
|
properties = lxml.etree.Element('properties')
|
|
|
|
for prop in self.property_list():
|
|
# pylint: disable=protected-access
|
|
try:
|
|
value = getattr(
|
|
self, (prop.__name__ if with_defaults else prop._attr_name))
|
|
except AttributeError:
|
|
continue
|
|
|
|
try:
|
|
value = prop._saver(self, prop, value)
|
|
except property.DontSave:
|
|
continue
|
|
|
|
element = lxml.etree.Element('property', name=prop.__name__)
|
|
if prop.save_via_ref:
|
|
element.set('ref', value)
|
|
else:
|
|
element.text = value
|
|
properties.append(element)
|
|
|
|
return properties
|
|
|
|
|
|
# this was clone_attrs
|
|
def clone_properties(self, src, proplist=None):
|
|
'''Clone properties from other object.
|
|
|
|
:param PropertyHolder src: source object
|
|
:param list proplist: list of properties \
|
|
(:py:obj:`None` or omit for all properties except those with \
|
|
:py:attr:`property.clone` set to :py:obj:`False`)
|
|
'''
|
|
|
|
if proplist is None:
|
|
proplist = [prop for prop in self.property_list()
|
|
if prop.clone]
|
|
else:
|
|
proplist = [prop for prop in self.property_list()
|
|
if prop.__name__ in proplist or prop in proplist]
|
|
|
|
for prop in proplist:
|
|
try:
|
|
# pylint: disable=protected-access
|
|
self._property_init(prop, getattr(src, prop._attr_name))
|
|
except AttributeError:
|
|
continue
|
|
|
|
self.fire_event('clone-properties', src=src, proplist=proplist)
|
|
|
|
|
|
def property_require(self, prop, allow_none=False, hard=False):
|
|
'''Complain badly when property is not set.
|
|
|
|
:param prop: property name or object
|
|
:type prop: qubes.property or str
|
|
:param bool allow_none: if :py:obj:`True`, don't complain if \
|
|
:py:obj:`None` is found
|
|
:param bool hard: if :py:obj:`True`, raise :py:class:`AssertionError`; \
|
|
if :py:obj:`False`, log warning instead
|
|
'''
|
|
|
|
if isinstance(prop, qubes.property):
|
|
prop = prop.__name__
|
|
|
|
try:
|
|
value = getattr(self, prop)
|
|
if value is None and not allow_none:
|
|
raise AttributeError()
|
|
except AttributeError:
|
|
# pylint: disable=no-member
|
|
msg = 'Required property {!r} not set on {!r}'.format(prop, self)
|
|
if hard:
|
|
raise AssertionError(msg)
|
|
else:
|
|
# pylint: disable=no-member
|
|
self.log.fatal(msg)
|
|
|
|
# pylint: disable=wrong-import-position
|
|
from qubes.vm import VMProperty
|
|
from qubes.app import Qubes
|
|
|
|
__all__ = [
|
|
'Label',
|
|
'PropertyHolder',
|
|
'Qubes',
|
|
'VMProperty',
|
|
'property',
|
|
]
|