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::ops::RangeInclusive;
10
11use crate::xml::dom::{Document, Node};
12
13const MAX_NODE_SET_ENTRIES: usize = crate::hard_limits::NODE_SET_ENTRY_CEILING;
14const MAX_NODE_SET_OWNED_STRING_BYTES: usize =
15    crate::hard_limits::NODE_SET_OWNED_STRING_BYTE_CEILING;
16const MAX_NODE_SET_CUMULATIVE_OWNED_STRING_BYTES: usize =
17    crate::hard_limits::NODE_SET_CUMULATIVE_OWNED_STRING_BYTE_CEILING;
18
19use crate::c14n::NodeVisibility;
20
21pub(crate) mod projection;
22
23// roxmltree 0.21 uses `Node<'a, 'input: 'a>`. We tie both lifetimes together
24// with a single `'a` by requiring `'input = 'a` at every use site (`Node<'a, 'a>`).
25// This is safe because our NodeSet borrows the Document which owns the input.
26
27/// Data flowing between transforms in the verification/signing pipeline.
28///
29/// Transforms consume and produce either a node set (XML-level) or raw bytes
30/// (after canonicalization or base64 decode).
31pub enum TransformData<'a> {
32    /// A set of nodes from the parsed XML document.
33    NodeSet(NodeSet<'a>),
34    /// Raw bytes (e.g., after canonicalization).
35    Binary(Vec<u8>),
36    /// Original caller-supplied bytes, borrowed for a scoped transform chain.
37    BorrowedBinary(&'a [u8]),
38    /// Caller-owned detached bytes and their resolved document-entity URI.
39    /// XSLT uses the borrowed URI as the input document-entity base URI.
40    Resource {
41        /// Original bytes supplied by the caller's external-resource map.
42        bytes: &'a [u8],
43        /// Resolved RFC 3986 identity of that map entry, not a filesystem path.
44        uri: &'a str,
45    },
46}
47
48impl std::fmt::Debug for TransformData<'_> {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::NodeSet(_) => f.debug_tuple("NodeSet").field(&"...").finish(),
52            Self::Binary(b) => f.debug_tuple("Binary").field(&b.len()).finish(),
53            Self::BorrowedBinary(b) => f.debug_tuple("BorrowedBinary").field(&b.len()).finish(),
54            Self::Resource { bytes, uri } => f
55                .debug_struct("Resource")
56                .field("bytes", &bytes.len())
57                .field("uri", uri)
58                .finish(),
59        }
60    }
61}
62
63impl<'a> TransformData<'a> {
64    /// Convert to `NodeSet`, returning an error if this is `Binary` data.
65    pub fn into_node_set(self) -> Result<NodeSet<'a>, TransformError> {
66        match self {
67            Self::NodeSet(ns) => Ok(ns),
68            Self::Binary(_) | Self::BorrowedBinary(_) | Self::Resource { .. } => {
69                Err(TransformError::TypeMismatch {
70                    expected: "NodeSet",
71                    got: "Binary",
72                })
73            }
74        }
75    }
76
77    /// Convert to binary bytes, returning an error if this is a `NodeSet`.
78    pub fn into_binary(self) -> Result<Vec<u8>, TransformError> {
79        match self {
80            Self::Binary(b) => Ok(b),
81            Self::BorrowedBinary(b) => Ok(b.to_vec()),
82            Self::Resource { bytes, .. } => Ok(bytes.to_vec()),
83            Self::NodeSet(_) => Err(TransformError::TypeMismatch {
84                expected: "Binary",
85                got: "NodeSet",
86            }),
87        }
88    }
89}
90
91/// A set of nodes from a roxmltree document.
92///
93/// Represents the exact XPath nodes included for canonicalization and transforms.
94///
95/// Attributes and namespace bindings are first-class XPath nodes even though
96/// roxmltree exposes them through their owner element. Materializing them here
97/// lets XPath filters independently include or remove those nodes as required
98/// by canonical XML document-subset processing.
99pub struct NodeSet<'a> {
100    pub(crate) projection: projection::Projection<'a, projection::NativeKeys>,
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<'a> NodeSet<'a> {
178    pub(crate) fn projected(
179        root: Node<'a, 'a>,
180        comments: bool,
181        budget: Option<&NodeSetMaterializationBudget>,
182    ) -> Result<Self, TransformError> {
183        let default;
184        let budget = match budget {
185            Some(budget) => budget,
186            None => {
187                default = NodeSetMaterializationBudget::default();
188                &default
189            }
190        };
191        Ok(Self {
192            projection: projection::Projection::subtree(
193                root,
194                comments,
195                budget,
196                projection::NativeKeys,
197            )?,
198        })
199    }
200
201    /// Project the complete document with comment nodes omitted.
202    ///
203    /// XMLDSig 1.1 section 4.4.3.3 excludes comments for an empty Reference URI.
204    /// Returns a policy error when the projection allowance is exceeded.
205    pub fn entire_document_without_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
206        Self::projected(doc.root(), false, None)
207    }
208    pub(crate) fn entire_document_without_comments_with_budget(
209        doc: &'a Document<'a>,
210        budget: &NodeSetMaterializationBudget,
211    ) -> Result<Self, TransformError> {
212        Self::projected(doc.root(), false, Some(budget))
213    }
214    pub(crate) fn entire_document_without_comments_from_view(
215        view: crate::DocumentView<'a>,
216        budget: Option<&NodeSetMaterializationBudget>,
217    ) -> Result<Self, TransformError> {
218        Self::projected(view.document().root(), false, budget)
219    }
220
221    /// Project the complete document including comments.
222    ///
223    /// Returns a policy error when the projection allowance is exceeded.
224    pub fn entire_document_with_comments(doc: &'a Document<'a>) -> Result<Self, TransformError> {
225        Self::projected(doc.root(), true, None)
226    }
227
228    /// Project a subtree including its attribute, namespace and comment nodes.
229    ///
230    /// Returns a policy error when the projection allowance is exceeded.
231    pub fn subtree(element: Node<'a, 'a>) -> Result<Self, TransformError> {
232        Self::projected(element, true, None)
233    }
234    #[cfg(test)]
235    pub(crate) fn subtree_without_comments_with_budget(
236        element: Node<'a, 'a>,
237        budget: Option<&NodeSetMaterializationBudget>,
238    ) -> Result<Self, TransformError> {
239        Self::projected(element, false, budget)
240    }
241    #[cfg(test)]
242    pub(crate) fn subtree_with_budget(
243        element: Node<'a, 'a>,
244        budget: &NodeSetMaterializationBudget,
245    ) -> Result<Self, TransformError> {
246        Self::projected(element, true, Some(budget))
247    }
248
249    /// Return the original source document.
250    pub fn document(&self) -> &'a Document<'a> {
251        self.projection.document()
252    }
253    /// Test tree membership; a node from another document is never included.
254    pub fn contains(&self, node: Node<'_, '_>) -> bool {
255        self.projection
256            .contains_tree(node)
257            .expect("native projection lookup is infallible")
258    }
259    /// Exclude a subtree and every projected axis owned by its nodes.
260    pub fn exclude_subtree(&mut self, node: Node<'_, '_>) {
261        self.projection
262            .exclude_subtree(node)
263            .expect("native projection retention is infallible");
264    }
265    /// Whether this projection permits comment nodes.
266    pub fn with_comments(&self) -> bool {
267        self.projection.with_comments()
268    }
269
270    #[cfg(test)]
271    pub(crate) fn empty(doc: &'a Document<'a>) -> Self {
272        Self {
273            projection: projection::Projection::empty(doc, projection::NativeKeys),
274        }
275    }
276    #[cfg(test)]
277    pub(crate) fn try_entire_document(doc: &'a Document<'a>) -> Result<Self, TransformError> {
278        Self::entire_document_with_comments(doc)
279    }
280    #[cfg(test)]
281    pub(crate) fn len(&self) -> usize {
282        self.projection.len()
283    }
284    #[cfg(test)]
285    pub(crate) fn insert_attribute_with_budget(
286        &mut self,
287        owner: Node<'_, '_>,
288        namespace: Option<&str>,
289        local: &str,
290        budget: &NodeSetMaterializationBudget,
291    ) -> Result<(), TransformError> {
292        self.projection
293            .insert_attribute(owner, namespace, local, Some(budget))
294    }
295    #[cfg(test)]
296    pub(crate) fn insert_namespace_with_budget(
297        &mut self,
298        owner: Node<'_, '_>,
299        prefix: &str,
300        uri: &str,
301        budget: &NodeSetMaterializationBudget,
302    ) -> Result<(), TransformError> {
303        self.projection
304            .insert_namespace(owner, prefix, uri, Some(budget))
305    }
306    pub(crate) fn ensure_subtree_materialization_fits_with_budget(
307        root: Node<'_, '_>,
308        comments: bool,
309        budget: &NodeSetMaterializationBudget,
310    ) -> Result<usize, TransformError> {
311        Ok(projection::measure(
312            root,
313            comments,
314            budget.max_entries,
315            budget.max_owned_string_bytes,
316            &projection::NativeKeys,
317        )?
318        .entries)
319    }
320}
321
322pub(super) fn charge_resource_bytes(
323    current: usize,
324    additional: usize,
325    max_bytes: usize,
326    resource: &'static str,
327) -> Result<usize, TransformError> {
328    let total = current
329        .checked_add(additional)
330        .ok_or_else(|| transform_resource_limit(resource, max_bytes, usize::MAX))?;
331    if total > max_bytes {
332        return Err(transform_resource_limit(resource, max_bytes, total));
333    }
334    Ok(total)
335}
336
337pub(crate) fn subtree_node_id_range<D: crate::xml::dom::storage::Storage>(
338    node: Node<'_, '_, D>,
339) -> RangeInclusive<u32> {
340    let last_id = node
341        .descendants()
342        .next_back()
343        .map_or(node.id(), |descendant| descendant.id());
344    node.id().get()..=last_id.get()
345}
346
347impl NodeSet<'_> {
348    pub(crate) fn contains_attribute(
349        &self,
350        owner: Node<'_, '_>,
351        namespace: Option<&str>,
352        local_name: &str,
353    ) -> bool {
354        self.projection
355            .contains_attribute(owner, namespace, local_name)
356            .expect("native projection lookup is infallible")
357    }
358
359    pub(crate) fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool {
360        self.projection
361            .contains_namespace(owner, prefix, uri)
362            .expect("native projection lookup is infallible")
363    }
364}
365
366impl NodeVisibility for NodeSet<'_> {
367    fn contains_node(&self, node: Node<'_, '_>) -> Result<bool, crate::c14n::C14nError> {
368        Ok(self.contains(node))
369    }
370    fn contains_attribute(
371        &self,
372        owner: Node<'_, '_>,
373        namespace: Option<&str>,
374        local_name: &str,
375    ) -> Result<bool, crate::c14n::C14nError> {
376        Ok(self.contains_attribute(owner, namespace, local_name))
377    }
378    fn contains_namespace(
379        &self,
380        owner: Node<'_, '_>,
381        prefix: &str,
382        uri: &str,
383    ) -> Result<bool, crate::c14n::C14nError> {
384        Ok(self.contains_namespace(owner, prefix, uri))
385    }
386    fn contains_attribute_at(
387        &self,
388        owner: Node<'_, '_>,
389        ordinal: usize,
390        _: crate::xml::dom::Attribute<'_>,
391    ) -> Result<bool, crate::c14n::C14nError> {
392        self.projection.contains_attribute_at(owner, ordinal)
393    }
394    fn contains_namespace_at(
395        &self,
396        owner: Node<'_, '_>,
397        ordinal: usize,
398        _: crate::xml::dom::Namespace<'_>,
399    ) -> Result<bool, crate::c14n::C14nError> {
400        self.projection.contains_namespace_at(owner, ordinal)
401    }
402}
403
404/// Errors during transform processing.
405#[derive(Debug, thiserror::Error)]
406#[non_exhaustive]
407pub enum TransformError {
408    /// The active operation policy rejected transform processing.
409    #[error("transform policy violation: {0}")]
410    Policy(#[from] crate::policy::PolicyViolation),
411
412    /// Data type mismatch between transforms.
413    #[error("type mismatch: expected {expected}, got {got}")]
414    TypeMismatch {
415        /// Expected data type.
416        expected: &'static str,
417        /// Actual data type.
418        got: &'static str,
419    },
420
421    /// Element not found by ID.
422    #[error("element not found by ID: {0}")]
423    ElementNotFound(String),
424
425    /// Missing ID whose diagnostic retains its current-query capacity charge.
426    #[error("element not found by ID: {0}")]
427    AccountedElementNotFound(structured_asn1::memory::OwnedText),
428
429    /// Unsupported URI scheme or format.
430    #[error("unsupported URI: {0}")]
431    UnsupportedUri(String),
432
433    /// Rejected URI whose diagnostic retains its current-query capacity charge.
434    #[error("unsupported URI: {0}")]
435    AccountedUnsupportedUri(structured_asn1::memory::OwnedText),
436
437    /// Malformed same-document syntax, requiring no diagnostic allocation.
438    #[error("unsupported URI: invalid same-document fragment")]
439    InvalidFragment,
440
441    /// Unsupported transform algorithm.
442    #[error("unsupported transform: {0}")]
443    UnsupportedTransform(String),
444
445    /// Rejected transform syntax retaining current-query diagnostic capacity.
446    #[error("unsupported transform: {0}")]
447    AccountedUnsupportedTransform(structured_asn1::memory::OwnedText),
448
449    /// Canonicalization error during transform.
450    #[error("C14N error: {0}")]
451    C14n(#[from] crate::c14n::C14nError),
452
453    /// Base64 decoding failed during the standard XMLDSig Base64 transform.
454    #[error("base64 transform decode error: {0}")]
455    Base64(#[source] base64::DecodeSliceError),
456
457    /// XPath parsing or evaluation failed.
458    #[error("XPath transform error: {0}")]
459    XPath(String),
460
461    /// XPath diagnostic whose capacity remains charged to its current query.
462    #[error("XPath transform error: {0}")]
463    AccountedXPath(structured_asn1::memory::OwnedText),
464
465    /// OPC relationship parameters, MCE, or part structure are invalid.
466    #[error("OPC relationship transform error: {0}")]
467    Relationship(&'static str),
468
469    /// The XSLT stylesheet, its input document, or its execution failed.
470    #[cfg(feature = "xslt")]
471    #[error("XSLT transform error: {0}")]
472    Xslt(String),
473
474    /// XSLT diagnostic whose capacity remains charged to its current query.
475    #[cfg(feature = "xslt")]
476    #[error("XSLT transform error: {0}")]
477    AccountedXslt(structured_asn1::memory::OwnedText),
478
479    /// XML octets could not be parsed while adapting binary transform output
480    /// to the node-set required by a subsequent transform.
481    #[error("XML transform input parse error: {0}")]
482    XmlParse(#[source] crate::document::XmlDocumentError),
483
484    /// Encoding rejection whose diagnostic label retains its admitted storage.
485    #[error("XML transform encoding error: {0}")]
486    AccountedXmlEncoding(
487        #[source] crate::encoding::XmlEncodingError<structured_asn1::memory::OwnedText>,
488    ),
489
490    /// The Signature node passed to the enveloped transform belongs to a
491    /// different `Document` than the input `NodeSet`.
492    #[error("enveloped-signature transform: invalid Signature node for this document")]
493    CrossDocumentSignatureNode,
494
495    /// A retained document view and a projected node came from different documents.
496    #[error("node-set projection: input node belongs to a different document")]
497    CrossDocumentNodeSetInput,
498}
499
500impl From<structured_asn1::memory::MemoryError> for TransformError {
501    fn from(error: structured_asn1::memory::MemoryError) -> Self {
502        Self::C14n(crate::c14n::C14nError::SharedExecution(error))
503    }
504}
505
506pub(crate) fn transform_resource_limit(
507    resource: &'static str,
508    maximum: usize,
509    actual: usize,
510) -> TransformError {
511    crate::policy::PolicyViolation::ResourceLimit {
512        resource,
513        maximum,
514        actual,
515    }
516    .into()
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::c14n::{C14nAlgorithm, C14nMode, canonicalize_with_visibility};
523
524    #[test]
525    fn incremental_projection_enforces_aggregate_owned_string_policy() {
526        // XPath builds arbitrary projected sets incrementally. Individually small
527        // attribute keys must not bypass the per-set aggregate string ceiling.
528        let document = Document::parse("<root a='1' bbb='2'/>").expect("fixed XML must parse");
529        let root = document.root_element();
530        let mut nodes = NodeSet::empty(&document);
531        let budget = NodeSetMaterializationBudget::with_limits(16, 3, 16);
532
533        nodes
534            .insert_attribute_with_budget(root, None, "a", &budget)
535            .expect("the first one-byte attribute name must fit");
536        let error = nodes
537            .insert_attribute_with_budget(root, None, "bbb", &budget)
538            .expect_err("aggregate projected names must exceed three bytes");
539
540        assert!(matches!(
541            error,
542            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
543                resource: "node-set owned string bytes",
544                maximum: 3,
545                actual: 4,
546            })
547        ));
548    }
549
550    #[test]
551    fn projected_attribute_and_namespace_share_one_budget_path() {
552        // Duplicate projected keys are free, while distinct attributes and
553        // namespaces consume the same operation-wide owned-string allowance.
554        let document =
555            Document::parse("<root xmlns:p='u' a='1' b='2'/>").expect("fixed XML must parse");
556        let root = document.root_element();
557        let mut nodes = NodeSet::empty(&document);
558        let budget = NodeSetMaterializationBudget::with_limits(16, 16, 3);
559
560        nodes
561            .insert_namespace_with_budget(root, "p", "u", &budget)
562            .expect("two namespace bytes must fit");
563        nodes
564            .insert_namespace_with_budget(root, "p", "u", &budget)
565            .expect("a duplicate namespace must not consume budget twice");
566        nodes
567            .insert_attribute_with_budget(root, None, "a", &budget)
568            .expect("one remaining byte must admit an attribute");
569        let error = nodes
570            .insert_attribute_with_budget(root, None, "b", &budget)
571            .expect_err("distinct projected keys must share cumulative accounting");
572
573        assert!(matches!(
574            error,
575            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
576                resource: "cumulative node-set owned string bytes",
577                maximum: 3,
578                actual: 4,
579            })
580        ));
581    }
582
583    #[test]
584    fn denied_attribute_projection_does_not_insert_key() {
585        // The compact key cannot own copied names. A zero projection allowance
586        // must still reject before growing the table or selecting the axis.
587        let document = Document::parse("<root a='1'/>").expect("fixed XML must parse");
588        let root = document.root_element();
589        let mut nodes = NodeSet::empty(&document);
590        let budget = NodeSetMaterializationBudget::with_limits(16, 0, 0);
591        let error = nodes
592            .insert_attribute_with_budget(root, None, "a", &budget)
593            .expect_err("the borrowed attribute name exceeds the zero-byte budget");
594
595        assert_eq!(nodes.len(), 0);
596        assert!(!nodes.contains_attribute(root, None, "a"));
597        assert!(matches!(
598            error,
599            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
600                resource: "node-set owned string bytes",
601                maximum: 0,
602                actual: 1,
603            })
604        ));
605    }
606
607    #[test]
608    fn denied_namespace_projection_does_not_insert_key() {
609        // Prefix/URI policy checks precede table growth; source names remain
610        // borrowed and a denied namespace must not become visible.
611        let document = Document::parse("<root xmlns:p='u'/>").expect("fixed XML must parse");
612        let root = document.root_element();
613        let mut nodes = NodeSet::empty(&document);
614        let budget = NodeSetMaterializationBudget::with_limits(16, 16, 0);
615        let error = nodes
616            .insert_namespace_with_budget(root, "p", "u", &budget)
617            .expect_err("borrowed namespace strings exceed the zero-byte budget");
618
619        assert_eq!(nodes.len(), 0);
620        assert!(!nodes.contains_namespace(root, "p", "u"));
621        assert!(matches!(
622            error,
623            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
624                resource: "cumulative node-set owned string bytes",
625                maximum: 0,
626                actual: 2,
627            })
628        ));
629    }
630
631    #[test]
632    fn document_without_comments_preserves_comment_policy() {
633        // Empty-URI dereferencing strips comment nodes and must not retain a
634        // stale flag merely because comments were seen while materializing.
635        let document = Document::parse("<root><!-- excluded --><child/></root>")
636            .expect("fixed comment fixture must parse");
637        let nodes = NodeSet::entire_document_without_comments(&document)
638            .expect("fixed fixture must fit the node-set materialization budget");
639        let comment = document
640            .descendants()
641            .find(|node| node.is_comment())
642            .expect("fixed fixture contains one comment");
643
644        assert!(!nodes.contains(comment));
645        assert!(!nodes.with_comments());
646    }
647
648    #[test]
649    fn document_without_comments_preflights_the_materialized_set() {
650        // Empty-URI dereference excludes comments before the node-set exists, so
651        // comments must not consume the configured entry ceiling during preflight.
652        let document = Document::parse("<root><!-- one --><child/><!-- two --></root>")
653            .expect("fixed comment fixture must parse");
654        let budget = NodeSetMaterializationBudget::with_limits(3, 1, 1);
655
656        let nodes = NodeSet::entire_document_without_comments_with_budget(&document, &budget)
657            .expect("the three materialized tree nodes must fit exactly");
658
659        assert_eq!(nodes.len(), 3);
660        assert!(
661            document
662                .descendants()
663                .filter(Node::is_comment)
664                .all(|comment| !nodes.contains(comment))
665        );
666    }
667
668    #[test]
669    fn bare_fragment_preflights_the_materialized_set_without_comments() {
670        // Bare-name fragments apply the same XMLDSig comment omission rule to a
671        // subtree and therefore must charge only nodes that reach the result.
672        let document =
673            Document::parse("<root><target><!-- one --><child/><!-- two --></target></root>")
674                .expect("fixed comment fixture must parse");
675        let target = document
676            .descendants()
677            .find(|node| node.has_tag_name("target"))
678            .expect("fixed fixture contains the selected target");
679        let budget = NodeSetMaterializationBudget::with_limits(2, 1, 1);
680
681        let nodes = NodeSet::subtree_without_comments_with_budget(target, Some(&budget))
682            .expect("the target and child must fit exactly");
683
684        assert_eq!(nodes.len(), 2);
685        assert!(!nodes.with_comments());
686    }
687
688    #[test]
689    fn excluding_disjoint_oversized_subtree_only_scans_input_keys() {
690        // The excluded Signature subtree is intentionally larger than the
691        // constructor budget; a small referenced subtree must remain unchanged
692        // without attempting to materialize the irrelevant Signature content.
693        let xml = format!(
694            "<root><target Id=\"selected\"><child/></target><Signature>{}</Signature></root>",
695            "<Object/>".repeat(129)
696        );
697        let document = Document::parse(&xml).expect("fixed oversized fixture must parse");
698        let target = document
699            .descendants()
700            .find(|node| node.attribute("Id") == Some("selected"))
701            .expect("fixed fixture contains the selected subtree");
702        let signature = document
703            .descendants()
704            .find(|node| node.has_tag_name("Signature"))
705            .expect("fixed fixture contains the excluded Signature subtree");
706        let budget = NodeSetMaterializationBudget::with_limits(3, 8, 8);
707        let mut nodes = NodeSet::subtree_with_budget(target, &budget)
708            .expect("small selected subtree must fit the materialization budget");
709        let entries_before = nodes.len();
710
711        nodes.exclude_subtree(signature);
712
713        assert_eq!(nodes.len(), entries_before);
714        assert!(nodes.contains(target));
715        assert!(
716            nodes.contains(
717                target
718                    .first_element_child()
719                    .expect("fixed target subtree contains a child")
720            )
721        );
722    }
723
724    #[test]
725    fn materialization_rejects_inherited_namespace_byte_amplification() {
726        // One declaration is cheap in the source XML, but XPath exposes the
727        // inherited binding on every descendant. Materializing owned namespace
728        // keys must reject the amplified bytes before cloning those strings.
729        let namespace_uri = "x".repeat(8_192);
730        let xml = format!(
731            "<root xmlns:amplified=\"{namespace_uri}\">{}</root>",
732            "<child/>".repeat(1_025)
733        );
734        let document = Document::parse(&xml).expect("fixed namespace fixture must parse");
735
736        let error = NodeSet::entire_document_without_comments(&document)
737            .err()
738            .expect("amplified namespace bytes must exceed the materialization budget");
739
740        assert!(matches!(
741            error,
742            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
743                resource: "node-set owned string bytes",
744                ..
745            })
746        ));
747    }
748
749    #[test]
750    fn subtree_node_id_range_contains_only_the_selected_subtree() {
751        // roxmltree stores a subtree in one contiguous document-order span.
752        // The exclusion fast path relies on that span including attributes and
753        // namespaces through their owner element, but no adjacent siblings.
754        let document = Document::parse(
755            "<root><before/><excluded xmlns:gone=\"urn:gone\" a=\"1\"><child/></excluded><after/></root>",
756        )
757        .expect("fixed subtree range fixture must parse");
758        let excluded = document
759            .descendants()
760            .find(|node| node.has_tag_name("excluded"))
761            .expect("fixed fixture contains the excluded subtree");
762        let range = subtree_node_id_range(excluded);
763        let before = document
764            .descendants()
765            .find(|node| node.has_tag_name("before"))
766            .expect("fixed fixture contains the preceding sibling");
767        let child = excluded
768            .first_element_child()
769            .expect("fixed fixture contains an excluded child");
770        let after = document
771            .descendants()
772            .find(|node| node.has_tag_name("after"))
773            .expect("fixed fixture contains the following sibling");
774
775        assert!(!range.contains(&before.id().get()));
776        assert!(range.contains(&excluded.id().get()));
777        assert!(range.contains(&child.id().get()));
778        assert!(!range.contains(&after.id().get()));
779
780        let mut nodes = NodeSet::entire_document_with_comments(&document)
781            .expect("fixed fixture must fit the node-set materialization budget");
782        nodes.exclude_subtree(excluded);
783
784        assert!(nodes.contains(before));
785        assert!(!nodes.contains(excluded));
786        assert!(!nodes.contains(child));
787        assert!(!nodes.contains_attribute(excluded, None, "a"));
788        assert!(!nodes.contains_namespace(excluded, "gone", "urn:gone"));
789        assert!(nodes.contains(after));
790    }
791
792    #[test]
793    fn excluding_subtree_removes_trailing_text_and_comments_from_canonical_output() {
794        // Pretty-printed Signature elements can end in text or comments rather
795        // than an element. The contiguous owner-ID range must exclude those tail
796        // nodes while preserving text and elements surrounding the subtree.
797        let document = Document::parse(
798            "<root><before/>keep-before<excluded><child/>drop-text<!--drop-comment--></excluded>keep-after<after/></root>",
799        )
800        .expect("fixed trailing-node fixture must parse");
801        let excluded = document
802            .descendants()
803            .find(|node| node.has_tag_name("excluded"))
804            .expect("fixed fixture contains the excluded subtree");
805        let trailing_text = excluded
806            .children()
807            .find(|node| node.is_text())
808            .expect("fixed fixture contains trailing text");
809        let trailing_comment = excluded
810            .children()
811            .find(|node| node.is_comment())
812            .expect("fixed fixture contains a trailing comment");
813        let mut nodes = NodeSet::entire_document_with_comments(&document)
814            .expect("fixed fixture must fit the node-set materialization budget");
815
816        nodes.exclude_subtree(excluded);
817
818        assert!(!nodes.contains(trailing_text));
819        assert!(!nodes.contains(trailing_comment));
820        let mut output = Vec::new();
821        canonicalize_with_visibility(
822            &document,
823            Some(&nodes),
824            &C14nAlgorithm::new(C14nMode::Inclusive1_0, true),
825            &mut output,
826        )
827        .expect("the retained node set must canonicalize");
828        assert_eq!(
829            output,
830            b"<root><before></before>keep-beforekeep-after<after></after></root>"
831        );
832    }
833}