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