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