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