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