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 = crate::hard_limits::NODE_SET_ENTRY_CEILING;
15const MAX_NODE_SET_OWNED_STRING_BYTES: usize =
16    crate::hard_limits::NODE_SET_OWNED_STRING_BYTE_CEILING;
17const MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES: usize =
18    crate::hard_limits::NODE_SET_CUMULATIVE_OWNED_STRING_BYTE_CEILING;
19
20use crate::c14n::NodeVisibility;
21
22// roxmltree 0.21 uses `Node<'a, 'input: 'a>`. We tie both lifetimes together
23// with a single `'a` by requiring `'input = 'a` at every use site (`Node<'a, 'a>`).
24// This is safe because our NodeSet borrows the Document which owns the input.
25
26/// Data flowing between transforms in the verification/signing pipeline.
27///
28/// Transforms consume and produce either a node set (XML-level) or raw bytes
29/// (after canonicalization or base64 decode).
30pub enum TransformData<'a> {
31    /// A set of nodes from the parsed XML document.
32    NodeSet(NodeSet<'a>),
33    /// Raw bytes (e.g., after canonicalization).
34    Binary(Vec<u8>),
35}
36
37impl std::fmt::Debug for TransformData<'_> {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::NodeSet(_) => f.debug_tuple("NodeSet").field(&"...").finish(),
41            Self::Binary(b) => f.debug_tuple("Binary").field(&b.len()).finish(),
42        }
43    }
44}
45
46impl<'a> TransformData<'a> {
47    /// Convert to `NodeSet`, returning an error if this is `Binary` data.
48    pub fn into_node_set(self) -> Result<NodeSet<'a>, TransformError> {
49        match self {
50            Self::NodeSet(ns) => Ok(ns),
51            Self::Binary(_) => Err(TransformError::TypeMismatch {
52                expected: "NodeSet",
53                got: "Binary",
54            }),
55        }
56    }
57
58    /// Convert to binary bytes, returning an error if this is a `NodeSet`.
59    pub fn into_binary(self) -> Result<Vec<u8>, TransformError> {
60        match self {
61            Self::Binary(b) => Ok(b),
62            Self::NodeSet(_) => Err(TransformError::TypeMismatch {
63                expected: "Binary",
64                got: "NodeSet",
65            }),
66        }
67    }
68}
69
70/// A set of nodes from a roxmltree document.
71///
72/// Represents the exact XPath nodes included for canonicalization and transforms.
73///
74/// Attributes and namespace bindings are first-class XPath nodes even though
75/// roxmltree exposes them through their owner element. Materializing them here
76/// lets XPath filters independently include or remove those nodes as required
77/// by canonical XML document-subset processing.
78pub struct NodeSet<'a> {
79    /// Reference to the parsed document.
80    doc: &'a Document<'a>,
81    nodes: HashSet<XmlNodeKey>,
82    owned_string_bytes: usize,
83    /// Whether comment nodes are included. For empty URI dereference (whole
84    /// document), comments are excluded per XMLDSig spec.
85    with_comments: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89enum XmlNodeKey {
90    Tree(NodeId),
91    Attribute {
92        owner: NodeId,
93        namespace: Option<String>,
94        local_name: String,
95    },
96    Namespace {
97        owner: NodeId,
98        prefix: String,
99        uri: String,
100    },
101}
102
103pub(crate) struct NodeSetMaterializationBudget {
104    remaining_owned_string_bytes: Cell<usize>,
105    max_entries: usize,
106    max_owned_string_bytes: usize,
107    max_cumulative_owned_string_bytes: usize,
108}
109
110impl Default for NodeSetMaterializationBudget {
111    fn default() -> Self {
112        Self {
113            remaining_owned_string_bytes: Cell::new(MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES),
114            max_entries: MAX_NODE_SET_ENTRIES,
115            max_owned_string_bytes: MAX_NODE_SET_OWNED_STRING_BYTES,
116            max_cumulative_owned_string_bytes: MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
117        }
118    }
119}
120
121impl NodeSetMaterializationBudget {
122    fn charge(&self, owned_string_bytes: usize) -> Result<(), TransformError> {
123        let remaining_before = self.remaining_owned_string_bytes.get();
124        let Some(remaining) = self
125            .remaining_owned_string_bytes
126            .get()
127            .checked_sub(owned_string_bytes)
128        else {
129            self.remaining_owned_string_bytes.set(0);
130            let consumed = self
131                .max_cumulative_owned_string_bytes
132                .saturating_sub(remaining_before);
133            return Err(transform_resource_limit(
134                crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
135                self.max_cumulative_owned_string_bytes,
136                consumed.saturating_add(owned_string_bytes),
137            ));
138        };
139        self.remaining_owned_string_bytes.set(remaining);
140        Ok(())
141    }
142
143    #[cfg(test)]
144    pub(crate) fn with_limit(limit: usize) -> Self {
145        Self {
146            remaining_owned_string_bytes: Cell::new(limit),
147            max_cumulative_owned_string_bytes: limit,
148            ..Self::default()
149        }
150    }
151
152    pub(crate) fn with_limits(
153        max_entries: usize,
154        max_owned_string_bytes: usize,
155        max_cumulative_owned_string_bytes: usize,
156    ) -> Self {
157        Self {
158            remaining_owned_string_bytes: Cell::new(max_cumulative_owned_string_bytes),
159            max_entries,
160            max_owned_string_bytes,
161            max_cumulative_owned_string_bytes,
162        }
163    }
164}
165
166impl XmlNodeKey {
167    fn owner_id(&self) -> NodeId {
168        match self {
169            Self::Tree(id) => *id,
170            Self::Attribute { owner, .. } | Self::Namespace { owner, .. } => *owner,
171        }
172    }
173
174    fn owned_string_bytes(&self) -> usize {
175        match self {
176            Self::Tree(_) => 0,
177            Self::Attribute {
178                namespace,
179                local_name,
180                ..
181            } => namespace
182                .as_ref()
183                .map_or(0, String::len)
184                .saturating_add(local_name.len()),
185            Self::Namespace { prefix, uri, .. } => prefix.len().saturating_add(uri.len()),
186        }
187    }
188}
189
190impl<'a> NodeSet<'a> {
191    /// Create a node set representing the entire document without comments.
192    ///
193    /// Per XMLDSig §4.3.3.2: "An empty URI [...] is a reference to the document
194    /// [...] and the comment nodes are not included."
195    ///
196    /// # Errors
197    ///
198    /// Returns [`TransformError::Policy`] when projecting the document's tree,
199    /// attribute, namespace, or owned string data would exceed its budget.
200    pub fn entire_document_without_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
201        Self::ensure_subtree_materialization_fits(doc.root(), false)?;
202        Ok(Self::collect_document(doc, false))
203    }
204
205    pub(crate) fn entire_document_without_comments_with_budget(
206        doc: &'a Document<'a>,
207        budget: &NodeSetMaterializationBudget,
208    ) -> Result<Self, TransformError> {
209        Self::charge_subtree_materialization(doc.root(), false, budget)?;
210        Ok(Self::collect_document(doc, false))
211    }
212
213    /// Create a node set representing the entire document with comments.
214    ///
215    /// Used for `#xpointer(/)` which, unlike empty URI, includes comment nodes.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`TransformError::Policy`] when projecting the document's tree,
220    /// attribute, namespace, or owned string data would exceed its budget.
221    pub fn entire_document_with_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
222        Self::ensure_subtree_materialization_fits(doc.root(), true)?;
223        Ok(Self::collect_document(doc, true))
224    }
225
226    pub(crate) fn entire_document_with_comments_with_budget(
227        doc: &'a Document<'a>,
228        budget: &NodeSetMaterializationBudget,
229    ) -> Result<Self, TransformError> {
230        Self::charge_subtree_materialization(doc.root(), true, budget)?;
231        Ok(Self::collect_document(doc, true))
232    }
233
234    /// Create a node set rooted at `element`, containing that element and all
235    /// of its descendant nodes (elements, text, and, for this constructor,
236    /// comment nodes).
237    ///
238    /// # Errors
239    ///
240    /// Returns [`TransformError::Policy`] when projecting the subtree's tree,
241    /// attribute, namespace, or owned string data would exceed its budget.
242    pub fn subtree(element: Node<'a, 'a>) -> Result<Self, TransformError> {
243        Self::ensure_subtree_materialization_fits(element, true)?;
244        Ok(Self::collect_subtree(element))
245    }
246
247    /// Create a bare-name same-document fragment node-set, which excludes
248    /// comment nodes before any transforms are applied.
249    pub(crate) fn subtree_without_comments_with_budget(
250        element: Node<'a, 'a>,
251        budget: Option<&NodeSetMaterializationBudget>,
252    ) -> Result<Self, TransformError> {
253        match budget {
254            Some(budget) => Self::charge_subtree_materialization(element, false, budget)?,
255            None => {
256                Self::ensure_subtree_materialization_fits(element, false)?;
257            }
258        }
259        let mut set = Self {
260            doc: element.document(),
261            nodes: HashSet::new(),
262            owned_string_bytes: 0,
263            with_comments: false,
264        };
265        for node in element.descendants().filter(|node| !node.is_comment()) {
266            set.insert_node(node);
267            if node.is_element() {
268                for attribute in node.attributes() {
269                    set.insert_attribute(node, attribute.namespace(), attribute.name());
270                }
271                for namespace in node.namespaces() {
272                    set.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri());
273                }
274            }
275        }
276        Ok(set)
277    }
278
279    pub(crate) fn subtree_with_budget(
280        element: Node<'a, 'a>,
281        budget: &NodeSetMaterializationBudget,
282    ) -> Result<Self, TransformError> {
283        Self::charge_subtree_materialization(element, true, budget)?;
284        Ok(Self::collect_subtree(element))
285    }
286
287    fn collect_subtree(element: Node<'a, 'a>) -> Self {
288        let mut set = Self {
289            doc: element.document(),
290            nodes: HashSet::new(),
291            owned_string_bytes: 0,
292            with_comments: true,
293        };
294        set.insert_subtree(element);
295        set
296    }
297
298    /// Reference to the underlying document.
299    pub fn document(&self) -> &'a Document<'a> {
300        self.doc
301    }
302
303    /// Check whether a node is in this set.
304    ///
305    /// Returns `false` for nodes from a different document than this set's
306    /// owning document (prevents cross-document NodeId collisions).
307    pub fn contains(&self, node: Node<'_, '_>) -> bool {
308        // Guard: reject nodes from a different document. NodeIds are
309        // per-document indices — the same index from another document
310        // would reference a completely different node.
311        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
312            return false;
313        }
314
315        self.nodes.contains(&XmlNodeKey::Tree(node.id()))
316    }
317
318    /// Exclude a node and all its descendants from this set.
319    ///
320    /// No-op for nodes from a different document.
321    pub fn exclude_subtree(&mut self, node: Node<'_, '_>) {
322        // Guard: only exclude nodes from our document
323        if !std::ptr::eq(node.document() as *const _, self.doc as *const _) {
324            return;
325        }
326        let excluded_ids = subtree_node_id_range(node);
327        // roxmltree NodeIds index a document-order Vec, and descendants() is a
328        // contiguous slice of that Vec. Attribute and namespace keys carry the
329        // owner NodeId, so one range check excludes every XPath node kind without
330        // either walking ancestors per key or materializing the excluded subtree.
331        self.nodes
332            .retain(|key| !excluded_ids.contains(&key.owner_id().get()));
333        self.refresh_owned_string_bytes();
334    }
335
336    /// Whether comments are included in this node set.
337    pub fn with_comments(&self) -> bool {
338        self.with_comments
339    }
340
341    pub(crate) fn empty(doc: &'a Document<'a>) -> Self {
342        Self {
343            doc,
344            nodes: HashSet::new(),
345            owned_string_bytes: 0,
346            with_comments: false,
347        }
348    }
349
350    #[cfg(test)]
351    pub(crate) fn try_entire_document(doc: &'a Document<'a>) -> Result<Self, TransformError> {
352        Self::entire_document_with_comments(doc)
353    }
354
355    pub(crate) fn try_entire_document_with_budget(
356        doc: &'a Document<'a>,
357        budget: &NodeSetMaterializationBudget,
358    ) -> Result<Self, TransformError> {
359        Self::entire_document_with_comments_with_budget(doc, budget)
360    }
361
362    pub(crate) fn len(&self) -> usize {
363        self.nodes.len()
364    }
365
366    pub(crate) fn insert_node(&mut self, node: Node<'_, '_>) {
367        if self.owns(node) {
368            self.with_comments |= node.is_comment();
369            self.nodes.insert(XmlNodeKey::Tree(node.id()));
370        }
371    }
372
373    pub(crate) fn insert_attribute(
374        &mut self,
375        owner: Node<'_, '_>,
376        namespace: Option<&str>,
377        local_name: &str,
378    ) {
379        if self.owns(owner) {
380            let key = XmlNodeKey::Attribute {
381                owner: owner.id(),
382                namespace: namespace.map(str::to_owned),
383                local_name: local_name.to_owned(),
384            };
385            if self.nodes.insert(key) {
386                self.owned_string_bytes = self
387                    .owned_string_bytes
388                    .saturating_add(namespace.map_or(0, str::len))
389                    .saturating_add(local_name.len());
390            }
391        }
392    }
393
394    pub(crate) fn insert_attribute_with_budget(
395        &mut self,
396        owner: Node<'_, '_>,
397        namespace: Option<&str>,
398        local_name: &str,
399        budget: &NodeSetMaterializationBudget,
400    ) -> Result<(), TransformError> {
401        if self.owns(owner) {
402            let owner_id = owner.id();
403            let additional_bytes = namespace.map_or(0, str::len).checked_add(local_name.len());
404            self.insert_projected_key_with_budget(
405                additional_bytes,
406                |key| {
407                    matches!(
408                        key,
409                        XmlNodeKey::Attribute {
410                            owner,
411                            namespace: stored_namespace,
412                            local_name: stored_local_name,
413                        } if *owner == owner_id
414                            && stored_namespace.as_deref() == namespace
415                            && stored_local_name == local_name
416                    )
417                },
418                || XmlNodeKey::Attribute {
419                    owner: owner_id,
420                    namespace: namespace.map(str::to_owned),
421                    local_name: local_name.to_owned(),
422                },
423                budget,
424            )?;
425        }
426        Ok(())
427    }
428
429    pub(crate) fn insert_namespace(&mut self, owner: Node<'_, '_>, prefix: &str, uri: &str) {
430        if self.owns(owner) {
431            let key = XmlNodeKey::Namespace {
432                owner: owner.id(),
433                prefix: prefix.to_owned(),
434                uri: uri.to_owned(),
435            };
436            if self.nodes.insert(key) {
437                self.owned_string_bytes = self
438                    .owned_string_bytes
439                    .saturating_add(prefix.len())
440                    .saturating_add(uri.len());
441            }
442        }
443    }
444
445    pub(crate) fn insert_namespace_with_budget(
446        &mut self,
447        owner: Node<'_, '_>,
448        prefix: &str,
449        uri: &str,
450        budget: &NodeSetMaterializationBudget,
451    ) -> Result<(), TransformError> {
452        if self.owns(owner) {
453            let owner_id = owner.id();
454            self.insert_projected_key_with_budget(
455                prefix.len().checked_add(uri.len()),
456                |key| {
457                    matches!(
458                        key,
459                        XmlNodeKey::Namespace {
460                            owner,
461                            prefix: stored_prefix,
462                            uri: stored_uri,
463                        } if *owner == owner_id && stored_prefix == prefix && stored_uri == uri
464                    )
465                },
466                || XmlNodeKey::Namespace {
467                    owner: owner_id,
468                    prefix: prefix.to_owned(),
469                    uri: uri.to_owned(),
470                },
471                budget,
472            )?;
473        }
474        Ok(())
475    }
476
477    fn insert_projected_key_with_budget<D, F>(
478        &mut self,
479        additional_bytes: Option<usize>,
480        is_duplicate: D,
481        build_key: F,
482        budget: &NodeSetMaterializationBudget,
483    ) -> Result<(), TransformError>
484    where
485        D: Fn(&XmlNodeKey) -> bool,
486        F: FnOnce() -> XmlNodeKey,
487    {
488        let (additional_bytes, preflight_error) = match additional_bytes {
489            None => (
490                0,
491                Some(transform_resource_limit(
492                    crate::policy::resource_name::NODE_SET_OWNED_STRING_BYTES,
493                    budget.max_owned_string_bytes,
494                    usize::MAX,
495                )),
496            ),
497            Some(additional_bytes) => {
498                let total_bytes = self.owned_string_bytes.saturating_add(additional_bytes);
499                let error = if total_bytes > budget.max_owned_string_bytes {
500                    Some(transform_resource_limit(
501                        crate::policy::resource_name::NODE_SET_OWNED_STRING_BYTES,
502                        budget.max_owned_string_bytes,
503                        total_bytes,
504                    ))
505                } else if self.nodes.len() >= budget.max_entries {
506                    Some(transform_resource_limit(
507                        crate::policy::resource_name::NODE_SET_ENTRIES,
508                        budget.max_entries,
509                        self.nodes.len().saturating_add(1),
510                    ))
511                } else {
512                    let remaining = budget.remaining_owned_string_bytes.get();
513                    remaining.checked_sub(additional_bytes).is_none().then(|| {
514                        let consumed = budget
515                            .max_cumulative_owned_string_bytes
516                            .saturating_sub(remaining);
517                        transform_resource_limit(
518                            crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
519                            budget.max_cumulative_owned_string_bytes,
520                            consumed.saturating_add(additional_bytes),
521                        )
522                    })
523                };
524                (additional_bytes, error)
525            }
526        };
527        if let Some(error) = preflight_error {
528            // Duplicate projections consume no capacity. Scan only when a new
529            // key would fail so the normal insertion path keeps hash-set cost.
530            if self.nodes.iter().any(is_duplicate) {
531                return Ok(());
532            }
533            return Err(error);
534        }
535
536        let total_bytes = self.owned_string_bytes.saturating_add(additional_bytes);
537        let key = build_key();
538        if self.nodes.contains(&key) {
539            return Ok(());
540        }
541        budget.charge(additional_bytes)?;
542        let inserted = self.nodes.insert(key);
543        debug_assert!(inserted, "the duplicate key was checked before insertion");
544        if inserted {
545            self.owned_string_bytes = total_bytes;
546        }
547        Ok(())
548    }
549
550    pub(crate) fn insert_subtree(&mut self, root: Node<'_, '_>) {
551        if !self.owns(root) {
552            return;
553        }
554        let mut stack = vec![root];
555        while let Some(node) = stack.pop() {
556            self.insert_node(node);
557            if node.is_element() {
558                for attribute in node.attributes() {
559                    self.insert_attribute(node, attribute.namespace(), attribute.name());
560                }
561                for namespace in node.namespaces() {
562                    self.insert_namespace(node, namespace.name().unwrap_or(""), namespace.uri());
563                }
564            }
565            stack.extend(node.children());
566        }
567    }
568
569    pub(crate) fn intersect_with(&mut self, other: &Self) {
570        if !std::ptr::eq(self.doc as *const _, other.doc as *const _) {
571            self.nodes.clear();
572            self.owned_string_bytes = 0;
573            self.with_comments = false;
574            return;
575        }
576        self.nodes.retain(|key| other.nodes.contains(key));
577        self.refresh_owned_string_bytes();
578        self.with_comments &= other.with_comments;
579    }
580
581    pub(crate) fn subtract(&mut self, other: &Self) {
582        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
583            self.nodes.retain(|key| !other.nodes.contains(key));
584            self.refresh_owned_string_bytes();
585        }
586    }
587
588    pub(crate) fn union_with_budget(
589        &mut self,
590        other: &Self,
591        budget: &NodeSetMaterializationBudget,
592    ) -> Result<(), TransformError> {
593        if std::ptr::eq(self.doc as *const _, other.doc as *const _) {
594            for key in &other.nodes {
595                if self.nodes.contains(key) {
596                    continue;
597                }
598                let owned_string_bytes = key.owned_string_bytes();
599                let total_bytes = self.owned_string_bytes.saturating_add(owned_string_bytes);
600                if total_bytes > budget.max_owned_string_bytes {
601                    return Err(transform_resource_limit(
602                        crate::policy::resource_name::NODE_SET_OWNED_STRING_BYTES,
603                        budget.max_owned_string_bytes,
604                        total_bytes,
605                    ));
606                }
607                if self.nodes.len() >= budget.max_entries {
608                    return Err(transform_resource_limit(
609                        crate::policy::resource_name::NODE_SET_ENTRIES,
610                        budget.max_entries,
611                        self.nodes.len().saturating_add(1),
612                    ));
613                }
614                budget.charge(owned_string_bytes)?;
615                self.nodes.insert(key.clone());
616                self.owned_string_bytes = total_bytes;
617            }
618            self.with_comments |= other.with_comments;
619        }
620        Ok(())
621    }
622
623    fn collect_document(doc: &'a Document<'a>, with_comments: bool) -> Self {
624        let mut set = Self::empty(doc);
625        set.insert_subtree(doc.root());
626        if !with_comments {
627            set.nodes.retain(|key| match key {
628                XmlNodeKey::Tree(id) => !doc.get_node(*id).is_some_and(|node| node.is_comment()),
629                _ => true,
630            });
631        }
632        set.with_comments = with_comments;
633        set
634    }
635
636    fn refresh_owned_string_bytes(&mut self) {
637        self.owned_string_bytes = self.nodes.iter().fold(0_usize, |total, key| {
638            total.saturating_add(key.owned_string_bytes())
639        });
640    }
641
642    pub(crate) fn ensure_subtree_materialization_fits(
643        root: Node<'_, '_>,
644        with_comments: bool,
645    ) -> Result<usize, TransformError> {
646        Ok(Self::subtree_materialization(root, with_comments)?.entries)
647    }
648
649    pub(crate) fn ensure_subtree_materialization_fits_with_budget(
650        root: Node<'_, '_>,
651        with_comments: bool,
652        budget: &NodeSetMaterializationBudget,
653    ) -> Result<usize, TransformError> {
654        Ok(Self::subtree_materialization_with_limits(
655            root,
656            with_comments,
657            budget.max_entries,
658            budget.max_owned_string_bytes,
659        )?
660        .entries)
661    }
662
663    fn charge_subtree_materialization(
664        root: Node<'_, '_>,
665        with_comments: bool,
666        budget: &NodeSetMaterializationBudget,
667    ) -> Result<(), TransformError> {
668        let materialization = Self::subtree_materialization_with_limits(
669            root,
670            with_comments,
671            budget.max_entries,
672            budget.max_owned_string_bytes,
673        )?;
674        budget.charge(materialization.owned_string_bytes)
675    }
676
677    fn subtree_materialization(
678        root: Node<'_, '_>,
679        with_comments: bool,
680    ) -> Result<NodeSetMaterialization, TransformError> {
681        Self::subtree_materialization_with_limits(
682            root,
683            with_comments,
684            MAX_NODE_SET_ENTRIES,
685            MAX_NODE_SET_OWNED_STRING_BYTES,
686        )
687    }
688
689    fn subtree_materialization_with_limits(
690        root: Node<'_, '_>,
691        with_comments: bool,
692        max_entries: usize,
693        max_owned_string_bytes: usize,
694    ) -> Result<NodeSetMaterialization, TransformError> {
695        let mut entries = 0_usize;
696        let mut owned_string_bytes = 0_usize;
697        let mut stack = vec![root];
698        while let Some(node) = stack.pop() {
699            if node.is_comment() && !with_comments {
700                continue;
701            }
702            let projected = if node.is_element() {
703                for attribute in node.attributes() {
704                    owned_string_bytes = charge_node_set_string_bytes(
705                        owned_string_bytes,
706                        attribute.namespace().map_or(0, str::len),
707                        max_owned_string_bytes,
708                    )?;
709                    owned_string_bytes = charge_node_set_string_bytes(
710                        owned_string_bytes,
711                        attribute.name().len(),
712                        max_owned_string_bytes,
713                    )?;
714                }
715                for namespace in node.namespaces() {
716                    owned_string_bytes = charge_node_set_string_bytes(
717                        owned_string_bytes,
718                        namespace.name().map_or(0, str::len),
719                        max_owned_string_bytes,
720                    )?;
721                    owned_string_bytes = charge_node_set_string_bytes(
722                        owned_string_bytes,
723                        namespace.uri().len(),
724                        max_owned_string_bytes,
725                    )?;
726                }
727                1_usize
728                    .checked_add(node.attributes().len())
729                    .and_then(|count| count.checked_add(node.namespaces().len()))
730            } else {
731                Some(1)
732            }
733            .ok_or_else(|| {
734                transform_resource_limit(
735                    crate::policy::resource_name::NODE_SET_ENTRIES,
736                    max_entries,
737                    usize::MAX,
738                )
739            })?;
740            entries = entries.checked_add(projected).ok_or_else(|| {
741                transform_resource_limit(
742                    crate::policy::resource_name::NODE_SET_ENTRIES,
743                    max_entries,
744                    usize::MAX,
745                )
746            })?;
747            if entries > max_entries {
748                return Err(transform_resource_limit(
749                    crate::policy::resource_name::NODE_SET_ENTRIES,
750                    max_entries,
751                    entries,
752                ));
753            }
754            stack.extend(node.children());
755        }
756        Ok(NodeSetMaterialization {
757            entries,
758            owned_string_bytes,
759        })
760    }
761
762    fn owns(&self, node: Node<'_, '_>) -> bool {
763        std::ptr::eq(node.document() as *const _, self.doc as *const _)
764    }
765}
766
767struct NodeSetMaterialization {
768    entries: usize,
769    owned_string_bytes: usize,
770}
771
772fn charge_node_set_string_bytes(
773    current: usize,
774    additional: usize,
775    max_bytes: usize,
776) -> Result<usize, TransformError> {
777    let total = current.checked_add(additional).ok_or_else(|| {
778        transform_resource_limit(
779            crate::policy::resource_name::NODE_SET_OWNED_STRING_BYTES,
780            max_bytes,
781            usize::MAX,
782        )
783    })?;
784    if total > max_bytes {
785        return Err(transform_resource_limit(
786            crate::policy::resource_name::NODE_SET_OWNED_STRING_BYTES,
787            max_bytes,
788            total,
789        ));
790    }
791    Ok(total)
792}
793
794fn subtree_node_id_range(node: Node<'_, '_>) -> RangeInclusive<u32> {
795    let last_id = node
796        .descendants()
797        .next_back()
798        .map_or(node.id(), |descendant| descendant.id());
799    node.id().get()..=last_id.get()
800}
801
802impl NodeVisibility for NodeSet<'_> {
803    fn contains_node(&self, node: Node<'_, '_>) -> bool {
804        self.contains(node)
805    }
806
807    fn contains_attribute(
808        &self,
809        owner: Node<'_, '_>,
810        namespace: Option<&str>,
811        local_name: &str,
812    ) -> bool {
813        self.owns(owner)
814            && self.nodes.contains(&XmlNodeKey::Attribute {
815                owner: owner.id(),
816                namespace: namespace.map(str::to_owned),
817                local_name: local_name.to_owned(),
818            })
819    }
820
821    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool {
822        self.owns(owner)
823            && self.nodes.contains(&XmlNodeKey::Namespace {
824                owner: owner.id(),
825                prefix: prefix.to_owned(),
826                uri: uri.to_owned(),
827            })
828    }
829}
830
831/// Errors during transform processing.
832#[derive(Debug, thiserror::Error)]
833#[non_exhaustive]
834pub enum TransformError {
835    /// The active operation policy rejected transform processing.
836    #[error("transform policy violation: {0}")]
837    Policy(#[from] crate::policy::PolicyViolation),
838
839    /// Data type mismatch between transforms.
840    #[error("type mismatch: expected {expected}, got {got}")]
841    TypeMismatch {
842        /// Expected data type.
843        expected: &'static str,
844        /// Actual data type.
845        got: &'static str,
846    },
847
848    /// Element not found by ID.
849    #[error("element not found by ID: {0}")]
850    ElementNotFound(String),
851
852    /// Unsupported URI scheme or format.
853    #[error("unsupported URI: {0}")]
854    UnsupportedUri(String),
855
856    /// Unsupported transform algorithm.
857    #[error("unsupported transform: {0}")]
858    UnsupportedTransform(String),
859
860    /// Canonicalization error during transform.
861    #[error("C14N error: {0}")]
862    C14n(#[from] crate::c14n::C14nError),
863
864    /// Base64 decoding failed during the standard XMLDSig Base64 transform.
865    #[error("base64 transform decode error: {0}")]
866    Base64(String),
867
868    /// XPath parsing or evaluation failed.
869    #[error("XPath transform error: {0}")]
870    XPath(String),
871
872    /// XML octets could not be parsed while adapting binary transform output
873    /// to the node-set required by a subsequent transform.
874    #[error("XML transform input parse error: {0}")]
875    XmlParse(String),
876
877    /// The Signature node passed to the enveloped transform belongs to a
878    /// different `Document` than the input `NodeSet`.
879    #[error("enveloped-signature transform: invalid Signature node for this document")]
880    CrossDocumentSignatureNode,
881}
882
883pub(crate) fn transform_resource_limit(
884    resource: &'static str,
885    maximum: usize,
886    actual: usize,
887) -> TransformError {
888    crate::policy::PolicyViolation::ResourceLimit {
889        resource,
890        maximum,
891        actual,
892    }
893    .into()
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899    use crate::c14n::{C14nAlgorithm, C14nMode, canonicalize_with_visibility};
900
901    #[test]
902    fn incremental_projection_enforces_aggregate_owned_string_policy() {
903        // XPath builds arbitrary projected sets incrementally. Individually small
904        // attribute keys must not bypass the per-set aggregate string ceiling.
905        let document = Document::parse("<root/>").expect("fixed XML must parse");
906        let root = document.root_element();
907        let mut nodes = NodeSet::empty(&document);
908        let budget = NodeSetMaterializationBudget::with_limits(16, 3, 16);
909
910        nodes
911            .insert_attribute_with_budget(root, None, "a", &budget)
912            .expect("the first one-byte attribute name must fit");
913        let error = nodes
914            .insert_attribute_with_budget(root, None, "bbb", &budget)
915            .expect_err("aggregate projected names must exceed three bytes");
916
917        assert!(matches!(
918            error,
919            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
920                resource: "node-set owned string bytes",
921                maximum: 3,
922                actual: 4,
923            })
924        ));
925    }
926
927    #[test]
928    fn projected_attribute_and_namespace_share_one_budget_path() {
929        // Duplicate projected keys are free, while distinct attributes and
930        // namespaces consume the same operation-wide owned-string allowance.
931        let document = Document::parse("<root/>").expect("fixed XML must parse");
932        let root = document.root_element();
933        let mut nodes = NodeSet::empty(&document);
934        let budget = NodeSetMaterializationBudget::with_limits(16, 16, 3);
935
936        nodes
937            .insert_namespace_with_budget(root, "p", "u", &budget)
938            .expect("two namespace bytes must fit");
939        nodes
940            .insert_namespace_with_budget(root, "p", "u", &budget)
941            .expect("a duplicate namespace must not consume budget twice");
942        nodes
943            .insert_attribute_with_budget(root, None, "a", &budget)
944            .expect("one remaining byte must admit an attribute");
945        let error = nodes
946            .insert_attribute_with_budget(root, None, "b", &budget)
947            .expect_err("distinct projected keys must share cumulative accounting");
948
949        assert!(matches!(
950            error,
951            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
952                resource: "cumulative node-set owned string bytes",
953                maximum: 3,
954                actual: 4,
955            })
956        ));
957    }
958
959    #[test]
960    fn denied_attribute_projection_does_not_construct_owned_key() {
961        // A zero string budget must reject borrowed attribute names before
962        // cloning attacker-controlled namespace or local-name text.
963        let document = Document::parse("<root/>").expect("fixed XML must parse");
964        let root = document.root_element();
965        let mut nodes = NodeSet::empty(&document);
966        let budget = NodeSetMaterializationBudget::with_limits(16, 0, 0);
967        let constructed = Cell::new(false);
968
969        let error = nodes
970            .insert_projected_key_with_budget(
971                Some(1),
972                |_| false,
973                || {
974                    constructed.set(true);
975                    XmlNodeKey::Attribute {
976                        owner: root.id(),
977                        namespace: None,
978                        local_name: "a".to_owned(),
979                    }
980                },
981                &budget,
982            )
983            .expect_err("the borrowed attribute name exceeds the zero-byte budget");
984
985        assert!(!constructed.get(), "denied names must not be cloned");
986        assert!(matches!(
987            error,
988            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
989                resource: "node-set owned string bytes",
990                maximum: 0,
991                actual: 1,
992            })
993        ));
994    }
995
996    #[test]
997    fn denied_namespace_projection_does_not_construct_owned_key() {
998        // Namespace prefix and URI follow the same borrowed preflight path as
999        // attribute names rather than allocating before cumulative rejection.
1000        let document = Document::parse("<root/>").expect("fixed XML must parse");
1001        let root = document.root_element();
1002        let mut nodes = NodeSet::empty(&document);
1003        let budget = NodeSetMaterializationBudget::with_limits(16, 16, 0);
1004        let constructed = Cell::new(false);
1005
1006        let error = nodes
1007            .insert_projected_key_with_budget(
1008                Some(2),
1009                |_| false,
1010                || {
1011                    constructed.set(true);
1012                    XmlNodeKey::Namespace {
1013                        owner: root.id(),
1014                        prefix: "p".to_owned(),
1015                        uri: "u".to_owned(),
1016                    }
1017                },
1018                &budget,
1019            )
1020            .expect_err("borrowed namespace strings exceed the zero-byte budget");
1021
1022        assert!(
1023            !constructed.get(),
1024            "denied namespace strings must not be cloned"
1025        );
1026        assert!(matches!(
1027            error,
1028            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
1029                resource: "cumulative node-set owned string bytes",
1030                maximum: 0,
1031                actual: 2,
1032            })
1033        ));
1034    }
1035
1036    #[test]
1037    fn document_without_comments_preserves_comment_policy() {
1038        // Empty-URI dereferencing strips comment nodes and must not retain a
1039        // stale flag merely because comments were seen while materializing.
1040        let document = Document::parse("<root><!-- excluded --><child/></root>")
1041            .expect("fixed comment fixture must parse");
1042        let nodes = NodeSet::entire_document_without_comments(&document)
1043            .expect("fixed fixture must fit the node-set materialization budget");
1044        let comment = document
1045            .descendants()
1046            .find(|node| node.is_comment())
1047            .expect("fixed fixture contains one comment");
1048
1049        assert!(!nodes.contains(comment));
1050        assert!(!nodes.with_comments());
1051    }
1052
1053    #[test]
1054    fn document_without_comments_preflights_the_materialized_set() {
1055        // Empty-URI dereference excludes comments before the node-set exists, so
1056        // comments must not consume the configured entry ceiling during preflight.
1057        let document = Document::parse("<root><!-- one --><child/><!-- two --></root>")
1058            .expect("fixed comment fixture must parse");
1059        let budget = NodeSetMaterializationBudget::with_limits(3, 1, 1);
1060
1061        let nodes = NodeSet::entire_document_without_comments_with_budget(&document, &budget)
1062            .expect("the three materialized tree nodes must fit exactly");
1063
1064        assert_eq!(nodes.nodes.len(), 3);
1065        assert!(nodes.nodes.iter().all(|key| match key {
1066            XmlNodeKey::Tree(id) => !document.get_node(*id).is_some_and(|node| node.is_comment()),
1067            _ => true,
1068        }));
1069    }
1070
1071    #[test]
1072    fn bare_fragment_preflights_the_materialized_set_without_comments() {
1073        // Bare-name fragments apply the same XMLDSig comment omission rule to a
1074        // subtree and therefore must charge only nodes that reach the result.
1075        let document =
1076            Document::parse("<root><target><!-- one --><child/><!-- two --></target></root>")
1077                .expect("fixed comment fixture must parse");
1078        let target = document
1079            .descendants()
1080            .find(|node| node.has_tag_name("target"))
1081            .expect("fixed fixture contains the selected target");
1082        let budget = NodeSetMaterializationBudget::with_limits(2, 1, 1);
1083
1084        let nodes = NodeSet::subtree_without_comments_with_budget(target, Some(&budget))
1085            .expect("the target and child must fit exactly");
1086
1087        assert_eq!(nodes.nodes.len(), 2);
1088        assert!(!nodes.with_comments());
1089    }
1090
1091    #[test]
1092    fn excluding_disjoint_oversized_subtree_only_scans_input_keys() {
1093        // The excluded Signature subtree is intentionally larger than the
1094        // constructor budget; a small referenced subtree must remain unchanged
1095        // without attempting to materialize the irrelevant Signature content.
1096        let xml = format!(
1097            "<root><target Id=\"selected\"><child/></target><Signature>{}</Signature></root>",
1098            "<Object/>".repeat(MAX_NODE_SET_ENTRIES + 1)
1099        );
1100        let document = Document::parse(&xml).expect("fixed oversized fixture must parse");
1101        let target = document
1102            .descendants()
1103            .find(|node| node.attribute("Id") == Some("selected"))
1104            .expect("fixed fixture contains the selected subtree");
1105        let signature = document
1106            .descendants()
1107            .find(|node| node.has_tag_name("Signature"))
1108            .expect("fixed fixture contains the excluded Signature subtree");
1109        let mut nodes = NodeSet::subtree(target)
1110            .expect("small selected subtree must fit the materialization budget");
1111        let entries_before = nodes.nodes.len();
1112
1113        nodes.exclude_subtree(signature);
1114
1115        assert_eq!(nodes.nodes.len(), entries_before);
1116        assert!(nodes.contains(target));
1117        assert!(
1118            nodes.contains(
1119                target
1120                    .first_element_child()
1121                    .expect("fixed target subtree contains a child")
1122            )
1123        );
1124    }
1125
1126    #[test]
1127    fn materialization_rejects_inherited_namespace_byte_amplification() {
1128        // One declaration is cheap in the source XML, but XPath exposes the
1129        // inherited binding on every descendant. Materializing owned namespace
1130        // keys must reject the amplified bytes before cloning those strings.
1131        let namespace_uri = "x".repeat(8_192);
1132        let xml = format!(
1133            "<root xmlns:amplified=\"{namespace_uri}\">{}</root>",
1134            "<child/>".repeat(1_025)
1135        );
1136        let document = Document::parse(&xml).expect("fixed namespace fixture must parse");
1137
1138        let error = NodeSet::entire_document_without_comments(&document)
1139            .err()
1140            .expect("amplified namespace bytes must exceed the materialization budget");
1141
1142        assert!(matches!(
1143            error,
1144            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
1145                resource: "node-set owned string bytes",
1146                ..
1147            })
1148        ));
1149    }
1150
1151    #[test]
1152    fn subtree_node_id_range_contains_only_the_selected_subtree() {
1153        // roxmltree stores a subtree in one contiguous document-order span.
1154        // The exclusion fast path relies on that span including attributes and
1155        // namespaces through their owner element, but no adjacent siblings.
1156        let document = Document::parse(
1157            "<root><before/><excluded xmlns:gone=\"urn:gone\" a=\"1\"><child/></excluded><after/></root>",
1158        )
1159        .expect("fixed subtree range fixture must parse");
1160        let excluded = document
1161            .descendants()
1162            .find(|node| node.has_tag_name("excluded"))
1163            .expect("fixed fixture contains the excluded subtree");
1164        let range = subtree_node_id_range(excluded);
1165        let before = document
1166            .descendants()
1167            .find(|node| node.has_tag_name("before"))
1168            .expect("fixed fixture contains the preceding sibling");
1169        let child = excluded
1170            .first_element_child()
1171            .expect("fixed fixture contains an excluded child");
1172        let after = document
1173            .descendants()
1174            .find(|node| node.has_tag_name("after"))
1175            .expect("fixed fixture contains the following sibling");
1176
1177        assert!(!range.contains(&before.id().get()));
1178        assert!(range.contains(&excluded.id().get()));
1179        assert!(range.contains(&child.id().get()));
1180        assert!(!range.contains(&after.id().get()));
1181
1182        let mut nodes = NodeSet::entire_document_with_comments(&document)
1183            .expect("fixed fixture must fit the node-set materialization budget");
1184        nodes.exclude_subtree(excluded);
1185
1186        assert!(nodes.contains(before));
1187        assert!(!nodes.contains(excluded));
1188        assert!(!nodes.contains(child));
1189        assert!(!nodes.contains_attribute(excluded, None, "a"));
1190        assert!(!nodes.contains_namespace(excluded, "gone", "urn:gone"));
1191        assert!(nodes.contains(after));
1192    }
1193
1194    #[test]
1195    fn excluding_subtree_removes_trailing_text_and_comments_from_canonical_output() {
1196        // Pretty-printed Signature elements can end in text or comments rather
1197        // than an element. The contiguous owner-ID range must exclude those tail
1198        // nodes while preserving text and elements surrounding the subtree.
1199        let document = Document::parse(
1200            "<root><before/>keep-before<excluded><child/>drop-text<!--drop-comment--></excluded>keep-after<after/></root>",
1201        )
1202        .expect("fixed trailing-node fixture must parse");
1203        let excluded = document
1204            .descendants()
1205            .find(|node| node.has_tag_name("excluded"))
1206            .expect("fixed fixture contains the excluded subtree");
1207        let trailing_text = excluded
1208            .children()
1209            .find(|node| node.is_text())
1210            .expect("fixed fixture contains trailing text");
1211        let trailing_comment = excluded
1212            .children()
1213            .find(|node| node.is_comment())
1214            .expect("fixed fixture contains a trailing comment");
1215        let mut nodes = NodeSet::entire_document_with_comments(&document)
1216            .expect("fixed fixture must fit the node-set materialization budget");
1217
1218        nodes.exclude_subtree(excluded);
1219
1220        assert!(!nodes.contains(trailing_text));
1221        assert!(!nodes.contains(trailing_comment));
1222        let mut output = Vec::new();
1223        canonicalize_with_visibility(
1224            &document,
1225            Some(&nodes),
1226            &C14nAlgorithm::new(C14nMode::Inclusive1_0, true),
1227            &mut output,
1228        )
1229        .expect("the retained node set must canonicalize");
1230        assert_eq!(
1231            output,
1232            b"<root><before></before>keep-beforekeep-after<after></after></root>"
1233        );
1234    }
1235}