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::borrow::Cow;
23use std::cell::Cell;
24use std::collections::BTreeMap;
25
26use base64::{Engine as _, engine::general_purpose::STANDARD};
27use roxmltree::{Document, Node};
28use sha2::{Digest as _, Sha256};
29
30use super::parse::XMLDSIG_NS;
31use super::types::{NodeSetMaterializationBudget, TransformData, TransformError};
32use super::whitespace::is_xml_whitespace_only;
33use super::xpath::{
34    XPathDocumentRelation, XPathWorkBudget, apply_xpath_filter_with_semantics_and_budget,
35    apply_xpath_filter2_with_semantics_and_budget, compile_xpath, is_xpath_whitespace,
36};
37use crate::c14n::{self, C14nAlgorithm};
38
39/// The algorithm URI for the enveloped signature transform.
40pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
41/// The algorithm URI for the Base64 decode transform.
42pub const BASE64_TRANSFORM_URI: &str = "http://www.w3.org/2000/09/xmldsig#base64";
43/// The algorithm URI for the XPath 1.0 transform.
44pub const XPATH_TRANSFORM_URI: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
45/// The algorithm URI for the XPath Filter 2.0 transform.
46pub const XPATH_FILTER2_TRANSFORM_URI: &str = "http://www.w3.org/2002/06/xmldsig-filter2";
47/// The implicit default canonicalization URI applied when no explicit C14N
48/// transform is present in a `<Reference>`.
49pub const DEFAULT_IMPLICIT_C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
50/// Maximum transforms accepted for one reference.
51///
52/// Execution retains one stack frame when a binary-to-node-set adapter parses
53/// temporary XML, so this bounds recursion depth. The signature-wide C14N output
54/// budget below bounds both total work and buffers retained by those frames.
55pub const MAX_TRANSFORMS_PER_REFERENCE: usize = 64;
56/// xmlsec1 donor vectors use this XPath expression as a compatibility form of
57/// enveloped-signature exclusion.
58const ENVELOPED_SIGNATURE_XPATH_EXPR: &str = "not(ancestor-or-self::dsig:Signature)";
59pub(super) const MAX_XPATH_EXPRESSION_BYTES: usize = 16 * 1024;
60pub(super) const MAX_XPATH_FILTERS: usize = 64;
61/// Maximum XPath programs retained and compiled while parsing one SignedInfo.
62///
63/// Per-reference bounds remain necessary for transform shape, while this bound
64/// prevents their multiplication across all references in one signature.
65pub(super) const MAX_XPATH_EXPRESSIONS_PER_SIGNATURE: usize = 4_096;
66const MAX_XPATH_NAMESPACE_BINDINGS: usize = 1_024;
67const MAX_XPATH_NAMESPACE_BYTES: usize = 64 * 1024;
68const MAX_BASE64_TRANSFORM_INPUT_BYTES: usize = 16 * 1024 * 1024;
69const MAX_BASE64_TRANSFORM_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
70const MAX_C14N_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
71/// Bound cumulative node-set visits performed by exclusion transforms.
72const MAX_NODE_SET_FILTER_WORK: usize = 6_000_000;
73
74/// Namespace URI for Exclusive C14N `<InclusiveNamespaces>` elements.
75const EXCLUSIVE_C14N_NS_URI: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
76
77/// Node returned by the XMLDSig XPath `here()` extension function.
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub enum XPathHereSemantics {
80    /// Follow XMLDSig: return the `<XPath>` parameter element that contains
81    /// the expression text.
82    #[default]
83    Specification,
84    /// Match libxmlsec1, which returns the owning `<Transform>` element.
85    ///
86    /// This mode is opt-in because the two interpretations can select
87    /// different data for the same signed XML document.
88    XmlSecLegacy,
89}
90
91/// Options controlling execution of an XMLDSig transform chain.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub struct TransformOptions {
94    xpath_here_semantics: XPathHereSemantics,
95}
96
97#[derive(Default)]
98pub(crate) struct TransformExecutionBudget {
99    xpath: XPathWorkBudget,
100    base64: Base64WorkBudget,
101    c14n: C14nOutputBudget,
102    node_filter: NodeFilterWorkBudget,
103    node_set_materialization: NodeSetMaterializationBudget,
104}
105
106struct NodeFilterWorkBudget {
107    remaining: Cell<usize>,
108}
109
110impl Default for NodeFilterWorkBudget {
111    fn default() -> Self {
112        Self {
113            remaining: Cell::new(MAX_NODE_SET_FILTER_WORK),
114        }
115    }
116}
117
118impl NodeFilterWorkBudget {
119    fn charge(&self, entries: usize) -> Result<(), TransformError> {
120        if !charge_byte_budget(&self.remaining, entries) {
121            return Err(TransformError::NodeSetFilterWorkTooLarge {
122                max_entries: MAX_NODE_SET_FILTER_WORK,
123            });
124        }
125        Ok(())
126    }
127}
128
129struct Base64WorkBudget {
130    remaining: Cell<usize>,
131}
132
133struct C14nOutputBudget {
134    remaining: Cell<usize>,
135}
136
137fn charge_byte_budget(remaining: &Cell<usize>, bytes: usize) -> bool {
138    let Some(next) = remaining.get().checked_sub(bytes) else {
139        remaining.set(0);
140        return false;
141    };
142    remaining.set(next);
143    true
144}
145
146impl Default for C14nOutputBudget {
147    fn default() -> Self {
148        Self {
149            remaining: Cell::new(MAX_C14N_OUTPUT_BYTES),
150        }
151    }
152}
153
154impl C14nOutputBudget {
155    fn remaining(&self) -> usize {
156        self.remaining.get()
157    }
158
159    fn charge(&self, bytes: usize) -> Result<(), TransformError> {
160        if !charge_byte_budget(&self.remaining, bytes) {
161            return Err(TransformError::C14nOutputTooLarge {
162                max_bytes: MAX_C14N_OUTPUT_BYTES,
163            });
164        }
165        Ok(())
166    }
167}
168
169impl Default for Base64WorkBudget {
170    fn default() -> Self {
171        Self {
172            remaining: Cell::new(MAX_BASE64_TRANSFORM_INPUT_BYTES),
173        }
174    }
175}
176
177impl Base64WorkBudget {
178    fn charge(&self, bytes: usize) -> Result<(), TransformError> {
179        if !charge_byte_budget(&self.remaining, bytes) {
180            return Err(TransformError::Base64InputTooLarge {
181                max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES,
182            });
183        }
184        Ok(())
185    }
186}
187
188#[cfg(test)]
189impl TransformExecutionBudget {
190    pub(crate) fn with_xpath_limit(limit: usize) -> Self {
191        Self {
192            xpath: XPathWorkBudget::with_limit(limit),
193            base64: Base64WorkBudget::default(),
194            c14n: C14nOutputBudget::default(),
195            node_filter: NodeFilterWorkBudget::default(),
196            node_set_materialization: NodeSetMaterializationBudget::default(),
197        }
198    }
199
200    fn with_c14n_limit(limit: usize) -> Self {
201        Self {
202            xpath: XPathWorkBudget::default(),
203            base64: Base64WorkBudget::default(),
204            c14n: C14nOutputBudget {
205                remaining: Cell::new(limit),
206            },
207            node_filter: NodeFilterWorkBudget::default(),
208            node_set_materialization: NodeSetMaterializationBudget::default(),
209        }
210    }
211
212    fn with_node_filter_limit(limit: usize) -> Self {
213        Self {
214            xpath: XPathWorkBudget::default(),
215            base64: Base64WorkBudget::default(),
216            c14n: C14nOutputBudget::default(),
217            node_filter: NodeFilterWorkBudget {
218                remaining: Cell::new(limit),
219            },
220            node_set_materialization: NodeSetMaterializationBudget::default(),
221        }
222    }
223
224    pub(crate) fn with_node_set_materialization_limit(limit: usize) -> Self {
225        Self {
226            xpath: XPathWorkBudget::default(),
227            base64: Base64WorkBudget::default(),
228            c14n: C14nOutputBudget::default(),
229            node_filter: NodeFilterWorkBudget::default(),
230            node_set_materialization: NodeSetMaterializationBudget::with_limit(limit),
231        }
232    }
233}
234
235impl TransformExecutionBudget {
236    pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget {
237        &self.node_set_materialization
238    }
239}
240
241impl TransformOptions {
242    /// Select the node returned by the XPath `here()` extension function.
243    #[must_use]
244    pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self {
245        self.xpath_here_semantics = semantics;
246        self
247    }
248
249    pub(crate) fn here_semantics(self) -> XPathHereSemantics {
250        self.xpath_here_semantics
251    }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255struct XPathHereNodes {
256    specification_xpath_element: roxmltree::NodeId,
257    xmlsec_legacy_transform_element: roxmltree::NodeId,
258    document: XPathDocumentIdentity,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262struct XPathDocumentIdentity([u8; 32]);
263
264impl XPathDocumentIdentity {
265    fn from_document(document: &Document<'_>) -> Self {
266        #[cfg(test)]
267        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(count.get() + 1));
268        Self(Sha256::digest(document.input_text().as_bytes()).into())
269    }
270}
271
272#[cfg(test)]
273thread_local! {
274    static XPATH_DOCUMENT_IDENTITY_COMPUTATIONS: Cell<usize> = const { Cell::new(0) };
275}
276
277#[derive(Default)]
278struct TransformChainState {
279    xpath_document_identity: Cell<Option<CachedXPathDocumentIdentity>>,
280}
281
282#[derive(Clone, Copy)]
283struct CachedXPathDocumentIdentity {
284    document: *const (),
285    identity: XPathDocumentIdentity,
286}
287
288impl TransformChainState {
289    fn xpath_document_identity(&self, document: &Document<'_>) -> XPathDocumentIdentity {
290        let document_key = std::ptr::from_ref(document).cast::<()>();
291        if let Some(cached) = self.xpath_document_identity.get()
292            && cached.document == document_key
293        {
294            return cached.identity;
295        }
296        let identity = XPathDocumentIdentity::from_document(document);
297        self.xpath_document_identity
298            .set(Some(CachedXPathDocumentIdentity {
299                document: document_key,
300                identity,
301            }));
302        identity
303    }
304
305    fn document_reparsed(&self) {
306        self.xpath_document_identity.set(None);
307    }
308}
309
310struct TransformExecutionContext<'a> {
311    options: TransformOptions,
312    budget: &'a TransformExecutionBudget,
313    state: &'a TransformChainState,
314}
315
316/// An XPath 1.0 expression and the namespace bindings in scope where it was declared.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct XPathExpression {
319    expression: String,
320    namespaces: BTreeMap<String, String>,
321    here_nodes: Option<XPathHereNodes>,
322}
323
324impl XPathExpression {
325    /// Create an expression for signature-template generation.
326    pub fn new(expression: impl Into<String>) -> Self {
327        Self {
328            expression: expression.into(),
329            namespaces: BTreeMap::new(),
330            here_nodes: None,
331        }
332    }
333
334    /// Bind a prefix used by this XPath expression.
335    pub fn with_namespace(mut self, prefix: impl Into<String>, uri: impl Into<String>) -> Self {
336        self.namespaces.insert(prefix.into(), uri.into());
337        self
338    }
339
340    /// XPath source text.
341    pub fn expression(&self) -> &str {
342        &self.expression
343    }
344
345    /// Namespace prefix bindings used during evaluation.
346    pub fn namespaces(&self) -> &BTreeMap<String, String> {
347        &self.namespaces
348    }
349
350    pub(crate) fn here_context_node(
351        &self,
352        semantics: XPathHereSemantics,
353    ) -> Option<roxmltree::NodeId> {
354        self.here_nodes.map(|nodes| match semantics {
355            XPathHereSemantics::Specification => nodes.specification_xpath_element,
356            XPathHereSemantics::XmlSecLegacy => nodes.xmlsec_legacy_transform_element,
357        })
358    }
359
360    fn parsed_document_identity(&self) -> Option<XPathDocumentIdentity> {
361        self.here_nodes.map(|nodes| nodes.document)
362    }
363}
364
365/// Set operation applied by one XPath Filter 2.0 step.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum XPathFilterOperation {
368    /// Keep only nodes in the selected subtrees.
369    Intersect,
370    /// Remove nodes in the selected subtrees.
371    Subtract,
372    /// Add nodes in the selected subtrees.
373    Union,
374}
375
376impl XPathFilterOperation {
377    pub(crate) fn as_str(self) -> &'static str {
378        match self {
379            Self::Intersect => "intersect",
380            Self::Subtract => "subtract",
381            Self::Union => "union",
382        }
383    }
384}
385
386/// One expression and set operation in an XPath Filter 2.0 transform.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct XPathFilter {
389    operation: XPathFilterOperation,
390    xpath: XPathExpression,
391}
392
393impl XPathFilter {
394    /// Create a Filter 2.0 step.
395    pub fn new(operation: XPathFilterOperation, xpath: XPathExpression) -> Self {
396        Self { operation, xpath }
397    }
398
399    /// Operation applied to the subtree-expanded expression result.
400    pub fn operation(&self) -> XPathFilterOperation {
401        self.operation
402    }
403
404    /// XPath expression evaluated by this step.
405    pub fn xpath(&self) -> &XPathExpression {
406        &self.xpath
407    }
408}
409
410/// A single transform in the pipeline.
411#[derive(Debug, Clone)]
412pub enum Transform {
413    /// Enveloped signature: removes the `<Signature>` element subtree
414    /// that contains the `<Reference>` being processed.
415    ///
416    /// Input: `NodeSet` → Output: `NodeSet`
417    Enveloped,
418
419    /// Narrow XPath compatibility form used by some donor vectors:
420    /// `not(ancestor-or-self::dsig:Signature)`.
421    ///
422    /// Unlike `Enveloped`, this excludes every `ds:Signature` subtree in the
423    /// current document, not only the containing signature.
424    XpathExcludeAllSignatures,
425
426    /// General XMLDSig XPath 1.0 node filter.
427    XPath(XPathExpression),
428
429    /// XPath Filter 2.0 ordered subtree set operations.
430    XPathFilter2(Vec<XPathFilter>),
431
432    /// XML Canonicalization (any supported variant).
433    ///
434    /// Input: `NodeSet` → Output: `Binary`
435    C14n(C14nAlgorithm),
436
437    /// Decode base64 text into the octets consumed by the next transform or digest.
438    ///
439    /// Node-set input is converted by concatenating included text nodes in
440    /// document order, as required by XMLDSig section 6.6.2. Binary input is
441    /// decoded directly.
442    ///
443    /// Input: `NodeSet` or `Binary` → Output: `Binary`
444    Base64Decode,
445}
446
447/// Apply a single transform to the pipeline data.
448///
449/// `signature_node` is the `<Signature>` element that contains the
450/// `<Reference>` being processed. It is used by the enveloped transform
451/// to know which signature subtree to exclude. The node must belong to the
452/// same document as the `NodeSet` in `input`; a cross-document mismatch
453/// returns [`TransformError::CrossDocumentSignatureNode`].
454#[cfg(test)]
455pub(crate) fn apply_transform<'a>(
456    signature_node: Node<'a, 'a>,
457    transform: &Transform,
458    input: TransformData<'a>,
459) -> Result<TransformData<'a>, TransformError> {
460    let budget = TransformExecutionBudget::default();
461    let state = TransformChainState::default();
462    apply_transform_with_options_and_state(
463        signature_node,
464        transform,
465        input,
466        TransformOptions::default(),
467        &budget,
468        &state,
469    )
470}
471
472#[cfg(test)]
473pub(super) fn apply_transform_with_options<'s, 'd>(
474    signature_node: Node<'s, 's>,
475    transform: &Transform,
476    input: TransformData<'d>,
477    options: TransformOptions,
478    budget: &TransformExecutionBudget,
479) -> Result<TransformData<'d>, TransformError> {
480    let state = TransformChainState::default();
481    apply_transform_with_options_and_state(
482        signature_node,
483        transform,
484        input,
485        options,
486        budget,
487        &state,
488    )
489}
490
491fn apply_transform_with_options_and_state<'s, 'd>(
492    signature_node: Node<'s, 's>,
493    transform: &Transform,
494    input: TransformData<'d>,
495    options: TransformOptions,
496    budget: &TransformExecutionBudget,
497    state: &TransformChainState,
498) -> Result<TransformData<'d>, TransformError> {
499    match transform {
500        Transform::Enveloped => {
501            let mut nodes = input.into_node_set()?;
502            // Exclude the Signature element and all its descendants from
503            // the node set. This is the core mechanism of the enveloped
504            // signature transform: the digest is computed as if the
505            // <Signature> were not present in the document.
506            //
507            // xmlsec1 equivalent:
508            //   xmlSecNodeSetGetChildren(doc, signatureNode, 1, 1)  // inverted tree
509            //   xmlSecNodeSetAdd(inNodes, children, Intersection)   // intersect = subtract
510            if !std::ptr::eq(signature_node.document(), nodes.document()) {
511                return Err(TransformError::CrossDocumentSignatureNode);
512            }
513            budget.node_filter.charge(nodes.len())?;
514            nodes.exclude_subtree(signature_node);
515            Ok(TransformData::NodeSet(nodes))
516        }
517        Transform::XpathExcludeAllSignatures => {
518            let mut nodes = input.into_node_set()?;
519            let doc = nodes.document();
520
521            for node in doc.descendants().filter(|node| {
522                node.is_element()
523                    && node.tag_name().name() == "Signature"
524                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
525            }) {
526                budget.node_filter.charge(nodes.len())?;
527                nodes.exclude_subtree(node);
528            }
529
530            Ok(TransformData::NodeSet(nodes))
531        }
532        Transform::XPath(xpath) => {
533            let nodes = input.into_node_set()?;
534            let document_relation = xpath_document_relation(
535                signature_node.document(),
536                nodes.document(),
537                std::iter::once(xpath),
538                state,
539            );
540            Ok(TransformData::NodeSet(
541                apply_xpath_filter_with_semantics_and_budget(
542                    nodes,
543                    xpath,
544                    options.here_semantics(),
545                    document_relation,
546                    &budget.xpath,
547                    &budget.node_set_materialization,
548                )?,
549            ))
550        }
551        Transform::XPathFilter2(filters) => {
552            let nodes = input.into_node_set()?;
553            let document_relation = xpath_document_relation(
554                signature_node.document(),
555                nodes.document(),
556                filters.iter().map(XPathFilter::xpath),
557                state,
558            );
559            Ok(TransformData::NodeSet(
560                apply_xpath_filter2_with_semantics_and_budget(
561                    nodes,
562                    filters,
563                    options.here_semantics(),
564                    document_relation,
565                    &budget.xpath,
566                    &budget.node_set_materialization,
567                )?,
568            ))
569        }
570        Transform::C14n(algo) => {
571            let nodes = input.into_node_set()?;
572            let mut output = Vec::new();
573            c14n::canonicalize_with_visibility_and_position_bounded(
574                nodes.document(),
575                Some(&nodes),
576                algo,
577                None,
578                budget.c14n.remaining(),
579                &mut output,
580            )
581            .map_err(map_c14n_limit_error)?;
582            budget.c14n.charge(output.len())?;
583            Ok(TransformData::Binary(output))
584        }
585        Transform::Base64Decode => {
586            let mut normalized = Vec::new();
587            match input {
588                TransformData::Binary(bytes) => {
589                    append_normalized_base64(&bytes, &mut normalized, &budget.base64)?;
590                }
591                TransformData::NodeSet(nodes) => {
592                    for node in nodes.document().descendants() {
593                        if nodes.contains(node) && node.is_text() {
594                            append_normalized_base64(
595                                node.text().unwrap_or_default().as_bytes(),
596                                &mut normalized,
597                                &budget.base64,
598                            )?;
599                        }
600                    }
601                }
602            }
603            Ok(TransformData::Binary(decode_base64_transform(&normalized)?))
604        }
605    }
606}
607
608fn xpath_document_relation<'a>(
609    signature_document: &Document<'_>,
610    input_document: &Document<'_>,
611    expressions: impl IntoIterator<Item = &'a XPathExpression>,
612    state: &TransformChainState,
613) -> XPathDocumentRelation {
614    if matches!(
615        XPathDocumentRelation::between(signature_document, input_document),
616        XPathDocumentRelation::CrossDocument
617    ) {
618        return XPathDocumentRelation::CrossDocument;
619    }
620
621    let mut parsed_identities = expressions
622        .into_iter()
623        .filter_map(XPathExpression::parsed_document_identity);
624    let Some(first) = parsed_identities.next() else {
625        return XPathDocumentRelation::SameDocument;
626    };
627    let input_identity = state.xpath_document_identity(input_document);
628    if first == input_identity && parsed_identities.all(|identity| identity == input_identity) {
629        XPathDocumentRelation::SameDocument
630    } else {
631        XPathDocumentRelation::CrossDocument
632    }
633}
634
635/// Retain the RFC 2045 alphabet consumed by the XMLDSig Base64 transform.
636///
637/// RFC 2045 section 6.8 requires decoders to ignore every byte outside the
638/// alphabet. The raw-input budget is charged before filtering so ignored data
639/// cannot be used to force unbounded scanning or allocation.
640fn append_normalized_base64(
641    encoded: &[u8],
642    normalized: &mut Vec<u8>,
643    budget: &Base64WorkBudget,
644) -> Result<(), TransformError> {
645    budget.charge(encoded.len())?;
646
647    let additional = encoded
648        .iter()
649        .filter(|byte| is_rfc2045_base64_byte(**byte))
650        .count();
651    normalized.reserve(additional);
652    normalized.extend(
653        encoded
654            .iter()
655            .copied()
656            .filter(|byte| is_rfc2045_base64_byte(*byte)),
657    );
658    Ok(())
659}
660
661fn is_rfc2045_base64_byte(byte: u8) -> bool {
662    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
663}
664
665fn decode_base64_transform(normalized: &[u8]) -> Result<Vec<u8>, TransformError> {
666    let padding = normalized
667        .iter()
668        .rev()
669        .take_while(|byte| **byte == b'=')
670        .count();
671    let decoded_len = base64::decoded_len_estimate(normalized.len()).saturating_sub(padding);
672    if decoded_len > MAX_BASE64_TRANSFORM_OUTPUT_BYTES {
673        return Err(TransformError::Base64OutputTooLarge {
674            max_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
675        });
676    }
677
678    let mut decoded = vec![0_u8; decoded_len];
679    let written = STANDARD
680        .decode_slice(normalized, &mut decoded)
681        .map_err(|error| TransformError::Base64(error.to_string()))?;
682    decoded.truncate(written);
683    Ok(decoded)
684}
685
686/// Execute a chain of transforms for a single `<Reference>`.
687///
688/// 1. Start with `initial_data` (from URI dereference).
689/// 2. Apply each transform sequentially.
690/// 3. If the result is still a `NodeSet`, apply default inclusive C14N 1.0
691///    to produce bytes (per [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel)).
692///
693/// Returns the final byte sequence ready for digest computation.
694pub fn execute_transforms<'a>(
695    signature_node: Node<'a, 'a>,
696    initial_data: TransformData<'a>,
697    transforms: &[Transform],
698) -> Result<Vec<u8>, TransformError> {
699    execute_transforms_with_options(
700        signature_node,
701        initial_data,
702        transforms,
703        TransformOptions::default(),
704    )
705}
706
707/// Execute a transform chain with explicit compatibility options.
708pub fn execute_transforms_with_options<'a>(
709    signature_node: Node<'a, 'a>,
710    initial_data: TransformData<'a>,
711    transforms: &[Transform],
712    options: TransformOptions,
713) -> Result<Vec<u8>, TransformError> {
714    let budget = TransformExecutionBudget::default();
715    execute_transforms_with_options_and_budget(
716        signature_node,
717        initial_data,
718        transforms,
719        options,
720        &budget,
721    )
722}
723
724pub(crate) fn execute_transforms_with_options_and_budget<'a>(
725    signature_node: Node<'a, 'a>,
726    initial_data: TransformData<'a>,
727    transforms: &[Transform],
728    options: TransformOptions,
729    budget: &TransformExecutionBudget,
730) -> Result<Vec<u8>, TransformError> {
731    ensure_transform_count(transforms.len())?;
732    let state = TransformChainState::default();
733    let context = TransformExecutionContext {
734        options,
735        budget,
736        state: &state,
737    };
738    execute_transform_chain(
739        signature_node,
740        Some(signature_node),
741        initial_data,
742        transforms,
743        None,
744        &context,
745    )
746}
747
748fn ensure_transform_count(count: usize) -> Result<(), TransformError> {
749    if count > MAX_TRANSFORMS_PER_REFERENCE {
750        return Err(TransformError::TooManyTransforms {
751            max: MAX_TRANSFORMS_PER_REFERENCE,
752        });
753    }
754    Ok(())
755}
756
757fn execute_transform_chain<'s, 'e, 'd>(
758    source_signature: Node<'s, 's>,
759    enveloped_signature: Option<Node<'e, 'e>>,
760    data: TransformData<'d>,
761    transforms: &[Transform],
762    canonical_signature_position: Option<Option<usize>>,
763    context: &TransformExecutionContext<'_>,
764) -> Result<Vec<u8>, TransformError> {
765    let Some((transform, remaining)) = transforms.split_first() else {
766        return finalize_transform_data(data, &context.budget.c14n);
767    };
768
769    if transform_requires_node_set(transform)
770        && let TransformData::Binary(bytes) = data
771    {
772        // The parsed document must outlive every remaining node-set transform.
773        // Recursive execution keeps all borrows scoped to this stack frame and
774        // returns only owned digest bytes. Every C14N output is charged before
775        // recursion, so these retained buffers remain a bounded subset of the
776        // signature-wide canonicalization work budget.
777        let xml = decode_xml_octets(&bytes)?;
778        let document = roxmltree::Document::parse(&xml)
779            .map_err(|error| TransformError::XmlParse(error.to_string()))?;
780        context.state.document_reparsed();
781        let nodes = super::types::NodeSet::entire_document_with_comments_with_budget(
782            &document,
783            &context.budget.node_set_materialization,
784        )?;
785        return match canonical_signature_position {
786            Some(Some(position)) => {
787                let remapped = document
788                    .descendants()
789                    .find(|node| node.is_element() && node.range().start == position)
790                    .filter(|node| {
791                        enveloped_signature
792                            .is_some_and(|source| node.tag_name() == source.tag_name())
793                    })
794                    .ok_or(TransformError::CrossDocumentSignatureNode)?;
795                execute_transform_chain(
796                    source_signature,
797                    Some(remapped),
798                    TransformData::NodeSet(nodes),
799                    transforms,
800                    None,
801                    context,
802                )
803            }
804            Some(None) => execute_transform_chain(
805                source_signature,
806                None,
807                TransformData::NodeSet(nodes),
808                transforms,
809                None,
810                context,
811            ),
812            None => execute_transform_chain(
813                source_signature,
814                // Binary input not produced by tracked canonicalization is a
815                // different document. Keep source_signature for XPath here()
816                // semantics, but do not apply its identity to Enveloped.
817                None,
818                TransformData::NodeSet(nodes),
819                transforms,
820                None,
821                context,
822            ),
823        };
824    }
825
826    if let Transform::C14n(algo) = transform
827        && let TransformData::NodeSet(nodes) = &data
828    {
829        let tracked_element = enveloped_signature
830            .filter(|signature| std::ptr::eq(signature.document(), nodes.document()))
831            .filter(|signature| nodes.contains(*signature))
832            .map(|signature| signature.id());
833        let mut output = Vec::new();
834        let position = c14n::canonicalize_with_visibility_and_position_bounded(
835            nodes.document(),
836            Some(nodes),
837            algo,
838            tracked_element,
839            context.budget.c14n.remaining(),
840            &mut output,
841        )
842        .map_err(map_c14n_limit_error)?;
843        context.budget.c14n.charge(output.len())?;
844        return execute_transform_chain(
845            source_signature,
846            enveloped_signature,
847            TransformData::Binary(output),
848            remaining,
849            Some(position),
850            context,
851        );
852    }
853
854    if matches!(transform, Transform::Enveloped) {
855        let Some(signature) = enveloped_signature else {
856            return execute_transform_chain(source_signature, None, data, remaining, None, context);
857        };
858        let data = apply_transform_with_options_and_state(
859            signature,
860            transform,
861            data,
862            context.options,
863            context.budget,
864            context.state,
865        )?;
866        return execute_transform_chain(
867            source_signature,
868            Some(signature),
869            data,
870            remaining,
871            None,
872            context,
873        );
874    }
875
876    let data = apply_transform_with_options_and_state(
877        source_signature,
878        transform,
879        data,
880        context.options,
881        context.budget,
882        context.state,
883    )?;
884    execute_transform_chain(
885        source_signature,
886        enveloped_signature,
887        data,
888        remaining,
889        None,
890        context,
891    )
892}
893
894fn decode_xml_octets(bytes: &[u8]) -> Result<Cow<'_, str>, TransformError> {
895    // XML 1.0 requires processors to accept UTF-8 and UTF-16. UTF-16 external
896    // entities carry a BOM, which also makes byte order detection deterministic.
897    let (utf16, little_endian) = if let Some(payload) = bytes.strip_prefix(&[0xff, 0xfe]) {
898        (Some(payload), true)
899    } else if let Some(payload) = bytes.strip_prefix(&[0xfe, 0xff]) {
900        (Some(payload), false)
901    } else {
902        (None, false)
903    };
904    if let Some(payload) = utf16 {
905        if payload.len() % 2 != 0 {
906            return Err(TransformError::XmlParse(
907                "UTF-16 XML input has an odd byte length".into(),
908            ));
909        }
910        let code_units = payload
911            .chunks_exact(2)
912            .map(|chunk| {
913                let bytes = [chunk[0], chunk[1]];
914                if little_endian {
915                    u16::from_le_bytes(bytes)
916                } else {
917                    u16::from_be_bytes(bytes)
918                }
919            })
920            .collect::<Vec<_>>();
921        return String::from_utf16(&code_units)
922            .map(Cow::Owned)
923            .map_err(|error| TransformError::XmlParse(error.to_string()));
924    }
925
926    let payload = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
927    std::str::from_utf8(payload)
928        .map(Cow::Borrowed)
929        .map_err(|error| TransformError::XmlParse(error.to_string()))
930}
931
932fn transform_requires_node_set(transform: &Transform) -> bool {
933    !matches!(transform, Transform::Base64Decode)
934}
935
936fn finalize_transform_data(
937    data: TransformData<'_>,
938    c14n_budget: &C14nOutputBudget,
939) -> Result<Vec<u8>, TransformError> {
940    // Final coercion: if the result is still a NodeSet, canonicalize with
941    // default inclusive C14N 1.0 per XMLDSig spec §4.3.3.2.
942    match data {
943        TransformData::Binary(bytes) => Ok(bytes),
944        TransformData::NodeSet(nodes) => {
945            #[expect(clippy::expect_used, reason = "hardcoded URI is a known constant")]
946            let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI)
947                .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri");
948            let mut output = Vec::new();
949            c14n::canonicalize_with_visibility_and_position_bounded(
950                nodes.document(),
951                Some(&nodes),
952                &algo,
953                None,
954                c14n_budget.remaining(),
955                &mut output,
956            )
957            .map_err(map_c14n_limit_error)?;
958            c14n_budget.charge(output.len())?;
959            Ok(output)
960        }
961    }
962}
963
964fn map_c14n_limit_error(error: c14n::C14nError) -> TransformError {
965    if c14n::is_output_limit_error(&error) {
966        TransformError::C14nOutputTooLarge {
967            max_bytes: MAX_C14N_OUTPUT_BYTES,
968        }
969    } else {
970        TransformError::C14n(error)
971    }
972}
973
974/// Parse a `<Transforms>` element into a `Vec<Transform>`.
975///
976/// Reads each `<Transform Algorithm="...">` child element and constructs
977/// the corresponding [`Transform`] variant. Unrecognized algorithm URIs
978/// produce an error.
979///
980/// For Exclusive C14N, also parses the optional `<InclusiveNamespaces
981/// PrefixList="...">` child element.
982pub fn parse_transforms(transforms_node: Node) -> Result<Vec<Transform>, TransformError> {
983    parse_transforms_with_budget(transforms_node, &mut XPathSignatureParseBudget::default())
984}
985
986pub(crate) fn parse_transforms_with_budget(
987    transforms_node: Node,
988    signature_budget: &mut XPathSignatureParseBudget,
989) -> Result<Vec<Transform>, TransformError> {
990    // Validate that we received a <ds:Transforms> element.
991    if !transforms_node.is_element() {
992        return Err(TransformError::UnsupportedTransform(
993            "expected <Transforms> element but got non-element node".into(),
994        ));
995    }
996    let transforms_tag = transforms_node.tag_name();
997    if transforms_tag.name() != "Transforms" || transforms_tag.namespace() != Some(XMLDSIG_NS) {
998        return Err(TransformError::UnsupportedTransform(
999            "expected <ds:Transforms> element in XMLDSig namespace".into(),
1000        ));
1001    }
1002
1003    let mut chain = Vec::new();
1004    let mut xpath_state = XPathParseState::new(signature_budget);
1005
1006    for child in transforms_node.children() {
1007        if !child.is_element() {
1008            continue;
1009        }
1010        ensure_transform_count(chain.len() + 1)?;
1011
1012        // Only <ds:Transform> children are allowed; fail closed on any other element.
1013        let tag = child.tag_name();
1014        if tag.name() != "Transform" || tag.namespace() != Some(XMLDSIG_NS) {
1015            return Err(TransformError::UnsupportedTransform(
1016                "unexpected child element of <ds:Transforms>; only <ds:Transform> is allowed"
1017                    .into(),
1018            ));
1019        }
1020        let uri = child.attribute("Algorithm").ok_or_else(|| {
1021            TransformError::UnsupportedTransform(
1022                "missing Algorithm attribute on <Transform>".into(),
1023            )
1024        })?;
1025
1026        let transform = if uri == ENVELOPED_SIGNATURE_URI {
1027            Transform::Enveloped
1028        } else if uri == BASE64_TRANSFORM_URI {
1029            validate_empty_transform(child, "Base64")?;
1030            Transform::Base64Decode
1031        } else if uri == XPATH_TRANSFORM_URI {
1032            parse_xpath_transform_with_state(child, &mut xpath_state)?
1033        } else if uri == XPATH_FILTER2_TRANSFORM_URI {
1034            parse_xpath_filter2_transform(child, &mut xpath_state)?
1035        } else if let Some(mut algo) = C14nAlgorithm::from_uri(uri) {
1036            // For exclusive C14N, check for InclusiveNamespaces child
1037            if algo.mode() == c14n::C14nMode::Exclusive1_0
1038                && let Some(prefix_list) = parse_inclusive_prefixes(child)?
1039            {
1040                algo = algo.with_prefix_list(&prefix_list);
1041            }
1042            Transform::C14n(algo)
1043        } else {
1044            return Err(TransformError::UnsupportedTransform(uri.to_string()));
1045        };
1046        chain.push(transform);
1047    }
1048
1049    Ok(chain)
1050}
1051
1052/// Validate transforms whose XML syntax does not define parameter content.
1053fn validate_empty_transform(
1054    transform_node: Node,
1055    transform_name: &'static str,
1056) -> Result<(), TransformError> {
1057    for child in transform_node.children() {
1058        if child.is_element()
1059            || (child.is_text()
1060                && child
1061                    .text()
1062                    .is_some_and(|text| !is_xml_whitespace_only(text)))
1063        {
1064            return Err(TransformError::UnsupportedTransform(format!(
1065                "{transform_name} transform must not contain parameters"
1066            )));
1067        }
1068    }
1069    Ok(())
1070}
1071
1072#[cfg(test)]
1073pub(super) fn parse_xpath_transform(transform_node: Node) -> Result<Transform, TransformError> {
1074    parse_xpath_transform_with_state(
1075        transform_node,
1076        &mut XPathParseState::new(&mut XPathSignatureParseBudget::default()),
1077    )
1078}
1079
1080fn parse_xpath_transform_with_state(
1081    transform_node: Node,
1082    xpath_state: &mut XPathParseState,
1083) -> Result<Transform, TransformError> {
1084    let mut xpath_node = None;
1085
1086    for child in transform_node.children() {
1087        if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1088            continue;
1089        }
1090        if child.is_comment() || child.is_pi() {
1091            continue;
1092        }
1093        if !child.is_element() {
1094            return Err(TransformError::XPath(
1095                "XPath transform contains non-whitespace parameter content".into(),
1096            ));
1097        }
1098        let tag = child.tag_name();
1099        if tag.name() == "XPath" && tag.namespace() == Some(XMLDSIG_NS) {
1100            if xpath_node.is_some() {
1101                return Err(TransformError::XPath(
1102                    "XPath transform must contain exactly one XMLDSig <XPath> child element".into(),
1103                ));
1104            }
1105            xpath_node = Some(child);
1106        } else {
1107            return Err(TransformError::XPath(
1108                "XPath transform allows only a single XMLDSig <XPath> child element".into(),
1109            ));
1110        }
1111    }
1112
1113    let xpath_node = xpath_node.ok_or_else(|| {
1114        TransformError::XPath(
1115            "XPath transform requires a single XMLDSig <XPath> child element".into(),
1116        )
1117    })?;
1118    if xpath_node.attributes().len() != 0 {
1119        return Err(TransformError::XPath(
1120            "XMLDSig <XPath> does not allow attributes".into(),
1121        ));
1122    }
1123    let xpath = parse_xpath_expression(xpath_node, transform_node.id(), xpath_state)?;
1124
1125    if xpath.expression() == ENVELOPED_SIGNATURE_XPATH_EXPR
1126        && xpath.namespaces().get("dsig").map(String::as_str) == Some(XMLDSIG_NS)
1127    {
1128        Ok(Transform::XpathExcludeAllSignatures)
1129    } else {
1130        Ok(Transform::XPath(xpath))
1131    }
1132}
1133
1134fn parse_xpath_filter2_transform(
1135    transform_node: Node,
1136    xpath_state: &mut XPathParseState,
1137) -> Result<Transform, TransformError> {
1138    let mut filters = Vec::new();
1139    for child in transform_node.children() {
1140        if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1141            continue;
1142        }
1143        if child.is_comment() || child.is_pi() {
1144            continue;
1145        }
1146        if !child.is_element()
1147            || child.tag_name().name() != "XPath"
1148            || child.tag_name().namespace() != Some(XPATH_FILTER2_TRANSFORM_URI)
1149        {
1150            return Err(TransformError::XPath(
1151                "XPath Filter 2.0 allows only filter-namespace <XPath> children".into(),
1152            ));
1153        }
1154        if filters.len() == MAX_XPATH_FILTERS {
1155            return Err(TransformError::XPath(format!(
1156                "XPath Filter 2.0 exceeds the maximum of {MAX_XPATH_FILTERS} expressions"
1157            )));
1158        }
1159        if child.attributes().len() != 1 || child.attribute("Filter").is_none() {
1160            return Err(TransformError::XPath(
1161                "XPath Filter 2.0 <XPath> requires only the unqualified Filter attribute".into(),
1162            ));
1163        }
1164        let operation = match child.attribute("Filter") {
1165            Some("intersect") => XPathFilterOperation::Intersect,
1166            Some("subtract") => XPathFilterOperation::Subtract,
1167            Some("union") => XPathFilterOperation::Union,
1168            Some(value) => {
1169                return Err(TransformError::XPath(format!(
1170                    "unsupported XPath Filter 2.0 operation: {value}"
1171                )));
1172            }
1173            None => unreachable!("Filter presence was checked above"),
1174        };
1175        filters.push(XPathFilter::new(
1176            operation,
1177            parse_xpath_expression(child, transform_node.id(), xpath_state)?,
1178        ));
1179    }
1180    if filters.is_empty() {
1181        return Err(TransformError::XPath(
1182            "XPath Filter 2.0 requires at least one expression".into(),
1183        ));
1184    }
1185    Ok(Transform::XPathFilter2(filters))
1186}
1187
1188fn parse_xpath_expression(
1189    xpath_node: Node,
1190    transform_node: roxmltree::NodeId,
1191    xpath_state: &mut XPathParseState,
1192) -> Result<XPathExpression, TransformError> {
1193    let mut source = String::new();
1194    for child in xpath_node.children() {
1195        if child.is_text() {
1196            let text = child.text().unwrap_or_default();
1197            if source
1198                .len()
1199                .checked_add(text.len())
1200                .is_none_or(|length| length > MAX_XPATH_EXPRESSION_BYTES)
1201            {
1202                return Err(TransformError::XPath(format!(
1203                    "XPath expression exceeds {MAX_XPATH_EXPRESSION_BYTES} bytes"
1204                )));
1205            }
1206            source.push_str(text);
1207        } else if child.is_element() {
1208            return Err(TransformError::XPath(
1209                "XPath expressions must contain text only".into(),
1210            ));
1211        }
1212    }
1213    let source = source.trim_matches(is_xpath_whitespace);
1214    if source.is_empty() {
1215        return Err(TransformError::XPath(
1216            "XPath expression must not be empty".into(),
1217        ));
1218    }
1219    xpath_state.signature_budget.charge()?;
1220    compile_xpath(source).map_err(TransformError::XPath)?;
1221
1222    let mut xpath = XPathExpression {
1223        expression: source.to_owned(),
1224        namespaces: BTreeMap::new(),
1225        here_nodes: Some(XPathHereNodes {
1226            // XMLDSig defines here() as the parent element of the text node
1227            // bearing the expression, not as the text node itself.
1228            specification_xpath_element: xpath_node.id(),
1229            xmlsec_legacy_transform_element: transform_node,
1230            // NodeId is only meaningful within one roxmltree Document. Keep an
1231            // owned content identity so parsed transforms cannot outlive the
1232            // source and later alias unrelated nodes carrying the same indices.
1233            document: xpath_state.document_identity(xpath_node.document()),
1234        }),
1235    };
1236    for namespace in xpath_node.namespaces() {
1237        if let Some(prefix) = namespace.name() {
1238            xpath_state
1239                .namespace_budget
1240                .charge(prefix, namespace.uri())?;
1241            xpath
1242                .namespaces
1243                .insert(prefix.to_owned(), namespace.uri().to_owned());
1244        }
1245    }
1246    Ok(xpath)
1247}
1248
1249struct XPathParseState<'a> {
1250    namespace_budget: XPathNamespaceBudget,
1251    document_identity: Option<XPathDocumentIdentity>,
1252    signature_budget: &'a mut XPathSignatureParseBudget,
1253}
1254
1255impl<'a> XPathParseState<'a> {
1256    fn new(signature_budget: &'a mut XPathSignatureParseBudget) -> Self {
1257        Self {
1258            namespace_budget: XPathNamespaceBudget::default(),
1259            document_identity: None,
1260            signature_budget,
1261        }
1262    }
1263
1264    fn document_identity(&mut self, document: &Document<'_>) -> XPathDocumentIdentity {
1265        *self
1266            .document_identity
1267            .get_or_insert_with(|| XPathDocumentIdentity::from_document(document))
1268    }
1269}
1270
1271#[derive(Default)]
1272/// Parser state shared by every Reference in one Signature, including Manifests.
1273pub(crate) struct XPathSignatureParseBudget {
1274    expressions: usize,
1275}
1276
1277impl XPathSignatureParseBudget {
1278    pub(crate) fn charge(&mut self) -> Result<(), TransformError> {
1279        self.expressions = self.expressions.checked_add(1).ok_or_else(Self::error)?;
1280        if self.expressions > MAX_XPATH_EXPRESSIONS_PER_SIGNATURE {
1281            return Err(Self::error());
1282        }
1283        Ok(())
1284    }
1285
1286    fn error() -> TransformError {
1287        TransformError::XPath(format!(
1288            "signature-wide XPath expression budget exceeds {MAX_XPATH_EXPRESSIONS_PER_SIGNATURE} entries"
1289        ))
1290    }
1291}
1292
1293#[derive(Default)]
1294struct XPathNamespaceBudget {
1295    bindings: usize,
1296    bytes: usize,
1297}
1298
1299impl XPathNamespaceBudget {
1300    fn charge(&mut self, prefix: &str, uri: &str) -> Result<(), TransformError> {
1301        self.bindings = self.bindings.checked_add(1).ok_or_else(Self::error)?;
1302        self.bytes = self
1303            .bytes
1304            .checked_add(prefix.len())
1305            .and_then(|bytes| bytes.checked_add(uri.len()))
1306            .ok_or_else(Self::error)?;
1307        if self.bindings > MAX_XPATH_NAMESPACE_BINDINGS || self.bytes > MAX_XPATH_NAMESPACE_BYTES {
1308            return Err(Self::error());
1309        }
1310        Ok(())
1311    }
1312
1313    fn error() -> TransformError {
1314        TransformError::XPath(format!(
1315            "XPath namespace binding budget exceeds {MAX_XPATH_NAMESPACE_BINDINGS} entries or \
1316             {MAX_XPATH_NAMESPACE_BYTES} bytes per transform chain"
1317        ))
1318    }
1319}
1320
1321pub(crate) fn validate_xpath_namespace_budget(
1322    transforms: &[Transform],
1323    inherited_namespace: Option<(&str, &str)>,
1324) -> Result<(), TransformError> {
1325    let mut budget = XPathNamespaceBudget::default();
1326    for transform in transforms {
1327        match transform {
1328            Transform::XPath(xpath) => {
1329                for (prefix, uri) in xpath.namespaces() {
1330                    budget.charge(prefix, uri)?;
1331                }
1332                if let Some((prefix, uri)) = inherited_namespace
1333                    && !xpath.namespaces().contains_key(prefix)
1334                {
1335                    budget.charge(prefix, uri)?;
1336                }
1337            }
1338            Transform::XPathFilter2(filters) => {
1339                for filter in filters {
1340                    for (prefix, uri) in filter.xpath().namespaces() {
1341                        budget.charge(prefix, uri)?;
1342                    }
1343                    if let Some((prefix, uri)) = inherited_namespace
1344                        && !filter.xpath().namespaces().contains_key(prefix)
1345                    {
1346                        budget.charge(prefix, uri)?;
1347                    }
1348                }
1349            }
1350            _ => {}
1351        }
1352    }
1353    Ok(())
1354}
1355
1356/// Parse the `PrefixList` attribute from an `<ec:InclusiveNamespaces>` child
1357/// element, if present.
1358///
1359/// Per the [Exclusive C14N spec](https://www.w3.org/TR/xml-exc-c14n/#def-InclusiveNamespaces-PrefixList),
1360/// the element MUST be in the `http://www.w3.org/2001/10/xml-exc-c14n#` namespace.
1361/// Elements with the same local name but a different namespace are ignored.
1362///
1363/// Returns `Ok(None)` if no `<InclusiveNamespaces>` child is present.
1364/// Returns `Err` if the element exists but lacks the required `PrefixList` attribute
1365/// (fail-closed: malformed control elements are rejected, not silently ignored).
1366///
1367/// The element is typically:
1368/// ```xml
1369/// <ec:InclusiveNamespaces
1370///     xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"
1371///     PrefixList="ds saml #default"/>
1372/// ```
1373fn parse_inclusive_prefixes(transform_node: Node) -> Result<Option<String>, TransformError> {
1374    for child in transform_node.children() {
1375        if child.is_element() {
1376            let tag = child.tag_name();
1377            if tag.name() == "InclusiveNamespaces" && tag.namespace() == Some(EXCLUSIVE_C14N_NS_URI)
1378            {
1379                let prefix_list = child.attribute("PrefixList").ok_or_else(|| {
1380                    TransformError::UnsupportedTransform(
1381                        "missing PrefixList attribute on <InclusiveNamespaces>".into(),
1382                    )
1383                })?;
1384                return Ok(Some(prefix_list.to_string()));
1385            }
1386        }
1387    }
1388    Ok(None)
1389}
1390
1391#[cfg(test)]
1392#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
1393mod tests {
1394    use super::*;
1395    use crate::xmldsig::NodeSet;
1396    use roxmltree::Document;
1397
1398    // ── Enveloped transform ──────────────────────────────────────────
1399
1400    #[test]
1401    fn enveloped_excludes_signature_subtree() {
1402        // Simulates a SAML-like document with an enveloped signature
1403        let xml = r#"<root>
1404            <data>hello</data>
1405            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
1406                <SignedInfo><Reference URI=""/></SignedInfo>
1407                <SignatureValue>abc</SignatureValue>
1408            </Signature>
1409        </root>"#;
1410        let doc = Document::parse(xml).unwrap();
1411
1412        // Find the Signature element
1413        let sig_node = doc
1414            .descendants()
1415            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1416            .unwrap();
1417
1418        // Start with entire document without comments (empty URI)
1419        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
1420        let data = TransformData::NodeSet(node_set);
1421
1422        // Apply enveloped transform
1423        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
1424        let node_set = result.into_node_set().unwrap();
1425
1426        // Root and data should be in the set
1427        assert!(node_set.contains(doc.root_element()));
1428        let data_elem = doc
1429            .descendants()
1430            .find(|n| n.is_element() && n.tag_name().name() == "data")
1431            .unwrap();
1432        assert!(node_set.contains(data_elem));
1433
1434        // Signature and its children should be excluded
1435        assert!(
1436            !node_set.contains(sig_node),
1437            "Signature element should be excluded"
1438        );
1439        let signed_info = doc
1440            .descendants()
1441            .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
1442            .unwrap();
1443        assert!(
1444            !node_set.contains(signed_info),
1445            "SignedInfo (child of Signature) should be excluded"
1446        );
1447    }
1448
1449    #[test]
1450    fn enveloped_requires_node_set_input() {
1451        let xml = "<root/>";
1452        let doc = Document::parse(xml).unwrap();
1453        // Binary input should fail with TypeMismatch
1454        let data = TransformData::Binary(vec![1, 2, 3]);
1455        let result = apply_transform(doc.root_element(), &Transform::Enveloped, data);
1456        assert!(result.is_err());
1457        match result.unwrap_err() {
1458            TransformError::TypeMismatch { expected, got } => {
1459                assert_eq!(expected, "NodeSet");
1460                assert_eq!(got, "Binary");
1461            }
1462            other => panic!("expected TypeMismatch, got: {other:?}"),
1463        }
1464    }
1465
1466    #[test]
1467    fn enveloped_rejects_cross_document_signature_node() {
1468        // Signature node from a different Document must be rejected,
1469        // not silently used to exclude wrong subtree.
1470        let xml = r#"<Root><Signature Id="sig"/></Root>"#;
1471        let doc1 = Document::parse(xml).unwrap();
1472        let doc2 = Document::parse(xml).unwrap();
1473
1474        // NodeSet from doc1, Signature node from doc2
1475        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
1476        let input = TransformData::NodeSet(node_set);
1477        let sig_from_doc2 = doc2
1478            .descendants()
1479            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1480            .unwrap();
1481
1482        let result = apply_transform(sig_from_doc2, &Transform::Enveloped, input);
1483        assert!(matches!(
1484            result,
1485            Err(TransformError::CrossDocumentSignatureNode)
1486        ));
1487    }
1488
1489    // ── C14N transform ───────────────────────────────────────────────
1490
1491    #[test]
1492    fn c14n_transform_produces_bytes() {
1493        let xml = r#"<root b="2" a="1"><child/></root>"#;
1494        let doc = Document::parse(xml).unwrap();
1495
1496        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
1497        let data = TransformData::NodeSet(node_set);
1498
1499        let algo =
1500            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1501        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data).unwrap();
1502
1503        let bytes = result.into_binary().unwrap();
1504        let output = String::from_utf8(bytes).unwrap();
1505        // Attributes sorted, empty element expanded
1506        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
1507    }
1508
1509    #[test]
1510    fn c14n_transform_requires_node_set() {
1511        let xml = "<root/>";
1512        let doc = Document::parse(xml).unwrap();
1513
1514        let algo =
1515            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1516        let data = TransformData::Binary(vec![1, 2, 3]);
1517        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data);
1518
1519        assert!(result.is_err());
1520        assert!(matches!(
1521            result.unwrap_err(),
1522            TransformError::TypeMismatch { .. }
1523        ));
1524    }
1525
1526    // ── Base64 transform ────────────────────────────────────────────
1527
1528    #[test]
1529    fn base64_transform_decodes_binary_with_xml_whitespace() {
1530        // XML line wrapping is insignificant to the standard transform.
1531        let doc = Document::parse("<root/>").unwrap();
1532        let input = TransformData::Binary(b" SGV\tsbG8=\r\n".to_vec());
1533
1534        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1535
1536        assert_eq!(result.into_binary().unwrap(), b"Hello");
1537    }
1538
1539    #[test]
1540    fn base64_transform_concatenates_only_selected_text_nodes_in_document_order() {
1541        // Tags, comments, and processing instructions must not enter the
1542        // encoded octet stream; descendant text remains in document order.
1543        let xml = r#"<root><Data ID="payload">SGV<!-- split --><Part>sb</Part><?pi ignored?>G8=</Data></root>"#;
1544        let doc = Document::parse(xml).unwrap();
1545        let data = doc
1546            .descendants()
1547            .find(|node| node.attribute("ID") == Some("payload"))
1548            .unwrap();
1549        let input = TransformData::NodeSet(NodeSet::subtree(data).unwrap());
1550
1551        let result = apply_transform(data, &Transform::Base64Decode, input).unwrap();
1552
1553        assert_eq!(result.into_binary().unwrap(), b"Hello");
1554    }
1555
1556    #[test]
1557    fn base64_transform_omits_text_excluded_from_the_node_set() {
1558        // A prior node-set transform can remove a subtree. Its text must not
1559        // be resurrected while converting the remaining node set to octets.
1560        let xml = "<root>SGV<Excluded>QUJD</Excluded>sbG8=</root>";
1561        let doc = Document::parse(xml).unwrap();
1562        let excluded = doc
1563            .descendants()
1564            .find(|node| node.has_tag_name("Excluded"))
1565            .unwrap();
1566        let mut nodes = NodeSet::subtree(doc.root_element()).unwrap();
1567        nodes.exclude_subtree(excluded);
1568
1569        let result = apply_transform(
1570            doc.root_element(),
1571            &Transform::Base64Decode,
1572            TransformData::NodeSet(nodes),
1573        )
1574        .unwrap();
1575
1576        assert_eq!(result.into_binary().unwrap(), b"Hello");
1577    }
1578
1579    #[test]
1580    fn base64_transform_ignores_rfc2045_non_alphabet_bytes() {
1581        let doc = Document::parse("<root/>").unwrap();
1582        let input = TransformData::Binary(b"SGVs!\xFFbG8=".to_vec());
1583
1584        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1585
1586        assert_eq!(result.into_binary().unwrap(), b"Hello");
1587    }
1588
1589    #[test]
1590    fn base64_transform_rejects_invalid_padding() {
1591        let doc = Document::parse("<root/>").unwrap();
1592        let result = apply_transform(
1593            doc.root_element(),
1594            &Transform::Base64Decode,
1595            TransformData::Binary(b"SGVsbG8===".to_vec()),
1596        );
1597
1598        assert!(matches!(result, Err(TransformError::Base64(_))));
1599    }
1600
1601    #[test]
1602    fn base64_transform_accepts_empty_input() {
1603        let doc = Document::parse("<root/>").unwrap();
1604        let result = apply_transform(
1605            doc.root_element(),
1606            &Transform::Base64Decode,
1607            TransformData::Binary(Vec::new()),
1608        )
1609        .unwrap();
1610
1611        assert!(result.into_binary().unwrap().is_empty());
1612    }
1613
1614    #[test]
1615    fn base64_transform_rejects_oversized_raw_binary_before_normalization() {
1616        // XML whitespace does not reach the normalized buffer, but scanning an
1617        // unbounded whitespace-only reference is still attacker-controlled work.
1618        let doc = Document::parse("<root/>").unwrap();
1619        let input = TransformData::Binary(vec![b' '; MAX_BASE64_TRANSFORM_INPUT_BYTES + 1]);
1620
1621        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
1622
1623        assert!(matches!(
1624            result,
1625            Err(TransformError::Base64InputTooLarge {
1626                max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES
1627            })
1628        ));
1629    }
1630
1631    #[test]
1632    fn base64_transform_rejects_node_set_that_decodes_past_output_budget() {
1633        // The output limit must be checked before the decoder allocates a
1634        // second buffer beside the normalized encoded text.
1635        let encoded_len = MAX_BASE64_TRANSFORM_OUTPUT_BYTES.div_ceil(3) * 4 + 4;
1636        let xml = format!("<root>{}</root>", "A".repeat(encoded_len));
1637        let doc = Document::parse(&xml).unwrap();
1638        let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
1639
1640        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
1641
1642        assert!(matches!(
1643            result,
1644            Err(TransformError::Base64OutputTooLarge {
1645                max_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES
1646            })
1647        ));
1648    }
1649
1650    #[test]
1651    fn base64_transform_handles_highly_fragmented_node_set_input() {
1652        // Comments can split an untrusted payload into thousands of tiny text
1653        // nodes. Normalization must retain linear allocation behavior while
1654        // preserving document-order concatenation.
1655        let expected = vec![0x42_u8; 3 * 1_024];
1656        let encoded = STANDARD.encode(&expected);
1657        let mut xml = String::from("<root>");
1658        for byte in encoded.bytes() {
1659            xml.push(char::from(byte));
1660            xml.push_str("<!-- split -->");
1661        }
1662        xml.push_str("</root>");
1663        let doc = Document::parse(&xml).unwrap();
1664        let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
1665
1666        let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1667
1668        assert_eq!(result.into_binary().unwrap(), expected);
1669    }
1670
1671    #[test]
1672    fn pipeline_rejects_cumulative_base64_input_past_budget() {
1673        // Each transform is individually under 16 MiB, but charging only the
1674        // current input permits one reference chain to exceed the total bound.
1675        let doc = Document::parse("<root/>").unwrap();
1676        let inner = vec![b'A'; MAX_BASE64_TRANSFORM_OUTPUT_BYTES];
1677        let outer = STANDARD.encode(&inner);
1678        let transforms = [Transform::Base64Decode, Transform::Base64Decode];
1679
1680        let result = execute_transforms(
1681            doc.root_element(),
1682            TransformData::Binary(outer.into_bytes()),
1683            &transforms,
1684        );
1685
1686        assert!(matches!(
1687            result,
1688            Err(TransformError::Base64InputTooLarge {
1689                max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES
1690            })
1691        ));
1692    }
1693
1694    #[test]
1695    fn pipeline_rejects_unbounded_programmatic_transform_chain() {
1696        // The public executor is a trust boundary too: callers can bypass XML
1697        // parsing and must not be able to create an arbitrarily deep recursion.
1698        let doc = Document::parse("<root/>").unwrap();
1699        let transforms = vec![Transform::Base64Decode; 65];
1700
1701        let result = execute_transforms(
1702            doc.root_element(),
1703            TransformData::Binary(Vec::new()),
1704            &transforms,
1705        );
1706
1707        assert!(matches!(
1708            result,
1709            Err(TransformError::TooManyTransforms {
1710                max: MAX_TRANSFORMS_PER_REFERENCE
1711            })
1712        ));
1713    }
1714
1715    // ── Pipeline execution ───────────────────────────────────────────
1716
1717    #[test]
1718    fn byte_budgets_remain_exhausted_after_overflow() {
1719        // An overflow is a terminal state: callers must not be able to recover
1720        // budget by following a rejected large charge with a smaller one.
1721        let c14n = C14nOutputBudget::default();
1722        assert!(c14n.charge(MAX_C14N_OUTPUT_BYTES + 1).is_err());
1723        assert!(c14n.charge(1).is_err());
1724
1725        let base64 = Base64WorkBudget::default();
1726        assert!(base64.charge(MAX_BASE64_TRANSFORM_INPUT_BYTES + 1).is_err());
1727        assert!(base64.charge(1).is_err());
1728    }
1729
1730    #[test]
1731    fn pipeline_rejects_cumulative_c14n_output() {
1732        // Every C14N result is individually moderate, but a long chain can
1733        // multiply canonicalization work and adapter-buffer retention. The
1734        // shared meter must reject their cumulative output.
1735        let xml = format!("<root>{}</root>", "x".repeat(512 * 1024));
1736        let document = Document::parse(&xml).unwrap();
1737        let algorithm =
1738            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1739        let transforms = vec![Transform::C14n(algorithm); 40];
1740
1741        let result = execute_transforms(
1742            document.root_element(),
1743            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
1744            &transforms,
1745        );
1746
1747        assert!(matches!(
1748            result,
1749            Err(TransformError::C14nOutputTooLarge {
1750                max_bytes: MAX_C14N_OUTPUT_BYTES
1751            })
1752        ));
1753    }
1754
1755    #[test]
1756    fn binary_to_node_set_adapter_uses_shared_materialization_budget() {
1757        // A binary transform result can be reparsed before a later node-set
1758        // transform. That internal adapter must not bypass the signature-wide
1759        // owned-string budget enforced by ordinary URI dereference.
1760        let signature_document = Document::parse("<Signature/>").unwrap();
1761        let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
1762        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
1763
1764        let error = execute_transforms_with_options_and_budget(
1765            signature_document.root_element(),
1766            TransformData::Binary(b"<root xmlns:n=\"urn:namespace\"/>".to_vec()),
1767            &transforms,
1768            TransformOptions::default(),
1769            &budget,
1770        )
1771        .expect_err("the binary adapter must charge cloned namespace strings");
1772
1773        assert!(matches!(
1774            error,
1775            TransformError::NodeSetCumulativeStringsTooLarge { .. }
1776        ));
1777    }
1778
1779    #[test]
1780    fn xpath_projection_uses_shared_materialization_budget() {
1781        // XPath projects exact attribute and namespace identities back into a
1782        // fresh NodeSet. Those owned keys must consume the same budget as URI
1783        // dereference and binary adapters.
1784        let document = Document::parse("<root attribute=\"value\"/>").unwrap();
1785        let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
1786        let transforms = [Transform::XPath(XPathExpression::new("true()"))];
1787
1788        let error = execute_transforms_with_options_and_budget(
1789            document.root_element(),
1790            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
1791            &transforms,
1792            TransformOptions::default(),
1793            &budget,
1794        )
1795        .expect_err("XPath projection must charge cloned attribute names");
1796
1797        assert!(matches!(
1798            error,
1799            TransformError::NodeSetCumulativeStringsTooLarge { .. }
1800        ));
1801    }
1802
1803    #[test]
1804    fn explicit_and_implicit_c14n_stop_at_the_execution_ceiling() {
1805        // Both routes must use the bounded serializer. A post-serialization
1806        // charge would return the same error but only after retaining all bytes.
1807        let xml = format!("<root>{}</root>", "x".repeat(4_096));
1808        let document = Document::parse(&xml).unwrap();
1809        let nodes = || {
1810            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1811        };
1812        let algorithm =
1813            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1814
1815        for transforms in [&[][..], &[Transform::C14n(algorithm)][..]] {
1816            let error = execute_transforms_with_options_and_budget(
1817                document.root_element(),
1818                nodes(),
1819                transforms,
1820                TransformOptions::default(),
1821                &TransformExecutionBudget::with_c14n_limit(64),
1822            )
1823            .expect_err("canonicalization must stop at the execution ceiling");
1824
1825            assert!(matches!(
1826                error,
1827                TransformError::C14nOutputTooLarge {
1828                    max_bytes: MAX_C14N_OUTPUT_BYTES
1829                }
1830            ));
1831        }
1832    }
1833
1834    #[test]
1835    fn execution_budget_bounds_c14n_output_across_references() {
1836        // Each Reference remains below the signature-wide output ceiling, but
1837        // signing and verification share one execution budget. Repeating the
1838        // same C14N work across References must not reset that meter.
1839        let xml = format!("<root>{}</root>", "x".repeat(512 * 1024));
1840        let document = Document::parse(&xml).unwrap();
1841        let algorithm =
1842            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1843        let transforms = vec![Transform::C14n(algorithm); 20];
1844        let execution_budget = TransformExecutionBudget::default();
1845        let input = || {
1846            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1847        };
1848
1849        execute_transforms_with_options_and_budget(
1850            document.root_element(),
1851            input(),
1852            &transforms,
1853            TransformOptions::default(),
1854            &execution_budget,
1855        )
1856        .expect("the first Reference must fit the cumulative C14N output budget");
1857        let result = execute_transforms_with_options_and_budget(
1858            document.root_element(),
1859            input(),
1860            &transforms,
1861            TransformOptions::default(),
1862            &execution_budget,
1863        );
1864
1865        assert!(matches!(
1866            result,
1867            Err(TransformError::C14nOutputTooLarge {
1868                max_bytes: MAX_C14N_OUTPUT_BYTES
1869            })
1870        ));
1871    }
1872
1873    #[test]
1874    fn execution_budget_bounds_repeated_node_set_exclusions() {
1875        // Repeating an exclusion over a large node set must consume one shared
1876        // signature budget instead of permitting references to multiply full-set scans.
1877        let document =
1878            Document::parse("<root><payload/><Signature><Object/></Signature></root>").unwrap();
1879        let signature = document
1880            .descendants()
1881            .find(|node| node.has_tag_name("Signature"))
1882            .unwrap();
1883        let input = || NodeSet::entire_document_with_comments(&document).unwrap();
1884        let entries_per_exclusion = input().len();
1885        let budget = TransformExecutionBudget::with_node_filter_limit(
1886            entries_per_exclusion.saturating_mul(2).saturating_sub(1),
1887        );
1888
1889        execute_transforms_with_options_and_budget(
1890            signature,
1891            TransformData::NodeSet(input()),
1892            &[Transform::Enveloped],
1893            TransformOptions::default(),
1894            &budget,
1895        )
1896        .expect("the first reference exclusion must fit the shared budget");
1897        let result = execute_transforms_with_options_and_budget(
1898            signature,
1899            TransformData::NodeSet(input()),
1900            &[Transform::Enveloped],
1901            TransformOptions::default(),
1902            &budget,
1903        );
1904
1905        assert!(
1906            matches!(
1907                result,
1908                Err(TransformError::NodeSetFilterWorkTooLarge { .. })
1909            ),
1910            "the second reference exclusion must exhaust the shared budget"
1911        );
1912    }
1913
1914    #[test]
1915    fn execution_budget_bounds_implicit_c14n_across_references() {
1916        // References ending in node sets use implicit C14N 1.0. That terminal
1917        // coercion must share the same signature-wide byte ceiling as explicit
1918        // canonicalization transforms.
1919        let xml = format!("<root>{}</root>", "x".repeat(6 * 1024 * 1024));
1920        let document = Document::parse(&xml).unwrap();
1921        let execution_budget = TransformExecutionBudget::default();
1922        let input = || {
1923            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1924        };
1925
1926        for _ in 0..2 {
1927            execute_transforms_with_options_and_budget(
1928                document.root_element(),
1929                input(),
1930                &[],
1931                TransformOptions::default(),
1932                &execution_budget,
1933            )
1934            .expect("two implicit C14N outputs must fit the shared budget");
1935        }
1936        let result = execute_transforms_with_options_and_budget(
1937            document.root_element(),
1938            input(),
1939            &[],
1940            TransformOptions::default(),
1941            &execution_budget,
1942        );
1943
1944        assert!(matches!(
1945            result,
1946            Err(TransformError::C14nOutputTooLarge {
1947                max_bytes: MAX_C14N_OUTPUT_BYTES
1948            })
1949        ));
1950    }
1951
1952    #[test]
1953    fn pipeline_enveloped_then_c14n() {
1954        // Standard SAML transform chain: enveloped-signature → exc-c14n
1955        let xml = r#"<root xmlns:ns="http://example.com" b="2" a="1">
1956            <data>hello</data>
1957            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
1958                <SignedInfo/>
1959                <SignatureValue>abc</SignatureValue>
1960            </Signature>
1961        </root>"#;
1962        let doc = Document::parse(xml).unwrap();
1963
1964        let sig_node = doc
1965            .descendants()
1966            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1967            .unwrap();
1968
1969        let initial =
1970            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
1971        let transforms = vec![
1972            Transform::Enveloped,
1973            Transform::C14n(
1974                C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#").unwrap(),
1975            ),
1976        ];
1977
1978        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
1979
1980        let output = String::from_utf8(result).unwrap();
1981        // Signature subtree should be gone; attributes sorted
1982        assert!(!output.contains("Signature"));
1983        assert!(!output.contains("SignedInfo"));
1984        assert!(!output.contains("SignatureValue"));
1985        assert!(output.contains("<data>hello</data>"));
1986    }
1987
1988    #[test]
1989    fn pipeline_c14n_then_enveloped_remaps_the_exact_signature() {
1990        // Reparsing canonical octets creates a new Document. The adapter must
1991        // preserve which Signature owns this transform rather than removing an
1992        // arbitrary signature or rejecting the new node set as cross-document.
1993        let xml = r#"<root>
1994            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
1995            <data>hello</data>
1996            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
1997        </root>"#;
1998        let document = Document::parse(xml).unwrap();
1999        let signature = document
2000            .descendants()
2001            .find(|node| node.attribute("Id") == Some("owner"))
2002            .unwrap();
2003        let initial =
2004            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
2005        let transforms = vec![
2006            Transform::C14n(
2007                C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
2008            ),
2009            Transform::Enveloped,
2010        ];
2011
2012        let output = execute_transforms(signature, initial, &transforms).unwrap();
2013        let output = String::from_utf8(output).unwrap();
2014
2015        assert!(output.contains("Id=\"other\""));
2016        assert!(!output.contains("Id=\"owner\""));
2017        assert!(output.contains("<data>hello</data>"));
2018    }
2019
2020    #[test]
2021    fn pipeline_remaps_signature_after_xpath_removes_an_earlier_sibling() {
2022        // The identity of the owning Signature must survive canonicalization;
2023        // its source-tree sibling index is not stable after XPath filtering.
2024        let xml = r#"<root>
2025            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
2026            <discard/>
2027            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
2028        </root>"#;
2029        let document = Document::parse(xml).unwrap();
2030        let signature = document
2031            .descendants()
2032            .find(|node| node.attribute("Id") == Some("owner"))
2033            .unwrap();
2034        let initial =
2035            TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
2036        let transforms = vec![
2037            Transform::XPath(XPathExpression::new("not(self::discard)")),
2038            Transform::C14n(
2039                C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
2040            ),
2041            Transform::Enveloped,
2042        ];
2043
2044        let output = execute_transforms(signature, initial, &transforms).unwrap();
2045        let output = String::from_utf8(output).unwrap();
2046
2047        assert!(output.contains("Id=\"other\""));
2048        assert!(!output.contains("Id=\"owner\""));
2049        assert!(!output.contains("discard"));
2050    }
2051
2052    #[test]
2053    fn pipeline_enveloped_ignores_signature_absent_after_base64_adaptation() {
2054        // A binary-producing transform may replace the source document rather
2055        // than serialize it. The enveloped transform must not carry the source
2056        // Signature identity into that unrelated decoded document.
2057        let source = Document::parse(
2058            r#"<root><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"/></root>"#,
2059        )
2060        .unwrap();
2061        let signature = source
2062            .descendants()
2063            .find(|node| node.tag_name().name() == "Signature")
2064            .unwrap();
2065        let encoded = base64::engine::general_purpose::STANDARD.encode(b"<payload>ok</payload>");
2066        let transforms = vec![
2067            Transform::Base64Decode,
2068            Transform::XPath(XPathExpression::new("true()")),
2069            Transform::Enveloped,
2070        ];
2071
2072        let output = execute_transforms(
2073            signature,
2074            TransformData::Binary(encoded.into()),
2075            &transforms,
2076        )
2077        .unwrap();
2078
2079        assert_eq!(output, b"<payload>ok</payload>");
2080    }
2081
2082    #[test]
2083    fn pipeline_no_transforms_applies_default_c14n() {
2084        // No explicit transforms → pipeline falls back to inclusive C14N 1.0
2085        let xml = r#"<root b="2" a="1"><child/></root>"#;
2086        let doc = Document::parse(xml).unwrap();
2087
2088        let initial =
2089            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
2090        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
2091
2092        let output = String::from_utf8(result).unwrap();
2093        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
2094    }
2095
2096    #[test]
2097    fn pipeline_binary_passthrough() {
2098        // If initial data is already binary (unusual, but spec-compliant)
2099        // and no transforms, returns bytes directly
2100        let xml = "<root/>";
2101        let doc = Document::parse(xml).unwrap();
2102
2103        let initial = TransformData::Binary(b"raw bytes".to_vec());
2104        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
2105
2106        assert_eq!(result, b"raw bytes");
2107    }
2108
2109    // ── Nested signatures ────────────────────────────────────────────
2110
2111    #[test]
2112    fn enveloped_only_excludes_own_signature() {
2113        // Two real <Signature> elements: enveloped transform should only
2114        // exclude the specific one being verified, not the other.
2115        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2116            <data>hello</data>
2117            <ds:Signature Id="sig-other">
2118                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
2119            </ds:Signature>
2120            <ds:Signature Id="sig-target">
2121                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
2122            </ds:Signature>
2123        </root>"#;
2124        let doc = Document::parse(xml).unwrap();
2125
2126        // We are verifying sig-target, not sig-other
2127        let sig_node = doc
2128            .descendants()
2129            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-target"))
2130            .unwrap();
2131
2132        let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2133        let data = TransformData::NodeSet(node_set);
2134
2135        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
2136        let node_set = result.into_node_set().unwrap();
2137
2138        // sig-other should still be in the set
2139        let sig_other = doc
2140            .descendants()
2141            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-other"))
2142            .unwrap();
2143        assert!(
2144            node_set.contains(sig_other),
2145            "other Signature elements should NOT be excluded"
2146        );
2147
2148        // Signature should be excluded
2149        assert!(
2150            !node_set.contains(sig_node),
2151            "the specific Signature being verified should be excluded"
2152        );
2153    }
2154
2155    // ── parse_transforms ─────────────────────────────────────────────
2156
2157    #[test]
2158    fn parse_transforms_enveloped_and_exc_c14n() {
2159        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2160            <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
2161            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2162        </Transforms>"#;
2163        let doc = Document::parse(xml).unwrap();
2164        let transforms_node = doc.root_element();
2165
2166        let chain = parse_transforms(transforms_node).unwrap();
2167        assert_eq!(chain.len(), 2);
2168        assert!(matches!(chain[0], Transform::Enveloped));
2169        assert!(matches!(chain[1], Transform::C14n(_)));
2170    }
2171
2172    #[test]
2173    fn parse_transforms_rejects_unbounded_chain() {
2174        // Signed XML is untrusted input; reject excess transforms before
2175        // constructing a chain that would consume one stack frame per entry.
2176        let entries = format!(r#"<Transform Algorithm="{BASE64_TRANSFORM_URI}"/>"#).repeat(65);
2177        let xml = format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{entries}</Transforms>"#);
2178        let doc = Document::parse(&xml).unwrap();
2179
2180        assert!(matches!(
2181            parse_transforms(doc.root_element()),
2182            Err(TransformError::TooManyTransforms {
2183                max: MAX_TRANSFORMS_PER_REFERENCE
2184            })
2185        ));
2186    }
2187
2188    #[test]
2189    fn parse_transforms_accepts_parameterless_base64() {
2190        let xml = format!(
2191            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">
2192            </Transform></Transforms>"#
2193        );
2194        let doc = Document::parse(&xml).unwrap();
2195
2196        let chain = parse_transforms(doc.root_element()).unwrap();
2197
2198        assert_eq!(chain.len(), 1);
2199        assert!(matches!(chain[0], Transform::Base64Decode));
2200    }
2201
2202    #[test]
2203    fn parse_transforms_rejects_base64_parameters() {
2204        for parameter in ["<Parameter/>", "unexpected", "\u{00A0}"] {
2205            let xml = format!(
2206                r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
2207            );
2208            let doc = Document::parse(&xml).unwrap();
2209
2210            let result = parse_transforms(doc.root_element());
2211
2212            assert!(matches!(
2213                result,
2214                Err(TransformError::UnsupportedTransform(_))
2215            ));
2216        }
2217    }
2218
2219    #[test]
2220    fn parse_transforms_rejects_non_xpath_boundary_whitespace() {
2221        // XPath 1.0 S excludes NBSP, so parser-level trimming must not turn
2222        // this malformed signed expression into a conforming `true()` call.
2223        let xml = format!(
2224            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath> true()</XPath></Transform></Transforms>"#
2225        );
2226        let doc = Document::parse(&xml).unwrap();
2227
2228        let result = parse_transforms(doc.root_element());
2229
2230        assert!(matches!(result, Err(TransformError::XPath(_))));
2231    }
2232
2233    #[test]
2234    fn parse_transforms_with_inclusive_prefixes() {
2235        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
2236                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
2237            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2238                <ec:InclusiveNamespaces PrefixList="ds saml #default"/>
2239            </Transform>
2240        </Transforms>"#;
2241        let doc = Document::parse(xml).unwrap();
2242        let transforms_node = doc.root_element();
2243
2244        let chain = parse_transforms(transforms_node).unwrap();
2245        assert_eq!(chain.len(), 1);
2246        match &chain[0] {
2247            Transform::C14n(algo) => {
2248                assert!(algo.inclusive_prefixes().contains("ds"));
2249                assert!(algo.inclusive_prefixes().contains("saml"));
2250                assert!(algo.inclusive_prefixes().contains("")); // #default
2251            }
2252            other => panic!("expected C14n, got: {other:?}"),
2253        }
2254    }
2255
2256    #[test]
2257    fn parse_transforms_ignores_wrong_ns_inclusive_namespaces() {
2258        // InclusiveNamespaces in a foreign namespace should be ignored —
2259        // only elements in http://www.w3.org/2001/10/xml-exc-c14n# are valid.
2260        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2261            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2262                <InclusiveNamespaces xmlns="http://example.com/fake"
2263                                     PrefixList="attacker-controlled"/>
2264            </Transform>
2265        </Transforms>"#;
2266        let doc = Document::parse(xml).unwrap();
2267
2268        let chain = parse_transforms(doc.root_element()).unwrap();
2269        assert_eq!(chain.len(), 1);
2270        match &chain[0] {
2271            Transform::C14n(algo) => {
2272                // PrefixList from wrong namespace should NOT be honoured
2273                assert!(
2274                    algo.inclusive_prefixes().is_empty(),
2275                    "should ignore InclusiveNamespaces in wrong namespace"
2276                );
2277            }
2278            other => panic!("expected C14n, got: {other:?}"),
2279        }
2280    }
2281
2282    #[test]
2283    fn parse_transforms_missing_prefix_list_is_error() {
2284        // InclusiveNamespaces in correct namespace but without PrefixList
2285        // attribute should be rejected (fail-closed), not silently ignored.
2286        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
2287                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
2288            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2289                <ec:InclusiveNamespaces/>
2290            </Transform>
2291        </Transforms>"#;
2292        let doc = Document::parse(xml).unwrap();
2293
2294        let result = parse_transforms(doc.root_element());
2295        assert!(result.is_err());
2296        assert!(matches!(
2297            result.unwrap_err(),
2298            TransformError::UnsupportedTransform(_)
2299        ));
2300    }
2301
2302    #[test]
2303    fn parse_transforms_unsupported_algorithm() {
2304        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2305            <Transform Algorithm="http://example.com/unknown"/>
2306        </Transforms>"#;
2307        let doc = Document::parse(xml).unwrap();
2308
2309        let result = parse_transforms(doc.root_element());
2310        assert!(result.is_err());
2311        assert!(matches!(
2312            result.unwrap_err(),
2313            TransformError::UnsupportedTransform(_)
2314        ));
2315    }
2316
2317    #[test]
2318    fn parse_transforms_missing_algorithm() {
2319        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2320            <Transform/>
2321        </Transforms>"#;
2322        let doc = Document::parse(xml).unwrap();
2323
2324        let result = parse_transforms(doc.root_element());
2325        assert!(result.is_err());
2326        assert!(matches!(
2327            result.unwrap_err(),
2328            TransformError::UnsupportedTransform(_)
2329        ));
2330    }
2331
2332    #[test]
2333    fn parse_transforms_empty() {
2334        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"/>"#;
2335        let doc = Document::parse(xml).unwrap();
2336
2337        let chain = parse_transforms(doc.root_element()).unwrap();
2338        assert!(chain.is_empty());
2339    }
2340
2341    #[test]
2342    fn parse_transforms_accepts_enveloped_compat_xpath() {
2343        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2344            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2345                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2346                    not(ancestor-or-self::dsig:Signature)
2347                </XPath>
2348            </Transform>
2349        </Transforms>"#;
2350        let doc = Document::parse(xml).unwrap();
2351
2352        let chain = parse_transforms(doc.root_element()).unwrap();
2353        assert_eq!(chain.len(), 1);
2354        assert!(matches!(chain[0], Transform::XpathExcludeAllSignatures));
2355    }
2356
2357    #[test]
2358    fn parse_transforms_accepts_general_xpath_expressions() {
2359        // XPath 1.0 is no longer restricted to the historical enveloped-
2360        // signature compatibility expression.
2361        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2362            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2363                <XPath>self::node()</XPath>
2364            </Transform>
2365        </Transforms>"#;
2366        let doc = Document::parse(xml).unwrap();
2367
2368        let result = parse_transforms(doc.root_element()).unwrap();
2369        assert!(matches!(result.as_slice(), [Transform::XPath(_)]));
2370    }
2371
2372    #[test]
2373    fn parse_xpath_transform_ignores_comments_and_processing_instructions() {
2374        // Comments and PIs are not transform parameters and may surround the
2375        // required XPath element in an otherwise valid signature.
2376        let xml = format!(
2377            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><!-- before --><?probe value?><XPath>true()</XPath><!-- after --><?done?></Transform></Transforms>"#
2378        );
2379        let doc = Document::parse(&xml).unwrap();
2380
2381        let transforms = parse_transforms(doc.root_element()).unwrap();
2382
2383        assert!(matches!(transforms.as_slice(), [Transform::XPath(_)]));
2384    }
2385
2386    #[test]
2387    fn parse_filter2_transform_ignores_comments_and_processing_instructions() {
2388        // Filter 2.0 has the same XML comment/PI treatment while retaining its
2389        // stricter element and attribute grammar.
2390        let xml = format!(
2391            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>"#
2392        );
2393        let doc = Document::parse(&xml).unwrap();
2394
2395        let transforms = parse_transforms(doc.root_element()).unwrap();
2396
2397        assert!(matches!(
2398            transforms.as_slice(),
2399            [Transform::XPathFilter2(filters)] if filters.len() == 1
2400        ));
2401    }
2402
2403    #[test]
2404    fn parse_transforms_bounds_raw_xpath_parameter_text() {
2405        // Trimming must not let an untrusted parameter force allocation of an
2406        // otherwise bounded expression-sized buffer.
2407        let padding = " ".repeat(MAX_XPATH_EXPRESSION_BYTES);
2408        let xml = format!(
2409            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath>{padding}true()</XPath></Transform></Transforms>"#
2410        );
2411        let doc = Document::parse(&xml).unwrap();
2412
2413        let error = parse_transforms(doc.root_element())
2414            .expect_err("raw XPath parameter text must obey the expression bound");
2415
2416        assert!(matches!(error, TransformError::XPath(_)));
2417        assert!(error.to_string().contains("exceeds"));
2418    }
2419
2420    #[test]
2421    fn parse_transforms_bounds_cumulative_xpath_namespace_storage() {
2422        // In-scope bindings are copied into every Filter 2.0 expression, so a
2423        // chain-level budget must reject their multiplicative amplification.
2424        let declarations = (0..32)
2425            .map(|index| {
2426                format!(
2427                    "xmlns:n{index}=\"urn:namespace:{index}:{}\"",
2428                    "x".repeat(64)
2429                )
2430            })
2431            .collect::<Vec<_>>()
2432            .join(" ");
2433        let filters = (0..MAX_XPATH_FILTERS)
2434            .map(|_| {
2435                format!(
2436                    r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
2437                )
2438            })
2439            .collect::<String>();
2440        let xml = format!(
2441            r#"<Transforms xmlns="{XMLDSIG_NS}" {declarations}><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform></Transforms>"#
2442        );
2443        let doc = Document::parse(&xml).unwrap();
2444
2445        let error = parse_transforms(doc.root_element())
2446            .expect_err("cumulative XPath namespace storage must be bounded");
2447
2448        assert!(error.to_string().contains("namespace binding budget"));
2449    }
2450
2451    #[test]
2452    fn parse_transforms_rejects_xpath_in_wrong_namespace() {
2453        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2454            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2455                <foo:XPath xmlns:foo="http://example.com/ns">
2456                    not(ancestor-or-self::dsig:Signature)
2457                </foo:XPath>
2458            </Transform>
2459        </Transforms>"#;
2460        let doc = Document::parse(xml).unwrap();
2461
2462        let result = parse_transforms(doc.root_element());
2463        assert!(result.is_err());
2464        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2465    }
2466
2467    #[test]
2468    fn parse_transforms_preserves_nonstandard_prefix_bindings() {
2469        // A prefix URI is expression data. Binding `dsig` to another namespace
2470        // is valid XPath and must select that namespace rather than being
2471        // rewritten to XMLDSig by the parser.
2472        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2473            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2474                <XPath xmlns:dsig="http://example.com/not-xmldsig">
2475                    not(ancestor-or-self::dsig:Signature)
2476                </XPath>
2477            </Transform>
2478        </Transforms>"#;
2479        let doc = Document::parse(xml).unwrap();
2480
2481        let result = parse_transforms(doc.root_element()).unwrap();
2482        let [Transform::XPath(xpath)] = result.as_slice() else {
2483            panic!("expected general XPath transform");
2484        };
2485        assert_eq!(
2486            xpath.namespaces().get("dsig").map(String::as_str),
2487            Some("http://example.com/not-xmldsig")
2488        );
2489    }
2490
2491    #[test]
2492    fn parse_transforms_rejects_xpath_with_internal_whitespace_mutation() {
2493        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2494            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2495                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2496                    not(ancestor-or-self::dsig:Signa ture)
2497                </XPath>
2498            </Transform>
2499        </Transforms>"#;
2500        let doc = Document::parse(xml).unwrap();
2501
2502        let result = parse_transforms(doc.root_element());
2503        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2504    }
2505
2506    #[test]
2507    fn parse_transforms_rejects_multiple_xpath_children() {
2508        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2509            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2510                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2511                    not(ancestor-or-self::dsig:Signature)
2512                </XPath>
2513                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2514                    not(ancestor-or-self::dsig:Signature)
2515                </XPath>
2516            </Transform>
2517        </Transforms>"#;
2518        let doc = Document::parse(xml).unwrap();
2519
2520        let result = parse_transforms(doc.root_element());
2521        assert!(result.is_err());
2522        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2523    }
2524
2525    #[test]
2526    fn parse_transforms_rejects_non_xpath_element_children() {
2527        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2528            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2529                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2530                    not(ancestor-or-self::dsig:Signature)
2531                </XPath>
2532                <Extra/>
2533            </Transform>
2534        </Transforms>"#;
2535        let doc = Document::parse(xml).unwrap();
2536
2537        let result = parse_transforms(doc.root_element());
2538        assert!(result.is_err());
2539        assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2540    }
2541
2542    #[test]
2543    fn parse_transforms_rejects_malformed_xpath_filter2_parameters() {
2544        // Filter 2.0 has a deliberately narrow parameter grammar. Rejecting
2545        // malformed variants prevents an unsupported parameter from being
2546        // silently ignored while computing security-sensitive digest input.
2547        for parameter in [
2548            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2">//Data</XPath>"#,
2549            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="exclude">//Data</XPath>"#,
2550            r#"<XPath xmlns="urn:wrong" Filter="intersect">//Data</XPath>"#,
2551            r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="intersect" Extra="value">//Data</XPath>"#,
2552        ] {
2553            let xml = format!(
2554                r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
2555            );
2556            let doc = Document::parse(&xml).unwrap();
2557
2558            let result = parse_transforms(doc.root_element());
2559
2560            assert!(matches!(result, Err(TransformError::XPath(_))));
2561        }
2562    }
2563
2564    #[test]
2565    fn parse_transforms_rejects_empty_xpath_filter2_sequence() {
2566        // A no-op empty filter list is not a valid Filter 2.0 transform and
2567        // must not be accepted as though the transform were absent.
2568        let xml = format!(
2569            r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"/></Transforms>"#
2570        );
2571        let doc = Document::parse(&xml).unwrap();
2572
2573        let result = parse_transforms(doc.root_element());
2574
2575        assert!(matches!(result, Err(TransformError::XPath(_))));
2576    }
2577
2578    #[test]
2579    fn parse_transform_chain_hashes_xpath_document_once() {
2580        // Every parsed XPath stores the same document provenance. A maximal
2581        // Filter 2.0 list must not rescan and hash the complete XML per entry.
2582        let filters = format!(
2583            r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
2584        )
2585        .repeat(MAX_XPATH_FILTERS);
2586        let transform = format!(
2587            r#"<Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform>"#
2588        );
2589        let xml =
2590            format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{transform}{transform}</Transforms>"#);
2591        let document = Document::parse(&xml).unwrap();
2592
2593        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2594        let transforms = parse_transforms(document.root_element()).unwrap();
2595        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2596
2597        assert_eq!(transforms.len(), 2);
2598        assert!(transforms.iter().all(
2599            |transform| matches!(transform, Transform::XPathFilter2(filters) if filters.len() == MAX_XPATH_FILTERS)
2600        ));
2601        assert_eq!(
2602            computations, 1,
2603            "one parsed transform chain must hash its source document once"
2604        );
2605    }
2606
2607    #[test]
2608    fn xpath_compat_excludes_other_signature_subtrees_too() {
2609        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2610            <payload>keep-me</payload>
2611            <ds:Signature Id="sig-1">
2612                <ds:SignedInfo/>
2613                <ds:SignatureValue>one</ds:SignatureValue>
2614            </ds:Signature>
2615            <ds:Signature Id="sig-2">
2616                <ds:SignedInfo/>
2617                <ds:SignatureValue>two</ds:SignatureValue>
2618            </ds:Signature>
2619        </root>"#;
2620        let doc = Document::parse(xml).unwrap();
2621        let signature_nodes: Vec<_> = doc
2622            .descendants()
2623            .filter(|node| {
2624                node.is_element()
2625                    && node.tag_name().name() == "Signature"
2626                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
2627            })
2628            .collect();
2629        let sig_node = signature_nodes[0];
2630
2631        let enveloped = execute_transforms(
2632            sig_node,
2633            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
2634            &[
2635                Transform::Enveloped,
2636                Transform::C14n(C14nAlgorithm::new(
2637                    crate::c14n::C14nMode::Inclusive1_0,
2638                    false,
2639                )),
2640            ],
2641        )
2642        .unwrap();
2643        let xpath_compat = execute_transforms(
2644            sig_node,
2645            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
2646            &[
2647                Transform::XpathExcludeAllSignatures,
2648                Transform::C14n(C14nAlgorithm::new(
2649                    crate::c14n::C14nMode::Inclusive1_0,
2650                    false,
2651                )),
2652            ],
2653        )
2654        .unwrap();
2655
2656        let enveloped = String::from_utf8(enveloped).unwrap();
2657        let xpath_compat = String::from_utf8(xpath_compat).unwrap();
2658
2659        assert!(enveloped.contains("sig-2"));
2660        assert!(!xpath_compat.contains("sig-1"));
2661        assert!(!xpath_compat.contains("sig-2"));
2662        assert!(xpath_compat.contains("keep-me"));
2663    }
2664
2665    #[test]
2666    fn parse_transforms_inclusive_c14n_variants() {
2667        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2668            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
2669            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
2670            <Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
2671        </Transforms>"#;
2672        let doc = Document::parse(xml).unwrap();
2673
2674        let chain = parse_transforms(doc.root_element()).unwrap();
2675        assert_eq!(chain.len(), 3);
2676        // All should be C14n variants
2677        for t in &chain {
2678            assert!(matches!(t, Transform::C14n(_)));
2679        }
2680    }
2681
2682    #[test]
2683    fn parsed_xpath_rejects_node_id_collision_from_another_document() {
2684        // NodeId is only a document-local index. Reusing a parsed transform
2685        // against another document must not let the same numeric id redirect
2686        // here() to an unrelated node in that document.
2687        let source = Document::parse(
2688            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>"#,
2689        )
2690        .unwrap();
2691        let transforms_node = source
2692            .descendants()
2693            .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
2694            .unwrap();
2695        let transforms = parse_transforms(transforms_node).unwrap();
2696
2697        let target = Document::parse(
2698            "<root><container><parameter><unrelated/></parameter></container></root>",
2699        )
2700        .unwrap();
2701        let error = execute_transforms(
2702            target.root_element(),
2703            TransformData::NodeSet(NodeSet::entire_document_without_comments(&target).unwrap()),
2704            &transforms,
2705        )
2706        .expect_err("parsed here() provenance must reject another XML document");
2707
2708        assert!(
2709            matches!(error, TransformError::XPath(ref message) if message.contains("same XML document"))
2710        );
2711    }
2712
2713    #[test]
2714    fn transform_chain_computes_document_identity_once() {
2715        // Parsed here() provenance needs a content hash, but every XPath step
2716        // over the same live document must reuse it rather than rehashing XML.
2717        let document = Document::parse(
2718            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>"#,
2719        )
2720        .unwrap();
2721        let transforms_node = document
2722            .descendants()
2723            .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
2724            .unwrap();
2725        let transforms = parse_transforms(transforms_node).unwrap();
2726        let signature = document
2727            .descendants()
2728            .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
2729            .unwrap();
2730        let initial = NodeSet::entire_document_without_comments(&document)
2731            .map(TransformData::NodeSet)
2732            .unwrap();
2733
2734        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2735        execute_transforms(signature, initial, &transforms).unwrap();
2736        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2737
2738        assert_eq!(
2739            computations, 1,
2740            "one live document must be hashed once per chain"
2741        );
2742    }
2743
2744    #[test]
2745    fn transform_chain_state_keys_identity_by_document() {
2746        // The cache must defend its own document association rather than rely
2747        // exclusively on every caller remembering explicit invalidation.
2748        let first_document = Document::parse("<first/>").unwrap();
2749        let second_document = Document::parse("<second/>").unwrap();
2750        let state = TransformChainState::default();
2751
2752        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2753        let first_identity = state.xpath_document_identity(&first_document);
2754        let second_identity = state.xpath_document_identity(&second_document);
2755        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2756
2757        assert_ne!(first_identity, second_identity);
2758        assert_eq!(
2759            computations, 2,
2760            "each distinct live document must receive its own cached identity"
2761        );
2762    }
2763
2764    #[test]
2765    fn template_xpath_skips_document_identity_hash() {
2766        // Builder-created expressions have no document-local here() node IDs,
2767        // so provenance validation must not scan and hash the input XML.
2768        let document = Document::parse("<root><value/></root>").unwrap();
2769        let transforms = [
2770            Transform::XPath(XPathExpression::new("true()")),
2771            Transform::XPath(XPathExpression::new("true()")),
2772        ];
2773        let initial = NodeSet::entire_document_without_comments(&document)
2774            .map(TransformData::NodeSet)
2775            .unwrap();
2776
2777        XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2778        execute_transforms(document.root_element(), initial, &transforms).unwrap();
2779        let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2780
2781        assert_eq!(
2782            computations, 0,
2783            "XPath without parsed here() provenance must not hash XML"
2784        );
2785    }
2786
2787    // ── Integration: SAML-like full pipeline ─────────────────────────
2788
2789    #[test]
2790    fn saml_enveloped_signature_full_pipeline() {
2791        // Realistic SAML Response with enveloped signature
2792        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
2793                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
2794                                     ID="_resp1">
2795            <saml:Assertion ID="_assert1">
2796                <saml:Subject>user@example.com</saml:Subject>
2797            </saml:Assertion>
2798            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2799                <ds:SignedInfo>
2800                    <ds:Reference URI="">
2801                        <ds:Transforms>
2802                            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
2803                            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2804                        </ds:Transforms>
2805                    </ds:Reference>
2806                </ds:SignedInfo>
2807                <ds:SignatureValue>fakesig==</ds:SignatureValue>
2808            </ds:Signature>
2809        </samlp:Response>"#;
2810        let doc = Document::parse(xml).unwrap();
2811
2812        // Find the Signature element
2813        let sig_node = doc
2814            .descendants()
2815            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2816            .unwrap();
2817
2818        // Parse the transforms from the XML
2819        let reference = doc
2820            .descendants()
2821            .find(|n| n.is_element() && n.tag_name().name() == "Reference")
2822            .unwrap();
2823        let transforms_elem = reference
2824            .children()
2825            .find(|n| n.is_element() && n.tag_name().name() == "Transforms")
2826            .unwrap();
2827        let transforms = parse_transforms(transforms_elem).unwrap();
2828        assert_eq!(transforms.len(), 2);
2829
2830        // Execute the pipeline with empty URI (entire document)
2831        let initial =
2832            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
2833        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
2834
2835        let output = String::from_utf8(result).unwrap();
2836
2837        // Signature subtree must be completely absent
2838        assert!(!output.contains("Signature"), "Signature should be removed");
2839        assert!(
2840            !output.contains("SignedInfo"),
2841            "SignedInfo should be removed"
2842        );
2843        assert!(
2844            !output.contains("SignatureValue"),
2845            "SignatureValue should be removed"
2846        );
2847        assert!(
2848            !output.contains("fakesig"),
2849            "signature value should be removed"
2850        );
2851
2852        // Document content should be present and canonicalized
2853        assert!(output.contains("samlp:Response"));
2854        assert!(output.contains("saml:Assertion"));
2855        assert!(output.contains("user@example.com"));
2856    }
2857}