Skip to main content

xml_sec/xml/dom/
tree.rs

1//! Parser-independent retained XML tree consumed by XML Security algorithms.
2
3#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
4use std::collections::HashMap;
5use std::{
6    hash::{Hash, Hasher},
7    ops::Range,
8};
9
10use super::{ParseError, ParsingOptions, XmlBackend};
11
12const XML_NAMESPACE_URI: &str = "http://www.w3.org/XML/1998/namespace";
13
14/// Stable arena index for a node in one parsed document.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct NodeId(u32);
17
18impl NodeId {
19    pub(crate) const fn get(self) -> u32 {
20        self.0
21    }
22    pub(crate) fn get_usize(self) -> usize {
23        self.index()
24    }
25    fn index(self) -> usize {
26        self.0 as usize
27    }
28}
29
30impl From<u32> for NodeId {
31    fn from(value: u32) -> Self {
32        Self(value)
33    }
34}
35
36/// Kind of a semantic XML node.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub enum NodeType {
39    /// Synthetic document root.
40    Root,
41    /// XML element.
42    Element,
43    /// Expanded character data.
44    Text,
45    /// XML comment.
46    Comment,
47    /// Processing instruction.
48    PI,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub(super) struct NodeData {
53    pub(super) parent: Option<NodeId>,
54    pub(super) children: Vec<NodeId>,
55    pub(super) kind: NodeKind,
56    pub(super) range: Range<usize>,
57    pub(super) range_actionable: bool,
58    pub(super) subtree_end: u32,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub(super) enum NodeKind {
63    Root,
64    Element {
65        name: String,
66        namespace: Option<String>,
67        prefix: Option<String>,
68        attributes: Vec<AttributeData>,
69        namespaces: Vec<NamespaceData>,
70    },
71    Text(String),
72    Comment(String),
73    PI {
74        target: String,
75        value: Option<String>,
76    },
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub(super) struct AttributeData {
81    pub(super) name: String,
82    pub(super) namespace: Option<String>,
83    pub(super) prefix: Option<String>,
84    pub(super) value: String,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Hash)]
88pub(super) struct NamespaceData {
89    pub(super) prefix: Option<String>,
90    pub(super) uri: String,
91}
92
93/// Builder used by parser adapters to populate the shared semantic arena.
94pub(super) struct TreeBuilder<'input> {
95    input: &'input str,
96    nodes: Vec<NodeData>,
97}
98
99impl<'input> TreeBuilder<'input> {
100    pub(super) fn new(input: &'input str, capacity: usize) -> Self {
101        Self {
102            input,
103            nodes: Vec::with_capacity(capacity),
104        }
105    }
106
107    #[cfg(feature = "xml-backend-xmloxide")]
108    pub(super) fn push(
109        &mut self,
110        parent: Option<NodeId>,
111        kind: NodeKind,
112        range: Range<usize>,
113    ) -> NodeId {
114        self.push_with_actionability(parent, kind, range, true)
115    }
116
117    pub(super) fn push_with_actionability(
118        &mut self,
119        parent: Option<NodeId>,
120        kind: NodeKind,
121        range: Range<usize>,
122        range_actionable: bool,
123    ) -> NodeId {
124        let id = NodeId(
125            u32::try_from(self.nodes.len()).expect("bounded XML node count must fit into u32"),
126        );
127        self.nodes.push(NodeData {
128            parent,
129            children: Vec::new(),
130            kind,
131            range,
132            range_actionable,
133            subtree_end: id.0 + 1,
134        });
135        if let Some(parent) = parent {
136            self.nodes[parent.index()].children.push(id);
137        }
138        id
139    }
140
141    pub(super) fn finish_subtree(&mut self, node: NodeId) {
142        self.nodes[node.index()].subtree_end =
143            u32::try_from(self.nodes.len()).expect("bounded XML node count must fit into u32");
144    }
145
146    #[cfg(feature = "xml-backend-xmloxide")]
147    pub(super) fn append_text(
148        &mut self,
149        parent: NodeId,
150        value: &str,
151        range: Range<usize>,
152        range_actionable: bool,
153    ) {
154        if let Some(last) = self.nodes[parent.index()].children.last().copied()
155            && let node = &mut self.nodes[last.index()]
156            && let NodeKind::Text(existing) = &mut node.kind
157        {
158            existing.push_str(value);
159            // One semantic text node can fold several adjacent lexical tokens
160            // (plain text, CDATA, and references). Its source range must cover
161            // every token so mutation never splices only a semantic prefix.
162            node.range.start = node.range.start.min(range.start);
163            node.range.end = node.range.end.max(range.end);
164            node.range_actionable &= range_actionable;
165            return;
166        }
167        self.push_with_actionability(
168            Some(parent),
169            NodeKind::Text(value.to_owned()),
170            range,
171            range_actionable,
172        );
173    }
174
175    #[cfg(feature = "xml-backend-xmloxide")]
176    pub(super) fn len(&self) -> usize {
177        self.nodes.len()
178    }
179
180    #[cfg(feature = "xml-backend-xmloxide")]
181    pub(super) fn namespaces(&self, node: NodeId) -> Option<&[NamespaceData]> {
182        match &self.nodes[node.index()].kind {
183            NodeKind::Element { namespaces, .. } => Some(namespaces),
184            _ => None,
185        }
186    }
187
188    pub(super) fn finish(self) -> Document<'input> {
189        Document {
190            input: self.input,
191            nodes: self.nodes,
192        }
193    }
194
195    #[cfg(feature = "xml-backend-roxmltree")]
196    pub(super) fn input(&self) -> &'input str {
197        self.input
198    }
199}
200
201/// Parsed backend-neutral XML document.
202///
203/// The selected parser is used only while constructing this arena. C14N,
204/// XPath, XMLDSig, XMLEnc, and mutation code never branch on the parser.
205#[derive(Debug)]
206pub struct Document<'input> {
207    input: &'input str,
208    nodes: Vec<NodeData>,
209}
210
211impl<'input> Document<'input> {
212    /// Parses XML with default backend-neutral options.
213    pub fn parse(input: &'input str) -> Result<Self, ParseError> {
214        Self::parse_with_options(input, ParsingOptions::default())
215    }
216
217    /// Parses XML with the build's default backend.
218    pub fn parse_with_options(
219        input: &'input str,
220        options: ParsingOptions,
221    ) -> Result<Self, ParseError> {
222        Self::parse_with_options_and_backend(input, options, XmlBackend::default())
223    }
224
225    /// Parses XML with an explicitly selected compiled backend.
226    pub fn parse_with_backend(input: &'input str, backend: XmlBackend) -> Result<Self, ParseError> {
227        Self::parse_with_options_and_backend(input, ParsingOptions::default(), backend)
228    }
229
230    /// Parses XML with explicit parser options and backend selection.
231    pub fn parse_with_options_and_backend(
232        input: &'input str,
233        options: ParsingOptions,
234        backend: XmlBackend,
235    ) -> Result<Self, ParseError> {
236        let options = crate::document::preflight_dom_limits(input, options)?;
237        Self::parse_after_limit_preflight_with_backend(input, options, backend)
238    }
239
240    pub(crate) fn parse_after_limit_preflight_with_backend(
241        input: &'input str,
242        options: ParsingOptions,
243        backend: XmlBackend,
244    ) -> Result<Self, ParseError> {
245        let preflight = super::LexicalPreflight::scan(input, options.allow_dtd)?;
246        backend.parse(input, options, &preflight)
247    }
248
249    /// Returns the original UTF-8 source.
250    pub fn input_text(&self) -> &'input str {
251        self.input
252    }
253
254    /// Returns the synthetic document root.
255    pub fn root(&self) -> Node<'_, 'input> {
256        self.get_node(NodeId(0))
257            .expect("parsed document has a root")
258    }
259
260    /// Returns the document element.
261    pub fn root_element(&self) -> Node<'_, 'input> {
262        self.root()
263            .children()
264            .find(Node::is_element)
265            .expect("well-formed XML has a document element")
266    }
267
268    /// Iterates the document root and all descendants in document order.
269    pub fn descendants(&self) -> Descendants<'_, 'input> {
270        self.root().descendants()
271    }
272
273    /// Resolves an arena node ID in this document.
274    pub fn get_node(&self, id: NodeId) -> Option<Node<'_, 'input>> {
275        self.nodes
276            .get(id.index())
277            .map(|_| Node { document: self, id })
278    }
279
280    #[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
281    pub(super) fn ensure_semantically_equivalent(
282        &self,
283        other: &Self,
284        doctype: Option<&Range<usize>>,
285    ) -> Result<(), ParseError> {
286        if self.nodes.len() != other.nodes.len() {
287            let detail = self
288                .nodes
289                .iter()
290                .zip(&other.nodes)
291                .position(|(left, right)| {
292                    node_kind_label(&left.kind) != node_kind_label(&right.kind)
293                })
294                .map_or_else(
295                    || "one adapter retained trailing nodes".to_owned(),
296                    |index| {
297                        format!(
298                            "node {index} is {} versus {}",
299                            node_kind_label(&self.nodes[index].kind),
300                            node_kind_label(&other.nodes[index].kind)
301                        )
302                    },
303                );
304            return Err(ParseError::BackendDivergence {
305                reason: format!(
306                    "retained semantic node counts differ ({} versus {}): {detail}",
307                    self.nodes.len(),
308                    other.nodes.len()
309                ),
310            });
311        }
312        if let Some((index, (left, right))) = self
313            .nodes
314            .iter()
315            .zip(&other.nodes)
316            .enumerate()
317            .find(|(_, (left, right))| !nodes_semantically_equivalent(left, right, doctype))
318        {
319            return Err(ParseError::BackendDivergence {
320                reason: format!(
321                    "retained semantic node {index} differs in {}",
322                    node_difference(left, right, doctype)
323                ),
324            });
325        }
326        Ok(())
327    }
328}
329
330#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
331fn node_kind_label(kind: &NodeKind) -> &'static str {
332    match kind {
333        NodeKind::Root => "root",
334        NodeKind::Element { .. } => "element",
335        NodeKind::Text(_) => "text",
336        NodeKind::Comment(_) => "comment",
337        NodeKind::PI { .. } => "processing instruction",
338    }
339}
340
341#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
342fn nodes_semantically_equivalent(
343    left: &NodeData,
344    right: &NodeData,
345    doctype: Option<&Range<usize>>,
346) -> bool {
347    left.parent == right.parent
348        && left.children == right.children
349        && node_kinds_semantically_equivalent(&left.kind, &right.kind)
350        && source_ranges_equivalent(&left.range, &right.range, doctype)
351        && range_actionability_equivalent(left, right)
352        && left.subtree_end == right.subtree_end
353}
354
355#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
356fn node_difference(left: &NodeData, right: &NodeData, doctype: Option<&Range<usize>>) -> String {
357    if left.parent != right.parent {
358        "parent identity".to_owned()
359    } else if left.children != right.children {
360        "child identities".to_owned()
361    } else if !node_kinds_semantically_equivalent(&left.kind, &right.kind) {
362        node_kind_difference(&left.kind, &right.kind).to_owned()
363    } else if !source_ranges_equivalent(&left.range, &right.range, doctype) {
364        format!(
365            "source range ({}..{} versus {}..{})",
366            left.range.start, left.range.end, right.range.start, right.range.end
367        )
368    } else if !range_actionability_equivalent(left, right) {
369        "source-range actionability".to_owned()
370    } else {
371        "subtree boundary".to_owned()
372    }
373}
374
375#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
376fn range_actionability_equivalent(left: &NodeData, right: &NodeData) -> bool {
377    // Mutation accepts any semantic node with a unique source span, so backend
378    // disagreement about actionability is always security-relevant.
379    left.range_actionable == right.range_actionable
380}
381
382#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
383fn source_ranges_equivalent(
384    left: &Range<usize>,
385    right: &Range<usize>,
386    doctype: Option<&Range<usize>>,
387) -> bool {
388    if left == right {
389        return true;
390    }
391    let Some(doctype) = doctype else {
392        return false;
393    };
394    // Entity-expanded nodes have no single lexical span in the instance:
395    // roxmltree reports declaration bytes while xmloxide reports reference
396    // bytes. Both positions encode the same synthetic entity provenance.
397    range_contains(doctype, left) != range_contains(doctype, right)
398}
399
400#[cfg(feature = "xml-backend-roxmltree")]
401pub(super) fn range_contains(container: &Range<usize>, candidate: &Range<usize>) -> bool {
402    candidate.start >= container.start && candidate.end <= container.end
403}
404
405#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
406fn node_kinds_semantically_equivalent(left: &NodeKind, right: &NodeKind) -> bool {
407    match (left, right) {
408        (
409            NodeKind::Element {
410                name: left_name,
411                namespace: left_namespace,
412                prefix: left_prefix,
413                attributes: left_attributes,
414                namespaces: left_namespaces,
415            },
416            NodeKind::Element {
417                name: right_name,
418                namespace: right_namespace,
419                prefix: right_prefix,
420                attributes: right_attributes,
421                namespaces: right_namespaces,
422            },
423        ) => {
424            left_name == right_name
425                && left_namespace == right_namespace
426                && left_prefix == right_prefix
427                && left_attributes == right_attributes
428                && namespace_axes_equivalent(left_namespaces, right_namespaces)
429        }
430        _ => left == right,
431    }
432}
433
434#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
435fn namespace_axes_equivalent(left: &[NamespaceData], right: &[NamespaceData]) -> bool {
436    if left.len() != right.len() {
437        return false;
438    }
439    let mut remaining = HashMap::with_capacity(left.len());
440    for item in left {
441        *remaining.entry(item).or_insert(0_usize) += 1;
442    }
443    right.iter().all(|item| {
444        remaining.get_mut(item).is_some_and(|count| {
445            if *count == 0 {
446                return false;
447            }
448            *count -= 1;
449            true
450        })
451    })
452}
453
454#[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
455fn node_kind_difference(left: &NodeKind, right: &NodeKind) -> &'static str {
456    match (left, right) {
457        (
458            NodeKind::Element {
459                name: left_name,
460                namespace: left_namespace,
461                prefix: left_prefix,
462                attributes: left_attributes,
463                namespaces: left_namespaces,
464            },
465            NodeKind::Element {
466                name: right_name,
467                namespace: right_namespace,
468                prefix: right_prefix,
469                attributes: right_attributes,
470                namespaces: right_namespaces,
471            },
472        ) => {
473            if left_name != right_name {
474                "element local name"
475            } else if left_namespace != right_namespace {
476                match (left_namespace.as_deref(), right_namespace.as_deref()) {
477                    (None, Some("")) => "element namespace URI (absent versus empty)",
478                    (Some(""), None) => "element namespace URI (empty versus absent)",
479                    (None, Some(_)) => "element namespace URI (absent versus non-empty)",
480                    (Some(_), None) => "element namespace URI (non-empty versus absent)",
481                    _ => "element namespace URI (different non-empty values)",
482                }
483            } else if left_prefix != right_prefix {
484                "element prefix"
485            } else if left_attributes != right_attributes {
486                "attribute axis"
487            } else if !namespace_axes_equivalent(left_namespaces, right_namespaces) {
488                "namespace axis"
489            } else {
490                "element semantics"
491            }
492        }
493        (NodeKind::Text(left), NodeKind::Text(right)) if left != right => "character data",
494        (NodeKind::Comment(left), NodeKind::Comment(right)) if left != right => "comment data",
495        (NodeKind::PI { .. }, NodeKind::PI { .. }) => "processing instruction data",
496        _ => "node kind",
497    }
498}
499
500/// Copyable handle into a backend-neutral document arena.
501#[derive(Clone, Copy, Debug)]
502pub struct Node<'a, 'input> {
503    document: &'a Document<'input>,
504    id: NodeId,
505}
506
507impl PartialEq for Node<'_, '_> {
508    fn eq(&self, other: &Self) -> bool {
509        std::ptr::eq(self.document, other.document) && self.id == other.id
510    }
511}
512impl Eq for Node<'_, '_> {}
513impl Hash for Node<'_, '_> {
514    fn hash<H: Hasher>(&self, state: &mut H) {
515        std::ptr::from_ref(self.document).hash(state);
516        self.id.hash(state);
517    }
518}
519
520impl<'a, 'input> Node<'a, 'input> {
521    fn data(self) -> &'a NodeData {
522        &self.document.nodes[self.id.index()]
523    }
524    /// Returns this node's stable arena ID.
525    pub fn id(self) -> NodeId {
526        self.id
527    }
528    /// Returns the owning document.
529    pub fn document(self) -> &'a Document<'input> {
530        self.document
531    }
532    /// Returns the semantic node kind.
533    pub fn node_type(self) -> NodeType {
534        match self.data().kind {
535            NodeKind::Root => NodeType::Root,
536            NodeKind::Element { .. } => NodeType::Element,
537            NodeKind::Text(_) => NodeType::Text,
538            NodeKind::Comment(_) => NodeType::Comment,
539            NodeKind::PI { .. } => NodeType::PI,
540        }
541    }
542    /// Returns whether this is the synthetic document root.
543    pub fn is_root(&self) -> bool {
544        self.node_type() == NodeType::Root
545    }
546    /// Returns whether this is an element.
547    pub fn is_element(&self) -> bool {
548        self.node_type() == NodeType::Element
549    }
550    /// Returns whether this is text.
551    pub fn is_text(&self) -> bool {
552        self.node_type() == NodeType::Text
553    }
554    /// Returns whether this is a comment.
555    pub fn is_comment(&self) -> bool {
556        self.node_type() == NodeType::Comment
557    }
558    /// Returns whether this is a processing instruction.
559    pub fn is_pi(&self) -> bool {
560        self.node_type() == NodeType::PI
561    }
562    /// Returns the source byte range represented by this node.
563    pub fn range(self) -> Range<usize> {
564        self.data().range.clone()
565    }
566    pub(crate) fn has_actionable_range(self) -> bool {
567        self.data().range_actionable
568    }
569    /// Returns the parent node.
570    pub fn parent(self) -> Option<Self> {
571        self.data().parent.and_then(|id| self.document.get_node(id))
572    }
573    /// Returns the parent only when it is an element.
574    pub fn parent_element(self) -> Option<Self> {
575        self.parent()
576            .and_then(|node| node.is_element().then_some(node))
577    }
578    /// Iterates direct children in document order.
579    pub fn children(self) -> Children<'a, 'input> {
580        Children {
581            document: self.document,
582            ids: self.data().children.iter(),
583        }
584    }
585    /// Returns the first direct child node.
586    pub fn first_child(self) -> Option<Self> {
587        self.children().next()
588    }
589    /// Returns the first direct element child.
590    pub fn first_element_child(self) -> Option<Self> {
591        self.children().find(Node::is_element)
592    }
593    /// Returns the last direct element child.
594    pub fn last_element_child(self) -> Option<Self> {
595        self.children().rev().find(Node::is_element)
596    }
597    /// Iterates this node and its descendants in document order.
598    pub fn descendants(self) -> Descendants<'a, 'input> {
599        Descendants {
600            document: self.document,
601            ids: self.id.0..self.data().subtree_end,
602        }
603    }
604    /// Iterates this node and then its ancestors.
605    pub fn ancestors(self) -> Ancestors<'a, 'input> {
606        Ancestors { next: Some(self) }
607    }
608    /// Returns the preceding sibling.
609    pub fn prev_sibling(self) -> Option<Self> {
610        let parent = self.parent()?;
611        let position = parent
612            .data()
613            .children
614            .iter()
615            .position(|id| *id == self.id)?;
616        position
617            .checked_sub(1)
618            .and_then(|index| self.document.get_node(parent.data().children[index]))
619    }
620    /// Returns the following sibling.
621    pub fn next_sibling(self) -> Option<Self> {
622        let parent = self.parent()?;
623        let position = parent
624            .data()
625            .children
626            .iter()
627            .position(|id| *id == self.id)?;
628        parent
629            .data()
630            .children
631            .get(position + 1)
632            .and_then(|id| self.document.get_node(*id))
633    }
634    /// Returns the next sibling that is an element.
635    pub fn next_sibling_element(self) -> Option<Self> {
636        let mut current = self.next_sibling();
637        while let Some(node) = current {
638            if node.is_element() {
639                return Some(node);
640            }
641            current = node.next_sibling();
642        }
643        None
644    }
645    /// Returns the expanded element name, or an empty name for non-elements.
646    pub fn tag_name(self) -> ExpandedName<'a> {
647        match &self.data().kind {
648            NodeKind::Element {
649                name, namespace, ..
650            } => ExpandedName {
651                name,
652                namespace: namespace.as_deref(),
653                match_namespace: true,
654            },
655            _ => ExpandedName {
656                name: "",
657                namespace: None,
658                match_namespace: true,
659            },
660        }
661    }
662    /// Tests an element against a local-name selector or an exact expanded name.
663    ///
664    /// A bare string matches any namespace, mirroring roxmltree. Tuple forms
665    /// match the namespace exactly, including `(None, name)` for an
666    /// unqualified element.
667    pub fn has_tag_name<'n, N>(self, name: N) -> bool
668    where
669        N: Into<ExpandedName<'n>>,
670    {
671        if !self.is_element() {
672            return false;
673        }
674        let name = name.into();
675        self.tag_name().name() == name.name()
676            && (!name.match_namespace || self.tag_name().namespace() == name.namespace())
677    }
678    /// Returns the element's lexical namespace prefix.
679    pub fn prefix(self) -> Option<&'a str> {
680        match &self.data().kind {
681            NodeKind::Element { prefix, .. } => prefix.as_deref(),
682            _ => None,
683        }
684    }
685    /// Iterates non-namespace attributes in source order.
686    pub fn attributes(self) -> Attributes<'a> {
687        let values: &[AttributeData] = match &self.data().kind {
688            NodeKind::Element { attributes, .. } => attributes.as_slice(),
689            _ => &[],
690        };
691        Attributes {
692            values: values.iter(),
693        }
694    }
695    /// Looks up an unqualified attribute by local name.
696    pub fn attribute<'n, N>(self, name: N) -> Option<&'a str>
697    where
698        N: Into<ExpandedName<'n>>,
699    {
700        let name = name.into();
701        self.attributes()
702            .find(|attr| attr.name() == name.name() && attr.namespace() == name.namespace())
703            .map(Attribute::value)
704    }
705    /// Iterates in-scope namespace bindings.
706    pub fn namespaces(self) -> Namespaces<'a> {
707        let values: &[NamespaceData] = match &self.data().kind {
708            NodeKind::Element { namespaces, .. } => namespaces.as_slice(),
709            _ => &[],
710        };
711        Namespaces {
712            values: values.iter(),
713        }
714    }
715    /// Resolves a namespace prefix in this element's in-scope bindings.
716    pub fn lookup_namespace_uri(self, prefix: Option<&str>) -> Option<&'a str> {
717        if prefix == Some("xml") {
718            return Some(XML_NAMESPACE_URI);
719        }
720        self.namespaces()
721            .find(|ns| ns.name() == prefix)
722            .map(Namespace::uri)
723    }
724    /// Finds an in-scope prefix for a namespace URI.
725    pub fn lookup_prefix(self, uri: &str) -> Option<&'a str> {
726        if uri == XML_NAMESPACE_URI {
727            return Some("xml");
728        }
729        self.namespaces()
730            .filter(|namespace| namespace.uri() == uri)
731            .find_map(Namespace::name)
732    }
733    /// Returns direct character data, or the first direct text child of an element.
734    pub fn text(self) -> Option<&'a str> {
735        match &self.data().kind {
736            NodeKind::Text(value) | NodeKind::Comment(value) => Some(value),
737            NodeKind::Element { .. } => {
738                self.first_child()
739                    .and_then(|child| match &child.data().kind {
740                        NodeKind::Text(value) => Some(value.as_str()),
741                        _ => None,
742                    })
743            }
744            NodeKind::PI { .. } => None,
745            NodeKind::Root => None,
746        }
747    }
748    /// Returns processing-instruction data for a PI node.
749    pub fn pi(self) -> Option<PI<'a>> {
750        match &self.data().kind {
751            NodeKind::PI { target, value } => Some(PI {
752                target,
753                value: value.as_deref(),
754            }),
755            _ => None,
756        }
757    }
758}
759
760/// Expanded XML name used by element and attribute comparisons.
761#[derive(Clone, Copy, Debug)]
762pub struct ExpandedName<'a> {
763    name: &'a str,
764    namespace: Option<&'a str>,
765    // Selector intent is separate from the represented expanded name so
766    // equality remains a comparison of XML names, not matching syntax.
767    match_namespace: bool,
768}
769impl<'a> ExpandedName<'a> {
770    /// Returns the local name.
771    pub fn name(self) -> &'a str {
772        self.name
773    }
774    /// Returns the namespace URI.
775    pub fn namespace(self) -> Option<&'a str> {
776        self.namespace
777    }
778}
779impl PartialEq for ExpandedName<'_> {
780    fn eq(&self, other: &Self) -> bool {
781        self.name == other.name && self.namespace == other.namespace
782    }
783}
784impl Eq for ExpandedName<'_> {}
785impl<'a> From<&'a str> for ExpandedName<'a> {
786    fn from(name: &'a str) -> Self {
787        Self {
788            name,
789            namespace: None,
790            match_namespace: false,
791        }
792    }
793}
794impl<'a> From<(Option<&'a str>, &'a str)> for ExpandedName<'a> {
795    fn from((namespace, name): (Option<&'a str>, &'a str)) -> Self {
796        Self {
797            name,
798            namespace,
799            match_namespace: true,
800        }
801    }
802}
803impl<'a> From<(&'a str, &'a str)> for ExpandedName<'a> {
804    fn from((namespace, name): (&'a str, &'a str)) -> Self {
805        Self {
806            name,
807            namespace: Some(namespace),
808            match_namespace: true,
809        }
810    }
811}
812
813/// Borrowed semantic XML attribute.
814#[derive(Clone, Copy)]
815pub struct Attribute<'a> {
816    data: &'a AttributeData,
817}
818impl<'a> Attribute<'a> {
819    /// Returns the local name.
820    pub fn name(self) -> &'a str {
821        &self.data.name
822    }
823    /// Returns the namespace URI.
824    pub fn namespace(self) -> Option<&'a str> {
825        self.data.namespace.as_deref()
826    }
827    /// Returns the expanded value.
828    pub fn value(self) -> &'a str {
829        &self.data.value
830    }
831    /// Returns the lexical namespace prefix.
832    pub fn prefix(self) -> Option<&'a str> {
833        self.data.prefix.as_deref()
834    }
835}
836
837/// Iterator over element attributes.
838pub struct Attributes<'a> {
839    values: std::slice::Iter<'a, AttributeData>,
840}
841impl<'a> Iterator for Attributes<'a> {
842    type Item = Attribute<'a>;
843    fn next(&mut self) -> Option<Self::Item> {
844        self.values.next().map(|data| Attribute { data })
845    }
846    fn size_hint(&self) -> (usize, Option<usize>) {
847        self.values.size_hint()
848    }
849}
850impl ExactSizeIterator for Attributes<'_> {}
851
852/// Borrowed in-scope namespace binding.
853#[derive(Clone, Copy)]
854pub struct Namespace<'a> {
855    data: &'a NamespaceData,
856}
857impl<'a> Namespace<'a> {
858    /// Returns the prefix, or `None` for the default namespace.
859    pub fn name(self) -> Option<&'a str> {
860        self.data.prefix.as_deref()
861    }
862    /// Returns the namespace URI.
863    pub fn uri(self) -> &'a str {
864        &self.data.uri
865    }
866}
867
868/// Iterator over in-scope namespace bindings.
869pub struct Namespaces<'a> {
870    values: std::slice::Iter<'a, NamespaceData>,
871}
872impl<'a> Iterator for Namespaces<'a> {
873    type Item = Namespace<'a>;
874    fn next(&mut self) -> Option<Self::Item> {
875        self.values.next().map(|data| Namespace { data })
876    }
877    fn size_hint(&self) -> (usize, Option<usize>) {
878        self.values.size_hint()
879    }
880}
881impl ExactSizeIterator for Namespaces<'_> {}
882
883/// Processing-instruction payload.
884#[derive(Clone, Copy)]
885pub struct PI<'a> {
886    /// Processing-instruction target.
887    pub target: &'a str,
888    /// Optional processing-instruction data.
889    pub value: Option<&'a str>,
890}
891
892/// Iterator over direct child nodes.
893pub struct Children<'a, 'input> {
894    document: &'a Document<'input>,
895    ids: std::slice::Iter<'a, NodeId>,
896}
897impl<'a, 'input> Iterator for Children<'a, 'input> {
898    type Item = Node<'a, 'input>;
899    fn next(&mut self) -> Option<Self::Item> {
900        self.ids.next().and_then(|id| self.document.get_node(*id))
901    }
902    fn size_hint(&self) -> (usize, Option<usize>) {
903        self.ids.size_hint()
904    }
905}
906impl ExactSizeIterator for Children<'_, '_> {}
907impl<'a, 'input> DoubleEndedIterator for Children<'a, 'input> {
908    fn next_back(&mut self) -> Option<Self::Item> {
909        self.ids
910            .next_back()
911            .and_then(|id| self.document.get_node(*id))
912    }
913}
914
915/// Pre-order iterator over a node and its descendants.
916pub struct Descendants<'a, 'input> {
917    document: &'a Document<'input>,
918    ids: Range<u32>,
919}
920impl<'a, 'input> Iterator for Descendants<'a, 'input> {
921    type Item = Node<'a, 'input>;
922    fn next(&mut self) -> Option<Self::Item> {
923        self.ids
924            .next()
925            .and_then(|id| self.document.get_node(NodeId(id)))
926    }
927    fn size_hint(&self) -> (usize, Option<usize>) {
928        self.ids.size_hint()
929    }
930}
931impl DoubleEndedIterator for Descendants<'_, '_> {
932    fn next_back(&mut self) -> Option<Self::Item> {
933        self.ids
934            .next_back()
935            .and_then(|id| self.document.get_node(NodeId(id)))
936    }
937}
938impl ExactSizeIterator for Descendants<'_, '_> {}
939
940/// Iterator over a node and its ancestors.
941pub struct Ancestors<'a, 'input> {
942    next: Option<Node<'a, 'input>>,
943}
944impl<'a, 'input> Iterator for Ancestors<'a, 'input> {
945    type Item = Node<'a, 'input>;
946    fn next(&mut self) -> Option<Self::Item> {
947        let current = self.next?;
948        self.next = current.parent();
949        Some(current)
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::Document;
956    #[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
957    use super::{NamespaceData, namespace_axes_equivalent};
958    use crate::xml::dom::{ParseError, ParsingOptions, XmlBackend};
959
960    #[test]
961    fn runtime_selection_never_falls_back_to_a_compiled_backend() {
962        // A stable selector can be supplied by application configuration even
963        // in a thin build. Missing implementations must fail explicitly.
964        for backend in [
965            XmlBackend::Xmloxide,
966            XmlBackend::Roxmltree,
967            XmlBackend::Differential,
968        ] {
969            let result = Document::parse_with_backend("<r/>", backend);
970            if backend.is_available() {
971                assert!(result.is_ok(), "{backend:?} should be available");
972            } else {
973                assert_eq!(
974                    result.expect_err("an unavailable backend must not fall back"),
975                    ParseError::BackendUnavailable { backend }
976                );
977            }
978        }
979    }
980
981    #[cfg(feature = "xml-backend-differential")]
982    #[test]
983    fn compatibility_feature_selects_differential_by_default() {
984        assert_eq!(XmlBackend::default(), XmlBackend::Differential);
985    }
986
987    #[cfg(all(
988        feature = "xml-backend-xmloxide",
989        feature = "xml-backend-roxmltree",
990        not(feature = "xml-backend-differential")
991    ))]
992    #[test]
993    fn fat_build_defaults_to_xmloxide_without_implicit_double_parsing() {
994        assert_eq!(XmlBackend::default(), XmlBackend::Xmloxide);
995    }
996
997    #[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
998    #[test]
999    fn fat_build_selects_each_semantically_equivalent_runtime_mode() {
1000        // Runtime selection changes only arena construction; downstream XML
1001        // Security semantics and stable source ranges remain identical.
1002        let xml = r#"<r xmlns:p="urn:p"><p:a x="1">value</p:a></r>"#;
1003        let snapshot = |backend| {
1004            let document = Document::parse_with_backend(xml, backend)
1005                .expect("every compiled runtime mode must parse the fixture");
1006            document
1007                .descendants()
1008                .map(|node| {
1009                    (
1010                        node.node_type(),
1011                        node.range(),
1012                        node.text().map(str::to_owned),
1013                    )
1014                })
1015                .collect::<Vec<_>>()
1016        };
1017
1018        let xmloxide = snapshot(XmlBackend::Xmloxide);
1019        assert_eq!(snapshot(XmlBackend::Roxmltree), xmloxide);
1020        assert_eq!(snapshot(XmlBackend::Differential), xmloxide);
1021    }
1022
1023    #[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
1024    #[test]
1025    fn differential_namespace_axis_comparison_preserves_multiplicity() {
1026        // The fail-closed gate must reject unequal axes even if a malformed
1027        // backend projection repeats one binding and hides another.
1028        let binding = |prefix: &str, uri: &str| NamespaceData {
1029            prefix: Some(prefix.to_owned()),
1030            uri: uri.to_owned(),
1031        };
1032        let a = binding("a", "urn:a");
1033        let b = binding("b", "urn:b");
1034
1035        assert!(!namespace_axes_equivalent(
1036            &[a.clone(), a.clone()],
1037            &[a.clone(), b.clone()]
1038        ));
1039        assert!(!namespace_axes_equivalent(
1040            &[a.clone(), b],
1041            &[a.clone(), a.clone()]
1042        ));
1043        assert!(namespace_axes_equivalent(
1044            &[a.clone(), a.clone()],
1045            &[a.clone(), a]
1046        ));
1047    }
1048
1049    #[test]
1050    fn selected_backend_preserves_element_source_ranges() {
1051        // Mutation must splice exact element ranges under either parser.
1052        let xml = "<?p before?><root><empty/><child>value</child></root><!--after-->";
1053        let document = Document::parse(xml).expect("fixture must parse");
1054        let root = document.root_element();
1055        let child = root
1056            .children()
1057            .find(|node| node.has_tag_name("child"))
1058            .expect("fixture must contain the child element");
1059        assert_eq!(
1060            &xml[root.range()],
1061            "<root><empty/><child>value</child></root>"
1062        );
1063        assert_eq!(&xml[child.range()], "<child>value</child>");
1064    }
1065
1066    #[test]
1067    fn selected_backend_materializes_shadowed_namespace_axis() {
1068        // C14N observes the complete in-scope axis, including undeclarations.
1069        let document = Document::parse(
1070            r#"<r xmlns="urn:outer" xmlns:p="urn:p1"><p:a xmlns:p="urn:p2" xmlns=""><p:b/></p:a></r>"#,
1071        ).expect("fixture must parse");
1072        let child = document
1073            .descendants()
1074            .find(|node| node.has_tag_name(("urn:p2", "b")))
1075            .expect("fixture must contain the namespaced descendant");
1076        assert_eq!(child.lookup_namespace_uri(Some("p")), Some("urn:p2"));
1077        assert_eq!(child.lookup_namespace_uri(None), Some(""));
1078        assert_eq!(child.namespaces().count(), 2);
1079    }
1080
1081    #[test]
1082    fn tag_name_matcher_distinguishes_local_from_unqualified_names() {
1083        // A bare string mirrors roxmltree's local-name selector, while an
1084        // explicit optional namespace represents exact expanded-name intent.
1085        let document = Document::parse(r#"<r xmlns:p="urn:p"><item/><p:item/></r>"#)
1086            .expect("namespace matching fixture must parse");
1087        let mut items = document
1088            .root_element()
1089            .children()
1090            .filter(|node| node.is_element());
1091        let unqualified = items.next().expect("unqualified item must exist");
1092        let namespaced = items.next().expect("namespaced item must exist");
1093
1094        assert!(unqualified.has_tag_name("item"));
1095        assert!(namespaced.has_tag_name("item"));
1096        assert!(unqualified.has_tag_name((None, "item")));
1097        assert!(!namespaced.has_tag_name((None, "item")));
1098        assert!(namespaced.has_tag_name((Some("urn:p"), "item")));
1099    }
1100
1101    #[test]
1102    fn namespace_lookup_includes_the_predefined_xml_binding() {
1103        // Namespaces in XML binds `xml` on every element without requiring a
1104        // lexical declaration in the source document.
1105        let document = Document::parse("<root><child xml:lang=\"en\"/></root>")
1106            .expect("predefined namespace fixture must parse");
1107        let child = document
1108            .root_element()
1109            .first_element_child()
1110            .expect("fixture must contain a child");
1111
1112        assert_eq!(
1113            child.lookup_namespace_uri(Some("xml")),
1114            Some("http://www.w3.org/XML/1998/namespace")
1115        );
1116        assert_eq!(
1117            child.lookup_prefix("http://www.w3.org/XML/1998/namespace"),
1118            Some("xml")
1119        );
1120    }
1121
1122    #[test]
1123    fn selected_backend_folds_cdata_and_entities_into_one_text_node() {
1124        // XMLDSig defines character data after entity and CDATA expansion.
1125        let document = Document::parse_with_options(
1126            "<!DOCTYPE r [<!ENTITY value 'two'>]><r>one<![CDATA[+]]>&value;three</r>",
1127            ParsingOptions {
1128                allow_dtd: true,
1129                nodes_limit: 8,
1130            },
1131        )
1132        .expect("bounded internal entity fixture must parse");
1133        let text = document
1134            .root_element()
1135            .children()
1136            .filter(|node| node.is_text())
1137            .collect::<Vec<_>>();
1138        assert_eq!(text.len(), 1);
1139        assert_eq!(text[0].text(), Some("one+twothree"));
1140    }
1141
1142    #[test]
1143    fn selected_backend_maps_builtin_references_into_the_surrounding_text_range() {
1144        // xmloxide expands predefined references into its adjacent text node;
1145        // the lexical sidecar must mirror that boundary rather than invent a
1146        // second semantic text node or lose the original source range.
1147        let xml = "<r>left&amp;right</r>";
1148        let document = Document::parse(xml).expect("fixture must parse");
1149        let text = document
1150            .root_element()
1151            .first_child()
1152            .expect("text child must exist");
1153
1154        assert_eq!(text.text(), Some("left&right"));
1155        assert_eq!(&xml[text.range()], "left&amp;right");
1156    }
1157
1158    #[test]
1159    fn selected_backend_rejects_dtd_before_building_a_tree() {
1160        // The parser option must fail closed before backend-specific handling.
1161        assert_eq!(
1162            Document::parse("<!DOCTYPE r><r/>")
1163                .expect_err("DTD-disabled parsing must reject a document type"),
1164            ParseError::DtdDetected,
1165        );
1166    }
1167
1168    #[test]
1169    fn selected_backend_enforces_semantic_node_limit_after_text_folding() {
1170        // The shared ceiling counts retained nodes rather than lexical events.
1171        let error = Document::parse_with_options(
1172            "<r><a/><b/></r>",
1173            ParsingOptions {
1174                allow_dtd: false,
1175                nodes_limit: 3,
1176            },
1177        )
1178        .expect_err("the semantic node limit must reject the fourth node");
1179        assert_eq!(error, ParseError::NodesLimitReached);
1180    }
1181
1182    #[test]
1183    fn selected_backend_enforces_the_absolute_node_ceiling_before_dom_allocation() {
1184        // Direct DOM callers may request an unbounded parser, but the crate's
1185        // process-safety ceiling must still stop a compact wide document before
1186        // either backend allocates its complete native tree.
1187        let count = crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize;
1188        let xml = format!("<r>{}</r>", "<n/>".repeat(count));
1189
1190        assert_eq!(
1191            Document::parse_with_options(
1192                &xml,
1193                ParsingOptions {
1194                    allow_dtd: false,
1195                    nodes_limit: u32::MAX,
1196                },
1197            )
1198            .expect_err("the absolute retained-node ceiling must remain effective"),
1199            ParseError::NodesLimitReached,
1200        );
1201    }
1202
1203    #[test]
1204    fn selected_backend_enforces_the_absolute_byte_ceiling_before_dom_allocation() {
1205        // Direct semantic-DOM callers have no policy object that can bound the
1206        // source, so the crate ceiling must run before either native backend.
1207        let maximum = crate::hard_limits::XML_DOCUMENT_BYTE_CEILING;
1208        let xml = format!("<r>{}</r>", "x".repeat(maximum));
1209        let expected = ParseError::ByteLimitReached {
1210            maximum,
1211            actual: xml.len(),
1212        };
1213
1214        assert_eq!(
1215            Document::parse(&xml)
1216                .expect_err("default direct parsing must enforce the byte ceiling"),
1217            expected,
1218        );
1219        assert_eq!(
1220            Document::parse_with_options(
1221                &xml,
1222                ParsingOptions {
1223                    allow_dtd: false,
1224                    nodes_limit: u32::MAX,
1225                },
1226            )
1227            .expect_err("custom direct parsing must retain the absolute byte ceiling"),
1228            expected,
1229        );
1230    }
1231
1232    #[test]
1233    fn selected_backend_bounds_direct_entity_expansion_count() {
1234        // A shallow sequence of references bypasses roxmltree's nested-entity
1235        // loop detector. Direct DOM parsing still needs a finite aggregate cap.
1236        let references =
1237            "&value;".repeat(crate::hard_limits::XML_ENTITY_EXPANSION_CEILING as usize + 1);
1238        let xml = format!("<!DOCTYPE r [<!ENTITY value 'x'>]><r>{references}</r>");
1239
1240        assert!(matches!(
1241            Document::parse_with_options(
1242                &xml,
1243                ParsingOptions {
1244                    allow_dtd: true,
1245                    nodes_limit: u32::MAX,
1246                },
1247            ),
1248            Err(ParseError::EntityExpansionLimitReached { maximum, actual })
1249                if maximum == crate::hard_limits::XML_ENTITY_EXPANSION_CEILING
1250                    && actual == maximum + 1
1251        ));
1252    }
1253
1254    #[test]
1255    fn selected_backend_bounds_source_positions_after_semantic_text_folding() {
1256        // Many adjacent CDATA tokens retain one semantic text node, but the
1257        // xmloxide range adapter still needs one source position per token.
1258        // Bound that sidecar independently before constructing a backend DOM.
1259        let segments = crate::hard_limits::XML_SOURCE_POSITION_CEILING + 1;
1260        let xml = format!("<r>{}</r>", "<![CDATA[x]]>".repeat(segments));
1261
1262        assert!(matches!(
1263            Document::parse_with_options(
1264                &xml,
1265                ParsingOptions {
1266                    allow_dtd: false,
1267                    nodes_limit: 3,
1268                },
1269            ),
1270            Err(ParseError::SourcePositionLimitReached { maximum, actual })
1271                if maximum == crate::hard_limits::XML_SOURCE_POSITION_CEILING
1272                    && actual == maximum + 1
1273        ));
1274    }
1275
1276    #[test]
1277    fn unqualified_attribute_lookup_requires_no_namespace() {
1278        // Schema attributes such as Algorithm and URI are unqualified. A
1279        // same-local-name extension attribute must never satisfy that lookup.
1280        let document = Document::parse(
1281            r#"<r xmlns:evil="urn:evil" evil:Algorithm="extension" Algorithm="schema"/>"#,
1282        )
1283        .expect("fixture must parse");
1284        let root = document.root_element();
1285
1286        assert_eq!(root.attribute("Algorithm"), Some("schema"));
1287        assert_eq!(root.attribute(("urn:evil", "Algorithm")), Some("extension"));
1288
1289        let extension_only =
1290            Document::parse(r#"<r xmlns:evil="urn:evil" evil:Algorithm="extension"/>"#)
1291                .expect("fixture must parse");
1292        assert_eq!(extension_only.root_element().attribute("Algorithm"), None);
1293    }
1294
1295    #[test]
1296    fn node_text_preserves_character_data_contract() {
1297        // PI data is not character data, and element text follows the first
1298        // direct child contract rather than searching later descendants.
1299        let document = Document::parse("<r><?p hidden?>visible</r>").expect("fixture must parse");
1300        let root = document.root_element();
1301        let pi = root.first_child().expect("PI must exist");
1302
1303        assert_eq!(pi.text(), None);
1304        assert_eq!(pi.pi().and_then(|value| value.value), Some("hidden"));
1305        assert_eq!(root.text(), None);
1306        assert_eq!(
1307            pi.next_sibling().and_then(|node| node.text()),
1308            Some("visible")
1309        );
1310
1311        let nested =
1312            Document::parse("<r><child>nested</child>later</r>").expect("fixture must parse");
1313        assert_eq!(nested.root_element().text(), None);
1314    }
1315
1316    #[test]
1317    fn selected_backend_rejects_deep_documents_before_dom_parsing() {
1318        // Direct DOM callers do not run operation-policy preflight. The common
1319        // lexical pass must enforce the absolute ceiling before either DOM.
1320        const DEPTH: usize = 20_000;
1321        let mut xml = String::with_capacity(DEPTH * 7);
1322        xml.push_str(&"<n>".repeat(DEPTH));
1323        xml.push_str(&"</n>".repeat(DEPTH));
1324
1325        assert!(matches!(
1326            Document::parse(&xml),
1327            Err(ParseError::DepthLimitReached { maximum, actual })
1328                if maximum == crate::hard_limits::XML_DOCUMENT_DEPTH_CEILING
1329                    && actual == maximum + 1
1330        ));
1331    }
1332
1333    #[cfg(all(feature = "xml-backend-roxmltree", feature = "xml-backend-xmloxide"))]
1334    #[test]
1335    fn differential_comparison_fails_closed_on_semantic_divergence() {
1336        // Differential mode must reject adapter disagreement rather than
1337        // silently selecting one parser's interpretation of attacker input.
1338        let left = Document::parse("<r><a/></r>").expect("fixture must parse");
1339        let mut right = Document::parse("<r><a/></r>").expect("fixture must parse");
1340        right.nodes[2].range.end -= 1;
1341
1342        assert!(matches!(
1343            left.ensure_semantically_equivalent(&right, None),
1344            Err(ParseError::BackendDivergence { .. })
1345        ));
1346    }
1347
1348    #[test]
1349    fn dtd_internal_comments_are_not_document_nodes() {
1350        // A `]` or `>` in an internal-subset comment must not terminate DTD
1351        // range detection and leak that comment into the semantic document.
1352        let document = Document::parse_with_options(
1353            "<!--before--><!DOCTYPE r [<!-- ] > --><!ENTITY value 'ok'>]><r>&value;</r><!--after-->",
1354            ParsingOptions {
1355                allow_dtd: true,
1356                ..ParsingOptions::default()
1357            },
1358        )
1359        .expect("DTD fixture must parse");
1360        let root = document.root();
1361        let comments = root
1362            .descendants()
1363            .filter(|node| node.is_comment())
1364            .filter_map(|node| node.text())
1365            .collect::<Vec<_>>();
1366
1367        assert_eq!(comments, ["before", "after"]);
1368        assert_eq!(
1369            root.descendants()
1370                .find(|node| node.is_text())
1371                .and_then(|node| node.text()),
1372            Some("ok")
1373        );
1374    }
1375
1376    #[test]
1377    fn dtd_range_ignores_doctype_text_in_prolog_nodes() {
1378        // DOCTYPE-like text in ordinary prolog nodes must not redirect DTD
1379        // filtering away from the actual declaration and leak subset nodes.
1380        let document = Document::parse_with_options(
1381            "<!-- <!DOCTYPE fake> --><?probe <!DOCTYPE fake> ?><!DOCTYPE r [<!--hidden-->]><r/>",
1382            ParsingOptions {
1383                allow_dtd: true,
1384                ..ParsingOptions::default()
1385            },
1386        )
1387        .expect("DTD fixture must parse");
1388        let root = document.root();
1389        let comments = root
1390            .children()
1391            .filter(|node| node.is_comment())
1392            .filter_map(|node| node.text())
1393            .collect::<Vec<_>>();
1394        let processing_instructions = root
1395            .children()
1396            .filter(|node| node.is_pi())
1397            .filter_map(|node| node.pi())
1398            .collect::<Vec<_>>();
1399
1400        assert_eq!(comments, [" <!DOCTYPE fake> "]);
1401        assert_eq!(processing_instructions.len(), 1);
1402        assert_eq!(processing_instructions[0].target, "probe");
1403    }
1404
1405    #[cfg(feature = "xml-backend-roxmltree")]
1406    #[test]
1407    fn dtd_range_scanning_accepts_unicode_names() {
1408        // Scanner offsets are bytes; a multibyte XML name must not become an
1409        // invalid UTF-8 slicing boundary while locating the DTD terminator.
1410        let input = "<!DOCTYPE r [<!ENTITY café 'ok'>]><r/>";
1411        let end = input.find("><r/>").expect("fixture has document root") + 1;
1412
1413        let preflight = super::super::LexicalPreflight::scan(input, true)
1414            .expect("Unicode DTD fixture must pass lexical preflight");
1415        assert_eq!(preflight.doctype_range(), Some(&(0..end)));
1416    }
1417}