Skip to main content

xml_sec/xmldsig/
transforms.rs

1//! Transform pipeline for XMLDSig `<Reference>` processing.
2//!
3//! Implements [XMLDSig §6.6](https://www.w3.org/TR/xmldsig-core1/#sec-Transforms):
4//! each `<Reference>` specifies a chain of transforms applied sequentially to
5//! produce bytes for digest computation.
6//!
7//! The pipeline is a simple `Vec<Transform>` iterated front-to-back — a dramatic
8//! simplification of xmlsec1's bidirectional push/pop doubly-linked list with
9//! auto-inserted type adapters.
10//!
11//! ## Supported transforms
12//!
13//! | Transform | Direction | Priority |
14//! |-----------|-----------|----------|
15//! | Enveloped signature | NodeSet → NodeSet | P0 (SAML) |
16//! | Inclusive C14N 1.0/1.1 | NodeSet → Binary | P0 |
17//! | Exclusive C14N 1.0 | NodeSet → Binary | P0 |
18//! | Base64 decode | Binary → Binary | P1 (future) |
19
20use roxmltree::Node;
21
22use super::parse::XMLDSIG_NS;
23use super::types::{TransformData, TransformError};
24use crate::c14n::{self, C14nAlgorithm};
25
26/// The algorithm URI for the enveloped signature transform.
27pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
28/// The algorithm URI for the XPath 1.0 transform.
29pub const XPATH_TRANSFORM_URI: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
30/// The implicit default canonicalization URI applied when no explicit C14N
31/// transform is present in a `<Reference>`.
32pub const DEFAULT_IMPLICIT_C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
33/// xmlsec1 donor vectors use this XPath expression as a compatibility form of
34/// enveloped-signature exclusion.
35const ENVELOPED_SIGNATURE_XPATH_EXPR: &str = "not(ancestor-or-self::dsig:Signature)";
36
37/// Namespace URI for Exclusive C14N `<InclusiveNamespaces>` elements.
38const EXCLUSIVE_C14N_NS_URI: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
39
40/// A single transform in the pipeline.
41#[derive(Debug, Clone)]
42pub enum Transform {
43    /// Enveloped signature: removes the `<Signature>` element subtree
44    /// that contains the `<Reference>` being processed.
45    ///
46    /// Input: `NodeSet` → Output: `NodeSet`
47    Enveloped,
48
49    /// Narrow XPath compatibility form used by some donor vectors:
50    /// `not(ancestor-or-self::dsig:Signature)`.
51    ///
52    /// Unlike `Enveloped`, this excludes every `ds:Signature` subtree in the
53    /// current document, not only the containing signature.
54    XpathExcludeAllSignatures,
55
56    /// XML Canonicalization (any supported variant).
57    ///
58    /// Input: `NodeSet` → Output: `Binary`
59    C14n(C14nAlgorithm),
60}
61
62/// Apply a single transform to the pipeline data.
63///
64/// `signature_node` is the `<Signature>` element that contains the
65/// `<Reference>` being processed. It is used by the enveloped transform
66/// to know which signature subtree to exclude. The node must belong to the
67/// same document as the `NodeSet` in `input`; a cross-document mismatch
68/// returns [`TransformError::CrossDocumentSignatureNode`].
69pub(crate) fn apply_transform<'a>(
70    signature_node: Node<'a, 'a>,
71    transform: &Transform,
72    input: TransformData<'a>,
73) -> Result<TransformData<'a>, TransformError> {
74    match transform {
75        Transform::Enveloped => {
76            let mut nodes = input.into_node_set()?;
77            // Exclude the Signature element and all its descendants from
78            // the node set. This is the core mechanism of the enveloped
79            // signature transform: the digest is computed as if the
80            // <Signature> were not present in the document.
81            //
82            // xmlsec1 equivalent:
83            //   xmlSecNodeSetGetChildren(doc, signatureNode, 1, 1)  // inverted tree
84            //   xmlSecNodeSetAdd(inNodes, children, Intersection)   // intersect = subtract
85            if !std::ptr::eq(signature_node.document(), nodes.document()) {
86                return Err(TransformError::CrossDocumentSignatureNode);
87            }
88            nodes.exclude_subtree(signature_node);
89            Ok(TransformData::NodeSet(nodes))
90        }
91        Transform::XpathExcludeAllSignatures => {
92            let mut nodes = input.into_node_set()?;
93            let doc = nodes.document();
94
95            for node in doc.descendants().filter(|node| {
96                node.is_element()
97                    && node.tag_name().name() == "Signature"
98                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
99            }) {
100                nodes.exclude_subtree(node);
101            }
102
103            Ok(TransformData::NodeSet(nodes))
104        }
105        Transform::C14n(algo) => {
106            let nodes = input.into_node_set()?;
107            let mut output = Vec::new();
108            let predicate = |node: Node| nodes.contains(node);
109            c14n::canonicalize(nodes.document(), Some(&predicate), algo, &mut output)
110                .map_err(|e| TransformError::C14n(e.to_string()))?;
111            Ok(TransformData::Binary(output))
112        }
113    }
114}
115
116/// Execute a chain of transforms for a single `<Reference>`.
117///
118/// 1. Start with `initial_data` (from URI dereference).
119/// 2. Apply each transform sequentially.
120/// 3. If the result is still a `NodeSet`, apply default inclusive C14N 1.0
121///    to produce bytes (per [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel)).
122///
123/// Returns the final byte sequence ready for digest computation.
124pub fn execute_transforms<'a>(
125    signature_node: Node<'a, 'a>,
126    initial_data: TransformData<'a>,
127    transforms: &[Transform],
128) -> Result<Vec<u8>, TransformError> {
129    let mut data = initial_data;
130
131    for transform in transforms {
132        data = apply_transform(signature_node, transform, data)?;
133    }
134
135    // Final coercion: if the result is still a NodeSet, canonicalize with
136    // default inclusive C14N 1.0 per XMLDSig spec §4.3.3.2.
137    match data {
138        TransformData::Binary(bytes) => Ok(bytes),
139        TransformData::NodeSet(nodes) => {
140            #[expect(clippy::expect_used, reason = "hardcoded URI is a known constant")]
141            let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI)
142                .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri");
143            let mut output = Vec::new();
144            let predicate = |node: Node| nodes.contains(node);
145            c14n::canonicalize(nodes.document(), Some(&predicate), &algo, &mut output)
146                .map_err(|e| TransformError::C14n(e.to_string()))?;
147            Ok(output)
148        }
149    }
150}
151
152/// Parse a `<Transforms>` element into a `Vec<Transform>`.
153///
154/// Reads each `<Transform Algorithm="...">` child element and constructs
155/// the corresponding [`Transform`] variant. Unrecognized algorithm URIs
156/// produce an error.
157///
158/// For Exclusive C14N, also parses the optional `<InclusiveNamespaces
159/// PrefixList="...">` child element.
160pub fn parse_transforms(transforms_node: Node) -> Result<Vec<Transform>, TransformError> {
161    // Validate that we received a <ds:Transforms> element.
162    if !transforms_node.is_element() {
163        return Err(TransformError::UnsupportedTransform(
164            "expected <Transforms> element but got non-element node".into(),
165        ));
166    }
167    let transforms_tag = transforms_node.tag_name();
168    if transforms_tag.name() != "Transforms" || transforms_tag.namespace() != Some(XMLDSIG_NS) {
169        return Err(TransformError::UnsupportedTransform(
170            "expected <ds:Transforms> element in XMLDSig namespace".into(),
171        ));
172    }
173
174    let mut chain = Vec::new();
175
176    for child in transforms_node.children() {
177        if !child.is_element() {
178            continue;
179        }
180
181        // Only <ds:Transform> children are allowed; fail closed on any other element.
182        let tag = child.tag_name();
183        if tag.name() != "Transform" || tag.namespace() != Some(XMLDSIG_NS) {
184            return Err(TransformError::UnsupportedTransform(
185                "unexpected child element of <ds:Transforms>; only <ds:Transform> is allowed"
186                    .into(),
187            ));
188        }
189        let uri = child.attribute("Algorithm").ok_or_else(|| {
190            TransformError::UnsupportedTransform(
191                "missing Algorithm attribute on <Transform>".into(),
192            )
193        })?;
194
195        let transform = if uri == ENVELOPED_SIGNATURE_URI {
196            Transform::Enveloped
197        } else if uri == XPATH_TRANSFORM_URI {
198            parse_xpath_compat_transform(child)?
199        } else if let Some(mut algo) = C14nAlgorithm::from_uri(uri) {
200            // For exclusive C14N, check for InclusiveNamespaces child
201            if algo.mode() == c14n::C14nMode::Exclusive1_0
202                && let Some(prefix_list) = parse_inclusive_prefixes(child)?
203            {
204                algo = algo.with_prefix_list(&prefix_list);
205            }
206            Transform::C14n(algo)
207        } else {
208            return Err(TransformError::UnsupportedTransform(uri.to_string()));
209        };
210        chain.push(transform);
211    }
212
213    Ok(chain)
214}
215
216/// Parse the narrow XPath compatibility case we currently support.
217///
218/// We do not implement general XPath evaluation here. The only accepted form is
219/// the xmlsec1 donor-vector expression that excludes all `ds:Signature`
220/// subtrees from the current node-set.
221fn parse_xpath_compat_transform(transform_node: Node) -> Result<Transform, TransformError> {
222    let mut xpath_node = None;
223
224    for child in transform_node.children().filter(|node| node.is_element()) {
225        let tag = child.tag_name();
226        if tag.name() == "XPath" && tag.namespace() == Some(XMLDSIG_NS) {
227            if xpath_node.is_some() {
228                return Err(TransformError::UnsupportedTransform(
229                    "XPath transform must contain exactly one XMLDSig <XPath> child element".into(),
230                ));
231            }
232            xpath_node = Some(child);
233        } else {
234            return Err(TransformError::UnsupportedTransform(
235                "XPath transform allows only a single XMLDSig <XPath> child element".into(),
236            ));
237        }
238    }
239
240    let xpath_node = xpath_node.ok_or_else(|| {
241        TransformError::UnsupportedTransform(
242            "XPath transform requires a single XMLDSig <XPath> child element".into(),
243        )
244    })?;
245
246    let expr = xpath_node
247        .text()
248        .map(|text| text.trim().to_string())
249        .unwrap_or_default();
250
251    if expr == ENVELOPED_SIGNATURE_XPATH_EXPR {
252        let dsig_ns = xpath_node.lookup_namespace_uri(Some("dsig"));
253        if dsig_ns == Some(XMLDSIG_NS) {
254            Ok(Transform::XpathExcludeAllSignatures)
255        } else {
256            Err(TransformError::UnsupportedTransform(
257                "XPath compatibility form requires the `dsig` prefix to be bound to the XMLDSig namespace"
258                    .into(),
259            ))
260        }
261    } else {
262        Err(TransformError::UnsupportedTransform(
263            "unsupported XPath expression in compatibility transform; only `not(ancestor-or-self::dsig:Signature)` is supported"
264                .into(),
265        ))
266    }
267}
268
269/// Parse the `PrefixList` attribute from an `<ec:InclusiveNamespaces>` child
270/// element, if present.
271///
272/// Per the [Exclusive C14N spec](https://www.w3.org/TR/xml-exc-c14n/#def-InclusiveNamespaces-PrefixList),
273/// the element MUST be in the `http://www.w3.org/2001/10/xml-exc-c14n#` namespace.
274/// Elements with the same local name but a different namespace are ignored.
275///
276/// Returns `Ok(None)` if no `<InclusiveNamespaces>` child is present.
277/// Returns `Err` if the element exists but lacks the required `PrefixList` attribute
278/// (fail-closed: malformed control elements are rejected, not silently ignored).
279///
280/// The element is typically:
281/// ```xml
282/// <ec:InclusiveNamespaces
283///     xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"
284///     PrefixList="ds saml #default"/>
285/// ```
286fn parse_inclusive_prefixes(transform_node: Node) -> Result<Option<String>, TransformError> {
287    for child in transform_node.children() {
288        if child.is_element() {
289            let tag = child.tag_name();
290            if tag.name() == "InclusiveNamespaces" && tag.namespace() == Some(EXCLUSIVE_C14N_NS_URI)
291            {
292                let prefix_list = child.attribute("PrefixList").ok_or_else(|| {
293                    TransformError::UnsupportedTransform(
294                        "missing PrefixList attribute on <InclusiveNamespaces>".into(),
295                    )
296                })?;
297                return Ok(Some(prefix_list.to_string()));
298            }
299        }
300    }
301    Ok(None)
302}
303
304#[cfg(test)]
305#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
306mod tests {
307    use super::*;
308    use crate::xmldsig::NodeSet;
309    use roxmltree::Document;
310
311    // ── Enveloped transform ──────────────────────────────────────────
312
313    #[test]
314    fn enveloped_excludes_signature_subtree() {
315        // Simulates a SAML-like document with an enveloped signature
316        let xml = r#"<root>
317            <data>hello</data>
318            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
319                <SignedInfo><Reference URI=""/></SignedInfo>
320                <SignatureValue>abc</SignatureValue>
321            </Signature>
322        </root>"#;
323        let doc = Document::parse(xml).unwrap();
324
325        // Find the Signature element
326        let sig_node = doc
327            .descendants()
328            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
329            .unwrap();
330
331        // Start with entire document without comments (empty URI)
332        let node_set = NodeSet::entire_document_without_comments(&doc);
333        let data = TransformData::NodeSet(node_set);
334
335        // Apply enveloped transform
336        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
337        let node_set = result.into_node_set().unwrap();
338
339        // Root and data should be in the set
340        assert!(node_set.contains(doc.root_element()));
341        let data_elem = doc
342            .descendants()
343            .find(|n| n.is_element() && n.tag_name().name() == "data")
344            .unwrap();
345        assert!(node_set.contains(data_elem));
346
347        // Signature and its children should be excluded
348        assert!(
349            !node_set.contains(sig_node),
350            "Signature element should be excluded"
351        );
352        let signed_info = doc
353            .descendants()
354            .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
355            .unwrap();
356        assert!(
357            !node_set.contains(signed_info),
358            "SignedInfo (child of Signature) should be excluded"
359        );
360    }
361
362    #[test]
363    fn enveloped_requires_node_set_input() {
364        let xml = "<root/>";
365        let doc = Document::parse(xml).unwrap();
366        // Binary input should fail with TypeMismatch
367        let data = TransformData::Binary(vec![1, 2, 3]);
368        let result = apply_transform(doc.root_element(), &Transform::Enveloped, data);
369        assert!(result.is_err());
370        match result.unwrap_err() {
371            TransformError::TypeMismatch { expected, got } => {
372                assert_eq!(expected, "NodeSet");
373                assert_eq!(got, "Binary");
374            }
375            other => panic!("expected TypeMismatch, got: {other:?}"),
376        }
377    }
378
379    #[test]
380    fn enveloped_rejects_cross_document_signature_node() {
381        // Signature node from a different Document must be rejected,
382        // not silently used to exclude wrong subtree.
383        let xml = r#"<Root><Signature Id="sig"/></Root>"#;
384        let doc1 = Document::parse(xml).unwrap();
385        let doc2 = Document::parse(xml).unwrap();
386
387        // NodeSet from doc1, Signature node from doc2
388        let node_set = NodeSet::entire_document_without_comments(&doc1);
389        let input = TransformData::NodeSet(node_set);
390        let sig_from_doc2 = doc2
391            .descendants()
392            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
393            .unwrap();
394
395        let result = apply_transform(sig_from_doc2, &Transform::Enveloped, input);
396        assert!(matches!(
397            result,
398            Err(TransformError::CrossDocumentSignatureNode)
399        ));
400    }
401
402    // ── C14N transform ───────────────────────────────────────────────
403
404    #[test]
405    fn c14n_transform_produces_bytes() {
406        let xml = r#"<root b="2" a="1"><child/></root>"#;
407        let doc = Document::parse(xml).unwrap();
408
409        let node_set = NodeSet::entire_document_without_comments(&doc);
410        let data = TransformData::NodeSet(node_set);
411
412        let algo =
413            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
414        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data).unwrap();
415
416        let bytes = result.into_binary().unwrap();
417        let output = String::from_utf8(bytes).unwrap();
418        // Attributes sorted, empty element expanded
419        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
420    }
421
422    #[test]
423    fn c14n_transform_requires_node_set() {
424        let xml = "<root/>";
425        let doc = Document::parse(xml).unwrap();
426
427        let algo =
428            C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
429        let data = TransformData::Binary(vec![1, 2, 3]);
430        let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data);
431
432        assert!(result.is_err());
433        assert!(matches!(
434            result.unwrap_err(),
435            TransformError::TypeMismatch { .. }
436        ));
437    }
438
439    // ── Pipeline execution ───────────────────────────────────────────
440
441    #[test]
442    fn pipeline_enveloped_then_c14n() {
443        // Standard SAML transform chain: enveloped-signature → exc-c14n
444        let xml = r#"<root xmlns:ns="http://example.com" b="2" a="1">
445            <data>hello</data>
446            <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
447                <SignedInfo/>
448                <SignatureValue>abc</SignatureValue>
449            </Signature>
450        </root>"#;
451        let doc = Document::parse(xml).unwrap();
452
453        let sig_node = doc
454            .descendants()
455            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
456            .unwrap();
457
458        let initial = TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc));
459        let transforms = vec![
460            Transform::Enveloped,
461            Transform::C14n(
462                C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#").unwrap(),
463            ),
464        ];
465
466        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
467
468        let output = String::from_utf8(result).unwrap();
469        // Signature subtree should be gone; attributes sorted
470        assert!(!output.contains("Signature"));
471        assert!(!output.contains("SignedInfo"));
472        assert!(!output.contains("SignatureValue"));
473        assert!(output.contains("<data>hello</data>"));
474    }
475
476    #[test]
477    fn pipeline_no_transforms_applies_default_c14n() {
478        // No explicit transforms → pipeline falls back to inclusive C14N 1.0
479        let xml = r#"<root b="2" a="1"><child/></root>"#;
480        let doc = Document::parse(xml).unwrap();
481
482        let initial = TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc));
483        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
484
485        let output = String::from_utf8(result).unwrap();
486        assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
487    }
488
489    #[test]
490    fn pipeline_binary_passthrough() {
491        // If initial data is already binary (unusual, but spec-compliant)
492        // and no transforms, returns bytes directly
493        let xml = "<root/>";
494        let doc = Document::parse(xml).unwrap();
495
496        let initial = TransformData::Binary(b"raw bytes".to_vec());
497        let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
498
499        assert_eq!(result, b"raw bytes");
500    }
501
502    // ── Nested signatures ────────────────────────────────────────────
503
504    #[test]
505    fn enveloped_only_excludes_own_signature() {
506        // Two real <Signature> elements: enveloped transform should only
507        // exclude the specific one being verified, not the other.
508        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
509            <data>hello</data>
510            <ds:Signature Id="sig-other">
511                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
512            </ds:Signature>
513            <ds:Signature Id="sig-target">
514                <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
515            </ds:Signature>
516        </root>"#;
517        let doc = Document::parse(xml).unwrap();
518
519        // We are verifying sig-target, not sig-other
520        let sig_node = doc
521            .descendants()
522            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-target"))
523            .unwrap();
524
525        let node_set = NodeSet::entire_document_without_comments(&doc);
526        let data = TransformData::NodeSet(node_set);
527
528        let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
529        let node_set = result.into_node_set().unwrap();
530
531        // sig-other should still be in the set
532        let sig_other = doc
533            .descendants()
534            .find(|n| n.is_element() && n.attribute("Id") == Some("sig-other"))
535            .unwrap();
536        assert!(
537            node_set.contains(sig_other),
538            "other Signature elements should NOT be excluded"
539        );
540
541        // Signature should be excluded
542        assert!(
543            !node_set.contains(sig_node),
544            "the specific Signature being verified should be excluded"
545        );
546    }
547
548    // ── parse_transforms ─────────────────────────────────────────────
549
550    #[test]
551    fn parse_transforms_enveloped_and_exc_c14n() {
552        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
553            <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
554            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
555        </Transforms>"#;
556        let doc = Document::parse(xml).unwrap();
557        let transforms_node = doc.root_element();
558
559        let chain = parse_transforms(transforms_node).unwrap();
560        assert_eq!(chain.len(), 2);
561        assert!(matches!(chain[0], Transform::Enveloped));
562        assert!(matches!(chain[1], Transform::C14n(_)));
563    }
564
565    #[test]
566    fn parse_transforms_with_inclusive_prefixes() {
567        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
568                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
569            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
570                <ec:InclusiveNamespaces PrefixList="ds saml #default"/>
571            </Transform>
572        </Transforms>"#;
573        let doc = Document::parse(xml).unwrap();
574        let transforms_node = doc.root_element();
575
576        let chain = parse_transforms(transforms_node).unwrap();
577        assert_eq!(chain.len(), 1);
578        match &chain[0] {
579            Transform::C14n(algo) => {
580                assert!(algo.inclusive_prefixes().contains("ds"));
581                assert!(algo.inclusive_prefixes().contains("saml"));
582                assert!(algo.inclusive_prefixes().contains("")); // #default
583            }
584            other => panic!("expected C14n, got: {other:?}"),
585        }
586    }
587
588    #[test]
589    fn parse_transforms_ignores_wrong_ns_inclusive_namespaces() {
590        // InclusiveNamespaces in a foreign namespace should be ignored —
591        // only elements in http://www.w3.org/2001/10/xml-exc-c14n# are valid.
592        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
593            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
594                <InclusiveNamespaces xmlns="http://example.com/fake"
595                                     PrefixList="attacker-controlled"/>
596            </Transform>
597        </Transforms>"#;
598        let doc = Document::parse(xml).unwrap();
599
600        let chain = parse_transforms(doc.root_element()).unwrap();
601        assert_eq!(chain.len(), 1);
602        match &chain[0] {
603            Transform::C14n(algo) => {
604                // PrefixList from wrong namespace should NOT be honoured
605                assert!(
606                    algo.inclusive_prefixes().is_empty(),
607                    "should ignore InclusiveNamespaces in wrong namespace"
608                );
609            }
610            other => panic!("expected C14n, got: {other:?}"),
611        }
612    }
613
614    #[test]
615    fn parse_transforms_missing_prefix_list_is_error() {
616        // InclusiveNamespaces in correct namespace but without PrefixList
617        // attribute should be rejected (fail-closed), not silently ignored.
618        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
619                                xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
620            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
621                <ec:InclusiveNamespaces/>
622            </Transform>
623        </Transforms>"#;
624        let doc = Document::parse(xml).unwrap();
625
626        let result = parse_transforms(doc.root_element());
627        assert!(result.is_err());
628        assert!(matches!(
629            result.unwrap_err(),
630            TransformError::UnsupportedTransform(_)
631        ));
632    }
633
634    #[test]
635    fn parse_transforms_unsupported_algorithm() {
636        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
637            <Transform Algorithm="http://example.com/unknown"/>
638        </Transforms>"#;
639        let doc = Document::parse(xml).unwrap();
640
641        let result = parse_transforms(doc.root_element());
642        assert!(result.is_err());
643        assert!(matches!(
644            result.unwrap_err(),
645            TransformError::UnsupportedTransform(_)
646        ));
647    }
648
649    #[test]
650    fn parse_transforms_missing_algorithm() {
651        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
652            <Transform/>
653        </Transforms>"#;
654        let doc = Document::parse(xml).unwrap();
655
656        let result = parse_transforms(doc.root_element());
657        assert!(result.is_err());
658        assert!(matches!(
659            result.unwrap_err(),
660            TransformError::UnsupportedTransform(_)
661        ));
662    }
663
664    #[test]
665    fn parse_transforms_empty() {
666        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"/>"#;
667        let doc = Document::parse(xml).unwrap();
668
669        let chain = parse_transforms(doc.root_element()).unwrap();
670        assert!(chain.is_empty());
671    }
672
673    #[test]
674    fn parse_transforms_accepts_enveloped_compat_xpath() {
675        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
676            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
677                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
678                    not(ancestor-or-self::dsig:Signature)
679                </XPath>
680            </Transform>
681        </Transforms>"#;
682        let doc = Document::parse(xml).unwrap();
683
684        let chain = parse_transforms(doc.root_element()).unwrap();
685        assert_eq!(chain.len(), 1);
686        assert!(matches!(chain[0], Transform::XpathExcludeAllSignatures));
687    }
688
689    #[test]
690    fn parse_transforms_rejects_other_xpath_expressions() {
691        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
692            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
693                <XPath>self::node()</XPath>
694            </Transform>
695        </Transforms>"#;
696        let doc = Document::parse(xml).unwrap();
697
698        let result = parse_transforms(doc.root_element());
699        assert!(result.is_err());
700        assert!(matches!(
701            result.unwrap_err(),
702            TransformError::UnsupportedTransform(_)
703        ));
704    }
705
706    #[test]
707    fn parse_transforms_rejects_xpath_in_wrong_namespace() {
708        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
709            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
710                <foo:XPath xmlns:foo="http://example.com/ns">
711                    not(ancestor-or-self::dsig:Signature)
712                </foo:XPath>
713            </Transform>
714        </Transforms>"#;
715        let doc = Document::parse(xml).unwrap();
716
717        let result = parse_transforms(doc.root_element());
718        assert!(result.is_err());
719        assert!(matches!(
720            result.unwrap_err(),
721            TransformError::UnsupportedTransform(_)
722        ));
723    }
724
725    #[test]
726    fn parse_transforms_rejects_xpath_with_wrong_dsig_prefix_binding() {
727        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
728            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
729                <XPath xmlns:dsig="http://example.com/not-xmldsig">
730                    not(ancestor-or-self::dsig:Signature)
731                </XPath>
732            </Transform>
733        </Transforms>"#;
734        let doc = Document::parse(xml).unwrap();
735
736        let result = parse_transforms(doc.root_element());
737        assert!(result.is_err());
738        assert!(matches!(
739            result.unwrap_err(),
740            TransformError::UnsupportedTransform(_)
741        ));
742    }
743
744    #[test]
745    fn parse_transforms_rejects_xpath_with_internal_whitespace_mutation() {
746        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
747            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
748                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
749                    not(ancestor-or-self::dsig:Signa ture)
750                </XPath>
751            </Transform>
752        </Transforms>"#;
753        let doc = Document::parse(xml).unwrap();
754
755        let result = parse_transforms(doc.root_element());
756        assert!(matches!(
757            result.unwrap_err(),
758            TransformError::UnsupportedTransform(_)
759        ));
760    }
761
762    #[test]
763    fn parse_transforms_rejects_multiple_xpath_children() {
764        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
765            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
766                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
767                    not(ancestor-or-self::dsig:Signature)
768                </XPath>
769                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
770                    not(ancestor-or-self::dsig:Signature)
771                </XPath>
772            </Transform>
773        </Transforms>"#;
774        let doc = Document::parse(xml).unwrap();
775
776        let result = parse_transforms(doc.root_element());
777        assert!(result.is_err());
778        assert!(matches!(
779            result.unwrap_err(),
780            TransformError::UnsupportedTransform(_)
781        ));
782    }
783
784    #[test]
785    fn parse_transforms_rejects_non_xpath_element_children() {
786        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
787            <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
788                <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
789                    not(ancestor-or-self::dsig:Signature)
790                </XPath>
791                <Extra/>
792            </Transform>
793        </Transforms>"#;
794        let doc = Document::parse(xml).unwrap();
795
796        let result = parse_transforms(doc.root_element());
797        assert!(result.is_err());
798        assert!(matches!(
799            result.unwrap_err(),
800            TransformError::UnsupportedTransform(_)
801        ));
802    }
803
804    #[test]
805    fn xpath_compat_excludes_other_signature_subtrees_too() {
806        let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
807            <payload>keep-me</payload>
808            <ds:Signature Id="sig-1">
809                <ds:SignedInfo/>
810                <ds:SignatureValue>one</ds:SignatureValue>
811            </ds:Signature>
812            <ds:Signature Id="sig-2">
813                <ds:SignedInfo/>
814                <ds:SignatureValue>two</ds:SignatureValue>
815            </ds:Signature>
816        </root>"#;
817        let doc = Document::parse(xml).unwrap();
818        let signature_nodes: Vec<_> = doc
819            .descendants()
820            .filter(|node| {
821                node.is_element()
822                    && node.tag_name().name() == "Signature"
823                    && node.tag_name().namespace() == Some(XMLDSIG_NS)
824            })
825            .collect();
826        let sig_node = signature_nodes[0];
827
828        let enveloped = execute_transforms(
829            sig_node,
830            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc)),
831            &[
832                Transform::Enveloped,
833                Transform::C14n(C14nAlgorithm::new(
834                    crate::c14n::C14nMode::Inclusive1_0,
835                    false,
836                )),
837            ],
838        )
839        .unwrap();
840        let xpath_compat = execute_transforms(
841            sig_node,
842            TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc)),
843            &[
844                Transform::XpathExcludeAllSignatures,
845                Transform::C14n(C14nAlgorithm::new(
846                    crate::c14n::C14nMode::Inclusive1_0,
847                    false,
848                )),
849            ],
850        )
851        .unwrap();
852
853        let enveloped = String::from_utf8(enveloped).unwrap();
854        let xpath_compat = String::from_utf8(xpath_compat).unwrap();
855
856        assert!(enveloped.contains("sig-2"));
857        assert!(!xpath_compat.contains("sig-1"));
858        assert!(!xpath_compat.contains("sig-2"));
859        assert!(xpath_compat.contains("keep-me"));
860    }
861
862    #[test]
863    fn parse_transforms_inclusive_c14n_variants() {
864        let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
865            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
866            <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
867            <Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
868        </Transforms>"#;
869        let doc = Document::parse(xml).unwrap();
870
871        let chain = parse_transforms(doc.root_element()).unwrap();
872        assert_eq!(chain.len(), 3);
873        // All should be C14n variants
874        for t in &chain {
875            assert!(matches!(t, Transform::C14n(_)));
876        }
877    }
878
879    // ── Integration: SAML-like full pipeline ─────────────────────────
880
881    #[test]
882    fn saml_enveloped_signature_full_pipeline() {
883        // Realistic SAML Response with enveloped signature
884        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
885                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
886                                     ID="_resp1">
887            <saml:Assertion ID="_assert1">
888                <saml:Subject>user@example.com</saml:Subject>
889            </saml:Assertion>
890            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
891                <ds:SignedInfo>
892                    <ds:Reference URI="">
893                        <ds:Transforms>
894                            <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
895                            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
896                        </ds:Transforms>
897                    </ds:Reference>
898                </ds:SignedInfo>
899                <ds:SignatureValue>fakesig==</ds:SignatureValue>
900            </ds:Signature>
901        </samlp:Response>"#;
902        let doc = Document::parse(xml).unwrap();
903
904        // Find the Signature element
905        let sig_node = doc
906            .descendants()
907            .find(|n| n.is_element() && n.tag_name().name() == "Signature")
908            .unwrap();
909
910        // Parse the transforms from the XML
911        let reference = doc
912            .descendants()
913            .find(|n| n.is_element() && n.tag_name().name() == "Reference")
914            .unwrap();
915        let transforms_elem = reference
916            .children()
917            .find(|n| n.is_element() && n.tag_name().name() == "Transforms")
918            .unwrap();
919        let transforms = parse_transforms(transforms_elem).unwrap();
920        assert_eq!(transforms.len(), 2);
921
922        // Execute the pipeline with empty URI (entire document)
923        let initial = TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc));
924        let result = execute_transforms(sig_node, initial, &transforms).unwrap();
925
926        let output = String::from_utf8(result).unwrap();
927
928        // Signature subtree must be completely absent
929        assert!(!output.contains("Signature"), "Signature should be removed");
930        assert!(
931            !output.contains("SignedInfo"),
932            "SignedInfo should be removed"
933        );
934        assert!(
935            !output.contains("SignatureValue"),
936            "SignatureValue should be removed"
937        );
938        assert!(
939            !output.contains("fakesig"),
940            "signature value should be removed"
941        );
942
943        // Document content should be present and canonicalized
944        assert!(output.contains("samlp:Response"));
945        assert!(output.contains("saml:Assertion"));
946        assert!(output.contains("user@example.com"));
947    }
948}