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