Skip to main content

xml_sec/c14n/
mod.rs

1//! XML Canonicalization (C14N).
2//!
3//! Implements:
4//! - [Canonical XML 1.0](https://www.w3.org/TR/xml-c14n/) (inclusive)
5//! - [Canonical XML 1.1](https://www.w3.org/TR/xml-c14n11/) (inclusive; xml:id non-inheritance and xml:base fixup)
6//! - [Exclusive XML Canonicalization 1.0](https://www.w3.org/TR/xml-exc-c14n/) (exclusive)
7//!
8//! # Example
9//!
10//! ```
11//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
12//! use xml_sec::c14n::{C14nAlgorithm, C14nMode, canonicalize_xml};
13//!
14//! let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
15//! let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
16//! let canonical = canonicalize_xml(xml, &algo)?;
17//! assert_eq!(
18//!     String::from_utf8(canonical)?,
19//!     "<root a=\"1\" b=\"2\"><empty></empty></root>"
20//! );
21//! # Ok(())
22//! # }
23//! ```
24
25mod escape;
26mod ns_common;
27pub(crate) mod ns_exclusive;
28pub(crate) mod ns_inclusive;
29pub(crate) mod prefix;
30pub(crate) mod serialize;
31mod xml_base;
32
33use std::collections::HashSet;
34
35use roxmltree::{Document, Node, NodeId};
36
37use ns_exclusive::ExclusiveNsRenderer;
38use ns_inclusive::InclusiveNsRenderer;
39#[cfg(any(feature = "xmldsig", test))]
40use serialize::CanonicalOutputLimitExceeded;
41use serialize::{
42    C14nConfig, CanonicalOutputOptions, serialize_canonical_visible_with_position,
43    serialize_canonical_visible_with_position_bounded,
44};
45
46/// C14N algorithm mode (without the comments flag).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum C14nMode {
49    /// Inclusive C14N 1.0 — all in-scope namespaces rendered.
50    Inclusive1_0,
51    /// Inclusive C14N 1.1 — like 1.0 with xml:id non-inheritance and xml:base fixup.
52    Inclusive1_1,
53    /// Exclusive C14N 1.0 — only visibly-utilized namespaces rendered.
54    Exclusive1_0,
55}
56
57/// Full C14N algorithm identifier.
58///
59/// Constructed from algorithm URIs found in `<CanonicalizationMethod>` or
60/// `<Transform>` elements.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct C14nAlgorithm {
63    mode: C14nMode,
64    with_comments: bool,
65    /// For Exclusive C14N: prefixes forced via InclusiveNamespaces PrefixList.
66    /// `"#default"` is normalized to `""` (empty string) by `with_prefix_list()`.
67    inclusive_prefixes: HashSet<String>,
68}
69
70impl C14nAlgorithm {
71    /// The canonicalization mode.
72    pub fn mode(&self) -> C14nMode {
73        self.mode
74    }
75
76    /// Whether comment nodes are preserved.
77    pub fn with_comments(&self) -> bool {
78        self.with_comments
79    }
80
81    /// Prefixes forced via InclusiveNamespaces PrefixList (exclusive C14N).
82    pub fn inclusive_prefixes(&self) -> &HashSet<String> {
83        &self.inclusive_prefixes
84    }
85
86    /// Create a new algorithm with the given mode and comments flag.
87    pub fn new(mode: C14nMode, with_comments: bool) -> Self {
88        Self {
89            mode,
90            with_comments,
91            inclusive_prefixes: HashSet::new(),
92        }
93    }
94
95    /// Parse from an algorithm URI. Returns `None` for unrecognized URIs.
96    pub fn from_uri(uri: &str) -> Option<Self> {
97        let (mode, with_comments) = match uri {
98            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" => (C14nMode::Inclusive1_0, false),
99            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments" => {
100                (C14nMode::Inclusive1_0, true)
101            }
102            "http://www.w3.org/2006/12/xml-c14n11" => (C14nMode::Inclusive1_1, false),
103            "http://www.w3.org/2006/12/xml-c14n11#WithComments" => (C14nMode::Inclusive1_1, true),
104            "http://www.w3.org/2001/10/xml-exc-c14n#" => (C14nMode::Exclusive1_0, false),
105            "http://www.w3.org/2001/10/xml-exc-c14n#WithComments" => (C14nMode::Exclusive1_0, true),
106            _ => return None,
107        };
108        Some(Self {
109            mode,
110            with_comments,
111            inclusive_prefixes: HashSet::new(),
112        })
113    }
114
115    /// Set the InclusiveNamespaces PrefixList (exclusive C14N only).
116    /// `"#default"` is normalized to empty string `""`.
117    ///
118    /// Only meaningful for [`C14nMode::Exclusive1_0`]. For inclusive modes,
119    /// the prefix list is ignored during canonicalization.
120    pub fn with_prefix_list(mut self, prefix_list: &str) -> Self {
121        self.inclusive_prefixes = prefix_list
122            .split_whitespace()
123            .map(|p| {
124                if p == "#default" {
125                    String::new()
126                } else {
127                    p.to_string()
128                }
129            })
130            .collect();
131        self
132    }
133
134    /// Get the algorithm URI for this configuration.
135    pub fn uri(&self) -> &'static str {
136        match (self.mode, self.with_comments) {
137            (C14nMode::Inclusive1_0, false) => "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
138            (C14nMode::Inclusive1_0, true) => {
139                "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"
140            }
141            (C14nMode::Inclusive1_1, false) => "http://www.w3.org/2006/12/xml-c14n11",
142            (C14nMode::Inclusive1_1, true) => "http://www.w3.org/2006/12/xml-c14n11#WithComments",
143            (C14nMode::Exclusive1_0, false) => "http://www.w3.org/2001/10/xml-exc-c14n#",
144            (C14nMode::Exclusive1_0, true) => "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
145        }
146    }
147}
148
149/// Error type for C14N operations.
150#[derive(Debug, thiserror::Error)]
151pub enum C14nError {
152    /// XML parsing error.
153    #[error("XML parse error: {0}")]
154    Parse(String),
155    /// Invalid node reference.
156    #[error("invalid node reference")]
157    InvalidNode,
158    /// Algorithm not yet implemented.
159    #[error("unsupported algorithm: {0}")]
160    UnsupportedAlgorithm(String),
161    /// I/O error.
162    #[error("I/O error: {0}")]
163    Io(#[from] std::io::Error),
164}
165
166#[cfg(any(feature = "xmldsig", test))]
167pub(crate) fn is_output_limit_error(error: &C14nError) -> bool {
168    matches!(
169        error,
170        C14nError::Io(error)
171            if error
172                .get_ref()
173                .is_some_and(|source| source.is::<CanonicalOutputLimitExceeded>())
174    )
175}
176
177/// Visibility contract for canonicalizing a precise XPath node-set.
178///
179/// XPath can select attributes and namespace nodes independently from their
180/// owner element. The public closure API predates that requirement and treats
181/// both categories as visible whenever their owner is visible; XMLDSig uses
182/// this richer crate-private contract for standards-compliant subsets.
183pub(crate) trait NodeVisibility {
184    fn contains_node(&self, node: Node<'_, '_>) -> bool;
185
186    fn contains_attribute(
187        &self,
188        owner: Node<'_, '_>,
189        namespace: Option<&str>,
190        local_name: &str,
191    ) -> bool;
192
193    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool;
194}
195
196struct ClosureVisibility<'a> {
197    predicate: &'a dyn Fn(Node<'_, '_>) -> bool,
198}
199
200impl NodeVisibility for ClosureVisibility<'_> {
201    fn contains_node(&self, node: Node<'_, '_>) -> bool {
202        (self.predicate)(node)
203    }
204
205    fn contains_attribute(
206        &self,
207        owner: Node<'_, '_>,
208        _namespace: Option<&str>,
209        _local_name: &str,
210    ) -> bool {
211        (self.predicate)(owner)
212    }
213
214    fn contains_namespace(&self, owner: Node<'_, '_>, _prefix: &str, _uri: &str) -> bool {
215        (self.predicate)(owner)
216    }
217}
218
219/// Canonicalize an XML document or document subset.
220///
221/// - `doc`: parsed roxmltree document (read-only DOM).
222/// - `node_set`: optional predicate controlling which nodes appear in output.
223///   `None` means the entire document.
224/// - `algo`: algorithm parameters (mode, comments, prefix list).
225/// - `output`: byte buffer receiving canonical XML.
226pub fn canonicalize(
227    doc: &Document,
228    node_set: Option<&dyn Fn(Node) -> bool>,
229    algo: &C14nAlgorithm,
230    output: &mut Vec<u8>,
231) -> Result<(), C14nError> {
232    let visibility = node_set.map(|predicate| ClosureVisibility { predicate });
233    canonicalize_with_visibility(
234        doc,
235        visibility
236            .as_ref()
237            .map(|visibility| visibility as &dyn NodeVisibility),
238        algo,
239        output,
240    )
241}
242
243pub(crate) fn canonicalize_with_visibility(
244    doc: &Document,
245    visibility: Option<&dyn NodeVisibility>,
246    algo: &C14nAlgorithm,
247    output: &mut Vec<u8>,
248) -> Result<(), C14nError> {
249    canonicalize_with_visibility_and_position(doc, visibility, algo, None, output)?;
250    Ok(())
251}
252
253pub(crate) fn canonicalize_with_visibility_and_position(
254    doc: &Document,
255    visibility: Option<&dyn NodeVisibility>,
256    algo: &C14nAlgorithm,
257    tracked_element: Option<NodeId>,
258    output: &mut Vec<u8>,
259) -> Result<Option<usize>, C14nError> {
260    canonicalize_with_visibility_and_position_impl(
261        doc,
262        visibility,
263        algo,
264        tracked_element,
265        None,
266        output,
267    )
268}
269
270#[cfg(any(feature = "xmldsig", test))]
271pub(crate) fn canonicalize_with_visibility_and_position_bounded(
272    doc: &Document,
273    visibility: Option<&dyn NodeVisibility>,
274    algo: &C14nAlgorithm,
275    tracked_element: Option<NodeId>,
276    max_output_bytes: usize,
277    output: &mut Vec<u8>,
278) -> Result<Option<usize>, C14nError> {
279    canonicalize_with_visibility_and_position_impl(
280        doc,
281        visibility,
282        algo,
283        tracked_element,
284        Some(max_output_bytes),
285        output,
286    )
287}
288
289fn canonicalize_with_visibility_and_position_impl(
290    doc: &Document,
291    visibility: Option<&dyn NodeVisibility>,
292    algo: &C14nAlgorithm,
293    tracked_element: Option<NodeId>,
294    max_output_bytes: Option<usize>,
295    output: &mut Vec<u8>,
296) -> Result<Option<usize>, C14nError> {
297    // inherit_xml_attrs: Inclusive C14N inherits xml:* attrs from ancestors
298    // per §2.4. Exclusive C14N explicitly omits this per Exc-C14N §3.
299    // fixup_xml_base: C14N 1.1 resolves relative xml:base URIs via RFC 3986.
300    match algo.mode {
301        C14nMode::Inclusive1_0 => {
302            let renderer = InclusiveNsRenderer;
303            let config = C14nConfig {
304                inherit_xml_attrs: true,
305                fixup_xml_base: false,
306            };
307            serialize_canonical_visible_with_position_dispatch(
308                doc,
309                visibility,
310                algo.with_comments,
311                &renderer,
312                config,
313                tracked_element,
314                max_output_bytes,
315                output,
316            )
317        }
318        C14nMode::Inclusive1_1 => {
319            let renderer = InclusiveNsRenderer;
320            let config = C14nConfig {
321                inherit_xml_attrs: true,
322                fixup_xml_base: true,
323            };
324            serialize_canonical_visible_with_position_dispatch(
325                doc,
326                visibility,
327                algo.with_comments,
328                &renderer,
329                config,
330                tracked_element,
331                max_output_bytes,
332                output,
333            )
334        }
335        C14nMode::Exclusive1_0 => {
336            let renderer = ExclusiveNsRenderer::new(&algo.inclusive_prefixes);
337            let config = C14nConfig {
338                inherit_xml_attrs: false,
339                fixup_xml_base: false,
340            };
341            serialize_canonical_visible_with_position_dispatch(
342                doc,
343                visibility,
344                algo.with_comments,
345                &renderer,
346                config,
347                tracked_element,
348                max_output_bytes,
349                output,
350            )
351        }
352    }
353}
354
355#[allow(clippy::too_many_arguments)]
356fn serialize_canonical_visible_with_position_dispatch(
357    doc: &Document,
358    visibility: Option<&dyn NodeVisibility>,
359    with_comments: bool,
360    renderer: &dyn serialize::NsRenderer,
361    config: C14nConfig,
362    tracked_element: Option<NodeId>,
363    max_output_bytes: Option<usize>,
364    output: &mut Vec<u8>,
365) -> Result<Option<usize>, C14nError> {
366    match max_output_bytes {
367        Some(max_output_bytes) => serialize_canonical_visible_with_position_bounded(
368            doc,
369            visibility,
370            with_comments,
371            renderer,
372            config,
373            CanonicalOutputOptions::bounded(tracked_element, max_output_bytes),
374            output,
375        ),
376        None => serialize_canonical_visible_with_position(
377            doc,
378            visibility,
379            with_comments,
380            renderer,
381            config,
382            tracked_element,
383            output,
384        ),
385    }
386}
387
388/// Convenience: parse XML bytes and canonicalize the whole document.
389///
390/// Input must be valid UTF-8 (XML 1.0 documents are UTF-8 or declare their
391/// encoding; roxmltree only accepts UTF-8). Returns `C14nError::Parse` for
392/// invalid UTF-8 or malformed XML.
393pub fn canonicalize_xml(xml: &[u8], algo: &C14nAlgorithm) -> Result<Vec<u8>, C14nError> {
394    let xml_str =
395        std::str::from_utf8(xml).map_err(|e| C14nError::Parse(format!("invalid UTF-8: {e}")))?;
396    let doc = Document::parse(xml_str).map_err(|e| C14nError::Parse(e.to_string()))?;
397    let mut output = Vec::new();
398    canonicalize(&doc, None, algo, &mut output)?;
399    Ok(output)
400}
401
402#[cfg(test)]
403#[allow(clippy::unwrap_used)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn from_uri_roundtrip() {
409        let uris = [
410            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
411            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments",
412            "http://www.w3.org/2006/12/xml-c14n11",
413            "http://www.w3.org/2006/12/xml-c14n11#WithComments",
414            "http://www.w3.org/2001/10/xml-exc-c14n#",
415            "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
416        ];
417        for uri in uris {
418            let algo = C14nAlgorithm::from_uri(uri).expect(uri);
419            assert_eq!(algo.uri(), uri);
420        }
421    }
422
423    #[test]
424    fn unknown_uri_returns_none() {
425        assert!(C14nAlgorithm::from_uri("http://example.com/unknown").is_none());
426    }
427
428    #[test]
429    fn prefix_list_parsing() {
430        let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, false)
431            .with_prefix_list("foo bar #default baz");
432        assert!(algo.inclusive_prefixes.contains("foo"));
433        assert!(algo.inclusive_prefixes.contains("bar"));
434        assert!(algo.inclusive_prefixes.contains("baz"));
435        assert!(algo.inclusive_prefixes.contains("")); // #default → ""
436        assert_eq!(algo.inclusive_prefixes.len(), 4);
437    }
438
439    #[test]
440    fn canonicalize_xml_basic() {
441        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
442        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
443        let result = canonicalize_xml(xml, &algo).expect("c14n");
444        assert_eq!(
445            String::from_utf8(result).expect("utf8"),
446            r#"<root a="1" b="2"><empty></empty></root>"#
447        );
448    }
449
450    #[test]
451    fn c14n_1_1_basic() {
452        // C14N 1.1 serialization is identical to 1.0 for full documents.
453        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
454        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
455        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1");
456        assert_eq!(
457            String::from_utf8(result).expect("utf8"),
458            r#"<root a="1" b="2"><empty></empty></root>"#
459        );
460    }
461
462    #[test]
463    fn c14n_1_1_with_comments() {
464        let xml = b"<root><!-- comment -->text</root>";
465        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, true);
466        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 with comments");
467        assert_eq!(
468            String::from_utf8(result).expect("utf8"),
469            "<root><!-- comment -->text</root>"
470        );
471    }
472
473    #[test]
474    fn c14n_1_1_without_comments() {
475        let xml = b"<root><!-- comment -->text</root>";
476        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
477        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 without comments");
478        assert_eq!(
479            String::from_utf8(result).expect("utf8"),
480            "<root>text</root>"
481        );
482    }
483
484    #[test]
485    fn c14n_1_1_namespaces() {
486        // C14N 1.1 renders all in-scope namespaces like 1.0.
487        let xml = b"<root xmlns:a=\"http://a\" xmlns:b=\"http://b\"><child/></root>";
488        let algo_10 = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
489        let algo_11 = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
490        let result_10 = canonicalize_xml(xml, &algo_10).expect("1.0");
491        let result_11 = canonicalize_xml(xml, &algo_11).expect("1.1");
492        // For full documents, 1.0 and 1.1 produce identical output.
493        assert_eq!(result_10, result_11);
494    }
495
496    #[test]
497    fn c14n_1_1_xml_id_is_not_inherited_in_subset() {
498        // C14N 1.1 explicitly excludes xml:id from simple inheritable
499        // attributes, so omitting its owner must also omit the attribute.
500        use roxmltree::Document;
501        use std::collections::HashSet;
502
503        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
504        let doc = Document::parse(xml).expect("parse");
505        let child = doc.root_element().first_element_child().expect("child");
506
507        // Build subset: child + its descendants, excluding root
508        let mut ids = HashSet::new();
509        let mut stack = vec![child];
510        while let Some(n) = stack.pop() {
511            ids.insert(n.id());
512            for c in n.children() {
513                stack.push(c);
514            }
515        }
516        let pred = move |n: roxmltree::Node| ids.contains(&n.id());
517
518        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
519        let mut out = Vec::new();
520        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.1 subset");
521        let result = String::from_utf8(out).expect("utf8");
522
523        assert!(
524            !result.contains(r#"xml:id="r1""#),
525            "xml:id must not be inherited in C14N 1.1 subset; got: {result}"
526        );
527    }
528
529    #[test]
530    fn c14n_1_0_xml_id_is_inherited_in_subset() {
531        // C14N 1.0 predates the C14N 1.1 xml:id exception, so xml:id follows
532        // the general xml:* apex inheritance rule in a document subset.
533        use roxmltree::Document;
534        use std::collections::HashSet;
535
536        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
537        let doc = Document::parse(xml).expect("parse");
538        let child = doc.root_element().first_element_child().expect("child");
539        let ids = child
540            .descendants()
541            .map(|node| node.id())
542            .collect::<HashSet<_>>();
543        let pred = move |node: roxmltree::Node| ids.contains(&node.id());
544
545        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
546        let mut out = Vec::new();
547        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.0 subset");
548
549        assert_eq!(
550            String::from_utf8(out).expect("utf8"),
551            r#"<child xml:id="r1">text</child>"#
552        );
553    }
554
555    #[test]
556    fn bounded_canonicalization_stops_before_exceeding_the_limit() {
557        // XMLDSig applies a signature-wide output ceiling. The serializer must
558        // stop at that ceiling instead of allocating the complete hostile value
559        // and rejecting it only after serialization has finished.
560        let xml = format!("<root>{}</root>", "x".repeat(4_096));
561        let document = Document::parse(&xml).expect("fixed XML must parse");
562        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
563        let mut output = Vec::new();
564
565        let error = canonicalize_with_visibility_and_position_bounded(
566            &document,
567            None,
568            &algorithm,
569            None,
570            64,
571            &mut output,
572        )
573        .expect_err("canonicalization must stop at the caller's byte ceiling");
574
575        assert!(is_output_limit_error(&error));
576        assert!(
577            output.len() <= 64,
578            "bounded output grew to {} bytes",
579            output.len()
580        );
581    }
582}