Skip to main content

xml_sec/xmldsig/
transforms.rs

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