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    /// Create a bare-name same-document fragment node-set, which excludes
206    /// comment nodes before any transforms are applied.
207    pub(crate) fn subtree_without_comments_with_budget(
208        element: Node<'a, 'a>,
209        budget: Option<&NodeSetMaterializationBudget>,
210    ) -> Result<Self, TransformError> {
211        match budget {
212            Some(budget) => Self::charge_subtree_materialization(element, budget)?,
213            None => {
214                Self::ensure_subtree_materialization_fits(element)?;
215            }
216        }
217        let mut set = Self {
218            doc: element.document(),
219            nodes: HashSet::new(),
220            with_comments: false,
221        };
222        for node in element.descendants().filter(|node| !node.is_comment()) {
223            set.insert_node(node);
224            if node.is_element() {
225                for attribute in node.attributes() {
226                    set.insert_attribute(node, attribute.namespace(), attribute.name());
227                }
228                for namespace in node.namespaces() {
229                    set.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri());
230                }
231            }
232        }
233        Ok(set)
234    }
235
236    pub(crate) fn subtree_with_budget(
237        element: Node<'a, 'a>,
238        budget: &NodeSetMaterializationBudget,
239    ) -> Result<Self, TransformError> {
240        Self::charge_subtree_materialization(element, budget)?;
241        Ok(Self::collect_subtree(element))
242    }
243
244    fn collect_subtree(element: Node<'a, 'a>) -> Self {
245        let mut set = Self {
246            doc: element.document(),
247            nodes: HashSet::new(),
248            with_comments: true,
249        };
250        set.insert_subtree(element);
251        set
252    }
253
254    /// Reference to the underlying document.
255    pub fn document(&self) -> &'a Document<'a> {
256        self.doc
257    }
258
259    /// Check whether a node is in this set.
260    ///
261    /// Returns `false` for nodes from a different document than this set's
262    /// owning document (prevents cross-document NodeId collisions).
263    pub fn contains(&self, node: Node<'_, '_>) -> bool {
264        // Guard: reject nodes from a different document. NodeIds are
265        // per-document indices — the same index from another document
266        // would reference a completely different node.
267        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
268            return false;
269        }
270
271        self.nodes.contains(&XmlNodeKey::Tree(node.id()))
272    }
273
274    /// Exclude a node and all its descendants from this set.
275    ///
276    /// No-op for nodes from a different document.
277    pub fn exclude_subtree(&mut self, node: Node<'_, '_>) {
278        // Guard: only exclude nodes from our document
279        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
280            return;
281        }
282        let excluded_ids = subtree_node_id_range(node);
283        // roxmltree NodeIds index a document-order Vec, and descendants() is a
284        // contiguous slice of that Vec. Attribute and namespace keys carry the
285        // owner NodeId, so one range check excludes every XPath node kind without
286        // either walking ancestors per key or materializing the excluded subtree.
287        self.nodes
288            .retain(|key| !excluded_ids.contains(&key.owner_id().get()));
289    }
290
291    /// Whether comments are included in this node set.
292    pub fn with_comments(&self) -> bool {
293        self.with_comments
294    }
295
296    pub(crate) fn empty(doc: &'a Document<'a>) -> Self {
297        Self {
298            doc,
299            nodes: HashSet::new(),
300            with_comments: false,
301        }
302    }
303
304    #[cfg(test)]
305    pub(crate) fn try_entire_document(doc: &'a Document<'a>) -> Result<Self, TransformError> {
306        Self::entire_document_with_comments(doc)
307    }
308
309    pub(crate) fn try_entire_document_with_budget(
310        doc: &'a Document<'a>,
311        budget: &NodeSetMaterializationBudget,
312    ) -> Result<Self, TransformError> {
313        Self::entire_document_with_comments_with_budget(doc, budget)
314    }
315
316    pub(crate) fn len(&self) -> usize {
317        self.nodes.len()
318    }
319
320    pub(crate) fn insert_node(&mut self, node: Node<'_, '_>) {
321        if self.owns(node) {
322            self.with_comments |= node.is_comment();
323            self.nodes.insert(XmlNodeKey::Tree(node.id()));
324        }
325    }
326
327    pub(crate) fn insert_attribute(
328        &mut self,
329        owner: Node<'_, '_>,
330        namespace: Option<&str>,
331        local_name: &str,
332    ) {
333        if self.owns(owner) {
334            self.nodes.insert(XmlNodeKey::Attribute {
335                owner: owner.id(),
336                namespace: namespace.map(str::to_owned),
337                local_name: local_name.to_owned(),
338            });
339        }
340    }
341
342    pub(crate) fn insert_attribute_with_budget(
343        &mut self,
344        owner: Node<'_, '_>,
345        namespace: Option<&str>,
346        local_name: &str,
347        budget: &NodeSetMaterializationBudget,
348    ) -> Result<(), TransformError> {
349        if self.owns(owner) {
350            let owned_string_bytes = namespace
351                .map_or(0, str::len)
352                .checked_add(local_name.len())
353                .ok_or(TransformError::NodeSetStringsTooLarge {
354                    max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
355                })?;
356            budget.charge(owned_string_bytes)?;
357            self.insert_attribute(owner, namespace, local_name);
358        }
359        Ok(())
360    }
361
362    pub(crate) fn insert_namespace(&mut self, owner: Node<'_, '_>, prefix: &str, uri: &str) {
363        if self.owns(owner) {
364            self.nodes.insert(XmlNodeKey::Namespace {
365                owner: owner.id(),
366                prefix: prefix.to_owned(),
367                uri: uri.to_owned(),
368            });
369        }
370    }
371
372    pub(crate) fn insert_namespace_with_budget(
373        &mut self,
374        owner: Node<'_, '_>,
375        prefix: &str,
376        uri: &str,
377        budget: &NodeSetMaterializationBudget,
378    ) -> Result<(), TransformError> {
379        if self.owns(owner) {
380            let owned_string_bytes = prefix.len().checked_add(uri.len()).ok_or(
381                TransformError::NodeSetStringsTooLarge {
382                    max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
383                },
384            )?;
385            budget.charge(owned_string_bytes)?;
386            self.insert_namespace(owner, prefix, uri);
387        }
388        Ok(())
389    }
390
391    pub(crate) fn insert_subtree(&mut self, root: Node<'_, '_>) {
392        if !self.owns(root) {
393            return;
394        }
395        let mut stack = vec![root];
396        while let Some(node) = stack.pop() {
397            self.insert_node(node);
398            if node.is_element() {
399                for attribute in node.attributes() {
400                    self.insert_attribute(node, attribute.namespace(), attribute.name());
401                }
402                for namespace in node.namespaces() {
403                    self.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri());
404                }
405            }
406            stack.extend(node.children());
407        }
408    }
409
410    pub(crate) fn intersect_with(&mut self, other: &Self) {
411        if !std::ptr::eq(self.doc as *const _, other.doc as *const _) {
412            self.nodes.clear();
413            self.with_comments = false;
414            return;
415        }
416        self.nodes.retain(|key| other.nodes.contains(key));
417        self.with_comments &= other.with_comments;
418    }
419
420    pub(crate) fn subtract(&mut self, other: &Self) {
421        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
422            self.nodes.retain(|key| !other.nodes.contains(key));
423        }
424    }
425
426    pub(crate) fn union_with_budget(
427        &mut self,
428        other: &Self,
429        budget: &NodeSetMaterializationBudget,
430    ) -> Result<(), TransformError> {
431        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
432            for key in &other.nodes {
433                if self.nodes.contains(key) {
434                    continue;
435                }
436                let owned_string_bytes = match key {
437                    XmlNodeKey::Tree(_) => 0,
438                    XmlNodeKey::Attribute {
439                        namespace,
440                        local_name,
441                        ..
442                    } => namespace.as_ref().map_or(0, String::len) + local_name.len(),
443                    XmlNodeKey::Namespace { prefix, uri, .. } => prefix.len() + uri.len(),
444                };
445                budget.charge(owned_string_bytes)?;
446                self.nodes.insert(key.clone());
447            }
448            self.with_comments |= other.with_comments;
449        }
450        Ok(())
451    }
452
453    fn collect_document(doc: &'a Document<'a>, with_comments: bool) -> Self {
454        let mut set = Self::empty(doc);
455        set.insert_subtree(doc.root());
456        if !with_comments {
457            set.nodes.retain(|key| match key {
458                XmlNodeKey::Tree(id) => !doc.get_node(*id).is_some_and(|node| node.is_comment()),
459                _ => true,
460            });
461        }
462        set.with_comments = with_comments;
463        set
464    }
465
466    pub(crate) fn ensure_subtree_materialization_fits(
467        root: Node<'_, '_>,
468    ) -> Result<usize, TransformError> {
469        Ok(Self::subtree_materialization(root)?.entries)
470    }
471
472    fn charge_subtree_materialization(
473        root: Node<'_, '_>,
474        budget: &NodeSetMaterializationBudget,
475    ) -> Result<(), TransformError> {
476        let materialization = Self::subtree_materialization(root)?;
477        budget.charge(materialization.owned_string_bytes)
478    }
479
480    fn subtree_materialization(
481        root: Node<'_, '_>,
482    ) -> Result<NodeSetMaterialization, TransformError> {
483        let mut entries = 0_usize;
484        let mut owned_string_bytes = 0_usize;
485        let mut stack = vec![root];
486        while let Some(node) = stack.pop() {
487            let projected = if node.is_element() {
488                for attribute in node.attributes() {
489                    owned_string_bytes = charge_node_set_string_bytes(
490                        owned_string_bytes,
491                        attribute.namespace().map_or(0, str::len),
492                    )?;
493                    owned_string_bytes =
494                        charge_node_set_string_bytes(owned_string_bytes, attribute.name().len())?;
495                }
496                for namespace in node.namespaces() {
497                    owned_string_bytes = charge_node_set_string_bytes(
498                        owned_string_bytes,
499                        namespace.name().map_or(0, str::len),
500                    )?;
501                    owned_string_bytes =
502                        charge_node_set_string_bytes(owned_string_bytes, namespace.uri().len())?;
503                }
504                1_usize
505                    .checked_add(node.attributes().len())
506                    .and_then(|count| count.checked_add(node.namespaces().len()))
507            } else {
508                Some(1)
509            }
510            .ok_or(TransformError::NodeSetTooLarge {
511                max: MAX_NODE_SET_ENTRIES,
512            })?;
513            entries = entries
514                .checked_add(projected)
515                .ok_or(TransformError::NodeSetTooLarge {
516                    max: MAX_NODE_SET_ENTRIES,
517                })?;
518            if entries > MAX_NODE_SET_ENTRIES {
519                return Err(TransformError::NodeSetTooLarge {
520                    max: MAX_NODE_SET_ENTRIES,
521                });
522            }
523            stack.extend(node.children());
524        }
525        Ok(NodeSetMaterialization {
526            entries,
527            owned_string_bytes,
528        })
529    }
530
531    fn owns(&self, node: Node<'_, '_>) -> bool {
532        std::ptr::eq(node.document() as *const _, self.doc as *const _)
533    }
534}
535
536struct NodeSetMaterialization {
537    entries: usize,
538    owned_string_bytes: usize,
539}
540
541fn charge_node_set_string_bytes(
542    current: usize,
543    additional: usize,
544) -> Result<usize, TransformError> {
545    let total = current
546        .checked_add(additional)
547        .ok_or(TransformError::NodeSetStringsTooLarge {
548            max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
549        })?;
550    if total > MAX_NODE_SET_OWNED_STRING_BYTES {
551        return Err(TransformError::NodeSetStringsTooLarge {
552            max_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
553        });
554    }
555    Ok(total)
556}
557
558fn subtree_node_id_range(node: Node<'_, '_>) -> RangeInclusive<u32> {
559    let last_id = node
560        .descendants()
561        .next_back()
562        .map_or(node.id(), |descendant| descendant.id());
563    node.id().get()..=last_id.get()
564}
565
566impl NodeVisibility for NodeSet<'_> {
567    fn contains_node(&self, node: Node<'_, '_>) -> bool {
568        self.contains(node)
569    }
570
571    fn contains_attribute(
572        &self,
573        owner: Node<'_, '_>,
574        namespace: Option<&str>,
575        local_name: &str,
576    ) -> bool {
577        self.owns(owner)
578            && self.nodes.contains(&XmlNodeKey::Attribute {
579                owner: owner.id(),
580                namespace: namespace.map(str::to_owned),
581                local_name: local_name.to_owned(),
582            })
583    }
584
585    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool {
586        self.owns(owner)
587            && self.nodes.contains(&XmlNodeKey::Namespace {
588                owner: owner.id(),
589                prefix: prefix.to_owned(),
590                uri: uri.to_owned(),
591            })
592    }
593}
594
595/// Errors during transform processing.
596#[derive(Debug, thiserror::Error)]
597#[non_exhaustive]
598pub enum TransformError {
599    /// Data type mismatch between transforms.
600    #[error("type mismatch: expected {expected}, got {got}")]
601    TypeMismatch {
602        /// Expected data type.
603        expected: &'static str,
604        /// Actual data type.
605        got: &'static str,
606    },
607
608    /// Element not found by ID.
609    #[error("element not found by ID: {0}")]
610    ElementNotFound(String),
611
612    /// Unsupported URI scheme or format.
613    #[error("unsupported URI: {0}")]
614    UnsupportedUri(String),
615
616    /// Unsupported transform algorithm.
617    #[error("unsupported transform: {0}")]
618    UnsupportedTransform(String),
619
620    /// A reference declared more transforms than the implementation permits.
621    #[error("transform chain exceeds maximum length of {max}")]
622    TooManyTransforms {
623        /// Maximum accepted transforms in one reference.
624        max: usize,
625    },
626
627    /// Exact XPath node projection would exceed the materialization budget.
628    #[error("node-set materialization exceeds maximum of {max} entries")]
629    NodeSetTooLarge {
630        /// Maximum tree, attribute, and namespace entries accepted.
631        max: usize,
632    },
633
634    /// Owned names and namespace bindings would exceed the byte budget.
635    #[error("node-set materialization exceeds maximum of {max_bytes} owned string bytes")]
636    NodeSetStringsTooLarge {
637        /// Maximum string bytes cloned into one exact XPath node projection.
638        max_bytes: usize,
639    },
640
641    /// Repeated node-set projections would cumulatively clone too many strings.
642    #[error(
643        "node-set materialization exceeds signature-wide maximum of {max_bytes} cumulative owned string bytes"
644    )]
645    NodeSetCumulativeStringsTooLarge {
646        /// Maximum owned string bytes cloned across one signature execution.
647        max_bytes: usize,
648    },
649
650    /// Node-set filtering would exceed the cumulative transform work budget.
651    #[error("node-set filtering exceeds signature-wide maximum of {max_entries} entry visits")]
652    NodeSetFilterWorkTooLarge {
653        /// Maximum node-set entries visited across one signature execution.
654        max_entries: usize,
655    },
656
657    /// Temporary XPath documents would cumulatively copy too many strings.
658    #[error(
659        "XPath mirrors exceed signature-wide maximum of {max_bytes} cumulative copied string bytes"
660    )]
661    XPathMirrorTooLarge {
662        /// Maximum string bytes copied across one signature execution.
663        max_bytes: usize,
664    },
665
666    /// Non-interruptible XPath evaluations would scan too much source text.
667    #[error(
668        "XPath transform exceeds signature-wide maximum of {max_bytes} string-processing work bytes"
669    )]
670    XPathStringWorkTooLarge {
671        /// Maximum conservatively charged source-string bytes per signature.
672        max_bytes: usize,
673    },
674
675    /// Canonicalization error during transform.
676    #[error("C14N error: {0}")]
677    C14n(#[from] crate::c14n::C14nError),
678
679    /// Explicit canonicalization produced too much output in one execution.
680    #[error("cumulative canonical output exceeds signature-wide maximum of {max_bytes} bytes")]
681    C14nOutputTooLarge {
682        /// Maximum canonical bytes produced across one signature execution.
683        max_bytes: usize,
684    },
685
686    /// Base64 decoding failed during the standard XMLDSig Base64 transform.
687    #[error("base64 transform decode error: {0}")]
688    Base64(String),
689
690    /// Raw Base64 transform input exceeded its cumulative execution budget.
691    #[error("cumulative base64 transform input exceeds maximum of {max_bytes} bytes")]
692    Base64InputTooLarge {
693        /// Maximum raw input bytes accepted across one signature execution.
694        max_bytes: usize,
695    },
696
697    /// Decoded Base64 transform output exceeded its allocation budget.
698    #[error("base64 transform output exceeds maximum of {max_bytes} bytes")]
699    Base64OutputTooLarge {
700        /// Maximum decoded output bytes produced by one Base64 transform.
701        max_bytes: usize,
702    },
703
704    /// XPath parsing or evaluation failed.
705    #[error("XPath transform error: {0}")]
706    XPath(String),
707
708    /// XML octets could not be parsed while adapting binary transform output
709    /// to the node-set required by a subsequent transform.
710    #[error("XML transform input parse error: {0}")]
711    XmlParse(String),
712
713    /// XML octets exceeded the operation policy's document node ceiling.
714    #[error("XML transform input exceeds the configured node limit")]
715    XmlNodeLimit,
716
717    /// Effective XML Base resolution crossed too many inherited attributes.
718    #[error("XML Base resolution exceeds maximum of {max} inherited components: got {actual}")]
719    XmlBaseComponentsTooLarge {
720        /// Maximum inherited components permitted by the operation policy.
721        max: usize,
722        /// Number of inherited components encountered.
723        actual: usize,
724    },
725
726    /// Effective XML Base resolution exhausted its cumulative byte budget.
727    #[error("XML Base resolution exceeds cumulative maximum of {max_bytes} bytes: got {actual}")]
728    XmlBaseResolutionTooLarge {
729        /// Maximum cumulative bytes permitted by the operation policy.
730        max_bytes: usize,
731        /// Conservatively charged cumulative byte count.
732        actual: usize,
733    },
734
735    /// One external resource exceeds the operation's per-resource byte limit.
736    #[error("external resource bytes exceed maximum of {max_bytes}: got {actual}")]
737    ExternalResourceTooLarge {
738        /// Maximum bytes permitted for one external resource.
739        max_bytes: usize,
740        /// Bytes in the selected external resource.
741        actual: usize,
742    },
743
744    /// Repeated external dereferences exhausted the operation-wide byte budget.
745    #[error("aggregate external resource bytes exceed maximum of {max_bytes}: got {actual}")]
746    ExternalResourceTotalTooLarge {
747        /// Maximum cumulative bytes permitted across dereferences.
748        max_bytes: usize,
749        /// Cumulative dereferenced bytes that exceeded the maximum.
750        actual: usize,
751    },
752
753    /// The Signature node passed to the enveloped transform belongs to a
754    /// different `Document` than the input `NodeSet`.
755    #[error("enveloped-signature transform: invalid Signature node for this document")]
756    CrossDocumentSignatureNode,
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use crate::c14n::{C14nAlgorithm, C14nMode, canonicalize_with_visibility};
763
764    #[test]
765    fn document_without_comments_preserves_comment_policy() {
766        // Empty-URI dereferencing strips comment nodes and must not retain a
767        // stale flag merely because comments were seen while materializing.
768        let document = Document::parse("<root><!-- excluded --><child/></root>")
769            .expect("fixed comment fixture must parse");
770        let nodes = NodeSet::entire_document_without_comments(&document)
771            .expect("fixed fixture must fit the node-set materialization budget");
772        let comment = document
773            .descendants()
774            .find(|node| node.is_comment())
775            .expect("fixed fixture contains one comment");
776
777        assert!(!nodes.contains(comment));
778        assert!(!nodes.with_comments());
779    }
780
781    #[test]
782    fn excluding_disjoint_oversized_subtree_only_scans_input_keys() {
783        // The excluded Signature subtree is intentionally larger than the
784        // constructor budget; a small referenced subtree must remain unchanged
785        // without attempting to materialize the irrelevant Signature content.
786        let xml = format!(
787            "<root><target Id=\"selected\"><child/></target><Signature>{}</Signature></root>",
788            "<Object/>".repeat(MAX_NODE_SET_ENTRIES + 1)
789        );
790        let document = Document::parse(&xml).expect("fixed oversized fixture must parse");
791        let target = document
792            .descendants()
793            .find(|node| node.attribute("Id") == Some("selected"))
794            .expect("fixed fixture contains the selected subtree");
795        let signature = document
796            .descendants()
797            .find(|node| node.has_tag_name("Signature"))
798            .expect("fixed fixture contains the excluded Signature subtree");
799        let mut nodes = NodeSet::subtree(target)
800            .expect("small selected subtree must fit the materialization budget");
801        let entries_before = nodes.nodes.len();
802
803        nodes.exclude_subtree(signature);
804
805        assert_eq!(nodes.nodes.len(), entries_before);
806        assert!(nodes.contains(target));
807        assert!(
808            nodes.contains(
809                target
810                    .first_element_child()
811                    .expect("fixed target subtree contains a child")
812            )
813        );
814    }
815
816    #[test]
817    fn materialization_rejects_inherited_namespace_byte_amplification() {
818        // One declaration is cheap in the source XML, but XPath exposes the
819        // inherited binding on every descendant. Materializing owned namespace
820        // keys must reject the amplified bytes before cloning those strings.
821        let namespace_uri = "x".repeat(8_192);
822        let xml = format!(
823            "<root xmlns:amplified=\"{namespace_uri}\">{}</root>",
824            "<child/>".repeat(1_025)
825        );
826        let document = Document::parse(&xml).expect("fixed namespace fixture must parse");
827
828        let error = NodeSet::entire_document_without_comments(&document)
829            .err()
830            .expect("amplified namespace bytes must exceed the materialization budget");
831
832        assert!(matches!(
833            error,
834            TransformError::NodeSetStringsTooLarge { .. }
835        ));
836    }
837
838    #[test]
839    fn subtree_node_id_range_contains_only_the_selected_subtree() {
840        // roxmltree stores a subtree in one contiguous document-order span.
841        // The exclusion fast path relies on that span including attributes and
842        // namespaces through their owner element, but no adjacent siblings.
843        let document = Document::parse(
844            "<root><before/><excluded xmlns:gone=\"urn:gone\" a=\"1\"><child/></excluded><after/></root>",
845        )
846        .expect("fixed subtree range fixture must parse");
847        let excluded = document
848            .descendants()
849            .find(|node| node.has_tag_name("excluded"))
850            .expect("fixed fixture contains the excluded subtree");
851        let range = subtree_node_id_range(excluded);
852        let before = document
853            .descendants()
854            .find(|node| node.has_tag_name("before"))
855            .expect("fixed fixture contains the preceding sibling");
856        let child = excluded
857            .first_element_child()
858            .expect("fixed fixture contains an excluded child");
859        let after = document
860            .descendants()
861            .find(|node| node.has_tag_name("after"))
862            .expect("fixed fixture contains the following sibling");
863
864        assert!(!range.contains(&before.id().get()));
865        assert!(range.contains(&excluded.id().get()));
866        assert!(range.contains(&child.id().get()));
867        assert!(!range.contains(&after.id().get()));
868
869        let mut nodes = NodeSet::entire_document_with_comments(&document)
870            .expect("fixed fixture must fit the node-set materialization budget");
871        nodes.exclude_subtree(excluded);
872
873        assert!(nodes.contains(before));
874        assert!(!nodes.contains(excluded));
875        assert!(!nodes.contains(child));
876        assert!(!nodes.contains_attribute(excluded, None, "a"));
877        assert!(!nodes.contains_namespace(excluded, "gone", "urn:gone"));
878        assert!(nodes.contains(after));
879    }
880
881    #[test]
882    fn excluding_subtree_removes_trailing_text_and_comments_from_canonical_output() {
883        // Pretty-printed Signature elements can end in text or comments rather
884        // than an element. The contiguous owner-ID range must exclude those tail
885        // nodes while preserving text and elements surrounding the subtree.
886        let document = Document::parse(
887            "<root><before/>keep-before<excluded><child/>drop-text<!--drop-comment--></excluded>keep-after<after/></root>",
888        )
889        .expect("fixed trailing-node fixture must parse");
890        let excluded = document
891            .descendants()
892            .find(|node| node.has_tag_name("excluded"))
893            .expect("fixed fixture contains the excluded subtree");
894        let trailing_text = excluded
895            .children()
896            .find(|node| node.is_text())
897            .expect("fixed fixture contains trailing text");
898        let trailing_comment = excluded
899            .children()
900            .find(|node| node.is_comment())
901            .expect("fixed fixture contains a trailing comment");
902        let mut nodes = NodeSet::entire_document_with_comments(&document)
903            .expect("fixed fixture must fit the node-set materialization budget");
904
905        nodes.exclude_subtree(excluded);
906
907        assert!(!nodes.contains(trailing_text));
908        assert!(!nodes.contains(trailing_comment));
909        let mut output = Vec::new();
910        canonicalize_with_visibility(
911            &document,
912            Some(&nodes),
913            &C14nAlgorithm::new(C14nMode::Inclusive1_0, true),
914            &mut output,
915        )
916        .expect("the retained node set must canonicalize");
917        assert_eq!(
918            output,
919            b"<root><before></before>keep-beforekeep-after<after></after></root>"
920        );
921    }
922}