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