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