import xml.etree.ElementTree as ET import xml.parsers.expat as _expat from .constants import ArgDirection, PropertyAccess from .errors import InvalidIntrospectionError from .signature import SignatureType, get_signature_tree from .validators import assert_interface_name_valid, assert_member_name_valid # https://dbus.freedesktop.org/doc/dbus-specification.html#introspection-format _MAX_NODE_DEPTH = 32 def _reject_internal_subset( _name: str, _sysid: str | None, _pubid: str | None, has_internal_subset: bool, ) -> None: # The standard D-Bus introspection DOCTYPE is PUBLIC-only with no # internal subset. Any `[ ... ]` block in the DOCTYPE is therefore # either the billion-laughs / quadratic-blowup ENTITY vector, an # ATTLIST default-value amplification, or an XXE attempt from a # hostile peer — reject the whole subset as a single boundary. if has_internal_subset: raise InvalidIntrospectionError( "internal DTD subsets are not allowed in introspection XML" ) def _parse_introspection_xml(data: str) -> ET.Element: """Parse introspection XML with the DTD-based amplification vectors closed.""" # namespace_separator="}" mirrors ElementTree's expat config so a # namespaced root (``) still surfaces with a # non-"node" tag and gets rejected by the root-element check below # rather than slipping through this stricter parser. parser = _expat.ParserCreate(namespace_separator="}") builder = ET.TreeBuilder() parser.StartElementHandler = builder.start parser.EndElementHandler = builder.end parser.CharacterDataHandler = builder.data parser.StartDoctypeDeclHandler = _reject_internal_subset # Defense-in-depth: refuse to expand parameter entities, which is the # path expat would otherwise take to fetch external DTDs. parser.SetParamEntityParsing(_expat.XML_PARAM_ENTITY_PARSING_NEVER) try: parser.Parse(data.encode("utf-8") if isinstance(data, str) else data, True) except _expat.ExpatError as e: # Preserve the code / position metadata ElementTree attaches to # ParseError so callers that inspect them (e.position, e.code) keep # working as they did with ET.fromstring. err = ET.ParseError(e) err.code = e.code err.position = (e.lineno, e.offset) raise err from e return builder.close() def _fetch_annotations(element: ET.Element) -> dict[str, str]: annotations: dict[str, str] = {} for child in element: if child.tag != "annotation": continue annotation_name = child.attrib.get("name") annotation_value = child.attrib.get("value") if not annotation_name: raise InvalidIntrospectionError('annotations must have a "name" attribute') if annotation_value is None: raise InvalidIntrospectionError('annotations must have a "value" attribute') annotations[annotation_name] = annotation_value return annotations def _extract_annotations(element: ET.Element, annotations: dict[str, str]) -> None: for key, value in annotations.items(): annotation = ET.Element("annotation", {"name": key, "value": value}) element.append(annotation) class Arg: """A class that represents an input or output argument to a signal or a method. :ivar name: The name of this arg. :vartype name: str :ivar direction: Whether this is an input or an output argument. :vartype direction: :class:`ArgDirection ` :ivar type: The parsed signature type of this argument. :vartype type: :class:`SignatureType ` :ivar signature: The signature string of this argument. :vartype signature: str :ivar annotations: The annotations of this arg. :vartype annotations: dict[str, str] :raises: - :class:`InvalidMemberNameError ` - If the name of the arg is not valid. - :class:`InvalidSignatureError ` - If the signature is not valid. - :class:`InvalidIntrospectionError ` - If the signature is not a single complete type. """ def __init__( self, signature: SignatureType | str, direction: ArgDirection | None = None, name: str | None = None, annotations: dict[str, str] | None = None, ): type_ = None if type(signature) is SignatureType: type_ = signature signature = signature.signature else: tree = get_signature_tree(signature) if len(tree.types) != 1: raise InvalidIntrospectionError( f"an argument must have a single complete type. (has {len(tree.types)} types)" ) type_ = tree.types[0] self.type = type_ self.signature = signature self.name = name self.direction = direction self.annotations = annotations or {} @staticmethod def from_xml(element: ET.Element, direction: ArgDirection) -> "Arg": """Convert a :class:`xml.etree.ElementTree.Element` into a :class:`Arg`. The element must be valid DBus introspection XML for an ``arg``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :param direction: The direction of this arg. Must be specified because it can default to different values depending on if it's in a method or signal. :type direction: :class:`ArgDirection ` :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ name = element.attrib.get("name") signature = element.attrib.get("type") if not signature: raise InvalidIntrospectionError( 'a method argument must have a "type" attribute' ) annotations = _fetch_annotations(element) return Arg(signature, direction, name, annotations) def to_xml(self) -> ET.Element: """Convert this :class:`Arg` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("arg") if self.name: element.set("name", self.name) if self.direction: element.set("direction", self.direction.value) element.set("type", self.signature) _extract_annotations(element, self.annotations) return element class Signal: """A class that represents a signal exposed on an interface. :ivar name: The name of this signal :vartype name: str :ivar args: A list of output arguments for this signal. :vartype args: list(Arg) :ivar signature: The collected signature of the output arguments. :vartype signature: str :ivar annotations: The annotations of this signal. :vartype annotations: dict[str, str] :raises: - :class:`InvalidMemberNameError ` - If the name of the signal is not a valid member name. """ def __init__( self, name: str, args: list[Arg] | None = None, annotations: dict[str, str] | None = None, ): if name is not None: assert_member_name_valid(name) self.name = name self.args = args or [] self.signature = "".join(arg.signature for arg in self.args) self.annotations = annotations or {} @staticmethod def from_xml(element: ET.Element) -> "Signal": """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Signal`. The element must be valid DBus introspection XML for a ``signal``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :param is_root: Whether this is the root node :type is_root: bool :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ name = element.attrib.get("name") if not name: raise InvalidIntrospectionError('signals must have a "name" attribute') args: list[Arg] = [] for child in element: if child.tag == "arg": args.append(Arg.from_xml(child, ArgDirection.OUT)) annotations = _fetch_annotations(element) signal = Signal(name, args, annotations) return signal def to_xml(self) -> ET.Element: """Convert this :class:`Signal` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("signal") element.set("name", self.name) for arg in self.args: element.append(arg.to_xml()) _extract_annotations(element, self.annotations) return element class Method: """A class that represents a method exposed on an :class:`Interface`. :ivar name: The name of this method. :vartype name: str :ivar in_args: A list of input arguments to this method. :vartype in_args: list(Arg) :ivar out_args: A list of output arguments to this method. :vartype out_args: list(Arg) :ivar in_signature: The collected signature string of the input arguments. :vartype in_signature: str :ivar out_signature: The collected signature string of the output arguments. :vartype out_signature: str :ivar annotations: The annotations of this method. :vartype annotations: dict[str, str] :raises: - :class:`InvalidMemberNameError ` - If the name of this method is not valid. """ def __init__( self, name: str, in_args: list[Arg] = [], out_args: list[Arg] = [], annotations: dict[str, str] | None = None, ): assert_member_name_valid(name) self.name = name self.in_args = in_args self.out_args = out_args self.in_signature = "".join(arg.signature for arg in in_args) self.out_signature = "".join(arg.signature for arg in out_args) self.annotations = annotations or {} @staticmethod def from_xml(element: ET.Element) -> "Method": """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Method`. The element must be valid DBus introspection XML for a ``method``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :param is_root: Whether this is the root node :type is_root: bool :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ name = element.attrib.get("name") if not name: raise InvalidIntrospectionError('methods must have a "name" attribute') in_args: list[Arg] = [] out_args: list[Arg] = [] for child in element: if child.tag == "arg": direction = ArgDirection(child.attrib.get("direction", "in")) arg = Arg.from_xml(child, direction) if direction == ArgDirection.IN: in_args.append(arg) elif direction == ArgDirection.OUT: out_args.append(arg) annotations = _fetch_annotations(element) return Method(name, in_args, out_args, annotations) def to_xml(self) -> ET.Element: """Convert this :class:`Method` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("method") element.set("name", self.name) for arg in self.in_args: element.append(arg.to_xml()) for arg in self.out_args: element.append(arg.to_xml()) _extract_annotations(element, self.annotations) return element class Property: """A class that represents a DBus property exposed on an :class:`Interface`. :ivar name: The name of this property. :vartype name: str :ivar signature: The signature string for this property. Must be a single complete type. :vartype signature: str :ivar access: Whether this property is readable and writable. :vartype access: :class:`PropertyAccess ` :ivar type: The parsed type of this property. :vartype type: :class:`SignatureType ` :ivar annotations: The annotations of this property. :vartype annotations: dict[str, str] :raises: - :class:`InvalidIntrospectionError ` - If the property is not a single complete type. - :class `InvalidSignatureError ` - If the given signature is not valid. - :class: `InvalidMemberNameError ` - If the member name is not valid. """ def __init__( self, name: str, signature: str, access: PropertyAccess = PropertyAccess.READWRITE, annotations: dict[str, str] | None = None, validate: bool = True, ): if validate: assert_member_name_valid(name) tree = get_signature_tree(signature) if len(tree.types) != 1: raise InvalidIntrospectionError( f"properties must have a single complete type. (has {len(tree.types)} types)" ) self.name = name self.signature = signature self.access = access self.type = tree.types[0] self.annotations = annotations or {} @staticmethod def from_xml(element: ET.Element, validate: bool = True) -> "Property": """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Property`. The element must be valid DBus introspection XML for a ``property``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ name = element.attrib.get("name") signature = element.attrib.get("type") access = PropertyAccess(element.attrib.get("access", "readwrite")) if not name: raise InvalidIntrospectionError('properties must have a "name" attribute') if not signature: raise InvalidIntrospectionError('properties must have a "type" attribute') annotations = _fetch_annotations(element) return Property( name, signature, access, annotations=annotations, validate=validate ) def to_xml(self) -> ET.Element: """Convert this :class:`Property` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("property") element.set("name", self.name) element.set("type", self.signature) element.set("access", self.access.value) _extract_annotations(element, self.annotations) return element class Interface: """A class that represents a DBus interface exported on on object path. Contains information about the methods, signals, and properties exposed on this interface. :ivar name: The name of this interface. :vartype name: str :ivar methods: A list of methods exposed on this interface. :vartype methods: list(:class:`Method`) :ivar signals: A list of signals exposed on this interface. :vartype signals: list(:class:`Signal`) :ivar properties: A list of properties exposed on this interface. :vartype properties: list(:class:`Property`) :ivar annotations: The annotations of this interface. :vartype annotations: dict[str, str] :raises: - :class:`InvalidInterfaceNameError ` - If the name is not a valid interface name. """ def __init__( self, name: str, methods: list[Method] | None = None, signals: list[Signal] | None = None, properties: list[Property] | None = None, annotations: dict[str, str] | None = None, ): assert_interface_name_valid(name) self.name = name self.methods = methods if methods is not None else [] self.signals = signals if signals is not None else [] self.properties = properties if properties is not None else [] self.annotations = annotations or {} @staticmethod def from_xml( element: ET.Element, validate_property_names: bool = True ) -> "Interface": """Convert a :class:`xml.etree.ElementTree.Element` into a :class:`Interface`. The element must be valid DBus introspection XML for an ``interface``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ name = element.attrib.get("name") if not name: raise InvalidIntrospectionError('interfaces must have a "name" attribute') interface = Interface(name) for child in element: if child.tag == "method": interface.methods.append(Method.from_xml(child)) elif child.tag == "signal": interface.signals.append(Signal.from_xml(child)) elif child.tag == "property": interface.properties.append( Property.from_xml(child, validate=validate_property_names) ) interface.annotations = _fetch_annotations(element) return interface def to_xml(self) -> ET.Element: """Convert this :class:`Interface` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("interface") element.set("name", self.name) for method in self.methods: element.append(method.to_xml()) for signal in self.signals: element.append(signal.to_xml()) for prop in self.properties: element.append(prop.to_xml()) _extract_annotations(element, self.annotations) return element class Node: """A class that represents a node in an object path in introspection data. A node contains information about interfaces exported on this path and child nodes. A node can be converted to and from introspection XML exposed through the ``org.freedesktop.DBus.Introspectable`` standard DBus interface. This class is an essential building block for a high-level DBus interface. This is the underlying data structure for the :class:`ProxyObject `. A :class:`ServiceInterface ` definition is converted to this class to expose XML on the introspectable interface. :ivar interfaces: A list of interfaces exposed on this node. :vartype interfaces: list(:class:`Interface `) :ivar nodes: A list of child nodes. :vartype nodes: list(:class:`Node`) :ivar name: The object path of this node. :vartype name: str :ivar is_root: Whether this is the root node. False if it is a child node. :vartype is_root: bool :raises: - :class:`InvalidIntrospectionError ` - If the name is not a valid node name. """ def __init__( self, name: str | None = None, interfaces: list[Interface] | None = None, is_root: bool = True, ): if not is_root and not name: raise InvalidIntrospectionError('child nodes must have a "name" attribute') self.interfaces = interfaces if interfaces is not None else [] self.nodes: list[Node] = [] self.name = name self.is_root = is_root @staticmethod def from_xml( element: ET.Element, is_root: bool = False, validate_property_names: bool = True ) -> "Node": """Convert an :class:`xml.etree.ElementTree.Element` to a :class:`Node`. The element must be valid DBus introspection XML for a ``node``. :param element: The parsed XML element. :type element: :class:`xml.etree.ElementTree.Element` :param is_root: Whether this is the root node :type is_root: bool :param validate_property_names: Whether to validate property names or not :type validate_property_names: bool :raises: - :class:`InvalidIntrospectionError ` - If the XML tree is not valid introspection data. """ return Node._from_xml(element, is_root, validate_property_names, 0) @staticmethod def _from_xml( element: ET.Element, is_root: bool, validate_property_names: bool, depth: int, ) -> "Node": # Real D-Bus object paths are at most a handful of components deep; # 32 is well above any plausible legitimate nesting and keeps a # hostile peer from exhausting the Python (or C) stack via a # `...` payload. if depth > _MAX_NODE_DEPTH: raise InvalidIntrospectionError( f"introspection node nesting exceeds {_MAX_NODE_DEPTH} levels" ) node = Node(element.attrib.get("name"), is_root=is_root) for child in element: if child.tag == "interface": node.interfaces.append( Interface.from_xml( child, validate_property_names=validate_property_names ) ) elif child.tag == "node": node.nodes.append( Node._from_xml( child, False, validate_property_names, depth + 1, ) ) return node @staticmethod def parse(data: str, validate_property_names: bool = True) -> "Node": """Parse XML data as a string into a :class:`Node`. The string must be valid DBus introspection XML. :param data: The XMl string. :type data: str :param validate_property_names: Whether to validate property names or not :type validate_property_names: bool :raises: - :class:`InvalidIntrospectionError ` - If the string is not valid introspection data. """ element = _parse_introspection_xml(data) if element.tag != "node": raise InvalidIntrospectionError( 'introspection data must have a "node" for the root element' ) return Node.from_xml( element, is_root=True, validate_property_names=validate_property_names ) def to_xml(self) -> ET.Element: """Convert this :class:`Node` into an :class:`xml.etree.ElementTree.Element`.""" element = ET.Element("node") if self.name: element.set("name", self.name) for interface in self.interfaces: element.append(interface.to_xml()) for node in self.nodes: element.append(node.to_xml()) return element def tostring(self) -> str: """Convert this :class:`Node` into a DBus introspection XML string.""" header = '\n' def indent(elem, level=0): i = "\n" + level * " " if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " if not elem.tail or not elem.tail.strip(): elem.tail = i for elem_ in elem: indent(elem_, level + 1) if not elem.tail or not elem.tail.strip(): elem.tail = i elif level and (not elem.tail or not elem.tail.strip()): elem.tail = i xml = self.to_xml() indent(xml) return header + ET.tostring(xml, encoding="unicode").rstrip() @staticmethod def default(name: str | None = None) -> "Node": """Create a :class:`Node` with the default interfaces supported by this library. The default interfaces include: * ``org.freedesktop.DBus.Introspectable`` * ``org.freedesktop.DBus.Peer`` * ``org.freedesktop.DBus.Properties`` * ``org.freedesktop.DBus.ObjectManager`` """ return Node( name, is_root=True, interfaces=[ Interface( "org.freedesktop.DBus.Introspectable", methods=[ Method( "Introspect", out_args=[Arg("s", ArgDirection.OUT, "data")] ) ], ), Interface( "org.freedesktop.DBus.Peer", methods=[ Method( "GetMachineId", out_args=[Arg("s", ArgDirection.OUT, "machine_uuid")], ), Method("Ping"), ], ), Interface( "org.freedesktop.DBus.Properties", methods=[ Method( "Get", in_args=[ Arg("s", ArgDirection.IN, "interface_name"), Arg("s", ArgDirection.IN, "property_name"), ], out_args=[Arg("v", ArgDirection.OUT, "value")], ), Method( "Set", in_args=[ Arg("s", ArgDirection.IN, "interface_name"), Arg("s", ArgDirection.IN, "property_name"), Arg("v", ArgDirection.IN, "value"), ], ), Method( "GetAll", in_args=[Arg("s", ArgDirection.IN, "interface_name")], out_args=[Arg("a{sv}", ArgDirection.OUT, "props")], ), ], signals=[ Signal( "PropertiesChanged", args=[ Arg("s", ArgDirection.OUT, "interface_name"), Arg("a{sv}", ArgDirection.OUT, "changed_properties"), Arg("as", ArgDirection.OUT, "invalidated_properties"), ], ) ], ), Interface( "org.freedesktop.DBus.ObjectManager", methods=[ Method( "GetManagedObjects", out_args=[ Arg( "a{oa{sa{sv}}}", ArgDirection.OUT, "objpath_interfaces_and_properties", ) ], ), ], signals=[ Signal( "InterfacesAdded", args=[ Arg("o", ArgDirection.OUT, "object_path"), Arg( "a{sa{sv}}", ArgDirection.OUT, "interfaces_and_properties", ), ], ), Signal( "InterfacesRemoved", args=[ Arg("o", ArgDirection.OUT, "object_path"), Arg("as", ArgDirection.OUT, "interfaces"), ], ), ], ), ], )