Skip to main content

xml_sec/xmldsig/
types.rs

1//! Core types for the XMLDSig transform pipeline.
2//!
3//! These types flow between URI dereference, transforms, and digest computation.
4//!
5//! These types are consumed by URI dereference, the transform chain (P1-014,
6//! P1-015), and reference processing (P1-018).
7
8use std::cell::Cell;
9use std::collections::HashSet;
10use std::ops::RangeInclusive;
11
12use roxmltree::{Document, Node, NodeId};
13
14const MAX_NODE_SET_ENTRIES: usize = 65_536;
15const MAX_NODE_SET_OWNED_STRING_BYTES: usize = 8 * 1024 * 1024;
16const MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES: usize = 64 * 1024 * 1024;
17
18use crate::c14n::NodeVisibility;
19
20// roxmltree 0.21 uses `Node<'a, 'input: 'a>`. We tie both lifetimes together
21// with a single `'a` by requiring `'input = 'a` at every use site (`Node<'a, 'a>`).
22// This is safe because our NodeSet borrows the Document which owns the input.
23
24/// Data flowing between transforms in the verification/signing pipeline.
25///
26/// Transforms consume and produce either a node set (XML-level) or raw bytes
27/// (after canonicalization or base64 decode).
28pub enum TransformData<'a> {
29    /// A set of nodes from the parsed XML document.
30    NodeSet(NodeSet<'a>),
31    /// Raw bytes (e.g., after canonicalization).
32    Binary(Vec<u8>),
33}
34
35impl std::fmt::Debug for TransformData<'_> {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::NodeSet(_) => f.debug_tuple("NodeSet").field(&"...").finish(),
39            Self::Binary(b) => f.debug_tuple("Binary").field(&b.len()).finish(),
40        }
41    }
42}
43
44impl<'a> TransformData<'a> {
45    /// Convert to `NodeSet`, returning an error if this is `Binary` data.
46    pub fn into_node_set(self) -> Result<NodeSet<'a>, TransformError> {
47        match self {
48            Self::NodeSet(ns) => Ok(ns),
49            Self::Binary(_) => Err(TransformError::TypeMismatch {
50                expected: "NodeSet",
51                got: "Binary",
52            }),
53        }
54    }
55
56    /// Convert to binary bytes, returning an error if this is a `NodeSet`.
57    pub fn into_binary(self) -> Result<Vec<u8>, TransformError> {
58        match self {
59            Self::Binary(b) => Ok(b),
60            Self::NodeSet(_) => Err(TransformError::TypeMismatch {
61                expected: "Binary",
62                got: "NodeSet",
63            }),
64        }
65    }
66}
67
68/// A set of nodes from a roxmltree document.
69///
70/// Represents the exact XPath nodes included for canonicalization and transforms.
71///
72/// Attributes and namespace bindings are first-class XPath nodes even though
73/// roxmltree exposes them through their owner element. Materializing them here
74/// lets XPath filters independently include or remove those nodes as required
75/// by canonical XML document-subset processing.
76pub struct NodeSet<'a> {
77    /// Reference to the parsed document.
78    doc: &'a Document<'a>,
79    nodes: HashSet<XmlNodeKey>,
80    /// Whether comment nodes are included. For empty URI dereference (whole
81    /// document), comments are excluded per XMLDSig spec.
82    with_comments: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86enum XmlNodeKey {
87    Tree(NodeId),
88    Attribute {
89        owner: NodeId,
90        namespace: Option<String>,
91        local_name: String,
92    },
93    Namespace {
94        owner: NodeId,
95        prefix: String,
96        uri: String,
97    },
98}
99
100pub(crate) struct NodeSetMaterializationBudget {
101    remaining_owned_string_bytes: Cell<usize>,
102}
103
104impl Default for NodeSetMaterializationBudget {
105    fn default() -> Self {
106        Self {
107            remaining_owned_string_bytes: Cell::new(MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES),
108        }
109    }
110}
111
112impl NodeSetMaterializationBudget {
113    fn charge(&self, owned_string_bytes: usize) -> Result<(), TransformError> {
114        let Some(remaining) = self
115            .remaining_owned_string_bytes
116            .get()
117            .checked_sub(owned_string_bytes)
118        else {
119            self.remaining_owned_string_bytes.set(0);
120            return Err(TransformError::NodeSetCumulativeStringsTooLarge {
121                max_bytes: MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
122            });
123        };
124        self.remaining_owned_string_bytes.set(remaining);
125        Ok(())
126    }
127
128    #[cfg(test)]
129    pub(crate) fn with_limit(limit: usize) -> Self {
130        Self {
131            remaining_owned_string_bytes: Cell::new(limit),
132        }
133    }
134}
135
136impl XmlNodeKey {
137    fn owner_id(&self) -> NodeId {
138        match self {
139            Self::Tree(id) => *id,
140            Self::Attribute { owner, .. } | Self::Namespace { owner, .. } => *owner,
141        }
142    }
143}
144
145impl<'a> NodeSet<'a> {
146    /// Create a node set representing the entire document without comments.
147    ///
148    /// Per XMLDSig §4.3.3.2: "An empty URI [...] is a reference to the document
149    /// [...] and the comment nodes are not included."
150    ///
151    /// # Errors
152    ///
153    /// Returns [`TransformError::NodeSetTooLarge`] or
154    /// [`TransformError::NodeSetStringsTooLarge`] when projecting the document's
155    /// tree, attribute, namespace, or owned string data would exceed its budget.
156    pub fn entire_document_without_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
157        Self::ensure_subtree_materialization_fits(doc.root())?;
158        Ok(Self::collect_document(doc, false))
159    }
160
161    pub(crate) fn entire_document_without_comments_with_budget(
162        doc: &'a Document<'a>,
163        budget: &NodeSetMaterializationBudget,
164    ) -> Result<Self, TransformError> {
165        Self::charge_subtree_materialization(doc.root(), budget)?;
166        Ok(Self::collect_document(doc, false))
167    }
168
169    /// Create a node set representing the entire document with comments.
170    ///
171    /// Used for `#xpointer(/)` which, unlike empty URI, includes comment nodes.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`TransformError::NodeSetTooLarge`] or
176    /// [`TransformError::NodeSetStringsTooLarge`] when projecting the document's
177    /// tree, attribute, namespace, or owned string data would exceed its budget.
178    pub fn entire_document_with_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
179        Self::ensure_subtree_materialization_fits(doc.root())?;
180        Ok(Self::collect_document(doc, true))
181    }
182
183    pub(crate) fn entire_document_with_comments_with_budget(
184        doc: &'a Document<'a>,
185        budget: &NodeSetMaterializationBudget,
186    ) -> Result<Self, TransformError> {
187        Self::charge_subtree_materialization(doc.root(), budget)?;
188        Ok(Self::collect_document(doc, true))
189    }
190
191    /// Create a node set rooted at `element`, containing that element and all
192    /// of its descendant nodes (elements, text, and, for this constructor,
193    /// comment nodes).
194    ///
195    /// # Errors
196    ///
197    /// Returns [`TransformError::NodeSetTooLarge`] or
198    /// [`TransformError::NodeSetStringsTooLarge`] when projecting the subtree's
199    /// tree, attribute, namespace, or owned string data would exceed its budget.
200    pub fn subtree(element: Node<'a, 'a>) -> Result<Self, TransformError> {
201        Self::ensure_subtree_materialization_fits(element)?;
202        Ok(Self::collect_subtree(element))
203    }
204
205    pub(crate) fn subtree_with_budget(
206        element: Node<'a, 'a>,
207        budget: &NodeSetMaterializationBudget,
208    ) -> Result<Self, TransformError> {
209        Self::charge_subtree_materialization(element, budget)?;
210        Ok(Self::collect_subtree(element))
211    }
212
213    fn collect_subtree(element: Node<'a, 'a>) -> Self {
214        let mut set = Self {
215            doc: element.document(),
216            nodes: HashSet::new(),
217            with_comments: true,
218        };
219        set.insert_subtree(element);
220        set
221    }
222
223    /// Reference to the underlying document.
224    pub fn document(&self) -> &'a Document<'a> {
225        self.doc
226    }
227
228    /// Check whether a node is in this set.
229    ///
230    /// Returns `false` for nodes from a different document than this set's
231    /// owning document (prevents cross-document NodeId collisions).
232    pub fn contains(&self, node: Node<'_, '_>) -> bool {
233        // Guard: reject nodes from a different document. NodeIds are
234        // per-document indices — the same index from another document
235        // would reference a completely different node.
236        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
237            return false;
238        }
239
240        self.nodes.contains(&XmlNodeKey::Tree(node.id()))
241    }
242
243    /// Exclude a node and all its descendants from this set.
244    ///
245    /// No-op for nodes from a different document.
246    pub fn exclude_subtree(&mut self, node: Node<'_, '_>) {
247        // Guard: only exclude nodes from our document
248        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
249            return;
250        }
251        let excluded_ids = subtree_node_id_range(node);
252        // roxmltree NodeIds index a document-order Vec, and descendants() is a
253        // contiguous slice of that Vec. Attribute and namespace keys carry the
254        // owner NodeId, so one range check excludes every XPath node kind without
255        // either walking ancestors per key or materializing the excluded subtree.
256        self.nodes
257            .retain(|key| !excluded_ids.contains(&key.owner_id().get()));
258    }
259
260    /// Whether comments are included in this node set.
261    pub fn with_comments(&self) -> bool {
262        self.with_comments
263    }
264
265    pub(crate) fn empty(doc: &'a Document<'a>) -> Self {
266        Self {
267            doc,
268            nodes: HashSet::new(),
269            with_comments: false,
270        }
271    }
272
273    #[cfg(test)]
274    pub(crate) fn try_entire_document(doc: &'a Document<'a>) -> Result<Self, TransformError> {
275        Self::entire_document_with_comments(doc)
276    }
277
278    pub(crate) fn try_entire_document_with_budget(
279        doc: &'a Document<'a>,
280        budget: &NodeSetMaterializationBudget,
281    ) -> Result<Self, TransformError> {
282        Self::entire_document_with_comments_with_budget(doc, budget)
283    }
284
285    pub(crate) fn len(&self) -> usize {
286        self.nodes.len()
287    }
288
289    pub(crate) fn insert_node(&mut self, node: Node<'_, '_>) {
290        if self.owns(node) {
291            self.with_comments |= node.is_comment();
292            self.nodes.insert(XmlNodeKey::Tree(node.id()));
293        }
294    }
295
296    pub(crate) fn insert_attribute(
297        &mut self,
298        owner: Node<'_, '_>,
299        namespace: Option<&str>,
300        local_name: &str,
301    ) {
302        if self.owns(owner) {
303            self.nodes.insert(XmlNodeKey::Attribute {
304                owner: owner.id(),
305                namespace: namespace.map(str::to_owned),
306                local_name: local_name.to_owned(),
307            });
308        }
309    }
310
311    pub(crate) fn insert_attribute_with_budget(
312        &mut self,
313        owner: Node<'_, '_>,
314        namespace: Option<&str>,
315        local_name: &str,
316        budget: &NodeSetMaterializationBudget,
317    ) -> Result<(), TransformError> {
318        if self.owns(owner) {
319            let owned_string_bytes = namespace
320                .map_or(0, str::len)
321                .checked_add(local_name.len())
322                .ok_or(TransformError::NodeSetStringsTooLarge {
323                    max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
324                })?;
325            budget.charge(owned_string_bytes)?;
326            self.insert_attribute(owner, namespace, local_name);
327        }
328        Ok(())
329    }
330
331    pub(crate) fn insert_namespace(&mut self, owner: Node<'_, '_>, prefix: &str, uri: &str) {
332        if self.owns(owner) {
333            self.nodes.insert(XmlNodeKey::Namespace {
334                owner: owner.id(),
335                prefix: prefix.to_owned(),
336                uri: uri.to_owned(),
337            });
338        }
339    }
340
341    pub(crate) fn insert_namespace_with_budget(
342        &mut self,
343        owner: Node<'_, '_>,
344        prefix: &str,
345        uri: &str,
346        budget: &NodeSetMaterializationBudget,
347    ) -> Result<(), TransformError> {
348        if self.owns(owner) {
349            let owned_string_bytes = prefix.len().checked_add(uri.len()).ok_or(
350                TransformError::NodeSetStringsTooLarge {
351                    max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
352                },
353            )?;
354            budget.charge(owned_string_bytes)?;
355            self.insert_namespace(owner, prefix, uri);
356        }
357        Ok(())
358    }
359
360    pub(crate) fn insert_subtree(&mut self, root: Node<'_, '_>) {
361        if !self.owns(root) {
362            return;
363        }
364        let mut stack = vec![root];
365        while let Some(node) = stack.pop() {
366            self.insert_node(node);
367            if node.is_element() {
368                for attribute in node.attributes() {
369                    self.insert_attribute(node, attribute.namespace(), attribute.name());
370                }
371                for namespace in node.namespaces() {
372                    self.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri());
373                }
374            }
375            stack.extend(node.children());
376        }
377    }
378
379    pub(crate) fn intersect_with(&mut self, other: &Self) {
380        if !std::ptr::eq(self.doc as *const _, other.doc as *const _) {
381            self.nodes.clear();
382            self.with_comments = false;
383            return;
384        }
385        self.nodes.retain(|key| other.nodes.contains(key));
386        self.with_comments &= other.with_comments;
387    }
388
389    pub(crate) fn subtract(&mut self, other: &Self) {
390        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
391            self.nodes.retain(|key| !other.nodes.contains(key));
392        }
393    }
394
395    pub(crate) fn union_with_budget(
396        &mut self,
397        other: &Self,
398        budget: &NodeSetMaterializationBudget,
399    ) -> Result<(), TransformError> {
400        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
401            for key in &other.nodes {
402                if self.nodes.contains(key) {
403                    continue;
404                }
405                let owned_string_bytes = match key {
406                    XmlNodeKey::Tree(_) => 0,
407                    XmlNodeKey::Attribute {
408                        namespace,
409                        local_name,
410                        ..
411                    } => namespace.as_ref().map_or(0, String::len) + local_name.len(),
412                    XmlNodeKey::Namespace { prefix, uri, .. } => prefix.len() + uri.len(),
413                };
414                budget.charge(owned_string_bytes)?;
415                self.nodes.insert(key.clone());
416            }
417            self.with_comments |= other.with_comments;
418        }
419        Ok(())
420    }
421
422    fn collect_document(doc: &'a Document<'a>, with_comments: bool) -> Self {
423        let mut set = Self::empty(doc);
424        set.insert_subtree(doc.root());
425        if !with_comments {
426            set.nodes.retain(|key| match key {
427                XmlNodeKey::Tree(id) => !doc.get_node(*id).is_some_and(|node| node.is_comment()),
428                _ => true,
429            });
430        }
431        set.with_comments = with_comments;
432        set
433    }
434
435    pub(crate) fn ensure_subtree_materialization_fits(
436        root: Node<'_, '_>,
437    ) -> Result<usize, TransformError> {
438        Ok(Self::subtree_materialization(root)?.entries)
439    }
440
441    fn charge_subtree_materialization(
442        root: Node<'_, '_>,
443        budget: &NodeSetMaterializationBudget,
444    ) -> Result<(), TransformError> {
445        let materialization = Self::subtree_materialization(root)?;
446        budget.charge(materialization.owned_string_bytes)
447    }
448
449    fn subtree_materialization(
450        root: Node<'_, '_>,
451    ) -> Result<NodeSetMaterialization, TransformError> {
452        let mut entries = 0_usize;
453        let mut owned_string_bytes = 0_usize;
454        let mut stack = vec![root];
455        while let Some(node) = stack.pop() {
456            let projected = if node.is_element() {
457                for attribute in node.attributes() {
458                    owned_string_bytes = charge_node_set_string_bytes(
459                        owned_string_bytes,
460                        attribute.namespace().map_or(0, str::len),
461                    )?;
462                    owned_string_bytes =
463                        charge_node_set_string_bytes(owned_string_bytes, attribute.name().len())?;
464                }
465                for namespace in node.namespaces() {
466                    owned_string_bytes = charge_node_set_string_bytes(
467                        owned_string_bytes,
468                        namespace.name().map_or(0, str::len),
469                    )?;
470                    owned_string_bytes =
471                        charge_node_set_string_bytes(owned_string_bytes, namespace.uri().len())?;
472                }
473                1_usize
474                    .checked_add(node.attributes().len())
475                    .and_then(|count| count.checked_add(node.namespaces().len()))
476            } else {
477                Some(1)
478            }
479            .ok_or(TransformError::NodeSetTooLarge {
480                max: MAX_NODE_SET_ENTRIES,
481            })?;
482            entries = entries
483                .checked_add(projected)
484                .ok_or(TransformError::NodeSetTooLarge {
485                    max: MAX_NODE_SET_ENTRIES,
486                })?;
487            if entries > MAX_NODE_SET_ENTRIES {
488                return Err(TransformError::NodeSetTooLarge {
489                    max: MAX_NODE_SET_ENTRIES,
490                });
491            }
492            stack.extend(node.children());
493        }
494        Ok(NodeSetMaterialization {
495            entries,
496            owned_string_bytes,
497        })
498    }
499
500    fn owns(&self, node: Node<'_, '_>) -> bool {
501        std::ptr::eq(node.document() as *const _, self.doc as *const _)
502    }
503}
504
505struct NodeSetMaterialization {
506    entries: usize,
507    owned_string_bytes: usize,
508}
509
510fn charge_node_set_string_bytes(
511    current: usize,
512    additional: usize,
513) -> Result<usize, TransformError> {
514    let total = current
515        .checked_add(additional)
516        .ok_or(TransformError::NodeSetStringsTooLarge {
517            max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
518        })?;
519    if total > MAX_NODE_SET_OWNED_STRING_BYTES {
520        return Err(TransformError::NodeSetStringsTooLarge {
521            max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
522        });
523    }
524    Ok(total)
525}
526
527fn subtree_node_id_range(node: Node<'_, '_>) -> RangeInclusive<u32> {
528    let last_id = node
529        .descendants()
530        .next_back()
531        .map_or(node.id(), |descendant| descendant.id());
532    node.id().get()..=last_id.get()
533}
534
535impl NodeVisibility for NodeSet<'_> {
536    fn contains_node(&self, node: Node<'_, '_>) -> bool {
537        self.contains(node)
538    }
539
540    fn contains_attribute(
541        &self,
542        owner: Node<'_, '_>,
543        namespace: Option<&str>,
544        local_name: &str,
545    ) -> bool {
546        self.owns(owner)
547            && self.nodes.contains(&XmlNodeKey::Attribute {
548                owner: owner.id(),
549                namespace: namespace.map(str::to_owned),
550                local_name: local_name.to_owned(),
551            })
552    }
553
554    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool {
555        self.owns(owner)
556            && self.nodes.contains(&XmlNodeKey::Namespace {
557                owner: owner.id(),
558                prefix: prefix.to_owned(),
559                uri: uri.to_owned(),
560            })
561    }
562}
563
564/// Errors during transform processing.
565#[derive(Debug, thiserror::Error)]
566#[non_exhaustive]
567pub enum TransformError {
568    /// Data type mismatch between transforms.
569    #[error("type mismatch: expected {expected}, got {got}")]
570    TypeMismatch {
571        /// Expected data type.
572        expected: &'static str,
573        /// Actual data type.
574        got: &'static str,
575    },
576
577    /// Element not found by ID.
578    #[error("element not found by ID: {0}")]
579    ElementNotFound(String),
580
581    /// Unsupported URI scheme or format.
582    #[error("unsupported URI: {0}")]
583    UnsupportedUri(String),
584
585    /// Unsupported transform algorithm.
586    #[error("unsupported transform: {0}")]
587    UnsupportedTransform(String),
588
589    /// A reference declared more transforms than the implementation permits.
590    #[error("transform chain exceeds maximum length of {max}")]
591    TooManyTransforms {
592        /// Maximum accepted transforms in one reference.
593        max: usize,
594    },
595
596    /// Exact XPath node projection would exceed the materialization budget.
597    #[error("node-set materialization exceeds maximum of {max} entries")]
598    NodeSetTooLarge {
599        /// Maximum tree, attribute, and namespace entries accepted.
600        max: usize,
601    },
602
603    /// Owned names and namespace bindings would exceed the byte budget.
604    #[error("node-set materialization exceeds maximum of {max_bytes} owned string bytes")]
605    NodeSetStringsTooLarge {
606        /// Maximum string bytes cloned into one exact XPath node projection.
607        max_bytes: usize,
608    },
609
610    /// Repeated node-set projections would cumulatively clone too many strings.
611    #[error(
612        "node-set materialization exceeds signature-wide maximum of {max_bytes} cumulative owned string bytes"
613    )]
614    NodeSetCumulativeStringsTooLarge {
615        /// Maximum owned string bytes cloned across one signature execution.
616        max_bytes: usize,
617    },
618
619    /// Node-set filtering would exceed the cumulative transform work budget.
620    #[error("node-set filtering exceeds signature-wide maximum of {max_entries} entry visits")]
621    NodeSetFilterWorkTooLarge {
622        /// Maximum node-set entries visited across one signature execution.
623        max_entries: usize,
624    },
625
626    /// Temporary XPath documents would cumulatively copy too many strings.
627    #[error(
628        "XPath mirrors exceed signature-wide maximum of {max_bytes} cumulative copied string bytes"
629    )]
630    XPathMirrorTooLarge {
631        /// Maximum string bytes copied across one signature execution.
632        max_bytes: usize,
633    },
634
635    /// Non-interruptible XPath evaluations would scan too much source text.
636    #[error(
637        "XPath transform exceeds signature-wide maximum of {max_bytes} string-processing work bytes"
638    )]
639    XPathStringWorkTooLarge {
640        /// Maximum conservatively charged source-string bytes per signature.
641        max_bytes: usize,
642    },
643
644    /// Canonicalization error during transform.
645    #[error("C14N error: {0}")]
646    C14n(#[from] crate::c14n::C14nError),
647
648    /// Explicit canonicalization produced too much output in one execution.
649    #[error("cumulative canonical output exceeds signature-wide maximum of {max_bytes} bytes")]
650    C14nOutputTooLarge {
651        /// Maximum canonical bytes produced across one signature execution.
652        max_bytes: usize,
653    },
654
655    /// Base64 decoding failed during the standard XMLDSig Base64 transform.
656    #[error("base64 transform decode error: {0}")]
657    Base64(String),
658
659    /// Raw Base64 transform input exceeded its cumulative execution budget.
660    #[error("cumulative base64 transform input exceeds maximum of {max_bytes} bytes")]
661    Base64InputTooLarge {
662        /// Maximum raw input bytes accepted across one signature execution.
663        max_bytes: usize,
664    },
665
666    /// Decoded Base64 transform output exceeded its allocation budget.
667    #[error("base64 transform output exceeds maximum of {max_bytes} bytes")]
668    Base64OutputTooLarge {
669        /// Maximum decoded output bytes produced by one Base64 transform.
670        max_bytes: usize,
671    },
672
673    /// XPath parsing or evaluation failed.
674    #[error("XPath transform error: {0}")]
675    XPath(String),
676
677    /// XML octets could not be parsed while adapting binary transform output
678    /// to the node-set required by a subsequent transform.
679    #[error("XML transform input parse error: {0}")]
680    XmlParse(String),
681
682    /// The Signature node passed to the enveloped transform belongs to a
683    /// different `Document` than the input `NodeSet`.
684    #[error("enveloped-signature transform: invalid Signature node for this document")]
685    CrossDocumentSignatureNode,
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::c14n::{C14nAlgorithm, C14nMode, canonicalize_with_visibility};
692
693    #[test]
694    fn document_without_comments_preserves_comment_policy() {
695        // Empty-URI dereferencing strips comment nodes and must not retain a
696        // stale flag merely because comments were seen while materializing.
697        let document = Document::parse("<root><!-- excluded --><child/></root>")
698            .expect("fixed comment fixture must parse");
699        let nodes = NodeSet::entire_document_without_comments(&document)
700            .expect("fixed fixture must fit the node-set materialization budget");
701        let comment = document
702            .descendants()
703            .find(|node| node.is_comment())
704            .expect("fixed fixture contains one comment");
705
706        assert!(!nodes.contains(comment));
707        assert!(!nodes.with_comments());
708    }
709
710    #[test]
711    fn excluding_disjoint_oversized_subtree_only_scans_input_keys() {
712        // The excluded Signature subtree is intentionally larger than the
713        // constructor budget; a small referenced subtree must remain unchanged
714        // without attempting to materialize the irrelevant Signature content.
715        let xml = format!(
716            "<root><target Id=\"selected\"><child/></target><Signature>{}</Signature></root>",
717            "<Object/>".repeat(MAX_NODE_SET_ENTRIES + 1)
718        );
719        let document = Document::parse(&xml).expect("fixed oversized fixture must parse");
720        let target = document
721            .descendants()
722            .find(|node| node.attribute("Id") == Some("selected"))
723            .expect("fixed fixture contains the selected subtree");
724        let signature = document
725            .descendants()
726            .find(|node| node.has_tag_name("Signature"))
727            .expect("fixed fixture contains the excluded Signature subtree");
728        let mut nodes = NodeSet::subtree(target)
729            .expect("small selected subtree must fit the materialization budget");
730        let entries_before = nodes.nodes.len();
731
732        nodes.exclude_subtree(signature);
733
734        assert_eq!(nodes.nodes.len(), entries_before);
735        assert!(nodes.contains(target));
736        assert!(
737            nodes.contains(
738                target
739                    .first_element_child()
740                    .expect("fixed target subtree contains a child")
741            )
742        );
743    }
744
745    #[test]
746    fn materialization_rejects_inherited_namespace_byte_amplification() {
747        // One declaration is cheap in the source XML, but XPath exposes the
748        // inherited binding on every descendant. Materializing owned namespace
749        // keys must reject the amplified bytes before cloning those strings.
750        let namespace_uri = "x".repeat(8_192);
751        let xml = format!(
752            "<root xmlns:amplified=\"{namespace_uri}\">{}</root>",
753            "<child/>".repeat(1_025)
754        );
755        let document = Document::parse(&xml).expect("fixed namespace fixture must parse");
756
757        let error = NodeSet::entire_document_without_comments(&document)
758            .err()
759            .expect("amplified namespace bytes must exceed the materialization budget");
760
761        assert!(matches!(
762            error,
763            TransformError::NodeSetStringsTooLarge { .. }
764        ));
765    }
766
767    #[test]
768    fn subtree_node_id_range_contains_only_the_selected_subtree() {
769        // roxmltree stores a subtree in one contiguous document-order span.
770        // The exclusion fast path relies on that span including attributes and
771        // namespaces through their owner element, but no adjacent siblings.
772        let document = Document::parse(
773            "<root><before/><excluded xmlns:gone=\"urn:gone\" a=\"1\"><child/></excluded><after/></root>",
774        )
775        .expect("fixed subtree range fixture must parse");
776        let excluded = document
777            .descendants()
778            .find(|node| node.has_tag_name("excluded"))
779            .expect("fixed fixture contains the excluded subtree");
780        let range = subtree_node_id_range(excluded);
781        let before = document
782            .descendants()
783            .find(|node| node.has_tag_name("before"))
784            .expect("fixed fixture contains the preceding sibling");
785        let child = excluded
786            .first_element_child()
787            .expect("fixed fixture contains an excluded child");
788        let after = document
789            .descendants()
790            .find(|node| node.has_tag_name("after"))
791            .expect("fixed fixture contains the following sibling");
792
793        assert!(!range.contains(&before.id().get()));
794        assert!(range.contains(&excluded.id().get()));
795        assert!(range.contains(&child.id().get()));
796        assert!(!range.contains(&after.id().get()));
797
798        let mut nodes = NodeSet::entire_document_with_comments(&document)
799            .expect("fixed fixture must fit the node-set materialization budget");
800        nodes.exclude_subtree(excluded);
801
802        assert!(nodes.contains(before));
803        assert!(!nodes.contains(excluded));
804        assert!(!nodes.contains(child));
805        assert!(!nodes.contains_attribute(excluded, None, "a"));
806        assert!(!nodes.contains_namespace(excluded, "gone", "urn:gone"));
807        assert!(nodes.contains(after));
808    }
809
810    #[test]
811    fn excluding_subtree_removes_trailing_text_and_comments_from_canonical_output() {
812        // Pretty-printed Signature elements can end in text or comments rather
813        // than an element. The contiguous owner-ID range must exclude those tail
814        // nodes while preserving text and elements surrounding the subtree.
815        let document = Document::parse(
816            "<root><before/>keep-before<excluded><child/>drop-text<!--drop-comment--></excluded>keep-after<after/></root>",
817        )
818        .expect("fixed trailing-node fixture must parse");
819        let excluded = document
820            .descendants()
821            .find(|node| node.has_tag_name("excluded"))
822            .expect("fixed fixture contains the excluded subtree");
823        let trailing_text = excluded
824            .children()
825            .find(|node| node.is_text())
826            .expect("fixed fixture contains trailing text");
827        let trailing_comment = excluded
828            .children()
829            .find(|node| node.is_comment())
830            .expect("fixed fixture contains a trailing comment");
831        let mut nodes = NodeSet::entire_document_with_comments(&document)
832            .expect("fixed fixture must fit the node-set materialization budget");
833
834        nodes.exclude_subtree(excluded);
835
836        assert!(!nodes.contains(trailing_text));
837        assert!(!nodes.contains(trailing_comment));
838        let mut output = Vec::new();
839        canonicalize_with_visibility(
840            &document,
841            Some(&nodes),
842            &C14nAlgorithm::new(C14nMode::Inclusive1_0, true),
843            &mut output,
844        )
845        .expect("the retained node set must canonicalize");
846        assert_eq!(
847            output,
848            b"<root><before></before>keep-beforekeep-after<after></after></root>"
849        );
850    }
851}