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