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