Skip to main content

xml_sec/xmldsig/
transforms.rs

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