Skip to main content

xml_sec/xmldsig/
transforms.rs

1//! Transform pipeline for XMLDSig `<Reference>` processing.
2//!
3//! Implements [XMLDSig §6.6](https://www.w3.org/TR/xmldsig-core1/#sec-Transforms):
4//! each `<Reference>` specifies a chain of transforms applied sequentially to
5//! produce bytes for digest computation.
6//!
7//! The pipeline is a simple `Vec<Transform>` iterated front-to-back — a dramatic
8//! simplification of xmlsec1's bidirectional push/pop doubly-linked list with
9//! auto-inserted type adapters.
10//!
11//! ## Supported transforms
12//!
13//! | Transform | Direction | Priority |
14//! |-----------|-----------|----------|
15//! | Enveloped signature | NodeSet → NodeSet | P0 (SAML) |
16//! | Inclusive C14N 1.0/1.1 | NodeSet → Binary | P0 |
17//! | Exclusive C14N 1.0 | NodeSet → Binary | P0 |
18//! | Base64 decode | NodeSet/Binary → Binary | P1 |
19//! | XPath 1.0 | NodeSet → NodeSet | P1 |
20//! | XPath Filter 2.0 | NodeSet → NodeSet | P1 |
21
22use std::cell::Cell;
23use std::collections::{BTreeMap, HashSet};
24
25use crate::xml::dom::{Document, Node, NodeId, NodeType};
26use base64::{Engine as _, engine::general_purpose::STANDARD};
27use sha2::{Digest as _, Sha256};
28
29use super::parse::XMLDSIG_NS;
30use super::types::{
31    NodeSetMaterializationBudget, TransformData, TransformError, transform_resource_limit,
32};
33use super::whitespace::is_xml_whitespace_only;
34use super::xpath::{
35    XPathDocumentRelation, XPathWorkBudget, apply_xpath_filter_with_semantics_and_budget,
36    apply_xpath_filter2_with_semantics_and_budget, is_xpath_whitespace,
37    xpath_may_read_mutable_character_data,
38};
39use crate::c14n::xml_base::XmlBaseResolutionBudget;
40use crate::c14n::{self, C14nAlgorithm};
41use crate::document::{
42    DocumentParseSettings, XmlDocumentError, XmlParseWorkBudget,
43    parse_borrowed_with_settings_and_budget,
44};
45#[cfg(test)]
46use crate::hard_limits::XML_DOCUMENT_NODE_CEILING;
47
48/// The algorithm URI for the enveloped signature transform.
49pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
50/// The algorithm URI for the Base64 decode transform.
51pub const BASE64_TRANSFORM_URI: &str = "http://www.w3.org/2000/09/xmldsig#base64";
52/// The algorithm URI for the XPath 1.0 transform.
53pub const XPATH_TRANSFORM_URI: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
54/// The algorithm URI for the XPath Filter 2.0 transform.
55pub const XPATH_FILTER2_TRANSFORM_URI: &str = "http://www.w3.org/2002/06/xmldsig-filter2";
56/// The implicit default canonicalization URI applied when no explicit C14N
57/// transform is present in a `<Reference>`.
58pub const DEFAULT_IMPLICIT_C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
59/// Maximum transforms accepted for one reference.
60///
61/// Execution retains one stack frame when a binary-to-node-set adapter parses
62/// temporary XML, so this bounds recursion depth. The signature-wide C14N output
63/// budget below bounds both total work and buffers retained by those frames.
64pub const MAX_TRANSFORMS_PER_REFERENCE: usize = crate::hard_limits::REFERENCE_TRANSFORM_CEILING;
65/// xmlsec1 donor vectors use this XPath expression as a compatibility form of
66/// enveloped-signature exclusion.
67pub(super) const ENVELOPED_SIGNATURE_XPATH_PREFIX: &str = "dsig";
68pub(super) const ENVELOPED_SIGNATURE_XPATH_EXPR: &str = "not(ancestor-or-self::dsig:Signature)";
69pub(super) const MAX_XPATH_EXPRESSION_BYTES: usize =
70    crate::hard_limits::XPATH_EXPRESSION_BYTE_CEILING;
71pub(super) const MAX_XPATH_FILTERS: usize = crate::hard_limits::XPATH_FILTER_COUNT_CEILING;
72/// Maximum XPath programs retained and compiled while parsing one SignedInfo.
73///
74/// Per-reference bounds remain necessary for transform shape, while this bound
75/// prevents their multiplication across all references in one signature.
76pub(super) const MAX_XPATH_EXPRESSIONS_PER_SIGNATURE: usize =
77    crate::hard_limits::XPATH_EXPRESSION_COUNT_CEILING;
78const MAX_XPATH_NAMESPACE_BINDINGS: usize = crate::hard_limits::XPATH_NAMESPACE_BINDING_CEILING;
79const MAX_XPATH_NAMESPACE_BYTES: usize = crate::hard_limits::XPATH_NAMESPACE_BYTE_CEILING;
80const MAX_BASE64_TRANSFORM_INPUT_BYTES: usize =
81    crate::hard_limits::BASE64_TRANSFORM_INPUT_BYTE_CEILING;
82const MAX_BASE64_TRANSFORM_OUTPUT_BYTES: usize =
83    crate::hard_limits::BASE64_TRANSFORM_OUTPUT_BYTE_CEILING;
84const MAX_C14N_OUTPUT_BYTES: usize = crate::hard_limits::CANONICALIZED_SIGNATURE_DATA_BYTE_CEILING;
85/// Bound cumulative node-set visits performed by exclusion transforms.
86const MAX_NODE_SET_FILTER_WORK: usize = crate::hard_limits::NODE_SET_FILTER_WORK_CEILING;
87
88/// Namespace URI for Exclusive C14N `<InclusiveNamespaces>` elements.
89const EXCLUSIVE_C14N_NS_URI: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
90
91/// Node returned by the XMLDSig XPath `here()` extension function.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum XPathHereSemantics {
94    /// Follow XMLDSig: return the `<XPath>` parameter element that contains
95    /// the expression text.
96    #[default]
97    Specification,
98    /// Match libxmlsec1, which returns the owning `<Transform>` element.
99    ///
100    /// This mode is opt-in because the two interpretations can select
101    /// different data for the same signed XML document.
102    XmlSecLegacy,
103}
104
105/// Execution settings derived from one immutable operation policy snapshot.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub(crate) struct TransformOptions {
108    xpath_here_semantics: XPathHereSemantics,
109    allow_internal_dtd: bool,
110}
111
112pub(crate) struct TransformExecutionBudget {
113    xpath: XPathWorkBudget,
114    base64: Base64WorkBudget,
115    c14n: C14nOutputBudget,
116    node_filter: NodeFilterWorkBudget,
117    node_set_materialization: NodeSetMaterializationBudget,
118    xml_base_resolution: XmlBaseResolutionBudget,
119    xml_parse_work: XmlParseWorkBudget,
120    xml_parse_settings: DocumentParseSettings,
121    state: TransformChainState,
122}
123
124impl Default for TransformExecutionBudget {
125    fn default() -> Self {
126        Self::from_resources(&crate::policy::ResourcePolicy::default())
127    }
128}
129
130pub(super) struct NodeFilterWorkBudget {
131    remaining: Cell<usize>,
132    maximum: usize,
133}
134
135impl Default for NodeFilterWorkBudget {
136    fn default() -> Self {
137        Self {
138            remaining: Cell::new(MAX_NODE_SET_FILTER_WORK),
139            maximum: MAX_NODE_SET_FILTER_WORK,
140        }
141    }
142}
143
144impl NodeFilterWorkBudget {
145    pub(super) fn charge(&self, entries: usize) -> Result<(), TransformError> {
146        let consumed = self.maximum.saturating_sub(self.remaining.get());
147        if !charge_byte_budget(&self.remaining, entries) {
148            return Err(transform_resource_limit(
149                crate::policy::resource_name::NODE_SET_FILTER_WORK,
150                self.maximum,
151                consumed.saturating_add(entries),
152            ));
153        }
154        Ok(())
155    }
156}
157
158struct Base64WorkBudget {
159    remaining_input_bytes: Cell<usize>,
160    remaining_output_bytes: Cell<usize>,
161    max_input_bytes: usize,
162    max_output_bytes: usize,
163}
164
165struct C14nOutputBudget {
166    remaining: Cell<usize>,
167    max_bytes: usize,
168}
169
170fn charge_byte_budget(remaining: &Cell<usize>, bytes: usize) -> bool {
171    let Some(next) = remaining.get().checked_sub(bytes) else {
172        remaining.set(0);
173        return false;
174    };
175    remaining.set(next);
176    true
177}
178
179impl Default for C14nOutputBudget {
180    fn default() -> Self {
181        Self {
182            remaining: Cell::new(MAX_C14N_OUTPUT_BYTES),
183            max_bytes: MAX_C14N_OUTPUT_BYTES,
184        }
185    }
186}
187
188impl C14nOutputBudget {
189    fn with_limit(max_bytes: usize) -> Self {
190        Self {
191            remaining: Cell::new(max_bytes),
192            max_bytes,
193        }
194    }
195
196    fn remaining(&self) -> usize {
197        self.remaining.get()
198    }
199
200    fn charge(&self, bytes: usize) -> Result<(), crate::policy::PolicyViolation> {
201        let consumed = self.max_bytes.saturating_sub(self.remaining.get());
202        if !charge_byte_budget(&self.remaining, bytes) {
203            return Err(crate::policy::PolicyViolation::ResourceLimit {
204                resource: crate::policy::resource_name::CANONICALIZED_BYTES,
205                maximum: self.max_bytes,
206                actual: consumed.saturating_add(bytes),
207            });
208        }
209        Ok(())
210    }
211
212    fn exhaust(&self) {
213        self.remaining.set(0);
214    }
215}
216
217#[cfg(test)]
218mod c14n_budget_regression_tests {
219    use super::*;
220    use crate::c14n::C14nMode;
221    use crate::xml::dom::Document;
222    use crate::xmldsig::types::NodeSet;
223
224    #[test]
225    fn bounded_c14n_failure_exhausts_the_shared_budget() {
226        let document = Document::parse("<root><payload>more than eight bytes</payload></root>")
227            .expect("test XML must parse");
228        let budget = TransformExecutionBudget::with_c14n_limit(8);
229
230        let error = execute_transforms_with_options_and_budget(
231            document.root_element(),
232            TransformData::NodeSet(
233                NodeSet::entire_document_without_comments(&document)
234                    .expect("test document must fit the node-set ceiling"),
235            ),
236            &[Transform::C14n(C14nAlgorithm::new(
237                C14nMode::Inclusive1_0,
238                false,
239            ))],
240            TransformOptions::default(),
241            &budget,
242        )
243        .expect_err("canonicalized output must exceed the shared budget");
244
245        assert!(matches!(
246            error,
247            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
248                resource: crate::policy::resource_name::CANONICALIZED_BYTES,
249                maximum: 8,
250                ..
251            })
252        ));
253        assert_eq!(
254            budget.remaining_c14n_output(),
255            0,
256            "a failed bounded render must not leave the same allowance reusable"
257        );
258    }
259}
260
261impl Default for Base64WorkBudget {
262    fn default() -> Self {
263        Self {
264            remaining_input_bytes: Cell::new(MAX_BASE64_TRANSFORM_INPUT_BYTES),
265            remaining_output_bytes: Cell::new(MAX_BASE64_TRANSFORM_OUTPUT_BYTES),
266            max_input_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES,
267            max_output_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
268        }
269    }
270}
271
272impl Base64WorkBudget {
273    fn charge_input(&self, bytes: usize) -> Result<(), TransformError> {
274        let consumed = self
275            .max_input_bytes
276            .saturating_sub(self.remaining_input_bytes.get());
277        if !charge_byte_budget(&self.remaining_input_bytes, bytes) {
278            return Err(transform_resource_limit(
279                crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
280                self.max_input_bytes,
281                consumed.saturating_add(bytes),
282            ));
283        }
284        Ok(())
285    }
286
287    fn ensure_output_capacity(&self, bytes: usize) -> Result<(), TransformError> {
288        let consumed = self
289            .max_output_bytes
290            .saturating_sub(self.remaining_output_bytes.get());
291        if bytes > self.remaining_output_bytes.get() {
292            return Err(transform_resource_limit(
293                crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
294                self.max_output_bytes,
295                consumed.saturating_add(bytes),
296            ));
297        }
298        Ok(())
299    }
300
301    fn charge_output(&self, bytes: usize) -> Result<(), TransformError> {
302        self.ensure_output_capacity(bytes)?;
303        let charged = charge_byte_budget(&self.remaining_output_bytes, bytes);
304        debug_assert!(charged, "preflighted Base64 output charge must fit");
305        Ok(())
306    }
307}
308
309#[cfg(test)]
310impl TransformExecutionBudget {
311    pub(crate) fn with_xpath_limit(limit: usize) -> Self {
312        Self {
313            xpath: XPathWorkBudget::with_limit(limit),
314            base64: Base64WorkBudget::default(),
315            c14n: C14nOutputBudget::default(),
316            node_filter: NodeFilterWorkBudget::default(),
317            node_set_materialization: NodeSetMaterializationBudget::default(),
318            xml_base_resolution: XmlBaseResolutionBudget::default(),
319            xml_parse_work: XmlParseWorkBudget::from_resources(
320                &crate::policy::ResourcePolicy::default(),
321            ),
322            xml_parse_settings: DocumentParseSettings::default(),
323            state: TransformChainState::default(),
324        }
325    }
326
327    fn with_node_filter_limit(limit: usize) -> Self {
328        Self {
329            xpath: XPathWorkBudget::default(),
330            base64: Base64WorkBudget::default(),
331            c14n: C14nOutputBudget::default(),
332            node_filter: NodeFilterWorkBudget {
333                remaining: Cell::new(limit),
334                maximum: limit,
335            },
336            node_set_materialization: NodeSetMaterializationBudget::default(),
337            xml_base_resolution: XmlBaseResolutionBudget::default(),
338            xml_parse_work: XmlParseWorkBudget::from_resources(
339                &crate::policy::ResourcePolicy::default(),
340            ),
341            xml_parse_settings: DocumentParseSettings::default(),
342            state: TransformChainState::default(),
343        }
344    }
345
346    pub(crate) fn with_node_set_materialization_limit(limit: usize) -> Self {
347        Self {
348            xpath: XPathWorkBudget::default(),
349            base64: Base64WorkBudget::default(),
350            c14n: C14nOutputBudget::default(),
351            node_filter: NodeFilterWorkBudget::default(),
352            node_set_materialization: NodeSetMaterializationBudget::with_limit(limit),
353            xml_base_resolution: XmlBaseResolutionBudget::default(),
354            xml_parse_work: XmlParseWorkBudget::from_resources(
355                &crate::policy::ResourcePolicy::default(),
356            ),
357            xml_parse_settings: DocumentParseSettings::default(),
358            state: TransformChainState::default(),
359        }
360    }
361
362    pub(crate) fn with_c14n_limit(max_bytes: usize) -> Self {
363        Self {
364            c14n: C14nOutputBudget::with_limit(max_bytes),
365            ..Self::default()
366        }
367    }
368}
369
370impl TransformExecutionBudget {
371    pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self {
372        Self {
373            xpath: XPathWorkBudget::with_limits(resources),
374            base64: Base64WorkBudget {
375                remaining_input_bytes: Cell::new(resources.max_base64_transform_input_bytes),
376                remaining_output_bytes: Cell::new(resources.max_base64_transform_output_bytes),
377                max_input_bytes: resources.max_base64_transform_input_bytes,
378                max_output_bytes: resources.max_base64_transform_output_bytes,
379            },
380            c14n: C14nOutputBudget::with_limit(resources.effective_canonicalized_bytes()),
381            node_filter: NodeFilterWorkBudget {
382                remaining: Cell::new(resources.max_node_set_filter_work),
383                maximum: resources.max_node_set_filter_work,
384            },
385            node_set_materialization: NodeSetMaterializationBudget::with_limits(
386                resources.max_node_set_entries,
387                resources.max_node_set_owned_string_bytes,
388                resources.max_node_set_cumulative_owned_string_bytes,
389            ),
390            xml_base_resolution: XmlBaseResolutionBudget::with_limits(
391                resources.effective_xml_base_components(),
392                resources.effective_xml_base_resolution_bytes(),
393            ),
394            xml_parse_work: XmlParseWorkBudget::from_resources(resources),
395            xml_parse_settings: DocumentParseSettings::for_transform_output(resources),
396            state: TransformChainState::default(),
397        }
398    }
399
400    pub(crate) fn with_xml_backend(mut self, backend: crate::XmlBackend) -> Self {
401        self.xml_parse_settings = self.xml_parse_settings.with_backend(backend);
402        self
403    }
404
405    pub(crate) fn charge_c14n_output(&self, bytes: usize) -> Result<(), TransformError> {
406        self.c14n.charge(bytes).map_err(TransformError::from)
407    }
408
409    pub(crate) fn charge_c14n_output_policy(
410        &self,
411        bytes: usize,
412    ) -> Result<(), crate::policy::PolicyViolation> {
413        self.c14n.charge(bytes)
414    }
415
416    pub(crate) fn remaining_c14n_output(&self) -> usize {
417        self.c14n.remaining()
418    }
419
420    pub(crate) fn c14n_output_limit(&self) -> usize {
421        self.c14n.max_bytes
422    }
423
424    pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget {
425        &self.node_set_materialization
426    }
427
428    pub(crate) fn xml_base_resolution(&self) -> &XmlBaseResolutionBudget {
429        &self.xml_base_resolution
430    }
431
432    pub(crate) fn xml_parse_work(&self) -> &XmlParseWorkBudget {
433        &self.xml_parse_work
434    }
435
436    pub(crate) fn charge_xpath_work(&self, work: usize) -> Result<(), TransformError> {
437        self.xpath.charge(work)
438    }
439
440    pub(crate) fn validate_xpath_context_evaluations(
441        &self,
442        actual: usize,
443    ) -> Result<(), TransformError> {
444        self.xpath.validate_context_evaluations(actual)
445    }
446
447    pub(crate) fn charge_node_filter_work(&self, nodes: usize) -> Result<(), TransformError> {
448        self.node_filter.charge(nodes)
449    }
450}
451
452impl TransformOptions {
453    /// Select the node returned by the XPath `here()` extension function.
454    #[must_use]
455    pub(crate) fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self {
456        self.xpath_here_semantics = semantics;
457        self
458    }
459
460    /// Allow internal DTD declarations when a transform parses caller-supplied
461    /// octets as XML. External entity resolution remains disabled.
462    #[must_use]
463    pub(crate) fn allow_internal_dtd(mut self, enabled: bool) -> Self {
464        self.allow_internal_dtd = enabled;
465        self
466    }
467
468    pub(crate) fn here_semantics(self) -> XPathHereSemantics {
469        self.xpath_here_semantics
470    }
471
472    pub(crate) fn internal_dtd_allowed(self) -> bool {
473        self.allow_internal_dtd
474    }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478struct XPathHereNodes {
479    specification_xpath_element: NodeId,
480    xmlsec_legacy_transform_element: NodeId,
481    document: XPathDocumentIdentity,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485struct XPathDocumentIdentity([u8; 32]);
486
487impl XPathDocumentIdentity {
488    fn from_document(document: &Document<'_>) -> Self {
489        #[cfg(test)]
490        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(count.get() + 1));
491        Self(Sha256::digest(document.input_text().as_bytes()).into())
492    }
493}
494
495#[cfg(test)]
496thread_local! {
497    static XPATH_DOCUMENT_IDENTITY_COMPUTATIONS: Cell<usize> = const { Cell::new(0) };
498}
499
500#[derive(Default)]
501struct TransformChainState {
502    xpath_document_identity: Cell<Option<CachedXPathDocumentIdentity>>,
503}
504
505#[derive(Clone, Copy)]
506struct CachedXPathDocumentIdentity {
507    document: *const (),
508    identity: XPathDocumentIdentity,
509}
510
511impl TransformChainState {
512    fn begin_chain(&self) {
513        // Document pointer identity is valid only while one transform chain
514        // keeps that parsed document alive. Never carry it into a later chain,
515        // where allocator address reuse could alias unrelated XML.
516        self.xpath_document_identity.set(None);
517    }
518
519    fn xpath_document_identity(&self, document: &Document<'_>) -> XPathDocumentIdentity {
520        let document_key = std::ptr::from_ref(document).cast::<()>();
521        if let Some(cached) = self.xpath_document_identity.get()
522            && cached.document == document_key
523        {
524            return cached.identity;
525        }
526        let identity = XPathDocumentIdentity::from_document(document);
527        self.xpath_document_identity
528            .set(Some(CachedXPathDocumentIdentity {
529                document: document_key,
530                identity,
531            }));
532        identity
533    }
534
535    fn document_reparsed(&self) {
536        self.xpath_document_identity.set(None);
537    }
538}
539
540struct TransformExecutionContext<'a> {
541    options: TransformOptions,
542    budget: &'a TransformExecutionBudget,
543    state: &'a TransformChainState,
544    xml_parse: &'a XmlParseWorkBudget,
545}
546
547/// An XPath 1.0 expression and the namespace bindings in scope where it was declared.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct XPathExpression {
550    expression: String,
551    namespaces: BTreeMap<String, String>,
552    here_nodes: Option<XPathHereNodes>,
553}
554
555impl XPathExpression {
556    /// Create an expression for signature-template generation.
557    pub fn new(expression: impl Into<String>) -> Self {
558        Self {
559            expression: expression.into(),
560            namespaces: BTreeMap::new(),
561            here_nodes: None,
562        }
563    }
564
565    /// Bind a prefix used by this XPath expression.
566    pub fn with_namespace(mut self, prefix: impl Into<String>, uri: impl Into<String>) -> Self {
567        self.namespaces.insert(prefix.into(), uri.into());
568        self
569    }
570
571    /// XPath source text.
572    pub fn expression(&self) -> &str {
573        &self.expression
574    }
575
576    /// Namespace prefix bindings used during evaluation.
577    pub fn namespaces(&self) -> &BTreeMap<String, String> {
578        &self.namespaces
579    }
580
581    pub(crate) fn here_context_node(&self, semantics: XPathHereSemantics) -> Option<NodeId> {
582        self.here_nodes.map(|nodes| match semantics {
583            XPathHereSemantics::Specification => nodes.specification_xpath_element,
584            XPathHereSemantics::XmlSecLegacy => nodes.xmlsec_legacy_transform_element,
585        })
586    }
587
588    fn parsed_document_identity(&self) -> Option<XPathDocumentIdentity> {
589        self.here_nodes.map(|nodes| nodes.document)
590    }
591}
592
593/// Set operation applied by one XPath Filter 2.0 step.
594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum XPathFilterOperation {
596    /// Keep only nodes in the selected subtrees.
597    Intersect,
598    /// Remove nodes in the selected subtrees.
599    Subtract,
600    /// Add nodes in the selected subtrees.
601    Union,
602}
603
604impl XPathFilterOperation {
605    pub(crate) fn as_str(self) -> &'static str {
606        match self {
607            Self::Intersect => "intersect",
608            Self::Subtract => "subtract",
609            Self::Union => "union",
610        }
611    }
612}
613
614/// One expression and set operation in an XPath Filter 2.0 transform.
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct XPathFilter {
617    operation: XPathFilterOperation,
618    xpath: XPathExpression,
619}
620
621impl XPathFilter {
622    /// Create a Filter 2.0 step.
623    pub fn new(operation: XPathFilterOperation, xpath: XPathExpression) -> Self {
624        Self { operation, xpath }
625    }
626
627    /// Operation applied to the subtree-expanded expression result.
628    pub fn operation(&self) -> XPathFilterOperation {
629        self.operation
630    }
631
632    /// XPath expression evaluated by this step.
633    pub fn xpath(&self) -> &XPathExpression {
634        &self.xpath
635    }
636}
637
638/// A single transform in the pipeline.
639#[derive(Debug, Clone, PartialEq, Eq)]
640pub enum Transform {
641    /// Enveloped signature: removes the `<Signature>` element subtree
642    /// that contains the `<Reference>` being processed.
643    ///
644    /// Input: `NodeSet` → Output: `NodeSet`
645    Enveloped,
646
647    /// Narrow XPath compatibility form used by some donor vectors:
648    /// `not(ancestor-or-self::dsig:Signature)`.
649    ///
650    /// Unlike `Enveloped`, this excludes every `ds:Signature` subtree in the
651    /// current document, not only the containing signature.
652    XpathExcludeAllSignatures,
653
654    /// General XMLDSig XPath 1.0 node filter.
655    XPath(XPathExpression),
656
657    /// XPath Filter 2.0 ordered subtree set operations.
658    XPathFilter2(Vec<XPathFilter>),
659
660    /// XML Canonicalization (any supported variant).
661    ///
662    /// Input: `NodeSet` → Output: `Binary`
663    C14n(C14nAlgorithm),
664
665    /// Decode base64 text into the octets consumed by the next transform or digest.
666    ///
667    /// Node-set input is converted by concatenating included text nodes in
668    /// document order, as required by XMLDSig section 6.6.2. Binary input is
669    /// decoded directly.
670    ///
671    /// Input: `NodeSet` or `Binary` → Output: `Binary`
672    Base64Decode,
673}
674
675impl Transform {
676    pub(crate) fn algorithm_uri(&self) -> &'static str {
677        match self {
678            Self::Enveloped => ENVELOPED_SIGNATURE_URI,
679            Self::XpathExcludeAllSignatures | Self::XPath(_) => XPATH_TRANSFORM_URI,
680            Self::XPathFilter2(_) => XPATH_FILTER2_TRANSFORM_URI,
681            Self::C14n(algorithm) => algorithm.uri(),
682            Self::Base64Decode => BASE64_TRANSFORM_URI,
683        }
684    }
685}
686
687/// Apply a single transform to the pipeline data.
688///
689/// `signature_node` is the `<Signature>` element that contains the
690/// `<Reference>` being processed. It is used by the enveloped transform
691/// to know which signature subtree to exclude. The node must belong to the
692/// same document as the `NodeSet` in `input`; a cross-document mismatch
693/// returns [`TransformError::CrossDocumentSignatureNode`].
694#[cfg(test)]
695pub(crate) fn apply_transform<'a>(
696    signature_node: Node<'a, 'a>,
697    transform: &Transform,
698    input: TransformData<'a>,
699) -> Result<TransformData<'a>, TransformError> {
700    let budget = TransformExecutionBudget::default();
701    let state = TransformChainState::default();
702    apply_transform_with_options_and_state(
703        signature_node,
704        transform,
705        input,
706        TransformOptions::default(),
707        &budget,
708        &state,
709    )
710}
711
712#[cfg(test)]
713pub(super) fn apply_transform_with_options<'s, 'd>(
714    signature_node: Node<'s, 's>,
715    transform: &Transform,
716    input: TransformData<'d>,
717    options: TransformOptions,
718    budget: &TransformExecutionBudget,
719) -> Result<TransformData<'d>, TransformError> {
720    let state = TransformChainState::default();
721    apply_transform_with_options_and_state(
722        signature_node,
723        transform,
724        input,
725        options,
726        budget,
727        &state,
728    )
729}
730
731fn apply_transform_with_options_and_state<'s, 'd>(
732    signature_node: Node<'s, 's>,
733    transform: &Transform,
734    input: TransformData<'d>,
735    options: TransformOptions,
736    budget: &TransformExecutionBudget,
737    state: &TransformChainState,
738) -> Result<TransformData<'d>, TransformError> {
739    match transform {
740        Transform::Enveloped => {
741            let mut nodes = input.into_node_set()?;
742            // Exclude the Signature element and all its descendants from
743            // the node set. This is the core mechanism of the enveloped
744            // signature transform: the digest is computed as if the
745            // <Signature> were not present in the document.
746            //
747            // xmlsec1 equivalent:
748            //   xmlSecNodeSetGetChildren(doc, signatureNode, 1, 1)  // inverted tree
749            //   xmlSecNodeSetAdd(inNodes, children, Intersection)   // intersect = subtract
750            if !std::ptr::eq(signature_node.document(), nodes.document()) {
751                return Err(TransformError::CrossDocumentSignatureNode);
752            }
753            budget.node_filter.charge(nodes.len())?;
754            nodes.exclude_subtree(signature_node);
755            Ok(TransformData::NodeSet(nodes))
756        }
757        Transform::XpathExcludeAllSignatures => {
758            let mut nodes = input.into_node_set()?;
759            let doc = nodes.document();
760
761            // This variant is an optimized execution of a concrete wire-level
762            // XPath expression. Optimization may avoid SXD, but it must retain
763            // the expression's policy accounting for every input context and
764            // the node-set filtering pass.
765            budget.xpath.validate_context_evaluations(nodes.len())?;
766            budget.xpath.charge(doc.descendants().count())?;
767
768            for node in doc.descendants().filter(|node| {
769                node.is_element()
770                    && node.tag_name().name() == "Signature"
771                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
772            }) {
773                budget.node_filter.charge(nodes.len())?;
774                nodes.exclude_subtree(node);
775            }
776
777            Ok(TransformData::NodeSet(nodes))
778        }
779        Transform::XPath(xpath) => {
780            let nodes = input.into_node_set()?;
781            let document_relation = xpath_document_relation(
782                signature_node.document(),
783                nodes.document(),
784                std::iter::once(xpath),
785                state,
786            );
787            Ok(TransformData::NodeSet(
788                apply_xpath_filter_with_semantics_and_budget(
789                    nodes,
790                    xpath,
791                    options.here_semantics(),
792                    document_relation,
793                    &budget.xpath,
794                    &budget.node_filter,
795                    &budget.node_set_materialization,
796                )?,
797            ))
798        }
799        Transform::XPathFilter2(filters) => {
800            let nodes = input.into_node_set()?;
801            let document_relation = xpath_document_relation(
802                signature_node.document(),
803                nodes.document(),
804                filters.iter().map(XPathFilter::xpath),
805                state,
806            );
807            Ok(TransformData::NodeSet(
808                apply_xpath_filter2_with_semantics_and_budget(
809                    nodes,
810                    filters,
811                    options.here_semantics(),
812                    document_relation,
813                    &budget.xpath,
814                    &budget.node_filter,
815                    &budget.node_set_materialization,
816                )?,
817            ))
818        }
819        Transform::C14n(algo) => {
820            let nodes = input.into_node_set()?;
821            let mut output = Vec::new();
822            c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
823                nodes.document(),
824                Some(&nodes),
825                algo,
826                None,
827                budget.c14n.remaining(),
828                budget.xml_base_resolution(),
829                &mut output,
830            )
831            .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?;
832            budget.c14n.charge(output.len())?;
833            Ok(TransformData::Binary(output))
834        }
835        Transform::Base64Decode => {
836            let mut normalized = Vec::new();
837            match input {
838                TransformData::Binary(bytes) => {
839                    append_normalized_base64(&bytes, &mut normalized, &budget.base64)?;
840                }
841                TransformData::NodeSet(nodes) => {
842                    for node in nodes.document().descendants() {
843                        if nodes.contains(node) && node.is_text() {
844                            append_normalized_base64(
845                                node.text().unwrap_or_default().as_bytes(),
846                                &mut normalized,
847                                &budget.base64,
848                            )?;
849                        }
850                    }
851                }
852            }
853            Ok(TransformData::Binary(decode_base64_transform(
854                &normalized,
855                &budget.base64,
856            )?))
857        }
858    }
859}
860
861fn xpath_document_relation<'a>(
862    signature_document: &Document<'_>,
863    input_document: &Document<'_>,
864    expressions: impl IntoIterator<Item = &'a XPathExpression>,
865    state: &TransformChainState,
866) -> XPathDocumentRelation {
867    if matches!(
868        XPathDocumentRelation::between(signature_document, input_document),
869        XPathDocumentRelation::CrossDocument
870    ) {
871        return XPathDocumentRelation::CrossDocument;
872    }
873
874    let mut parsed_identities = expressions
875        .into_iter()
876        .filter_map(XPathExpression::parsed_document_identity);
877    let Some(first) = parsed_identities.next() else {
878        return XPathDocumentRelation::SameDocument;
879    };
880    let input_identity = state.xpath_document_identity(input_document);
881    if first == input_identity && parsed_identities.all(|identity| identity == input_identity) {
882        XPathDocumentRelation::SameDocument
883    } else {
884        XPathDocumentRelation::CrossDocument
885    }
886}
887
888/// Retain the RFC 2045 alphabet consumed by the XMLDSig Base64 transform.
889///
890/// RFC 2045 section 6.8 requires decoders to ignore every byte outside the
891/// alphabet. The raw-input budget is charged before filtering so ignored data
892/// cannot be used to force unbounded scanning or allocation.
893fn append_normalized_base64(
894    encoded: &[u8],
895    normalized: &mut Vec<u8>,
896    budget: &Base64WorkBudget,
897) -> Result<(), TransformError> {
898    budget.charge_input(encoded.len())?;
899
900    let additional = encoded
901        .iter()
902        .filter(|byte| is_rfc2045_base64_byte(**byte))
903        .count();
904    normalized.reserve(additional);
905    normalized.extend(
906        encoded
907            .iter()
908            .copied()
909            .filter(|byte| is_rfc2045_base64_byte(*byte)),
910    );
911    Ok(())
912}
913
914fn is_rfc2045_base64_byte(byte: u8) -> bool {
915    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
916}
917
918fn decode_base64_transform(
919    normalized: &[u8],
920    budget: &Base64WorkBudget,
921) -> Result<Vec<u8>, TransformError> {
922    let padding = normalized
923        .iter()
924        .rev()
925        .take_while(|byte| **byte == b'=')
926        .count();
927    let decoded_len = base64::decoded_len_estimate(normalized.len()).saturating_sub(padding);
928    budget.ensure_output_capacity(decoded_len)?;
929
930    let mut decoded = vec![0_u8; decoded_len];
931    let written = STANDARD
932        .decode_slice(normalized, &mut decoded)
933        .map_err(|error| TransformError::Base64(error.to_string()))?;
934    budget.charge_output(written)?;
935    decoded.truncate(written);
936    Ok(decoded)
937}
938
939/// Execute a chain of transforms for a single `<Reference>`.
940///
941/// 1. Start with `initial_data` (from URI dereference).
942/// 2. Apply each transform sequentially.
943/// 3. If the result is still a `NodeSet`, apply default inclusive C14N 1.0
944///    to produce bytes (per [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel)).
945///
946/// Returns the final byte sequence ready for digest computation.
947pub fn execute_transforms<'a>(
948    signature_node: Node<'a, 'a>,
949    initial_data: TransformData<'a>,
950    transforms: &[Transform],
951) -> Result<Vec<u8>, TransformError> {
952    execute_transforms_with_options(
953        signature_node,
954        initial_data,
955        transforms,
956        TransformOptions::default(),
957    )
958}
959
960/// Execute a transform chain with explicit compatibility options.
961pub(crate) fn execute_transforms_with_options<'a>(
962    signature_node: Node<'a, 'a>,
963    initial_data: TransformData<'a>,
964    transforms: &[Transform],
965    options: TransformOptions,
966) -> Result<Vec<u8>, TransformError> {
967    let budget = TransformExecutionBudget::default();
968    execute_transforms_with_options_and_budget(
969        signature_node,
970        initial_data,
971        transforms,
972        options,
973        &budget,
974    )
975}
976
977pub(crate) fn execute_transforms_with_options_and_budget<'a>(
978    signature_node: Node<'a, 'a>,
979    initial_data: TransformData<'a>,
980    transforms: &[Transform],
981    options: TransformOptions,
982    budget: &TransformExecutionBudget,
983) -> Result<Vec<u8>, TransformError> {
984    execute_reference_transforms_with_budget(
985        signature_node,
986        Some(signature_node),
987        initial_data,
988        transforms,
989        options,
990        budget,
991        budget.xml_parse_work(),
992    )
993}
994
995/// Execute a reference in its actual source context. The caller supplies any
996/// enclosing Signature; XPath `here()` still uses the original source XML.
997pub(crate) fn execute_reference_transforms_with_budget<'a>(
998    source_node: Node<'a, 'a>,
999    enveloped_signature: Option<Node<'a, 'a>>,
1000    initial_data: TransformData<'a>,
1001    transforms: &[Transform],
1002    options: TransformOptions,
1003    budget: &TransformExecutionBudget,
1004    xml_parse: &XmlParseWorkBudget,
1005) -> Result<Vec<u8>, TransformError> {
1006    ensure_transform_count(transforms.len())?;
1007    budget.state.begin_chain();
1008    let context = TransformExecutionContext {
1009        options,
1010        budget,
1011        state: &budget.state,
1012        xml_parse,
1013    };
1014    execute_transform_chain(
1015        source_node,
1016        enveloped_signature,
1017        initial_data,
1018        transforms,
1019        None,
1020        None,
1021        &context,
1022    )
1023    .map(|output| output.bytes)
1024}
1025
1026/// Mutable signing fields that can affect a transform output.
1027pub(crate) struct TransformDependencyOutput {
1028    pub(crate) dependencies: HashSet<usize>,
1029}
1030
1031struct TransformChainOutput {
1032    bytes: Vec<u8>,
1033    dependencies: HashSet<usize>,
1034}
1035
1036struct DependencyTracking {
1037    // Node identities are remapped by canonical byte position whenever a later
1038    // node-set transform reparses C14N output into a new document.
1039    active_nodes: Vec<TrackedDependencyNode>,
1040    // Mutable nodes outside the dereferenced input cannot affect ordinary
1041    // serialization transforms. XPath can still read them through absolute or
1042    // document-scanning expressions, so retain their indexes until that choice
1043    // is known rather than treating them as active input provenance.
1044    dormant_indexes: HashSet<usize>,
1045    // Base64 decoding destroys XML identity. Dependencies that crossed that
1046    // boundary remain conservative because later XPath cannot recover origin.
1047    opaque_dependencies: HashSet<usize>,
1048    canonical_positions: Option<Vec<CanonicalDependencyPosition>>,
1049}
1050
1051struct TrackedDependencyNode {
1052    index: usize,
1053    node_id: NodeId,
1054    node_type: NodeType,
1055}
1056
1057struct CanonicalDependencyPosition {
1058    index: usize,
1059    position: usize,
1060    node_type: NodeType,
1061}
1062
1063/// Execute the production transform state machine while carrying node origin.
1064///
1065/// Dependency analysis deliberately shares this executor with digest
1066/// computation so binary-to-node-set adaptation and later XPath filters cannot
1067/// drift into a second, incomplete transform model.
1068pub(crate) fn execute_transforms_with_dependency_nodes<'a>(
1069    signature_node: Node<'a, 'a>,
1070    initial_data: TransformData<'a>,
1071    transforms: &[Transform],
1072    options: TransformOptions,
1073    budget: &TransformExecutionBudget,
1074    tracked_nodes: Vec<(usize, NodeId)>,
1075) -> Result<TransformDependencyOutput, TransformError> {
1076    ensure_transform_count(transforms.len())?;
1077    budget.state.begin_chain();
1078    let mut active_nodes = Vec::with_capacity(tracked_nodes.len());
1079    let mut opaque_dependencies = HashSet::new();
1080    let mut dormant_indexes = HashSet::new();
1081    for (index, node_id) in tracked_nodes {
1082        if let Some(node) = signature_node.document().get_node(node_id) {
1083            let belongs_to_input = match &initial_data {
1084                TransformData::NodeSet(nodes) => nodes.contains(node),
1085                TransformData::Binary(_) => false,
1086            };
1087            if belongs_to_input {
1088                active_nodes.push(TrackedDependencyNode {
1089                    index,
1090                    node_id,
1091                    node_type: node.node_type(),
1092                });
1093            } else {
1094                dormant_indexes.insert(index);
1095            }
1096        } else {
1097            opaque_dependencies.insert(index);
1098        }
1099    }
1100    let context = TransformExecutionContext {
1101        options,
1102        budget,
1103        state: &budget.state,
1104        xml_parse: budget.xml_parse_work(),
1105    };
1106    let output = execute_transform_chain(
1107        signature_node,
1108        Some(signature_node),
1109        initial_data,
1110        transforms,
1111        None,
1112        Some(DependencyTracking {
1113            active_nodes,
1114            dormant_indexes,
1115            opaque_dependencies,
1116            canonical_positions: None,
1117        }),
1118        &context,
1119    )?;
1120    Ok(TransformDependencyOutput {
1121        dependencies: output.dependencies,
1122    })
1123}
1124
1125fn ensure_transform_count(count: usize) -> Result<(), TransformError> {
1126    if count > MAX_TRANSFORMS_PER_REFERENCE {
1127        return Err(transform_resource_limit(
1128            crate::policy::resource_name::REFERENCE_TRANSFORMS,
1129            MAX_TRANSFORMS_PER_REFERENCE,
1130            count,
1131        ));
1132    }
1133    Ok(())
1134}
1135
1136fn execute_transform_chain<'s, 'e, 'd>(
1137    source_signature: Node<'s, 's>,
1138    enveloped_signature: Option<Node<'e, 'e>>,
1139    data: TransformData<'d>,
1140    transforms: &[Transform],
1141    canonical_signature_position: Option<Option<usize>>,
1142    mut dependency_tracking: Option<DependencyTracking>,
1143    context: &TransformExecutionContext<'_>,
1144) -> Result<TransformChainOutput, TransformError> {
1145    let Some((transform, remaining)) = transforms.split_first() else {
1146        if let (TransformData::NodeSet(nodes), Some(tracking)) = (&data, &mut dependency_tracking) {
1147            tracking.active_nodes.retain(|tracked| {
1148                nodes
1149                    .document()
1150                    .get_node(tracked.node_id)
1151                    .is_some_and(|node| nodes.contains(node))
1152            });
1153        }
1154        let bytes = finalize_transform_data(data, context.budget)?;
1155        return Ok(TransformChainOutput {
1156            bytes,
1157            dependencies: dependency_indexes(dependency_tracking),
1158        });
1159    };
1160
1161    if transform_requires_node_set(transform)
1162        && let TransformData::Binary(bytes) = data
1163    {
1164        // The parsed document must outlive every remaining node-set transform.
1165        // Recursive execution keeps all borrows scoped to this stack frame and
1166        // returns only owned digest bytes. Every C14N output is charged before
1167        // recursion, so these retained buffers remain a bounded subset of the
1168        // signature-wide canonicalization work budget.
1169        // Decoding is a separate input pass, including malformed-input failures. Reserve it
1170        // before any transcoding; the subsequent parser charges its own decoded-byte passes.
1171        context.xml_parse.charge_policy(bytes.len())?;
1172        let xml =
1173            crate::encoding::decode_xml_octets(&bytes, context.budget.xml_parse_settings.max_bytes)
1174                .map_err(map_transform_xml_decode_error)?;
1175        let settings = DocumentParseSettings {
1176            allow_dtd: context.options.internal_dtd_allowed(),
1177            ..context.budget.xml_parse_settings
1178        };
1179        let document =
1180            parse_borrowed_with_settings_and_budget(&xml, settings, Some(context.xml_parse))
1181                .map_err(|error| map_transform_xml_parse_error(error, settings))?;
1182        context.state.document_reparsed();
1183        let nodes = super::types::NodeSet::entire_document_with_comments_with_budget(
1184            &document,
1185            &context.budget.node_set_materialization,
1186        )?;
1187        if let Some(tracking) = &mut dependency_tracking
1188            && let Some(positions) = tracking.canonical_positions.take()
1189        {
1190            let mut remapped = Vec::with_capacity(positions.len());
1191            for tracked in positions {
1192                if let Some(node) = document.descendants().find(|node| {
1193                    node.node_type() == tracked.node_type && node.range().start == tracked.position
1194                }) {
1195                    remapped.push(TrackedDependencyNode {
1196                        index: tracked.index,
1197                        node_id: node.id(),
1198                        node_type: tracked.node_type,
1199                    });
1200                } else {
1201                    // Losing provenance must never become proof that later
1202                    // mutable DigestValue content cannot affect the digest.
1203                    tracking.opaque_dependencies.insert(tracked.index);
1204                }
1205            }
1206            tracking.active_nodes = remapped;
1207        }
1208        return match canonical_signature_position {
1209            Some(Some(position)) => {
1210                let remapped = document
1211                    .descendants()
1212                    .find(|node| node.is_element() && node.range().start == position)
1213                    .filter(|node| {
1214                        enveloped_signature
1215                            .is_some_and(|source| node.tag_name() == source.tag_name())
1216                    })
1217                    .ok_or(TransformError::CrossDocumentSignatureNode)?;
1218                execute_transform_chain(
1219                    source_signature,
1220                    Some(remapped),
1221                    TransformData::NodeSet(nodes),
1222                    transforms,
1223                    None,
1224                    dependency_tracking,
1225                    context,
1226                )
1227            }
1228            Some(None) => execute_transform_chain(
1229                source_signature,
1230                None,
1231                TransformData::NodeSet(nodes),
1232                transforms,
1233                None,
1234                dependency_tracking,
1235                context,
1236            ),
1237            None => execute_transform_chain(
1238                source_signature,
1239                // Binary input not produced by tracked canonicalization is a
1240                // different document. Keep source_signature for XPath here()
1241                // semantics, but do not apply its identity to Enveloped.
1242                None,
1243                TransformData::NodeSet(nodes),
1244                transforms,
1245                None,
1246                dependency_tracking,
1247                context,
1248            ),
1249        };
1250    }
1251
1252    if let Transform::C14n(algo) = transform
1253        && let TransformData::NodeSet(nodes) = &data
1254    {
1255        let tracked_element = enveloped_signature
1256            .filter(|signature| std::ptr::eq(signature.document(), nodes.document()))
1257            .filter(|signature| nodes.contains(*signature))
1258            .map(|signature| signature.id());
1259        let mut output = Vec::new();
1260        if let Some(tracking) = &mut dependency_tracking {
1261            tracking.active_nodes.retain(|tracked| {
1262                nodes
1263                    .document()
1264                    .get_node(tracked.node_id)
1265                    .is_some_and(|node| nodes.contains(node))
1266            });
1267            tracking.dormant_indexes.clear();
1268        }
1269        let position = if let Some(tracking) = &mut dependency_tracking {
1270            let mut tracked_ids = tracking
1271                .active_nodes
1272                .iter()
1273                .map(|tracked| tracked.node_id)
1274                .collect::<Vec<_>>();
1275            if let Some(signature_id) = tracked_element
1276                && !tracked_ids.contains(&signature_id)
1277            {
1278                tracked_ids.push(signature_id);
1279            }
1280            let positions =
1281                c14n::canonicalize_with_visibility_and_positions_bounded_with_xml_base_budget(
1282                    nodes.document(),
1283                    Some(nodes),
1284                    algo,
1285                    &tracked_ids,
1286                    context.budget.c14n.remaining(),
1287                    context.budget.xml_base_resolution(),
1288                    &mut output,
1289                )
1290                .map_err(|error| map_c14n_limit_error(error, &context.budget.c14n))?;
1291            let mut canonical_positions = Vec::with_capacity(tracking.active_nodes.len());
1292            for tracked in &tracking.active_nodes {
1293                if let Some((_, position)) = positions
1294                    .iter()
1295                    .find(|(tracked_id, _)| *tracked_id == tracked.node_id)
1296                {
1297                    canonical_positions.push(CanonicalDependencyPosition {
1298                        index: tracked.index,
1299                        position: *position,
1300                        node_type: tracked.node_type,
1301                    });
1302                } else {
1303                    tracking.opaque_dependencies.insert(tracked.index);
1304                }
1305            }
1306            tracking.canonical_positions = Some(canonical_positions);
1307            tracked_element.and_then(|signature_id| {
1308                positions
1309                    .iter()
1310                    .find(|(tracked_id, _)| *tracked_id == signature_id)
1311                    .map(|(_, position)| *position)
1312            })
1313        } else {
1314            c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
1315                nodes.document(),
1316                Some(nodes),
1317                algo,
1318                tracked_element,
1319                context.budget.c14n.remaining(),
1320                context.budget.xml_base_resolution(),
1321                &mut output,
1322            )
1323            .map_err(|error| map_c14n_limit_error(error, &context.budget.c14n))?
1324        };
1325        context.budget.c14n.charge(output.len())?;
1326        return execute_transform_chain(
1327            source_signature,
1328            enveloped_signature,
1329            TransformData::Binary(output),
1330            remaining,
1331            Some(position),
1332            dependency_tracking,
1333            context,
1334        );
1335    }
1336
1337    if matches!(transform, Transform::Enveloped) {
1338        let Some(signature) = enveloped_signature else {
1339            return execute_transform_chain(
1340                source_signature,
1341                None,
1342                data,
1343                remaining,
1344                None,
1345                dependency_tracking,
1346                context,
1347            );
1348        };
1349        let data = apply_transform_with_options_and_state(
1350            signature,
1351            transform,
1352            data,
1353            context.options,
1354            context.budget,
1355            context.state,
1356        )?;
1357        return execute_transform_chain(
1358            source_signature,
1359            Some(signature),
1360            data,
1361            remaining,
1362            None,
1363            dependency_tracking,
1364            context,
1365        );
1366    }
1367
1368    let data = apply_transform_with_options_and_state(
1369        source_signature,
1370        transform,
1371        data,
1372        context.options,
1373        context.budget,
1374        context.state,
1375    )?;
1376    if let Some(tracking) = &mut dependency_tracking {
1377        match &data {
1378            TransformData::NodeSet(nodes) => {
1379                let preserve_excluded_as_opaque = match transform {
1380                    Transform::XPath(expression) => {
1381                        xpath_may_read_mutable_character_data(expression.expression())
1382                    }
1383                    Transform::XPathFilter2(filters) => filters.iter().any(|filter| {
1384                        xpath_may_read_mutable_character_data(filter.xpath().expression())
1385                    }),
1386                    _ => false,
1387                };
1388                if preserve_excluded_as_opaque {
1389                    tracking
1390                        .opaque_dependencies
1391                        .extend(tracking.dormant_indexes.drain());
1392                }
1393                let mut active_nodes = Vec::with_capacity(tracking.active_nodes.len());
1394                for tracked in tracking.active_nodes.drain(..) {
1395                    let remains_visible = nodes
1396                        .document()
1397                        .get_node(tracked.node_id)
1398                        .is_some_and(|node| nodes.contains(node));
1399                    if remains_visible {
1400                        active_nodes.push(tracked);
1401                    } else if preserve_excluded_as_opaque {
1402                        // SXD exposes output membership but not which source
1403                        // values a predicate coerced. Structural selection does
1404                        // not depend on mutable DigestValue text, but a value
1405                        // scan may control another node's inclusion, so absence
1406                        // is not proof of independence in that case.
1407                        tracking.opaque_dependencies.insert(tracked.index);
1408                    } else {
1409                        tracking.dormant_indexes.insert(tracked.index);
1410                    }
1411                }
1412                tracking.active_nodes = active_nodes;
1413            }
1414            TransformData::Binary(_) => {
1415                tracking
1416                    .opaque_dependencies
1417                    .extend(tracking.active_nodes.drain(..).map(|tracked| tracked.index));
1418                tracking.dormant_indexes.clear();
1419                tracking.canonical_positions = None;
1420            }
1421        }
1422    }
1423    execute_transform_chain(
1424        source_signature,
1425        enveloped_signature,
1426        data,
1427        remaining,
1428        None,
1429        dependency_tracking,
1430        context,
1431    )
1432}
1433
1434fn map_transform_xml_parse_error(
1435    error: XmlDocumentError,
1436    settings: DocumentParseSettings,
1437) -> TransformError {
1438    match error.into_policy_violation(settings) {
1439        Ok(error) => TransformError::Policy(error),
1440        Err(error) => TransformError::XmlParse(error.to_string()),
1441    }
1442}
1443
1444fn map_transform_xml_decode_error(error: crate::encoding::XmlEncodingError) -> TransformError {
1445    match error {
1446        crate::encoding::XmlEncodingError::DecodedLimit { maximum, actual } => {
1447            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
1448                resource: crate::policy::resource_name::XML_DOCUMENT,
1449                maximum,
1450                actual,
1451            })
1452        }
1453        error => TransformError::XmlParse(error.to_string()),
1454    }
1455}
1456
1457fn dependency_indexes(tracking: Option<DependencyTracking>) -> HashSet<usize> {
1458    let Some(tracking) = tracking else {
1459        return HashSet::new();
1460    };
1461    tracking
1462        .active_nodes
1463        .into_iter()
1464        .map(|tracked| tracked.index)
1465        .chain(tracking.opaque_dependencies)
1466        .collect()
1467}
1468
1469fn transform_requires_node_set(transform: &Transform) -> bool {
1470    !matches!(transform, Transform::Base64Decode)
1471}
1472
1473fn finalize_transform_data(
1474    data: TransformData<'_>,
1475    budget: &TransformExecutionBudget,
1476) -> Result<Vec<u8>, TransformError> {
1477    // Final coercion: if the result is still a NodeSet, canonicalize with
1478    // default inclusive C14N 1.0 per XMLDSig spec §4.3.3.2.
1479    match data {
1480        TransformData::Binary(bytes) => Ok(bytes),
1481        TransformData::NodeSet(nodes) => {
1482            #[expect(clippy::expect_used, reason = "hardcoded URI is a known constant")]
1483            let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI)
1484                .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri");
1485            let mut output = Vec::new();
1486            c14n::canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
1487                nodes.document(),
1488                Some(&nodes),
1489                &algo,
1490                None,
1491                budget.c14n.remaining(),
1492                budget.xml_base_resolution(),
1493                &mut output,
1494            )
1495            .map_err(|error| map_c14n_limit_error(error, &budget.c14n))?;
1496            budget.c14n.charge(output.len())?;
1497            Ok(output)
1498        }
1499    }
1500}
1501
1502fn map_c14n_limit_error(error: c14n::C14nError, budget: &C14nOutputBudget) -> TransformError {
1503    if c14n::is_output_limit_error(&error) {
1504        // Rendering already spent work up to the remaining allowance. Mark it
1505        // consumed so another Reference cannot spend the same budget.
1506        budget.exhaust();
1507    }
1508    if let Some(violation) = map_c14n_resource_policy_violation(
1509        &error,
1510        crate::policy::resource_name::CANONICALIZED_BYTES,
1511        budget.max_bytes,
1512    ) {
1513        TransformError::Policy(violation)
1514    } else {
1515        TransformError::C14n(error)
1516    }
1517}
1518
1519pub(crate) fn transform_chain_produces_binary(
1520    initial_binary: bool,
1521    transforms: &[Transform],
1522) -> bool {
1523    transforms.last().map_or(initial_binary, |transform| {
1524        matches!(transform, Transform::C14n(_) | Transform::Base64Decode)
1525    })
1526}
1527
1528/// Map every C14N resource failure governed by the operation policy.
1529///
1530/// The output parameters apply only to the bounded-writer case; XML Base
1531/// component and byte failures carry their own policy maxima in the error.
1532pub(crate) fn map_c14n_resource_policy_violation(
1533    error: &c14n::C14nError,
1534    output_resource: &'static str,
1535    output_maximum: usize,
1536) -> Option<crate::policy::PolicyViolation> {
1537    match error {
1538        error if c14n::is_output_limit_error(error) => {
1539            Some(crate::policy::PolicyViolation::ResourceLimit {
1540                resource: output_resource,
1541                maximum: output_maximum,
1542                // The bounded writer stops at the limit, so only the smallest
1543                // rejected size is known; this sentinel is a lower bound.
1544                actual: output_maximum.saturating_add(1),
1545            })
1546        }
1547        c14n::C14nError::XmlBaseComponentsTooLarge { max, actual } => {
1548            Some(crate::policy::PolicyViolation::ResourceLimit {
1549                resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
1550                maximum: *max,
1551                actual: *actual,
1552            })
1553        }
1554        c14n::C14nError::XmlBaseResolutionTooLarge { max_bytes, actual } => {
1555            Some(crate::policy::PolicyViolation::ResourceLimit {
1556                resource: crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
1557                maximum: *max_bytes,
1558                actual: *actual,
1559            })
1560        }
1561        _ => None,
1562    }
1563}
1564
1565pub(crate) fn validate_signing_transform_policy(
1566    initial_binary: bool,
1567    transforms: &[Transform],
1568    allowed: Option<&HashSet<String>>,
1569) -> Result<(), crate::policy::PolicyViolation> {
1570    let Some(allowed) = allowed else {
1571        return Ok(());
1572    };
1573    for transform in transforms {
1574        let algorithm = transform.algorithm_uri();
1575        if !allowed.contains(algorithm) {
1576            return Err(crate::policy::PolicyViolation::Algorithm {
1577                operation: "signing transform",
1578                algorithm: algorithm.to_owned(),
1579            });
1580        }
1581    }
1582    if !transform_chain_produces_binary(initial_binary, transforms)
1583        && !allowed.contains(DEFAULT_IMPLICIT_C14N_URI)
1584    {
1585        return Err(crate::policy::PolicyViolation::Algorithm {
1586            operation: "signing transform",
1587            algorithm: DEFAULT_IMPLICIT_C14N_URI.to_owned(),
1588        });
1589    }
1590    Ok(())
1591}
1592
1593/// Parse a `<Transforms>` element into a `Vec<Transform>`.
1594///
1595/// Reads each `<Transform Algorithm="...">` child element and constructs
1596/// the corresponding [`Transform`] variant. Unrecognized algorithm URIs
1597/// produce an error.
1598///
1599/// For Exclusive C14N, also parses the optional `<InclusiveNamespaces
1600/// PrefixList="...">` child element.
1601pub fn parse_transforms(transforms_node: Node) -> Result<Vec<Transform>, TransformError> {
1602    parse_transforms_with_budget(transforms_node, &mut XPathSignatureParseBudget::default())
1603}
1604
1605pub(crate) fn parse_transforms_with_budget(
1606    transforms_node: Node,
1607    signature_budget: &mut XPathSignatureParseBudget,
1608) -> Result<Vec<Transform>, TransformError> {
1609    parse_reference_transforms_with_budget(transforms_node, XMLDSIG_NS, signature_budget)
1610}
1611
1612pub(crate) fn parse_reference_transforms_with_budget(
1613    transforms_node: Node,
1614    container_namespace: &str,
1615    signature_budget: &mut XPathSignatureParseBudget,
1616) -> Result<Vec<Transform>, TransformError> {
1617    // Validate that we received a <ds:Transforms> element.
1618    if !transforms_node.is_element() {
1619        return Err(TransformError::UnsupportedTransform(
1620            "expected <Transforms> element but got non-element node".into(),
1621        ));
1622    }
1623    let transforms_tag = transforms_node.tag_name();
1624    if transforms_tag.name() != "Transforms"
1625        || transforms_tag.namespace() != Some(container_namespace)
1626    {
1627        return Err(TransformError::UnsupportedTransform(
1628            "Transforms container has the wrong namespace".into(),
1629        ));
1630    }
1631
1632    let mut chain = Vec::new();
1633    let mut xpath_state = XPathParseState::new(signature_budget);
1634
1635    for child in transforms_node.children() {
1636        if !child.is_element() {
1637            // XMLDSig 1.1 section 4.4.3.4 and XMLEnc 1.1 section 3.3.1
1638            // define element-only Transforms content, not arbitrary text.
1639            // https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-Transforms
1640            // https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-CipherReference
1641            if child.is_text() && !child.text().is_some_and(is_xml_whitespace_only) {
1642                return Err(TransformError::UnsupportedTransform(
1643                    "Transforms must not contain character data".into(),
1644                ));
1645            }
1646            continue;
1647        }
1648        ensure_transform_count(chain.len() + 1)?;
1649
1650        // Only <ds:Transform> children are allowed; fail closed on any other element.
1651        let tag = child.tag_name();
1652        if tag.name() != "Transform" || tag.namespace() != Some(XMLDSIG_NS) {
1653            return Err(TransformError::UnsupportedTransform(
1654                "unexpected child element of <ds:Transforms>; only <ds:Transform> is allowed"
1655                    .into(),
1656            ));
1657        }
1658        let uri = child.attribute("Algorithm").ok_or_else(|| {
1659            TransformError::UnsupportedTransform(
1660                "missing Algorithm attribute on <Transform>".into(),
1661            )
1662        })?;
1663
1664        let transform = if uri == ENVELOPED_SIGNATURE_URI {
1665            Transform::Enveloped
1666        } else if uri == BASE64_TRANSFORM_URI {
1667            validate_empty_transform(child, "Base64")?;
1668            Transform::Base64Decode
1669        } else if uri == XPATH_TRANSFORM_URI {
1670            parse_xpath_transform_with_state(child, &mut xpath_state)?
1671        } else if uri == XPATH_FILTER2_TRANSFORM_URI {
1672            parse_xpath_filter2_transform(child, &mut xpath_state)?
1673        } else if let Some(mut algo) = C14nAlgorithm::from_uri(uri) {
1674            // For exclusive C14N, check for InclusiveNamespaces child
1675            if algo.mode() == c14n::C14nMode::Exclusive1_0
1676                && let Some(prefix_list) = parse_inclusive_prefixes(child)?
1677            {
1678                algo = algo.with_prefix_list(&prefix_list);
1679            }
1680            Transform::C14n(algo)
1681        } else {
1682            return Err(TransformError::UnsupportedTransform(uri.to_string()));
1683        };
1684        chain.push(transform);
1685    }
1686
1687    Ok(chain)
1688}
1689
1690/// Validate transforms whose XML syntax does not define parameter content.
1691fn validate_empty_transform(
1692    transform_node: Node,
1693    transform_name: &'static str,
1694) -> Result<(), TransformError> {
1695    for child in transform_node.children() {
1696        if child.is_element()
1697            || (child.is_text()
1698                && child
1699                    .text()
1700                    .is_some_and(|text| !is_xml_whitespace_only(text)))
1701        {
1702            return Err(TransformError::UnsupportedTransform(format!(
1703                "{transform_name} transform must not contain parameters"
1704            )));
1705        }
1706    }
1707    Ok(())
1708}
1709
1710#[cfg(test)]
1711pub(super) fn parse_xpath_transform(transform_node: Node) -> Result<Transform, TransformError> {
1712    parse_xpath_transform_with_state(
1713        transform_node,
1714        &mut XPathParseState::new(&mut XPathSignatureParseBudget::default()),
1715    )
1716}
1717
1718fn parse_xpath_transform_with_state(
1719    transform_node: Node,
1720    xpath_state: &mut XPathParseState,
1721) -> Result<Transform, TransformError> {
1722    let mut xpath_node = None;
1723
1724    for child in transform_node.children() {
1725        if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1726            continue;
1727        }
1728        if child.is_comment() || child.is_pi() {
1729            continue;
1730        }
1731        if !child.is_element() {
1732            return Err(TransformError::XPath(
1733                "XPath transform contains non-whitespace parameter content".into(),
1734            ));
1735        }
1736        let tag = child.tag_name();
1737        if tag.name() == "XPath" && tag.namespace() == Some(XMLDSIG_NS) {
1738            if xpath_node.is_some() {
1739                return Err(TransformError::XPath(
1740                    "XPath transform must contain exactly one XMLDSig <XPath> child element".into(),
1741                ));
1742            }
1743            xpath_node = Some(child);
1744        } else {
1745            return Err(TransformError::XPath(
1746                "XPath transform allows only a single XMLDSig <XPath> child element".into(),
1747            ));
1748        }
1749    }
1750
1751    let xpath_node = xpath_node.ok_or_else(|| {
1752        TransformError::XPath(
1753            "XPath transform requires a single XMLDSig <XPath> child element".into(),
1754        )
1755    })?;
1756    if xpath_node.attributes().len() != 0 {
1757        return Err(TransformError::XPath(
1758            "XMLDSig <XPath> does not allow attributes".into(),
1759        ));
1760    }
1761    let xpath = parse_xpath_expression(xpath_node, transform_node.id(), xpath_state)?;
1762
1763    if xpath.expression() == ENVELOPED_SIGNATURE_XPATH_EXPR
1764        && xpath.namespaces().get("dsig").map(String::as_str) == Some(XMLDSIG_NS)
1765    {
1766        Ok(Transform::XpathExcludeAllSignatures)
1767    } else {
1768        Ok(Transform::XPath(xpath))
1769    }
1770}
1771
1772fn parse_xpath_filter2_transform(
1773    transform_node: Node,
1774    xpath_state: &mut XPathParseState,
1775) -> Result<Transform, TransformError> {
1776    let mut filters = Vec::new();
1777    for child in transform_node.children() {
1778        if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1779            continue;
1780        }
1781        if child.is_comment() || child.is_pi() {
1782            continue;
1783        }
1784        if !child.is_element()
1785            || child.tag_name().name() != "XPath"
1786            || child.tag_name().namespace() != Some(XPATH_FILTER2_TRANSFORM_URI)
1787        {
1788            return Err(TransformError::XPath(
1789                "XPath Filter 2.0 allows only filter-namespace <XPath> children".into(),
1790            ));
1791        }
1792        if filters.len() == xpath_state.signature_budget.max_filters {
1793            return Err(transform_resource_limit(
1794                crate::policy::resource_name::XPATH_FILTERS,
1795                xpath_state.signature_budget.max_filters,
1796                filters.len().saturating_add(1),
1797            ));
1798        }
1799        if child.attributes().len() != 1 || child.attribute("Filter").is_none() {
1800            return Err(TransformError::XPath(
1801                "XPath Filter 2.0 <XPath> requires only the unqualified Filter attribute".into(),
1802            ));
1803        }
1804        let operation = match child.attribute("Filter") {
1805            Some("intersect") => XPathFilterOperation::Intersect,
1806            Some("subtract") => XPathFilterOperation::Subtract,
1807            Some("union") => XPathFilterOperation::Union,
1808            Some(value) => {
1809                return Err(TransformError::XPath(format!(
1810                    "unsupported XPath Filter 2.0 operation: {value}"
1811                )));
1812            }
1813            None => unreachable!("Filter presence was checked above"),
1814        };
1815        filters.push(XPathFilter::new(
1816            operation,
1817            parse_xpath_expression(child, transform_node.id(), xpath_state)?,
1818        ));
1819    }
1820    if filters.is_empty() {
1821        return Err(TransformError::XPath(
1822            "XPath Filter 2.0 requires at least one expression".into(),
1823        ));
1824    }
1825    Ok(Transform::XPathFilter2(filters))
1826}
1827
1828fn parse_xpath_expression(
1829    xpath_node: Node,
1830    transform_node: NodeId,
1831    xpath_state: &mut XPathParseState,
1832) -> Result<XPathExpression, TransformError> {
1833    let mut source = String::new();
1834    for child in xpath_node.children() {
1835        if child.is_text() {
1836            let text = child.text().unwrap_or_default();
1837            let attempted = source.len().saturating_add(text.len());
1838            if attempted > xpath_state.signature_budget.max_expression_bytes {
1839                return Err(transform_resource_limit(
1840                    crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1841                    xpath_state.signature_budget.max_expression_bytes,
1842                    attempted,
1843                ));
1844            }
1845            source.push_str(text);
1846        } else if child.is_element() {
1847            return Err(TransformError::XPath(
1848                "XPath expressions must contain text only".into(),
1849            ));
1850        }
1851    }
1852    let source = source.trim_matches(is_xpath_whitespace);
1853    if source.is_empty() {
1854        return Err(TransformError::XPath(
1855            "XPath expression must not be empty".into(),
1856        ));
1857    }
1858    xpath_state.signature_budget.charge()?;
1859    if source.len() > xpath_state.signature_budget.max_expression_bytes {
1860        return Err(transform_resource_limit(
1861            crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1862            xpath_state.signature_budget.max_expression_bytes,
1863            source.len(),
1864        ));
1865    }
1866    let complexity = crate::xmldsig::xpath::xpath_expression_complexity(source);
1867    if complexity > xpath_state.signature_budget.max_expression_complexity {
1868        return Err(transform_resource_limit(
1869            crate::policy::resource_name::XPATH_EXPRESSION_COMPLEXITY,
1870            xpath_state.signature_budget.max_expression_complexity,
1871            complexity,
1872        ));
1873    }
1874    crate::xmldsig::xpath::compile_xpath_with_policy_limits(
1875        source,
1876        xpath_state.signature_budget.max_expression_bytes,
1877        xpath_state.signature_budget.max_expression_complexity,
1878    )
1879    .map_err(TransformError::XPath)?;
1880
1881    let namespaces = collect_xpath_namespaces_with_limits(
1882        xpath_node,
1883        xpath_state.signature_budget.max_namespace_bindings,
1884        xpath_state.signature_budget.max_namespace_bytes,
1885    )?;
1886    let xpath = XPathExpression {
1887        expression: source.to_owned(),
1888        namespaces,
1889        here_nodes: Some(XPathHereNodes {
1890            // XMLDSig defines here() as the parent element of the text node
1891            // bearing the expression, not as the text node itself.
1892            specification_xpath_element: xpath_node.id(),
1893            xmlsec_legacy_transform_element: transform_node,
1894            // NodeId is only meaningful within one semantic DOM Document. Keep an
1895            // owned content identity so parsed transforms cannot outlive the
1896            // source and later alias unrelated nodes carrying the same indices.
1897            document: xpath_state.document_identity(xpath_node.document()),
1898        }),
1899    };
1900    Ok(xpath)
1901}
1902
1903struct XPathParseState<'a> {
1904    document_identity: Option<XPathDocumentIdentity>,
1905    signature_budget: &'a mut XPathSignatureParseBudget,
1906}
1907
1908impl<'a> XPathParseState<'a> {
1909    fn new(signature_budget: &'a mut XPathSignatureParseBudget) -> Self {
1910        Self {
1911            document_identity: None,
1912            signature_budget,
1913        }
1914    }
1915
1916    fn document_identity(&mut self, document: &Document<'_>) -> XPathDocumentIdentity {
1917        *self
1918            .document_identity
1919            .get_or_insert_with(|| XPathDocumentIdentity::from_document(document))
1920    }
1921}
1922
1923/// Parse/compile work shared by every Reference in one Signature, including
1924/// repeated Manifest parses required by dependency-ordered signing.
1925pub(crate) struct XPathSignatureParseBudget {
1926    expressions: usize,
1927    max_expressions: usize,
1928    max_expression_bytes: usize,
1929    max_expression_complexity: usize,
1930    max_namespace_bindings: usize,
1931    max_namespace_bytes: usize,
1932    max_filters: usize,
1933}
1934
1935impl Default for XPathSignatureParseBudget {
1936    fn default() -> Self {
1937        Self {
1938            expressions: 0,
1939            max_expressions: MAX_XPATH_EXPRESSIONS_PER_SIGNATURE,
1940            max_expression_bytes: MAX_XPATH_EXPRESSION_BYTES,
1941            max_expression_complexity: crate::hard_limits::XPATH_EXPRESSION_COMPLEXITY_CEILING,
1942            max_namespace_bindings: MAX_XPATH_NAMESPACE_BINDINGS,
1943            max_namespace_bytes: MAX_XPATH_NAMESPACE_BYTES,
1944            max_filters: MAX_XPATH_FILTERS,
1945        }
1946    }
1947}
1948
1949impl XPathSignatureParseBudget {
1950    pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self {
1951        Self {
1952            expressions: 0,
1953            max_expressions: resources.max_xpath_expressions,
1954            max_expression_bytes: resources.max_xpath_expression_bytes,
1955            max_expression_complexity: resources.max_xpath_expression_complexity,
1956            max_namespace_bindings: resources.max_xpath_namespace_bindings,
1957            max_namespace_bytes: resources.max_xpath_namespace_bytes,
1958            max_filters: resources.max_xpath_filters,
1959        }
1960    }
1961
1962    pub(crate) fn charge(&mut self) -> Result<(), TransformError> {
1963        self.expressions = self
1964            .expressions
1965            .checked_add(1)
1966            .ok_or_else(|| self.error())?;
1967        if self.expressions > self.max_expressions {
1968            return Err(self.error());
1969        }
1970        Ok(())
1971    }
1972
1973    pub(crate) fn validate_expression(&mut self, source: &str) -> Result<(), TransformError> {
1974        if source.is_empty() {
1975            return Err(TransformError::XPath(
1976                "XPath expression must not be empty".into(),
1977            ));
1978        }
1979        self.charge()?;
1980        if source.len() > self.max_expression_bytes {
1981            return Err(transform_resource_limit(
1982                crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
1983                self.max_expression_bytes,
1984                source.len(),
1985            ));
1986        }
1987        let complexity = crate::xmldsig::xpath::xpath_expression_complexity(source);
1988        if complexity > self.max_expression_complexity {
1989            return Err(transform_resource_limit(
1990                crate::policy::resource_name::XPATH_EXPRESSION_COMPLEXITY,
1991                self.max_expression_complexity,
1992                complexity,
1993            ));
1994        }
1995        crate::xmldsig::xpath::compile_xpath_with_policy_limits(
1996            source,
1997            self.max_expression_bytes,
1998            self.max_expression_complexity,
1999        )
2000        .map(|_| ())
2001        .map_err(TransformError::XPath)
2002    }
2003
2004    pub(crate) fn validate_namespaces(
2005        &self,
2006        namespaces: &BTreeMap<String, String>,
2007    ) -> Result<(), TransformError> {
2008        let mut budget = XPathNamespaceBudget::with_limits(
2009            self.max_namespace_bindings,
2010            self.max_namespace_bytes,
2011        );
2012        for (prefix, uri) in namespaces {
2013            budget.charge(prefix, uri)?;
2014        }
2015        Ok(())
2016    }
2017
2018    fn error(&self) -> TransformError {
2019        transform_resource_limit(
2020            crate::policy::resource_name::XPATH_EXPRESSIONS,
2021            self.max_expressions,
2022            self.expressions.max(self.max_expressions.saturating_add(1)),
2023        )
2024    }
2025}
2026
2027struct XPathNamespaceBudget {
2028    bindings: usize,
2029    bytes: usize,
2030    max_bindings: usize,
2031    max_bytes: usize,
2032}
2033
2034impl Default for XPathNamespaceBudget {
2035    fn default() -> Self {
2036        Self::with_limits(MAX_XPATH_NAMESPACE_BINDINGS, MAX_XPATH_NAMESPACE_BYTES)
2037    }
2038}
2039
2040impl XPathNamespaceBudget {
2041    fn with_limits(max_bindings: usize, max_bytes: usize) -> Self {
2042        Self {
2043            bindings: 0,
2044            bytes: 0,
2045            max_bindings,
2046            max_bytes,
2047        }
2048    }
2049
2050    fn charge(&mut self, prefix: &str, uri: &str) -> Result<(), TransformError> {
2051        let bindings = self.bindings.saturating_add(1);
2052        let bytes = self
2053            .bytes
2054            .checked_add(prefix.len())
2055            .and_then(|bytes| bytes.checked_add(uri.len()))
2056            .unwrap_or(usize::MAX);
2057        if bindings > self.max_bindings {
2058            return Err(transform_resource_limit(
2059                crate::policy::resource_name::XPATH_NAMESPACE_BINDINGS,
2060                self.max_bindings,
2061                bindings,
2062            ));
2063        }
2064        if bytes > self.max_bytes {
2065            return Err(transform_resource_limit(
2066                crate::policy::resource_name::XPATH_NAMESPACE_BYTES,
2067                self.max_bytes,
2068                bytes,
2069            ));
2070        }
2071        self.bindings = bindings;
2072        self.bytes = bytes;
2073        Ok(())
2074    }
2075}
2076
2077pub(crate) fn collect_xpath_namespaces_with_resources(
2078    xpath_node: Node<'_, '_>,
2079    resources: &crate::policy::ResourcePolicy,
2080) -> Result<BTreeMap<String, String>, TransformError> {
2081    collect_xpath_namespaces_with_limits(
2082        xpath_node,
2083        resources.max_xpath_namespace_bindings,
2084        resources.max_xpath_namespace_bytes,
2085    )
2086}
2087
2088fn collect_xpath_namespaces_with_limits(
2089    xpath_node: Node<'_, '_>,
2090    max_bindings: usize,
2091    max_bytes: usize,
2092) -> Result<BTreeMap<String, String>, TransformError> {
2093    let mut budget = XPathNamespaceBudget::with_limits(max_bindings, max_bytes);
2094    for namespace in xpath_node.namespaces() {
2095        if let Some(prefix) = namespace.name() {
2096            budget.charge(prefix, namespace.uri())?;
2097        }
2098    }
2099    Ok(xpath_node
2100        .namespaces()
2101        .filter_map(|namespace| {
2102            namespace
2103                .name()
2104                .map(|prefix| (prefix.to_owned(), namespace.uri().to_owned()))
2105        })
2106        .collect())
2107}
2108
2109pub(crate) fn validate_xpath_namespace_budget_with_resources(
2110    transforms: &[Transform],
2111    inherited_namespace: Option<(&str, &str)>,
2112    resources: &crate::policy::ResourcePolicy,
2113) -> Result<(), TransformError> {
2114    validate_xpath_namespace_budget_with_limits(
2115        transforms,
2116        inherited_namespace,
2117        resources.max_xpath_namespace_bindings,
2118        resources.max_xpath_namespace_bytes,
2119    )
2120}
2121
2122fn validate_xpath_namespace_budget_with_limits(
2123    transforms: &[Transform],
2124    inherited_namespace: Option<(&str, &str)>,
2125    max_bindings: usize,
2126    max_bytes: usize,
2127) -> Result<(), TransformError> {
2128    let validate_expression = |xpath: &XPathExpression| {
2129        let mut budget = XPathNamespaceBudget::with_limits(max_bindings, max_bytes);
2130        for (prefix, uri) in xpath.namespaces() {
2131            budget.charge(prefix, uri)?;
2132        }
2133        if let Some((prefix, uri)) = inherited_namespace
2134            && !xpath.namespaces().contains_key(prefix)
2135        {
2136            budget.charge(prefix, uri)?;
2137        }
2138        Ok::<(), TransformError>(())
2139    };
2140    for transform in transforms {
2141        match transform {
2142            Transform::XpathExcludeAllSignatures => {
2143                let xpath = XPathExpression::new(ENVELOPED_SIGNATURE_XPATH_EXPR)
2144                    .with_namespace(ENVELOPED_SIGNATURE_XPATH_PREFIX, XMLDSIG_NS);
2145                validate_expression(&xpath)?;
2146            }
2147            Transform::XPath(xpath) => validate_expression(xpath)?,
2148            Transform::XPathFilter2(filters) => {
2149                for filter in filters {
2150                    validate_expression(filter.xpath())?;
2151                }
2152            }
2153            _ => {}
2154        }
2155    }
2156    Ok(())
2157}
2158
2159/// Parse the `PrefixList` attribute from an `<ec:InclusiveNamespaces>` child
2160/// element, if present.
2161///
2162/// Per the [Exclusive C14N spec](https://www.w3.org/TR/xml-exc-c14n/#def-InclusiveNamespaces-PrefixList),
2163/// the element MUST be in the `http://www.w3.org/2001/10/xml-exc-c14n#` namespace.
2164/// Elements with the same local name but a different namespace are ignored.
2165///
2166/// Returns `Ok(None)` if no `<InclusiveNamespaces>` child is present.
2167/// Returns `Err` if the element exists but lacks the required `PrefixList` attribute
2168/// (fail-closed: malformed control elements are rejected, not silently ignored).
2169///
2170/// The element is typically:
2171/// ```xml
2172/// <ec:InclusiveNamespaces
2173///     xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"
2174///     PrefixList="ds saml #default"/>
2175/// ```
2176fn parse_inclusive_prefixes(transform_node: Node) -> Result<Option<String>, TransformError> {
2177    for child in transform_node.children() {
2178        if child.is_element() {
2179            let tag = child.tag_name();
2180            if tag.name() == "InclusiveNamespaces" && tag.namespace() == Some(EXCLUSIVE_C14N_NS_URI)
2181            {
2182                let prefix_list = child.attribute("PrefixList").ok_or_else(|| {
2183                    TransformError::UnsupportedTransform(
2184                        "missing PrefixList attribute on <InclusiveNamespaces>".into(),
2185                    )
2186                })?;
2187                return Ok(Some(prefix_list.to_string()));
2188            }
2189        }
2190    }
2191    Ok(None)
2192}
2193
2194#[cfg(test)]
2195#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
2196mod tests {
2197    use super::*;
2198    use crate::xml::dom::Document;
2199    use crate::xmldsig::NodeSet;
2200
2201    fn assert_resource_limit(error: &TransformError, expected_resource: &'static str) {
2202        assert!(
2203            matches!(
2204                error,
2205                TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2206                    resource,
2207                    maximum,
2208                    actual,
2209                }) if *resource == expected_resource && actual > maximum
2210            ),
2211            "unexpected error: {error:?}"
2212        );
2213    }
2214
2215    // ── Enveloped transform ──────────────────────────────────────────
2216
2217    #[test]
2218    fn enveloped_excludes_signature_subtree() {
2219        // Simulates a SAML-like document with an enveloped signature
2220        let xml = r#"<root>
2221            <data>hello</data>
2222            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
2223                <SignedInfo><Reference URI=""/></SignedInfo>
2224                <SignatureValue>abc</SignatureValue>
2225            </Signature>
2226        </root>"#;
2227        let doc = Document::parse(xml).unwrap();
2228
2229        // Find the Signature element
2230        let sig_node = doc
2231            .descendants()
2232            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2233            .unwrap();
2234
2235        // Start with entire document without comments (empty URI)
2236        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2237        let data = TransformData::NodeSet(node_set);
2238
2239        // Apply enveloped transform
2240        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
2241        let node_set = result.into_node_set().unwrap();
2242
2243        // Root and data should be in the set
2244        assert!(node_set.contains(doc.root_element()));
2245        let data_elem = doc
2246            .descendants()
2247            .find(|n| n.is_element() && n.tag_name().name() == "data")
2248            .unwrap();
2249        assert!(node_set.contains(data_elem));
2250
2251        // Signature and its children should be excluded
2252        assert!(
2253            !node_set.contains(sig_node),
2254            "Signature element should be excluded"
2255        );
2256        let signed_info = doc
2257            .descendants()
2258            .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
2259            .unwrap();
2260        assert!(
2261            !node_set.contains(signed_info),
2262            "SignedInfo (child of Signature) should be excluded"
2263        );
2264    }
2265
2266    #[test]
2267    fn enveloped_requires_node_set_input() {
2268        let xml = "<root/>";
2269        let doc = Document::parse(xml).unwrap();
2270        // Binary input should fail with TypeMismatch
2271        let data = TransformData::Binary(vec![1, 2, 3]);
2272        let result = apply_transform(doc.root_element(), &Transform::Enveloped, data);
2273        assert!(result.is_err());
2274        match result.unwrap_err() {
2275            TransformError::TypeMismatch { expected, got } => {
2276                assert_eq!(expected, "NodeSet");
2277                assert_eq!(got, "Binary");
2278            }
2279            other => panic!("expected TypeMismatch, got: {other:?}"),
2280        }
2281    }
2282
2283    #[test]
2284    fn enveloped_rejects_cross_document_signature_node() {
2285        // Signature node from a different Document must be rejected,
2286        // not silently used to exclude wrong subtree.
2287        let xml = r#"<Root><Signature Id="sig"/></Root>"#;
2288        let doc1 = Document::parse(xml).unwrap();
2289        let doc2 = Document::parse(xml).unwrap();
2290
2291        // NodeSet from doc1, Signature node from doc2
2292        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
2293        let input = TransformData::NodeSet(node_set);
2294        let sig_from_doc2 = doc2
2295            .descendants()
2296            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2297            .unwrap();
2298
2299        let result = apply_transform(sig_from_doc2, &Transform::Enveloped, input);
2300        assert!(matches!(
2301            result,
2302            Err(TransformError::CrossDocumentSignatureNode)
2303        ));
2304    }
2305
2306    // ── C14N transform ───────────────────────────────────────────────
2307
2308    #[test]
2309    fn c14n_transform_produces_bytes() {
2310        let xml = r#"<root b="2" a="1"><child/></root>"#;
2311        let doc = Document::parse(xml).unwrap();
2312
2313        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2314        let data = TransformData::NodeSet(node_set);
2315
2316        let algo =
2317            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2318        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data).unwrap();
2319
2320        let bytes = result.into_binary().unwrap();
2321        let output = String::from_utf8(bytes).unwrap();
2322        // Attributes sorted, empty element expanded
2323        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
2324    }
2325
2326    #[test]
2327    fn c14n_transform_requires_node_set() {
2328        let xml = "<root/>";
2329        let doc = Document::parse(xml).unwrap();
2330
2331        let algo =
2332            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2333        let data = TransformData::Binary(vec![1, 2, 3]);
2334        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data);
2335
2336        assert!(result.is_err());
2337        assert!(matches!(
2338            result.unwrap_err(),
2339            TransformError::TypeMismatch { .. }
2340        ));
2341    }
2342
2343    #[test]
2344    fn c14n_1_1_uses_the_compiled_xml_base_policy() {
2345        // The operation's compiled resource policy must govern C14N 1.1
2346        // fixup as well as external Reference and RetrievalMethod resolution.
2347        let document = Document::parse(
2348            r#"<root xml:base="one/"><parent xml:base="two/"><leaf/></parent></root>"#,
2349        )
2350        .unwrap();
2351        let leaf = document
2352            .descendants()
2353            .find(|node| node.has_tag_name("leaf"))
2354            .unwrap();
2355        let resources = crate::policy::ResourcePolicy {
2356            max_xml_base_components: 1,
2357            ..crate::policy::ResourcePolicy::default()
2358        };
2359        let budget = TransformExecutionBudget::from_resources(&resources);
2360        let algorithm = C14nAlgorithm::new(crate::c14n::C14nMode::Inclusive1_1, false);
2361
2362        let error = execute_transforms_with_options_and_budget(
2363            document.root_element(),
2364            TransformData::NodeSet(NodeSet::subtree(leaf).unwrap()),
2365            &[Transform::C14n(algorithm)],
2366            TransformOptions::default(),
2367            &budget,
2368        )
2369        .expect_err("C14N must use the operation's XML Base component limit");
2370
2371        assert!(matches!(
2372            error,
2373            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2374                resource: crate::policy::resource_name::XML_BASE_COMPONENTS,
2375                maximum: 1,
2376                actual: 2
2377            })
2378        ));
2379    }
2380
2381    // ── Base64 transform ────────────────────────────────────────────
2382
2383    #[test]
2384    fn base64_transform_decodes_binary_with_xml_whitespace() {
2385        // XML line wrapping is insignificant to the standard transform.
2386        let doc = Document::parse("<root/>").unwrap();
2387        let input = TransformData::Binary(b" SGV\tsbG8=\r\n".to_vec());
2388
2389        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2390
2391        assert_eq!(result.into_binary().unwrap(), b"Hello");
2392    }
2393
2394    #[test]
2395    fn base64_transform_concatenates_only_selected_text_nodes_in_document_order() {
2396        // Tags, comments, and processing instructions must not enter the
2397        // encoded octet stream; descendant text remains in document order.
2398        let xml = r#"<root><Data ID="payload">SGV<!-- split --><Part>sb</Part><?pi ignored?>G8=</Data></root>"#;
2399        let doc = Document::parse(xml).unwrap();
2400        let data = doc
2401            .descendants()
2402            .find(|node| node.attribute("ID") == Some("payload"))
2403            .unwrap();
2404        let input = TransformData::NodeSet(NodeSet::subtree(data).unwrap());
2405
2406        let result = apply_transform(data, &Transform::Base64Decode, input).unwrap();
2407
2408        assert_eq!(result.into_binary().unwrap(), b"Hello");
2409    }
2410
2411    #[test]
2412    fn base64_transform_omits_text_excluded_from_the_node_set() {
2413        // A prior node-set transform can remove a subtree. Its text must not
2414        // be resurrected while converting the remaining node set to octets.
2415        let xml = "<root>SGV<Excluded>QUJD</Excluded>sbG8=</root>";
2416        let doc = Document::parse(xml).unwrap();
2417        let excluded = doc
2418            .descendants()
2419            .find(|node| node.has_tag_name("Excluded"))
2420            .unwrap();
2421        let mut nodes = NodeSet::subtree(doc.root_element()).unwrap();
2422        nodes.exclude_subtree(excluded);
2423
2424        let result = apply_transform(
2425            doc.root_element(),
2426            &Transform::Base64Decode,
2427            TransformData::NodeSet(nodes),
2428        )
2429        .unwrap();
2430
2431        assert_eq!(result.into_binary().unwrap(), b"Hello");
2432    }
2433
2434    #[test]
2435    fn base64_transform_ignores_rfc2045_non_alphabet_bytes() {
2436        let doc = Document::parse("<root/>").unwrap();
2437        let input = TransformData::Binary(b"SGVs!\xFFbG8=".to_vec());
2438
2439        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2440
2441        assert_eq!(result.into_binary().unwrap(), b"Hello");
2442    }
2443
2444    #[test]
2445    fn base64_transform_rejects_invalid_padding() {
2446        let doc = Document::parse("<root/>").unwrap();
2447        let result = apply_transform(
2448            doc.root_element(),
2449            &Transform::Base64Decode,
2450            TransformData::Binary(b"SGVsbG8===".to_vec()),
2451        );
2452
2453        assert!(matches!(result, Err(TransformError::Base64(_))));
2454    }
2455
2456    #[test]
2457    fn base64_transform_accepts_empty_input() {
2458        let doc = Document::parse("<root/>").unwrap();
2459        let result = apply_transform(
2460            doc.root_element(),
2461            &Transform::Base64Decode,
2462            TransformData::Binary(Vec::new()),
2463        )
2464        .unwrap();
2465
2466        assert!(result.into_binary().unwrap().is_empty());
2467    }
2468
2469    #[test]
2470    fn base64_transform_rejects_oversized_raw_binary_before_normalization() {
2471        // XML whitespace does not reach the normalized buffer, but scanning an
2472        // unbounded whitespace-only reference is still attacker-controlled work.
2473        let doc = Document::parse("<root/>").unwrap();
2474        let input = TransformData::Binary(vec![b' '; MAX_BASE64_TRANSFORM_INPUT_BYTES + 1]);
2475
2476        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
2477
2478        assert!(matches!(
2479            result,
2480            Err(TransformError::Policy(
2481                crate::policy::PolicyViolation::ResourceLimit {
2482                    resource: crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
2483                    maximum: MAX_BASE64_TRANSFORM_INPUT_BYTES,
2484                    ..
2485                }
2486            ))
2487        ));
2488    }
2489
2490    #[test]
2491    fn base64_transform_rejects_node_set_that_decodes_past_output_budget() {
2492        // The output limit must be checked before the decoder allocates a
2493        // second buffer beside the normalized encoded text.
2494        let encoded_len = MAX_BASE64_TRANSFORM_OUTPUT_BYTES.div_ceil(3) * 4 + 4;
2495        let xml = format!("<root>{}</root>", "A".repeat(encoded_len));
2496        let doc = Document::parse(&xml).unwrap();
2497        let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
2498
2499        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
2500
2501        assert!(matches!(
2502            result,
2503            Err(TransformError::Policy(
2504                crate::policy::PolicyViolation::ResourceLimit {
2505                    resource: crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
2506                    maximum: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
2507                    ..
2508                }
2509            ))
2510        ));
2511    }
2512
2513    #[test]
2514    fn base64_transform_handles_highly_fragmented_node_set_input() {
2515        // Comments can split an untrusted payload into thousands of tiny text
2516        // nodes. Normalization must retain linear allocation behavior while
2517        // preserving document-order concatenation.
2518        let expected = vec![0x42_u8; 3 * 1_024];
2519        let encoded = STANDARD.encode(&expected);
2520        let mut xml = String::from("<root>");
2521        for byte in encoded.bytes() {
2522            xml.push(char::from(byte));
2523            xml.push_str("<!-- split -->");
2524        }
2525        xml.push_str("</root>");
2526        let doc = Document::parse(&xml).unwrap();
2527        let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
2528
2529        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
2530
2531        assert_eq!(result.into_binary().unwrap(), expected);
2532    }
2533
2534    #[test]
2535    fn pipeline_rejects_cumulative_base64_input_past_budget() {
2536        // Each transform is individually under 16 MiB, but charging only the
2537        // current input permits one reference chain to exceed the total bound.
2538        let doc = Document::parse("<root/>").unwrap();
2539        let inner = vec![b'A'; MAX_BASE64_TRANSFORM_OUTPUT_BYTES];
2540        let outer = STANDARD.encode(&inner);
2541        let transforms = [Transform::Base64Decode, Transform::Base64Decode];
2542
2543        let result = execute_transforms(
2544            doc.root_element(),
2545            TransformData::Binary(outer.into_bytes()),
2546            &transforms,
2547        );
2548
2549        assert!(matches!(
2550            result,
2551            Err(TransformError::Policy(
2552                crate::policy::PolicyViolation::ResourceLimit {
2553                    resource: crate::policy::resource_name::BASE64_TRANSFORM_INPUT_BYTES,
2554                    maximum: MAX_BASE64_TRANSFORM_INPUT_BYTES,
2555                    ..
2556                }
2557            ))
2558        ));
2559    }
2560
2561    #[test]
2562    fn operation_rejects_cumulative_base64_output_past_budget() {
2563        // Separate references share one operation budget. Each decoded value
2564        // fits alone, but their cumulative output must not reuse the allowance.
2565        let doc = Document::parse("<root/>").unwrap();
2566        let resources = crate::policy::ResourcePolicy {
2567            max_base64_transform_input_bytes: 8,
2568            max_base64_transform_output_bytes: 1,
2569            ..crate::policy::ResourcePolicy::default()
2570        };
2571        let budget = TransformExecutionBudget::from_resources(&resources);
2572
2573        let first = apply_transform_with_options(
2574            doc.root_element(),
2575            &Transform::Base64Decode,
2576            TransformData::Binary(b"YQ==".to_vec()),
2577            TransformOptions::default(),
2578            &budget,
2579        )
2580        .expect("the first one-byte output must fit");
2581        assert_eq!(first.into_binary().unwrap(), b"a");
2582
2583        let error = apply_transform_with_options(
2584            doc.root_element(),
2585            &Transform::Base64Decode,
2586            TransformData::Binary(b"Yg==".to_vec()),
2587            TransformOptions::default(),
2588            &budget,
2589        )
2590        .expect_err("the second output must exceed the cumulative allowance");
2591
2592        assert!(matches!(
2593            error,
2594            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2595                resource: crate::policy::resource_name::BASE64_TRANSFORM_OUTPUT_BYTES,
2596                maximum: 1,
2597                actual: 2,
2598            })
2599        ));
2600    }
2601
2602    #[test]
2603    fn pipeline_rejects_unbounded_programmatic_transform_chain() {
2604        // The public executor is a trust boundary too: callers can bypass XML
2605        // parsing and must not be able to create an arbitrarily deep recursion.
2606        let doc = Document::parse("<root/>").unwrap();
2607        let transforms = vec![Transform::Base64Decode; 65];
2608
2609        let result = execute_transforms(
2610            doc.root_element(),
2611            TransformData::Binary(Vec::new()),
2612            &transforms,
2613        );
2614
2615        assert!(matches!(
2616            result,
2617            Err(TransformError::Policy(
2618                crate::policy::PolicyViolation::ResourceLimit {
2619                    resource: crate::policy::resource_name::REFERENCE_TRANSFORMS,
2620                    maximum: MAX_TRANSFORMS_PER_REFERENCE,
2621                    ..
2622                }
2623            ))
2624        ));
2625    }
2626
2627    // ── Pipeline execution ───────────────────────────────────────────
2628
2629    #[test]
2630    fn byte_budgets_remain_exhausted_after_overflow() {
2631        // An overflow is a terminal state: callers must not be able to recover
2632        // budget by following a rejected large charge with a smaller one.
2633        let c14n = C14nOutputBudget::default();
2634        assert!(c14n.charge(MAX_C14N_OUTPUT_BYTES + 1).is_err());
2635        assert!(c14n.charge(1).is_err());
2636
2637        let base64 = Base64WorkBudget::default();
2638        assert!(
2639            base64
2640                .charge_input(MAX_BASE64_TRANSFORM_INPUT_BYTES + 1)
2641                .is_err()
2642        );
2643        assert!(base64.charge_input(1).is_err());
2644    }
2645
2646    #[test]
2647    fn pipeline_rejects_cumulative_c14n_output() {
2648        // Each C14N result fits independently, but reparsing a binary result for
2649        // another C14N transform must charge the same pipeline-wide byte meter.
2650        let xml = format!("<root>{}</root>", "x".repeat(4_096));
2651        let document = Document::parse(&xml).unwrap();
2652        let algorithm =
2653            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2654        let transforms = vec![
2655            Transform::C14n(algorithm.clone()),
2656            Transform::C14n(algorithm),
2657        ];
2658        let one_output = execute_transforms(
2659            document.root_element(),
2660            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2661            &transforms[..1],
2662        )
2663        .expect("one canonicalization must succeed")
2664        .len();
2665        let limit = one_output * 2 - 1;
2666
2667        let result = execute_transforms_with_options_and_budget(
2668            document.root_element(),
2669            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2670            &transforms,
2671            TransformOptions::default(),
2672            &TransformExecutionBudget::with_c14n_limit(limit),
2673        );
2674
2675        assert!(matches!(
2676            result,
2677            Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2678                resource: crate::policy::resource_name::CANONICALIZED_BYTES,
2679                maximum,
2680                ..
2681            })) if maximum == limit
2682        ));
2683    }
2684
2685    #[test]
2686    fn binary_to_node_set_adapter_uses_shared_materialization_budget() {
2687        // A binary transform result can be reparsed before a later node-set
2688        // transform. That internal adapter must not bypass the signature-wide
2689        // owned-string budget enforced by ordinary URI dereference.
2690        let signature_document = Document::parse("<Signature/>").unwrap();
2691        let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
2692        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2693
2694        let error = execute_transforms_with_options_and_budget(
2695            signature_document.root_element(),
2696            TransformData::Binary(b"<root xmlns:n=\"urn:namespace\"/>".to_vec()),
2697            &transforms,
2698            TransformOptions::default(),
2699            &budget,
2700        )
2701        .expect_err("the binary adapter must charge cloned namespace strings");
2702
2703        assert!(matches!(
2704            error,
2705            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2706                resource: crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
2707                ..
2708            })
2709        ));
2710    }
2711
2712    #[test]
2713    fn binary_to_node_set_adapter_preserves_decoded_document_limit() {
2714        // A compact single-byte XML resource can expand when decoded to UTF-8. That expansion
2715        // remains a document resource-limit failure rather than becoming a parser diagnostic.
2716        let signature_document = Document::parse("<Signature/>").unwrap();
2717        let xml = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root>\xe9\xe9</root>";
2718        let resources = crate::policy::ResourcePolicy {
2719            max_xml_document_bytes: xml.len(),
2720            ..crate::policy::ResourcePolicy::default()
2721        };
2722        let budget = TransformExecutionBudget::from_resources(&resources);
2723        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2724
2725        let error = execute_transforms_with_options_and_budget(
2726            signature_document.root_element(),
2727            TransformData::Binary(xml.to_vec()),
2728            &transforms,
2729            TransformOptions::default(),
2730            &budget,
2731        )
2732        .expect_err("decoded XML expansion must retain resource-limit classification");
2733
2734        assert!(matches!(
2735            error,
2736            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2737                resource: crate::policy::resource_name::XML_DOCUMENT,
2738                maximum,
2739                actual,
2740            }) if maximum == xml.len() && actual > maximum
2741        ));
2742    }
2743
2744    #[test]
2745    fn binary_adapter_gates_decode_even_when_input_is_malformed() {
2746        // Failed decoding is operation work too; denial must precede UTF-16 traversal/allocation.
2747        let signature_document = Document::parse("<Signature/>").unwrap();
2748        let resources = crate::policy::ResourcePolicy {
2749            max_xml_parse_work_bytes: 0,
2750            ..crate::policy::ResourcePolicy::default()
2751        };
2752        let budget = TransformExecutionBudget::from_resources(&resources);
2753        let error = execute_transforms_with_options_and_budget(
2754            signature_document.root_element(),
2755            TransformData::Binary(vec![0xff, 0xfe, 0x00, 0xd8]),
2756            &[Transform::XPath(XPathExpression::new("true()"))],
2757            TransformOptions::default(),
2758            &budget,
2759        )
2760        .expect_err("zero work must reject before malformed decoding");
2761        assert!(matches!(
2762            error,
2763            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2764                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
2765                maximum: 0,
2766                ..
2767            })
2768        ));
2769    }
2770
2771    #[test]
2772    fn recursive_binary_adapters_share_xml_parse_work() {
2773        // Binary-to-node-set adaptation can recur across references and nested
2774        // transform execution. Each decoded document must consume the same
2775        // operation meter rather than receiving a parser-local allowance.
2776        let signature_document = Document::parse("<Signature/>").unwrap();
2777        let xml = b"<root/>";
2778        let parser_passes = crate::document::selected_parser_passes();
2779        // Encoded-byte decoding precedes all decoded-text parser passes, even for UTF-8.
2780        let adapter_work = xml.len() * (1 + parser_passes);
2781        let resources = crate::policy::ResourcePolicy {
2782            max_xml_parse_work_bytes: adapter_work,
2783            ..crate::policy::ResourcePolicy::default()
2784        };
2785        let budget = TransformExecutionBudget::from_resources(&resources);
2786        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2787
2788        execute_transforms_with_options_and_budget(
2789            signature_document.root_element(),
2790            TransformData::Binary(xml.to_vec()),
2791            &transforms,
2792            TransformOptions::default(),
2793            &budget,
2794        )
2795        .expect("the first adapter parse must consume the exact allowance");
2796        let error = execute_transforms_with_options_and_budget(
2797            signature_document.root_element(),
2798            TransformData::Binary(xml.to_vec()),
2799            &transforms,
2800            TransformOptions::default(),
2801            &budget,
2802        )
2803        .expect_err("the second adapter parse must inherit exhausted work");
2804
2805        assert!(matches!(
2806            error,
2807            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2808                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
2809                maximum,
2810                actual,
2811            }) if maximum == adapter_work
2812                && actual == adapter_work + xml.len()
2813        ));
2814    }
2815
2816    #[test]
2817    fn binary_to_node_set_adapter_bounds_external_xml_nodes_during_parse() {
2818        // The parser must reject a dense external XML resource before allocating
2819        // an unbounded semantic DOM arena or beginning XPath materialization.
2820        let signature_document = Document::parse("<Signature/>").unwrap();
2821        let xml = format!(
2822            "<root>{}</root>",
2823            "<n/>".repeat(XML_DOCUMENT_NODE_CEILING as usize + 1),
2824        );
2825        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2826
2827        execute_transforms(
2828            signature_document.root_element(),
2829            TransformData::Binary(b"<root><n/></root>".to_vec()),
2830            &transforms,
2831        )
2832        .expect("external XML below the node ceiling must parse and transform");
2833
2834        let error = execute_transforms(
2835            signature_document.root_element(),
2836            TransformData::Binary(xml.into_bytes()),
2837            &transforms,
2838        )
2839        .expect_err("external XML exceeding the node ceiling must fail during parse");
2840
2841        assert!(matches!(
2842            error,
2843            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2844                resource: crate::policy::resource_name::XML_NODES,
2845                ..
2846            })
2847        ));
2848    }
2849
2850    #[test]
2851    fn binary_to_node_set_adapter_enforces_operation_depth() {
2852        // Transform-produced XML is a fresh untrusted document and must use
2853        // the same depth snapshot as the signature document's initial parse.
2854        let signature_document = Document::parse("<Signature/>").unwrap();
2855        let resources = crate::policy::ResourcePolicy {
2856            max_xml_depth: 2,
2857            ..crate::policy::ResourcePolicy::default()
2858        };
2859        let budget = TransformExecutionBudget::from_resources(&resources);
2860        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2861
2862        let error = execute_transforms_with_options_and_budget(
2863            signature_document.root_element(),
2864            TransformData::Binary(b"<root><child><leaf/></child></root>".to_vec()),
2865            &transforms,
2866            TransformOptions::default(),
2867            &budget,
2868        )
2869        .expect_err("over-depth transform XML must be rejected before XPath");
2870
2871        assert!(matches!(
2872            error,
2873            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2874                resource: crate::policy::resource_name::XML_DEPTH,
2875                maximum: 2,
2876                actual: 3,
2877            })
2878        ));
2879    }
2880
2881    #[test]
2882    fn binary_to_node_set_adapter_enforces_operation_namespace_limit() {
2883        // Transform-produced XML is reparsed under the same compiled namespace-binding policy as
2884        // the operation's initial document; the hard parser ceiling must not replace that limit.
2885        let signature_document = Document::parse("<Signature/>").unwrap();
2886        let resources = crate::policy::ResourcePolicy {
2887            max_xml_namespace_bindings: 1,
2888            ..crate::policy::ResourcePolicy::default()
2889        };
2890        let budget = TransformExecutionBudget::from_resources(&resources);
2891        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2892
2893        let error = execute_transforms_with_options_and_budget(
2894            signature_document.root_element(),
2895            TransformData::Binary(b"<root xmlns:a=\"urn:a\" xmlns:b=\"urn:b\"/>".to_vec()),
2896            &transforms,
2897            TransformOptions::default(),
2898            &budget,
2899        )
2900        .expect_err("detached XML must inherit the operation namespace-binding limit");
2901
2902        assert!(matches!(
2903            error,
2904            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2905                resource: crate::policy::resource_name::XML_NAMESPACE_BINDINGS,
2906                maximum: 1,
2907                actual: 2,
2908            })
2909        ));
2910    }
2911
2912    #[test]
2913    fn xpath_projection_uses_shared_materialization_budget() {
2914        // XPath projects exact attribute and namespace identities back into a
2915        // fresh NodeSet. Those owned keys must consume the same budget as URI
2916        // dereference and binary adapters.
2917        let document = Document::parse("<root attribute=\"value\"/>").unwrap();
2918        let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
2919        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
2920
2921        let error = execute_transforms_with_options_and_budget(
2922            document.root_element(),
2923            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
2924            &transforms,
2925            TransformOptions::default(),
2926            &budget,
2927        )
2928        .expect_err("XPath projection must charge cloned attribute names");
2929
2930        assert!(matches!(
2931            error,
2932            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2933                resource: crate::policy::resource_name::NODE_SET_CUMULATIVE_OWNED_STRING_BYTES,
2934                ..
2935            })
2936        ));
2937    }
2938
2939    #[test]
2940    fn explicit_and_implicit_c14n_stop_at_the_execution_ceiling() {
2941        // Both routes must use the bounded serializer. A post-serialization
2942        // charge would return the same error but only after retaining all bytes.
2943        let xml = format!("<root>{}</root>", "x".repeat(4_096));
2944        let document = Document::parse(&xml).unwrap();
2945        let nodes = || {
2946            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
2947        };
2948        let algorithm =
2949            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2950
2951        for transforms in [&[][..], &[Transform::C14n(algorithm)][..]] {
2952            let error = execute_transforms_with_options_and_budget(
2953                document.root_element(),
2954                nodes(),
2955                transforms,
2956                TransformOptions::default(),
2957                &TransformExecutionBudget::with_c14n_limit(64),
2958            )
2959            .expect_err("canonicalization must stop at the execution ceiling");
2960
2961            assert!(matches!(
2962                error,
2963                TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
2964                    resource: crate::policy::resource_name::CANONICALIZED_BYTES,
2965                    maximum: 64,
2966                    ..
2967                })
2968            ));
2969        }
2970    }
2971
2972    #[test]
2973    fn execution_budget_bounds_c14n_output_across_references() {
2974        // Each Reference remains below the signature-wide output ceiling, but
2975        // signing and verification share one execution budget. Repeating the
2976        // same C14N work across References must not reset that meter.
2977        let xml = format!("<root>{}</root>", "x".repeat(4_096));
2978        let document = Document::parse(&xml).unwrap();
2979        let algorithm =
2980            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
2981        let transforms = [Transform::C14n(algorithm)];
2982        let input = || {
2983            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
2984        };
2985        let one_output = execute_transforms(document.root_element(), input(), &transforms)
2986            .expect("one canonicalization must succeed")
2987            .len();
2988        let limit = one_output * 2 - 1;
2989        let execution_budget = TransformExecutionBudget::with_c14n_limit(limit);
2990
2991        execute_transforms_with_options_and_budget(
2992            document.root_element(),
2993            input(),
2994            &transforms,
2995            TransformOptions::default(),
2996            &execution_budget,
2997        )
2998        .expect("the first Reference must fit the cumulative C14N output budget");
2999        let result = execute_transforms_with_options_and_budget(
3000            document.root_element(),
3001            input(),
3002            &transforms,
3003            TransformOptions::default(),
3004            &execution_budget,
3005        );
3006
3007        assert!(matches!(
3008            result,
3009            Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3010                resource: crate::policy::resource_name::CANONICALIZED_BYTES,
3011                maximum,
3012                ..
3013            })) if maximum == limit
3014        ));
3015    }
3016
3017    #[test]
3018    fn execution_budget_bounds_repeated_node_set_exclusions() {
3019        // Repeating an exclusion over a large node set must consume one shared
3020        // signature budget instead of permitting references to multiply full-set scans.
3021        let document =
3022            Document::parse("<root><payload/><Signature><Object/></Signature></root>").unwrap();
3023        let signature = document
3024            .descendants()
3025            .find(|node| node.has_tag_name("Signature"))
3026            .unwrap();
3027        let input = || NodeSet::entire_document_with_comments(&document).unwrap();
3028        let entries_per_exclusion = input().len();
3029        let budget = TransformExecutionBudget::with_node_filter_limit(
3030            entries_per_exclusion.saturating_mul(2).saturating_sub(1),
3031        );
3032
3033        execute_transforms_with_options_and_budget(
3034            signature,
3035            TransformData::NodeSet(input()),
3036            &[Transform::Enveloped],
3037            TransformOptions::default(),
3038            &budget,
3039        )
3040        .expect("the first reference exclusion must fit the shared budget");
3041        let result = execute_transforms_with_options_and_budget(
3042            signature,
3043            TransformData::NodeSet(input()),
3044            &[Transform::Enveloped],
3045            TransformOptions::default(),
3046            &budget,
3047        );
3048
3049        assert!(
3050            matches!(
3051                result,
3052                Err(TransformError::Policy(
3053                    crate::policy::PolicyViolation::ResourceLimit {
3054                        resource: crate::policy::resource_name::NODE_SET_FILTER_WORK,
3055                        ..
3056                    }
3057                ))
3058            ),
3059            "the second reference exclusion must exhaust the shared budget"
3060        );
3061    }
3062
3063    #[test]
3064    fn xpath_node_set_operations_consume_filter_work_budget() {
3065        // XPath evaluation and node-set filtering are separate costs. A policy
3066        // that denies filtering must stop both XPath forms before they project,
3067        // intersect, subtract, or union their results.
3068        let document = Document::parse("<root><keep/><drop/></root>").unwrap();
3069        let transforms = [
3070            Transform::XPath(XPathExpression::new("true()")),
3071            Transform::XPathFilter2(vec![XPathFilter::new(
3072                XPathFilterOperation::Intersect,
3073                XPathExpression::new("//*"),
3074            )]),
3075        ];
3076
3077        for transform in transforms {
3078            let resources = crate::policy::ResourcePolicy {
3079                max_node_set_filter_work: 0,
3080                ..crate::policy::ResourcePolicy::default()
3081            };
3082            let budget = TransformExecutionBudget::from_resources(&resources);
3083            let input = NodeSet::entire_document_without_comments(&document)
3084                .map(TransformData::NodeSet)
3085                .unwrap();
3086            let error = execute_transforms_with_options_and_budget(
3087                document.root_element(),
3088                input,
3089                &[transform],
3090                TransformOptions::default(),
3091                &budget,
3092            )
3093            .expect_err("zero filter-work policy must deny XPath node-set operations");
3094
3095            assert!(matches!(
3096                error,
3097                TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3098                    resource: crate::policy::resource_name::NODE_SET_FILTER_WORK,
3099                    maximum: 0,
3100                    ..
3101                })
3102            ));
3103        }
3104    }
3105
3106    #[test]
3107    fn optimized_exclusion_consumes_xpath_execution_budgets() {
3108        // Recognizing the standard expression is an optimization, not a policy
3109        // bypass: its per-node predicate still consumes XPath contexts and work.
3110        let document = Document::parse(
3111            r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><value/><ds:Signature/></root>"#,
3112        )
3113        .unwrap();
3114
3115        for resource in [
3116            crate::policy::resource_name::XPATH_CONTEXT_EVALUATIONS,
3117            crate::policy::resource_name::XPATH_EVALUATION_WORK,
3118        ] {
3119            let resources = if resource == crate::policy::resource_name::XPATH_CONTEXT_EVALUATIONS {
3120                crate::policy::ResourcePolicy {
3121                    max_xpath_context_evaluations: 0,
3122                    ..crate::policy::ResourcePolicy::default()
3123                }
3124            } else {
3125                crate::policy::ResourcePolicy {
3126                    max_xpath_evaluation_work: 0,
3127                    ..crate::policy::ResourcePolicy::default()
3128                }
3129            };
3130            let budget = TransformExecutionBudget::from_resources(&resources);
3131            let input = NodeSet::entire_document_without_comments(&document)
3132                .map(TransformData::NodeSet)
3133                .unwrap();
3134            let error = execute_transforms_with_options_and_budget(
3135                document.root_element(),
3136                input,
3137                &[Transform::XpathExcludeAllSignatures],
3138                TransformOptions::default(),
3139                &budget,
3140            )
3141            .expect_err("optimized XPath must obey execution budgets");
3142
3143            assert!(matches!(
3144                error,
3145                TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3146                    resource: actual,
3147                    maximum: 0,
3148                    ..
3149                }) if actual == resource
3150            ));
3151        }
3152    }
3153
3154    #[test]
3155    fn optimized_exclusion_charges_document_scan_and_each_filter_pass() {
3156        // A fragment input can be much smaller than its owning document. The
3157        // optimized XPath still scans that document and filters the fragment
3158        // once for every matching Signature, so neither cost may use only the
3159        // fragment cardinality.
3160        let document = Document::parse(
3161            r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><payload><value/></payload><padding/><ds:Signature/><ds:Signature/></root>"#,
3162        )
3163        .unwrap();
3164        let payload = document
3165            .descendants()
3166            .find(|node| node.has_tag_name("payload"))
3167            .unwrap();
3168        let input = || NodeSet::subtree(payload).unwrap();
3169        let fragment_entries = input().len();
3170
3171        for resource in [
3172            crate::policy::resource_name::XPATH_EVALUATION_WORK,
3173            crate::policy::resource_name::NODE_SET_FILTER_WORK,
3174        ] {
3175            let resources = if resource == crate::policy::resource_name::XPATH_EVALUATION_WORK {
3176                crate::policy::ResourcePolicy {
3177                    max_xpath_evaluation_work: fragment_entries,
3178                    ..crate::policy::ResourcePolicy::default()
3179                }
3180            } else {
3181                crate::policy::ResourcePolicy {
3182                    max_node_set_filter_work: fragment_entries,
3183                    ..crate::policy::ResourcePolicy::default()
3184                }
3185            };
3186            let budget = TransformExecutionBudget::from_resources(&resources);
3187            let error = execute_transforms_with_options_and_budget(
3188                document.root_element(),
3189                TransformData::NodeSet(input()),
3190                &[Transform::XpathExcludeAllSignatures],
3191                TransformOptions::default(),
3192                &budget,
3193            )
3194            .expect_err("document-sized optimized XPath work must exceed the fragment budget");
3195
3196            assert_resource_limit(&error, resource);
3197        }
3198    }
3199
3200    #[test]
3201    fn filter2_charges_document_sized_set_operations() {
3202        // Filter 2 starts from a whole-document result even when dereferencing
3203        // selected a tiny fragment. Charge the set actually traversed by each
3204        // operation rather than the incoming fragment alone.
3205        let document =
3206            Document::parse("<root><payload><value/></payload><outside/><outside/></root>")
3207                .unwrap();
3208        let payload = document
3209            .descendants()
3210            .find(|node| node.has_tag_name("payload"))
3211            .unwrap();
3212        let input = NodeSet::subtree(payload).unwrap();
3213        let resources = crate::policy::ResourcePolicy {
3214            max_node_set_filter_work: input.len(),
3215            ..crate::policy::ResourcePolicy::default()
3216        };
3217        let budget = TransformExecutionBudget::from_resources(&resources);
3218        let transform = Transform::XPathFilter2(vec![XPathFilter::new(
3219            XPathFilterOperation::Intersect,
3220            XPathExpression::new("//*"),
3221        )]);
3222
3223        let error = execute_transforms_with_options_and_budget(
3224            document.root_element(),
3225            TransformData::NodeSet(input),
3226            &[transform],
3227            TransformOptions::default(),
3228            &budget,
3229        )
3230        .expect_err("Filter 2 must charge document-sized set operations");
3231
3232        assert_resource_limit(&error, crate::policy::resource_name::NODE_SET_FILTER_WORK);
3233    }
3234
3235    #[test]
3236    fn execution_budget_bounds_implicit_c14n_across_references() {
3237        // References ending in node sets use implicit C14N 1.0. That terminal
3238        // coercion must share the same signature-wide byte ceiling as explicit
3239        // canonicalization transforms.
3240        let xml = format!("<root>{}</root>", "x".repeat(4_096));
3241        let document = Document::parse(&xml).unwrap();
3242        let input = || {
3243            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
3244        };
3245        let one_output = execute_transforms(document.root_element(), input(), &[])
3246            .expect("one implicit canonicalization must succeed")
3247            .len();
3248        let limit = one_output * 3 - 1;
3249        let execution_budget = TransformExecutionBudget::with_c14n_limit(limit);
3250
3251        for _ in 0..2 {
3252            execute_transforms_with_options_and_budget(
3253                document.root_element(),
3254                input(),
3255                &[],
3256                TransformOptions::default(),
3257                &execution_budget,
3258            )
3259            .expect("two implicit C14N outputs must fit the shared budget");
3260        }
3261        let result = execute_transforms_with_options_and_budget(
3262            document.root_element(),
3263            input(),
3264            &[],
3265            TransformOptions::default(),
3266            &execution_budget,
3267        );
3268
3269        assert!(matches!(
3270            result,
3271            Err(TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3272                resource: crate::policy::resource_name::CANONICALIZED_BYTES,
3273                maximum,
3274                ..
3275            })) if maximum == limit
3276        ));
3277    }
3278
3279    #[test]
3280    fn pipeline_enveloped_then_c14n() {
3281        // Standard SAML transform chain: enveloped-signature → exc-c14n
3282        let xml = r#"<root xmlns:ns="http://example.com" b="2" a="1">
3283            <data>hello</data>
3284            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
3285                <SignedInfo/>
3286                <SignatureValue>abc</SignatureValue>
3287            </Signature>
3288        </root>"#;
3289        let doc = Document::parse(xml).unwrap();
3290
3291        let sig_node = doc
3292            .descendants()
3293            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
3294            .unwrap();
3295
3296        let initial =
3297            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
3298        let transforms = vec![
3299            Transform::Enveloped,
3300            Transform::C14n(
3301                C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#").unwrap(),
3302            ),
3303        ];
3304
3305        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
3306
3307        let output = String::from_utf8(result).unwrap();
3308        // Signature subtree should be gone; attributes sorted
3309        assert!(!output.contains("Signature"));
3310        assert!(!output.contains("SignedInfo"));
3311        assert!(!output.contains("SignatureValue"));
3312        assert!(output.contains("<data>hello</data>"));
3313    }
3314
3315    #[test]
3316    fn pipeline_c14n_then_enveloped_remaps_the_exact_signature() {
3317        // Reparsing canonical octets creates a new Document. The adapter must
3318        // preserve which Signature owns this transform rather than removing an
3319        // arbitrary signature or rejecting the new node set as cross-document.
3320        let xml = r#"<root>
3321            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
3322            <data>hello</data>
3323            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
3324        </root>"#;
3325        let document = Document::parse(xml).unwrap();
3326        let signature = document
3327            .descendants()
3328            .find(|node| node.attribute("Id") == Some("owner"))
3329            .unwrap();
3330        let initial =
3331            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
3332        let transforms = vec![
3333            Transform::C14n(
3334                C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3335            ),
3336            Transform::Enveloped,
3337        ];
3338
3339        let output = execute_transforms(signature, initial, &transforms).unwrap();
3340        let output = String::from_utf8(output).unwrap();
3341
3342        assert!(output.contains("Id=\"other\""));
3343        assert!(!output.contains("Id=\"owner\""));
3344        assert!(output.contains("<data>hello</data>"));
3345    }
3346
3347    #[test]
3348    fn pipeline_remaps_signature_after_xpath_removes_an_earlier_sibling() {
3349        // The identity of the owning Signature must survive canonicalization;
3350        // its source-tree sibling index is not stable after XPath filtering.
3351        let xml = r#"<root>
3352            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
3353            <discard/>
3354            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
3355        </root>"#;
3356        let document = Document::parse(xml).unwrap();
3357        let signature = document
3358            .descendants()
3359            .find(|node| node.attribute("Id") == Some("owner"))
3360            .unwrap();
3361        let initial =
3362            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
3363        let transforms = vec![
3364            Transform::XPath(XPathExpression::new("not(self::discard)")),
3365            Transform::C14n(
3366                C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3367            ),
3368            Transform::Enveloped,
3369        ];
3370
3371        let output = execute_transforms(signature, initial, &transforms).unwrap();
3372        let output = String::from_utf8(output).unwrap();
3373
3374        assert!(output.contains("Id=\"other\""));
3375        assert!(!output.contains("Id=\"owner\""));
3376        assert!(!output.contains("discard"));
3377    }
3378
3379    #[test]
3380    fn dependency_tracking_retains_structurally_excluded_nodes_for_later_xpath() {
3381        // A structural XPath may hide a mutable node from the current set, but
3382        // a later XPath still evaluates against the full source document.
3383        let document = Document::parse(
3384            r#"<root><Signature><Object><Manifest><DigestValue>pending</DigestValue></Manifest></Object></Signature></root>"#,
3385        )
3386        .unwrap();
3387        let signature = document
3388            .descendants()
3389            .find(|node| node.tag_name().name() == "Signature")
3390            .unwrap();
3391        let manifest = document
3392            .descendants()
3393            .find(|node| node.tag_name().name() == "Manifest")
3394            .unwrap();
3395        let digest_text = document
3396            .descendants()
3397            .find(|node| node.is_text() && node.text() == Some("pending"))
3398            .unwrap();
3399        let transforms = [
3400            Transform::XPath(XPathExpression::new("not(ancestor-or-self::DigestValue)")),
3401            Transform::XPath(XPathExpression::new(
3402                "string-length(string(//DigestValue)) >= 0",
3403            )),
3404        ];
3405
3406        let output = execute_transforms_with_dependency_nodes(
3407            signature,
3408            TransformData::NodeSet(NodeSet::subtree(manifest).unwrap()),
3409            &transforms,
3410            TransformOptions::default(),
3411            &TransformExecutionBudget::default(),
3412            vec![(7, digest_text.id())],
3413        )
3414        .unwrap();
3415
3416        assert_eq!(output.dependencies, HashSet::from([7]));
3417    }
3418
3419    #[test]
3420    fn dependency_tracking_discards_dormant_nodes_at_binary_boundary() {
3421        // C14N serializes only the current node set. A later XPath reparses
3422        // those bytes and cannot recover a tracked source node outside it.
3423        let document = Document::parse(
3424            r#"<root><DigestValue>external</DigestValue><Signature><payload>value</payload></Signature></root>"#,
3425        )
3426        .unwrap();
3427        let signature = document
3428            .descendants()
3429            .find(|node| node.tag_name().name() == "Signature")
3430            .unwrap();
3431        let digest_text = document
3432            .descendants()
3433            .find(|node| node.is_text() && node.text() == Some("external"))
3434            .unwrap();
3435        let transforms = [
3436            Transform::C14n(
3437                C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
3438            ),
3439            Transform::XPath(XPathExpression::new(
3440                "string-length(string(//DigestValue)) >= 0",
3441            )),
3442        ];
3443
3444        let output = execute_transforms_with_dependency_nodes(
3445            signature,
3446            TransformData::NodeSet(NodeSet::subtree(signature).unwrap()),
3447            &transforms,
3448            TransformOptions::default(),
3449            &TransformExecutionBudget::default(),
3450            vec![(11, digest_text.id())],
3451        )
3452        .unwrap();
3453
3454        assert!(output.dependencies.is_empty());
3455    }
3456
3457    #[test]
3458    fn pipeline_enveloped_ignores_signature_absent_after_base64_adaptation() {
3459        // A binary-producing transform may replace the source document rather
3460        // than serialize it. The enveloped transform must not carry the source
3461        // Signature identity into that unrelated decoded document.
3462        let source = Document::parse(
3463            r#"<root><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"/></root>"#,
3464        )
3465        .unwrap();
3466        let signature = source
3467            .descendants()
3468            .find(|node| node.tag_name().name() == "Signature")
3469            .unwrap();
3470        let encoded = base64::engine::general_purpose::STANDARD.encode(b"<payload>ok</payload>");
3471        let transforms = vec![
3472            Transform::Base64Decode,
3473            Transform::XPath(XPathExpression::new("true()")),
3474            Transform::Enveloped,
3475        ];
3476
3477        let output = execute_transforms(
3478            signature,
3479            TransformData::Binary(encoded.into()),
3480            &transforms,
3481        )
3482        .unwrap();
3483
3484        assert_eq!(output, b"<payload>ok</payload>");
3485    }
3486
3487    #[test]
3488    fn pipeline_no_transforms_applies_default_c14n() {
3489        // No explicit transforms → pipeline falls back to inclusive C14N 1.0
3490        let xml = r#"<root b="2" a="1"><child/></root>"#;
3491        let doc = Document::parse(xml).unwrap();
3492
3493        let initial =
3494            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
3495        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
3496
3497        let output = String::from_utf8(result).unwrap();
3498        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
3499    }
3500
3501    #[test]
3502    fn pipeline_binary_passthrough() {
3503        // If initial data is already binary (unusual, but spec-compliant)
3504        // and no transforms, returns bytes directly
3505        let xml = "<root/>";
3506        let doc = Document::parse(xml).unwrap();
3507
3508        let initial = TransformData::Binary(b"raw bytes".to_vec());
3509        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
3510
3511        assert_eq!(result, b"raw bytes");
3512    }
3513
3514    // ── Nested signatures ────────────────────────────────────────────
3515
3516    #[test]
3517    fn enveloped_only_excludes_own_signature() {
3518        // Two real <Signature> elements: enveloped transform should only
3519        // exclude the specific one being verified, not the other.
3520        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
3521            <data>hello</data>
3522            <ds:Signature Id="sig-other">
3523                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
3524            </ds:Signature>
3525            <ds:Signature Id="sig-target">
3526                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
3527            </ds:Signature>
3528        </root>"#;
3529        let doc = Document::parse(xml).unwrap();
3530
3531        // We are verifying sig-target, not sig-other
3532        let sig_node = doc
3533            .descendants()
3534            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-target"))
3535            .unwrap();
3536
3537        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
3538        let data = TransformData::NodeSet(node_set);
3539
3540        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
3541        let node_set = result.into_node_set().unwrap();
3542
3543        // sig-other should still be in the set
3544        let sig_other = doc
3545            .descendants()
3546            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-other"))
3547            .unwrap();
3548        assert!(
3549            node_set.contains(sig_other),
3550            "other Signature elements should NOT be excluded"
3551        );
3552
3553        // Signature should be excluded
3554        assert!(
3555            !node_set.contains(sig_node),
3556            "the specific Signature being verified should be excluded"
3557        );
3558    }
3559
3560    // ── parse_transforms ─────────────────────────────────────────────
3561
3562    #[test]
3563    fn parse_transforms_enveloped_and_exc_c14n() {
3564        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3565            <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
3566            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
3567        </Transforms>"#;
3568        let doc = Document::parse(xml).unwrap();
3569        let transforms_node = doc.root_element();
3570
3571        let chain = parse_transforms(transforms_node).unwrap();
3572        assert_eq!(chain.len(), 2);
3573        assert!(matches!(chain[0], Transform::Enveloped));
3574        assert!(matches!(chain[1], Transform::C14n(_)));
3575    }
3576
3577    #[test]
3578    fn parse_transforms_rejects_unbounded_chain() {
3579        // Signed XML is untrusted input; reject excess transforms before
3580        // constructing a chain that would consume one stack frame per entry.
3581        let entries = format!(r#"<Transform Algorithm="{BASE64_TRANSFORM_URI}"/>"#).repeat(65);
3582        let xml = format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{entries}</Transforms>"#);
3583        let doc = Document::parse(&xml).unwrap();
3584
3585        assert!(matches!(
3586            parse_transforms(doc.root_element()),
3587            Err(TransformError::Policy(
3588                crate::policy::PolicyViolation::ResourceLimit {
3589                    resource: crate::policy::resource_name::REFERENCE_TRANSFORMS,
3590                    maximum: MAX_TRANSFORMS_PER_REFERENCE,
3591                    ..
3592                }
3593            ))
3594        ));
3595    }
3596
3597    #[test]
3598    fn parse_transforms_accepts_parameterless_base64() {
3599        let xml = format!(
3600            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">
3601            </Transform></Transforms>"#
3602        );
3603        let doc = Document::parse(&xml).unwrap();
3604
3605        let chain = parse_transforms(doc.root_element()).unwrap();
3606
3607        assert_eq!(chain.len(), 1);
3608        assert!(matches!(chain[0], Transform::Base64Decode));
3609    }
3610
3611    #[test]
3612    fn parse_transforms_rejects_base64_parameters() {
3613        for parameter in ["<Parameter/>", "unexpected", "\u{00A0}"] {
3614            let xml = format!(
3615                r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
3616            );
3617            let doc = Document::parse(&xml).unwrap();
3618
3619            let result = parse_transforms(doc.root_element());
3620
3621            assert!(matches!(
3622                result,
3623                Err(TransformError::UnsupportedTransform(_))
3624            ));
3625        }
3626    }
3627
3628    #[test]
3629    fn parse_transforms_rejects_non_xpath_boundary_whitespace() {
3630        // XPath 1.0 S excludes NBSP, so parser-level trimming must not turn
3631        // this malformed signed expression into a conforming `true()` call.
3632        let xml = format!(
3633            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath> true()</XPath></Transform></Transforms>"#
3634        );
3635        let doc = Document::parse(&xml).unwrap();
3636
3637        let result = parse_transforms(doc.root_element());
3638
3639        assert!(matches!(result, Err(TransformError::XPath(_))));
3640    }
3641
3642    #[test]
3643    fn parse_transforms_with_inclusive_prefixes() {
3644        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
3645                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
3646            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3647                <ec:InclusiveNamespaces PrefixList="ds saml #default"/>
3648            </Transform>
3649        </Transforms>"#;
3650        let doc = Document::parse(xml).unwrap();
3651        let transforms_node = doc.root_element();
3652
3653        let chain = parse_transforms(transforms_node).unwrap();
3654        assert_eq!(chain.len(), 1);
3655        match &chain[0] {
3656            Transform::C14n(algo) => {
3657                assert!(algo.inclusive_prefixes().contains("ds"));
3658                assert!(algo.inclusive_prefixes().contains("saml"));
3659                assert!(algo.inclusive_prefixes().contains("")); // #default
3660            }
3661            other => panic!("expected C14n, got: {other:?}"),
3662        }
3663    }
3664
3665    #[test]
3666    fn parse_transforms_ignores_wrong_ns_inclusive_namespaces() {
3667        // InclusiveNamespaces in a foreign namespace should be ignored —
3668        // only elements in http://www.w3.org/2001/10/xml-exc-c14n# are valid.
3669        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3670            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3671                <InclusiveNamespaces xmlns="http://example.com/fake"
3672                                     PrefixList="attacker-controlled"/>
3673            </Transform>
3674        </Transforms>"#;
3675        let doc = Document::parse(xml).unwrap();
3676
3677        let chain = parse_transforms(doc.root_element()).unwrap();
3678        assert_eq!(chain.len(), 1);
3679        match &chain[0] {
3680            Transform::C14n(algo) => {
3681                // PrefixList from wrong namespace should NOT be honoured
3682                assert!(
3683                    algo.inclusive_prefixes().is_empty(),
3684                    "should ignore InclusiveNamespaces in wrong namespace"
3685                );
3686            }
3687            other => panic!("expected C14n, got: {other:?}"),
3688        }
3689    }
3690
3691    #[test]
3692    fn parse_transforms_missing_prefix_list_is_error() {
3693        // InclusiveNamespaces in correct namespace but without PrefixList
3694        // attribute should be rejected (fail-closed), not silently ignored.
3695        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
3696                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
3697            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
3698                <ec:InclusiveNamespaces/>
3699            </Transform>
3700        </Transforms>"#;
3701        let doc = Document::parse(xml).unwrap();
3702
3703        let result = parse_transforms(doc.root_element());
3704        assert!(result.is_err());
3705        assert!(matches!(
3706            result.unwrap_err(),
3707            TransformError::UnsupportedTransform(_)
3708        ));
3709    }
3710
3711    #[test]
3712    fn parse_transforms_unsupported_algorithm() {
3713        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3714            <Transform Algorithm="http://example.com/unknown"/>
3715        </Transforms>"#;
3716        let doc = Document::parse(xml).unwrap();
3717
3718        let result = parse_transforms(doc.root_element());
3719        assert!(result.is_err());
3720        assert!(matches!(
3721            result.unwrap_err(),
3722            TransformError::UnsupportedTransform(_)
3723        ));
3724    }
3725
3726    #[test]
3727    fn parse_transforms_missing_algorithm() {
3728        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3729            <Transform/>
3730        </Transforms>"#;
3731        let doc = Document::parse(xml).unwrap();
3732
3733        let result = parse_transforms(doc.root_element());
3734        assert!(result.is_err());
3735        assert!(matches!(
3736            result.unwrap_err(),
3737            TransformError::UnsupportedTransform(_)
3738        ));
3739    }
3740
3741    #[test]
3742    fn parse_transforms_empty() {
3743        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"/>"#;
3744        let doc = Document::parse(xml).unwrap();
3745
3746        let chain = parse_transforms(doc.root_element()).unwrap();
3747        assert!(chain.is_empty());
3748    }
3749
3750    #[test]
3751    fn parse_transforms_accepts_enveloped_compat_xpath() {
3752        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3753            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3754                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3755                    not(ancestor-or-self::dsig:Signature)
3756                </XPath>
3757            </Transform>
3758        </Transforms>"#;
3759        let doc = Document::parse(xml).unwrap();
3760
3761        let chain = parse_transforms(doc.root_element()).unwrap();
3762        assert_eq!(chain.len(), 1);
3763        assert!(matches!(chain[0], Transform::XpathExcludeAllSignatures));
3764    }
3765
3766    #[test]
3767    fn parse_transforms_accepts_general_xpath_expressions() {
3768        // XPath 1.0 is no longer restricted to the historical enveloped-
3769        // signature compatibility expression.
3770        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3771            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3772                <XPath>self::node()</XPath>
3773            </Transform>
3774        </Transforms>"#;
3775        let doc = Document::parse(xml).unwrap();
3776
3777        let result = parse_transforms(doc.root_element()).unwrap();
3778        assert!(matches!(result.as_slice(), [Transform::XPath(_)]));
3779    }
3780
3781    #[test]
3782    fn parse_xpath_transform_ignores_comments_and_processing_instructions() {
3783        // Comments and PIs are not transform parameters and may surround the
3784        // required XPath element in an otherwise valid signature.
3785        let xml = format!(
3786            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><!-- before --><?probe value?><XPath>true()</XPath><!-- after --><?done?></Transform></Transforms>"#
3787        );
3788        let doc = Document::parse(&xml).unwrap();
3789
3790        let transforms = parse_transforms(doc.root_element()).unwrap();
3791
3792        assert!(matches!(transforms.as_slice(), [Transform::XPath(_)]));
3793    }
3794
3795    #[test]
3796    fn parse_filter2_transform_ignores_comments_and_processing_instructions() {
3797        // Filter 2.0 has the same XML comment/PI treatment while retaining its
3798        // stricter element and attribute grammar.
3799        let xml = format!(
3800            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"><!-- before --><?probe value?><XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">/root</XPath><!-- after --><?done?></Transform></Transforms>"#
3801        );
3802        let doc = Document::parse(&xml).unwrap();
3803
3804        let transforms = parse_transforms(doc.root_element()).unwrap();
3805
3806        assert!(matches!(
3807            transforms.as_slice(),
3808            [Transform::XPathFilter2(filters)] if filters.len() == 1
3809        ));
3810    }
3811
3812    #[test]
3813    fn parse_transforms_bounds_raw_xpath_parameter_text() {
3814        // Trimming must not let an untrusted parameter force allocation of an
3815        // otherwise bounded expression-sized buffer.
3816        let padding = " ".repeat(MAX_XPATH_EXPRESSION_BYTES);
3817        let xml = format!(
3818            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath>{padding}true()</XPath></Transform></Transforms>"#
3819        );
3820        let doc = Document::parse(&xml).unwrap();
3821
3822        let error = parse_transforms(doc.root_element())
3823            .expect_err("raw XPath parameter text must obey the expression bound");
3824
3825        assert!(matches!(
3826            error,
3827            TransformError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3828                resource: crate::policy::resource_name::XPATH_EXPRESSION_BYTES,
3829                maximum: MAX_XPATH_EXPRESSION_BYTES,
3830                ..
3831            })
3832        ));
3833    }
3834
3835    #[test]
3836    fn xpath_namespace_limits_apply_to_each_expression() {
3837        // Namespace ceilings describe one compiled expression, while expression
3838        // count remains shared across the complete signature operation.
3839        let xml = format!(
3840            r#"<Transforms xmlns="{XMLDSIG_NS}">
3841                <Transform Algorithm="{XPATH_TRANSFORM_URI}">
3842                    <XPath xmlns:a="urn:a">a:item</XPath>
3843                </Transform>
3844                <Transform Algorithm="{XPATH_TRANSFORM_URI}">
3845                    <XPath xmlns:b="urn:b">b:item</XPath>
3846                </Transform>
3847            </Transforms>"#
3848        );
3849        let doc = Document::parse(&xml).unwrap();
3850        let resources = crate::policy::ResourcePolicy {
3851            max_xpath_namespace_bindings: 1,
3852            ..crate::policy::ResourcePolicy::default()
3853        };
3854        let mut budget = XPathSignatureParseBudget::from_resources(&resources);
3855
3856        let transforms = parse_transforms_with_budget(doc.root_element(), &mut budget)
3857            .expect("each XPath independently satisfies the one-binding ceiling");
3858
3859        assert_eq!(transforms.len(), 2);
3860    }
3861
3862    #[test]
3863    fn parse_transforms_applies_namespace_storage_limit_per_expression() {
3864        // Repeated expressions may each consume the configured per-expression
3865        // allowance without being charged for bindings copied into their peers.
3866        let declarations = (0..32)
3867            .map(|index| {
3868                format!(
3869                    "xmlns:n{index}=\"urn:namespace:{index}:{}\"",
3870                    "x".repeat(64)
3871                )
3872            })
3873            .collect::<Vec<_>>()
3874            .join(" ");
3875        let filters = (0..MAX_XPATH_FILTERS)
3876            .map(|_| {
3877                format!(
3878                    r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
3879                )
3880            })
3881            .collect::<String>();
3882        let xml = format!(
3883            r#"<Transforms xmlns="{XMLDSIG_NS}" {declarations}><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform></Transforms>"#
3884        );
3885        let doc = Document::parse(&xml).unwrap();
3886
3887        let transforms = parse_transforms(doc.root_element())
3888            .expect("each expression remains below its namespace storage ceiling");
3889
3890        assert!(matches!(
3891            transforms.as_slice(),
3892            [Transform::XPathFilter2(filters)] if filters.len() == MAX_XPATH_FILTERS
3893        ));
3894    }
3895
3896    #[test]
3897    fn parse_transforms_rejects_xpath_in_wrong_namespace() {
3898        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3899            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3900                <foo:XPath xmlns:foo="http://example.com/ns">
3901                    not(ancestor-or-self::dsig:Signature)
3902                </foo:XPath>
3903            </Transform>
3904        </Transforms>"#;
3905        let doc = Document::parse(xml).unwrap();
3906
3907        let result = parse_transforms(doc.root_element());
3908        assert!(result.is_err());
3909        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3910    }
3911
3912    #[test]
3913    fn parse_transforms_preserves_nonstandard_prefix_bindings() {
3914        // A prefix URI is expression data. Binding `dsig` to another namespace
3915        // is valid XPath and must select that namespace rather than being
3916        // rewritten to XMLDSig by the parser.
3917        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3918            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3919                <XPath xmlns:dsig="http://example.com/not-xmldsig">
3920                    not(ancestor-or-self::dsig:Signature)
3921                </XPath>
3922            </Transform>
3923        </Transforms>"#;
3924        let doc = Document::parse(xml).unwrap();
3925
3926        let result = parse_transforms(doc.root_element()).unwrap();
3927        let [Transform::XPath(xpath)] = result.as_slice() else {
3928            panic!("expected general XPath transform");
3929        };
3930        assert_eq!(
3931            xpath.namespaces().get("dsig").map(String::as_str),
3932            Some("http://example.com/not-xmldsig")
3933        );
3934    }
3935
3936    #[test]
3937    fn parse_transforms_rejects_xpath_with_internal_whitespace_mutation() {
3938        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3939            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3940                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3941                    not(ancestor-or-self::dsig:Signa ture)
3942                </XPath>
3943            </Transform>
3944        </Transforms>"#;
3945        let doc = Document::parse(xml).unwrap();
3946
3947        let result = parse_transforms(doc.root_element());
3948        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3949    }
3950
3951    #[test]
3952    fn parse_transforms_rejects_multiple_xpath_children() {
3953        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3954            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3955                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3956                    not(ancestor-or-self::dsig:Signature)
3957                </XPath>
3958                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3959                    not(ancestor-or-self::dsig:Signature)
3960                </XPath>
3961            </Transform>
3962        </Transforms>"#;
3963        let doc = Document::parse(xml).unwrap();
3964
3965        let result = parse_transforms(doc.root_element());
3966        assert!(result.is_err());
3967        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3968    }
3969
3970    #[test]
3971    fn parse_transforms_rejects_non_xpath_element_children() {
3972        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
3973            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
3974                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
3975                    not(ancestor-or-self::dsig:Signature)
3976                </XPath>
3977                <Extra/>
3978            </Transform>
3979        </Transforms>"#;
3980        let doc = Document::parse(xml).unwrap();
3981
3982        let result = parse_transforms(doc.root_element());
3983        assert!(result.is_err());
3984        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
3985    }
3986
3987    #[test]
3988    fn parse_transforms_rejects_malformed_xpath_filter2_parameters() {
3989        // Filter 2.0 has a deliberately narrow parameter grammar. Rejecting
3990        // malformed variants prevents an unsupported parameter from being
3991        // silently ignored while computing security-sensitive digest input.
3992        for parameter in [
3993            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2">//Data</XPath>"#,
3994            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="exclude">//Data</XPath>"#,
3995            r#"<XPath xmlns="urn:wrong" Filter="intersect">//Data</XPath>"#,
3996            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="intersect" Extra="value">//Data</XPath>"#,
3997        ] {
3998            let xml = format!(
3999                r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
4000            );
4001            let doc = Document::parse(&xml).unwrap();
4002
4003            let result = parse_transforms(doc.root_element());
4004
4005            assert!(matches!(result, Err(TransformError::XPath(_))));
4006        }
4007    }
4008
4009    #[test]
4010    fn parse_transforms_rejects_empty_xpath_filter2_sequence() {
4011        // A no-op empty filter list is not a valid Filter 2.0 transform and
4012        // must not be accepted as though the transform were absent.
4013        let xml = format!(
4014            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"/></Transforms>"#
4015        );
4016        let doc = Document::parse(&xml).unwrap();
4017
4018        let result = parse_transforms(doc.root_element());
4019
4020        assert!(matches!(result, Err(TransformError::XPath(_))));
4021    }
4022
4023    #[test]
4024    fn parse_transform_chain_hashes_xpath_document_once() {
4025        // Every parsed XPath stores the same document provenance. A maximal
4026        // Filter 2.0 list must not rescan and hash the complete XML per entry.
4027        let filters = format!(
4028            r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
4029        )
4030        .repeat(MAX_XPATH_FILTERS);
4031        let transform = format!(
4032            r#"<Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform>"#
4033        );
4034        let xml =
4035            format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{transform}{transform}</Transforms>"#);
4036        let document = Document::parse(&xml).unwrap();
4037
4038        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4039        let transforms = parse_transforms(document.root_element()).unwrap();
4040        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4041
4042        assert_eq!(transforms.len(), 2);
4043        assert!(transforms.iter().all(
4044            |transform| matches!(transform, Transform::XPathFilter2(filters) if filters.len() == MAX_XPATH_FILTERS)
4045        ));
4046        assert_eq!(
4047            computations, 1,
4048            "one parsed transform chain must hash its source document once"
4049        );
4050    }
4051
4052    #[test]
4053    fn xpath_compat_excludes_other_signature_subtrees_too() {
4054        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
4055            <payload>keep-me</payload>
4056            <ds:Signature Id="sig-1">
4057                <ds:SignedInfo/>
4058                <ds:SignatureValue>one</ds:SignatureValue>
4059            </ds:Signature>
4060            <ds:Signature Id="sig-2">
4061                <ds:SignedInfo/>
4062                <ds:SignatureValue>two</ds:SignatureValue>
4063            </ds:Signature>
4064        </root>"#;
4065        let doc = Document::parse(xml).unwrap();
4066        let signature_nodes: Vec<_> = doc
4067            .descendants()
4068            .filter(|node| {
4069                node.is_element()
4070                    && node.tag_name().name() == "Signature"
4071                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
4072            })
4073            .collect();
4074        let sig_node = signature_nodes[0];
4075
4076        let enveloped = execute_transforms(
4077            sig_node,
4078            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
4079            &[
4080                Transform::Enveloped,
4081                Transform::C14n(C14nAlgorithm::new(
4082                    crate::c14n::C14nMode::Inclusive1_0,
4083                    false,
4084                )),
4085            ],
4086        )
4087        .unwrap();
4088        let xpath_compat = execute_transforms(
4089            sig_node,
4090            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
4091            &[
4092                Transform::XpathExcludeAllSignatures,
4093                Transform::C14n(C14nAlgorithm::new(
4094                    crate::c14n::C14nMode::Inclusive1_0,
4095                    false,
4096                )),
4097            ],
4098        )
4099        .unwrap();
4100
4101        let enveloped = String::from_utf8(enveloped).unwrap();
4102        let xpath_compat = String::from_utf8(xpath_compat).unwrap();
4103
4104        assert!(enveloped.contains("sig-2"));
4105        assert!(!xpath_compat.contains("sig-1"));
4106        assert!(!xpath_compat.contains("sig-2"));
4107        assert!(xpath_compat.contains("keep-me"));
4108    }
4109
4110    #[test]
4111    fn parse_transforms_inclusive_c14n_variants() {
4112        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
4113            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
4114            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
4115            <Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
4116        </Transforms>"#;
4117        let doc = Document::parse(xml).unwrap();
4118
4119        let chain = parse_transforms(doc.root_element()).unwrap();
4120        assert_eq!(chain.len(), 3);
4121        // All should be C14n variants
4122        for t in &chain {
4123            assert!(matches!(t, Transform::C14n(_)));
4124        }
4125    }
4126
4127    #[test]
4128    fn parsed_xpath_rejects_node_id_collision_from_another_document() {
4129        // NodeId is only a document-local index. Reusing a parsed transform
4130        // against another document must not let the same numeric id redirect
4131        // here() to an unrelated node in that document.
4132        let source = Document::parse(
4133            r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1</ds:XPath></ds:Transform></ds:Transforms></root>"#,
4134        )
4135        .unwrap();
4136        let transforms_node = source
4137            .descendants()
4138            .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
4139            .unwrap();
4140        let transforms = parse_transforms(transforms_node).unwrap();
4141
4142        let target = Document::parse(
4143            "<root><container><parameter><unrelated/></parameter></container></root>",
4144        )
4145        .unwrap();
4146        let error = execute_transforms(
4147            target.root_element(),
4148            TransformData::NodeSet(NodeSet::entire_document_without_comments(&target).unwrap()),
4149            &transforms,
4150        )
4151        .expect_err("parsed here() provenance must reject another XML document");
4152
4153        assert!(
4154            matches!(error, TransformError::XPath(ref message) if message.contains("same XML document"))
4155        );
4156    }
4157
4158    #[test]
4159    fn transform_chain_computes_document_identity_once() {
4160        // Parsed here() provenance needs a content hash, but every XPath step
4161        // over the same live document must reuse it rather than rehashing XML.
4162        let document = Document::parse(
4163            r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Signature><ds:SignedInfo><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform></ds:Transforms></ds:Reference></ds:SignedInfo></ds:Signature></root>"#,
4164        )
4165        .unwrap();
4166        let transforms_node = document
4167            .descendants()
4168            .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
4169            .unwrap();
4170        let transforms = parse_transforms(transforms_node).unwrap();
4171        let signature = document
4172            .descendants()
4173            .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
4174            .unwrap();
4175        let initial = NodeSet::entire_document_without_comments(&document)
4176            .map(TransformData::NodeSet)
4177            .unwrap();
4178
4179        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4180        execute_transforms(signature, initial, &transforms).unwrap();
4181        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4182
4183        assert_eq!(
4184            computations, 1,
4185            "one live document must be hashed once per chain"
4186        );
4187    }
4188
4189    #[test]
4190    fn transform_cache_identity_does_not_cross_chain_boundaries() {
4191        // One operation budget may execute multiple references, but a parsed
4192        // document pointer is only a valid cache key while its chain is alive.
4193        let document = Document::parse(
4194            r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Signature><ds:SignedInfo><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>true()</ds:XPath></ds:Transform></ds:Transforms></ds:Reference></ds:SignedInfo></ds:Signature></root>"#,
4195        )
4196        .unwrap();
4197        let transforms_node = document
4198            .descendants()
4199            .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
4200            .unwrap();
4201        let transforms = parse_transforms(transforms_node).unwrap();
4202        let signature = document
4203            .descendants()
4204            .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
4205            .unwrap();
4206        let budget = TransformExecutionBudget::default();
4207
4208        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4209        for _ in 0..2 {
4210            let initial = NodeSet::entire_document_without_comments(&document)
4211                .map(TransformData::NodeSet)
4212                .unwrap();
4213            execute_transforms_with_options_and_budget(
4214                signature,
4215                initial,
4216                &transforms,
4217                TransformOptions::default(),
4218                &budget,
4219            )
4220            .unwrap();
4221        }
4222        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4223
4224        assert_eq!(
4225            computations, 2,
4226            "each transform chain must establish a fresh document identity"
4227        );
4228    }
4229
4230    #[test]
4231    fn transform_chain_state_keys_identity_by_document() {
4232        // The cache must defend its own document association rather than rely
4233        // exclusively on every caller remembering explicit invalidation.
4234        let first_document = Document::parse("<first/>").unwrap();
4235        let second_document = Document::parse("<second/>").unwrap();
4236        let state = TransformChainState::default();
4237
4238        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4239        let first_identity = state.xpath_document_identity(&first_document);
4240        let second_identity = state.xpath_document_identity(&second_document);
4241        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4242
4243        assert_ne!(first_identity, second_identity);
4244        assert_eq!(
4245            computations, 2,
4246            "each distinct live document must receive its own cached identity"
4247        );
4248    }
4249
4250    #[test]
4251    fn template_xpath_skips_document_identity_hash() {
4252        // Builder-created expressions have no document-local here() node IDs,
4253        // so provenance validation must not scan and hash the input XML.
4254        let document = Document::parse("<root><value/></root>").unwrap();
4255        let transforms = [
4256            Transform::XPath(XPathExpression::new("true()")),
4257            Transform::XPath(XPathExpression::new("true()")),
4258        ];
4259        let initial = NodeSet::entire_document_without_comments(&document)
4260            .map(TransformData::NodeSet)
4261            .unwrap();
4262
4263        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
4264        execute_transforms(document.root_element(), initial, &transforms).unwrap();
4265        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
4266
4267        assert_eq!(
4268            computations, 0,
4269            "XPath without parsed here() provenance must not hash XML"
4270        );
4271    }
4272
4273    // ── Integration: SAML-like full pipeline ─────────────────────────
4274
4275    #[test]
4276    fn saml_enveloped_signature_full_pipeline() {
4277        // Realistic SAML Response with enveloped signature
4278        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
4279                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
4280                                     ID="_resp1">
4281            <saml:Assertion ID="_assert1">
4282                <saml:Subject>user@example.com</saml:Subject>
4283            </saml:Assertion>
4284            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
4285                <ds:SignedInfo>
4286                    <ds:Reference URI="">
4287                        <ds:Transforms>
4288                            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
4289                            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
4290                        </ds:Transforms>
4291                    </ds:Reference>
4292                </ds:SignedInfo>
4293                <ds:SignatureValue>fakesig==</ds:SignatureValue>
4294            </ds:Signature>
4295        </samlp:Response>"#;
4296        let doc = Document::parse(xml).unwrap();
4297
4298        // Find the Signature element
4299        let sig_node = doc
4300            .descendants()
4301            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
4302            .unwrap();
4303
4304        // Parse the transforms from the XML
4305        let reference = doc
4306            .descendants()
4307            .find(|n| n.is_element() && n.tag_name().name() == "Reference")
4308            .unwrap();
4309        let transforms_elem = reference
4310            .children()
4311            .find(|n| n.is_element() && n.tag_name().name() == "Transforms")
4312            .unwrap();
4313        let transforms = parse_transforms(transforms_elem).unwrap();
4314        assert_eq!(transforms.len(), 2);
4315
4316        // Execute the pipeline with empty URI (entire document)
4317        let initial =
4318            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
4319        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
4320
4321        let output = String::from_utf8(result).unwrap();
4322
4323        // Signature subtree must be completely absent
4324        assert!(!output.contains("Signature"), "Signature should be removed");
4325        assert!(
4326            !output.contains("SignedInfo"),
4327            "SignedInfo should be removed"
4328        );
4329        assert!(
4330            !output.contains("SignatureValue"),
4331            "SignatureValue should be removed"
4332        );
4333        assert!(
4334            !output.contains("fakesig"),
4335            "signature value should be removed"
4336        );
4337
4338        // Document content should be present and canonicalized
4339        assert!(output.contains("samlp:Response"));
4340        assert!(output.contains("saml:Assertion"));
4341        assert!(output.contains("user@example.com"));
4342    }
4343}