Skip to main content

xml_sec/xmldsig/
transforms.rs

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