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