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;
26#[cfg(feature = "xmldsig")]
27pub(crate) use escape::escape_attr;
28mod ns_common;
29pub(crate) mod ns_exclusive;
30pub(crate) mod ns_inclusive;
31pub(crate) mod prefix;
32pub(crate) mod serialize;
33pub(crate) mod xml_base;
34
35use std::collections::HashSet;
36
37use crate::xml::dom::{Document, Node, NodeId};
38
39use ns_exclusive::ExclusiveNsRenderer;
40use ns_inclusive::InclusiveNsRenderer;
41#[cfg(any(feature = "xmldsig", test))]
42use serialize::CanonicalOutputLimitExceeded;
43#[cfg(any(feature = "xmldsig", test))]
44use serialize::serialize_canonical_visible_with_positions_bounded;
45use serialize::{
46    C14nConfig, CanonicalOutputOptions, serialize_canonical_visible_with_position_bounded,
47    serialize_canonical_visible_with_position_with_xml_base_budget,
48};
49
50/// C14N algorithm mode (without the comments flag).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum C14nMode {
53    /// Inclusive C14N 1.0 — all in-scope namespaces rendered.
54    Inclusive1_0,
55    /// Inclusive C14N 1.1 — like 1.0 with xml:id non-inheritance and xml:base fixup.
56    Inclusive1_1,
57    /// Exclusive C14N 1.0 — only visibly-utilized namespaces rendered.
58    Exclusive1_0,
59}
60
61/// Full C14N algorithm identifier.
62///
63/// Constructed from algorithm URIs found in `<CanonicalizationMethod>` or
64/// `<Transform>` elements.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct C14nAlgorithm {
67    mode: C14nMode,
68    with_comments: bool,
69    /// For Exclusive C14N: prefixes forced via InclusiveNamespaces PrefixList.
70    /// `"#default"` is normalized to `""` (empty string) by `with_prefix_list()`.
71    inclusive_prefixes: HashSet<String>,
72}
73
74impl C14nAlgorithm {
75    /// The canonicalization mode.
76    pub fn mode(&self) -> C14nMode {
77        self.mode
78    }
79
80    /// Whether comment nodes are preserved.
81    pub fn with_comments(&self) -> bool {
82        self.with_comments
83    }
84
85    /// Prefixes forced via InclusiveNamespaces PrefixList (exclusive C14N).
86    pub fn inclusive_prefixes(&self) -> &HashSet<String> {
87        &self.inclusive_prefixes
88    }
89
90    /// Create a new algorithm with the given mode and comments flag.
91    pub fn new(mode: C14nMode, with_comments: bool) -> Self {
92        Self {
93            mode,
94            with_comments,
95            inclusive_prefixes: HashSet::new(),
96        }
97    }
98
99    /// Parse from an algorithm URI. Returns `None` for unrecognized URIs.
100    pub fn from_uri(uri: &str) -> Option<Self> {
101        let (mode, with_comments) = match uri {
102            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" => (C14nMode::Inclusive1_0, false),
103            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments" => {
104                (C14nMode::Inclusive1_0, true)
105            }
106            "http://www.w3.org/2006/12/xml-c14n11" => (C14nMode::Inclusive1_1, false),
107            "http://www.w3.org/2006/12/xml-c14n11#WithComments" => (C14nMode::Inclusive1_1, true),
108            "http://www.w3.org/2001/10/xml-exc-c14n#" => (C14nMode::Exclusive1_0, false),
109            "http://www.w3.org/2001/10/xml-exc-c14n#WithComments" => (C14nMode::Exclusive1_0, true),
110            _ => return None,
111        };
112        Some(Self {
113            mode,
114            with_comments,
115            inclusive_prefixes: HashSet::new(),
116        })
117    }
118
119    /// Set the InclusiveNamespaces PrefixList (exclusive C14N only).
120    /// `"#default"` is normalized to empty string `""`.
121    ///
122    /// Only meaningful for [`C14nMode::Exclusive1_0`]. For inclusive modes,
123    /// the prefix list is ignored during canonicalization.
124    pub fn with_prefix_list(mut self, prefix_list: &str) -> Self {
125        self.inclusive_prefixes = prefix_list
126            .split_whitespace()
127            .map(|p| {
128                if p == "#default" {
129                    String::new()
130                } else {
131                    p.to_string()
132                }
133            })
134            .collect();
135        self
136    }
137
138    /// Get the algorithm URI for this configuration.
139    pub fn uri(&self) -> &'static str {
140        match (self.mode, self.with_comments) {
141            (C14nMode::Inclusive1_0, false) => "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
142            (C14nMode::Inclusive1_0, true) => {
143                "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"
144            }
145            (C14nMode::Inclusive1_1, false) => "http://www.w3.org/2006/12/xml-c14n11",
146            (C14nMode::Inclusive1_1, true) => "http://www.w3.org/2006/12/xml-c14n11#WithComments",
147            (C14nMode::Exclusive1_0, false) => "http://www.w3.org/2001/10/xml-exc-c14n#",
148            (C14nMode::Exclusive1_0, true) => "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
149        }
150    }
151}
152
153/// Error type for C14N operations.
154#[derive(Debug, thiserror::Error)]
155pub enum C14nError {
156    /// XML parsing error.
157    #[error("XML parse error: {0}")]
158    Parse(String),
159    /// Invalid node reference.
160    #[error("invalid node reference")]
161    InvalidNode,
162    /// Algorithm not yet implemented.
163    #[error("unsupported algorithm: {0}")]
164    UnsupportedAlgorithm(String),
165    /// The inherited `xml:base` chain exceeds the configured component limit.
166    #[error("XML Base resolution exceeds maximum of {max} inherited components: got {actual}")]
167    XmlBaseComponentsTooLarge {
168        /// Configured maximum inherited components.
169        max: usize,
170        /// Number of inherited components encountered.
171        actual: usize,
172    },
173    /// Cumulative `xml:base` resolution work exceeds the configured byte limit.
174    #[error("XML Base resolution exceeds maximum of {max_bytes} bytes: got at least {actual}")]
175    XmlBaseResolutionTooLarge {
176        /// Configured maximum cumulative bytes.
177        max_bytes: usize,
178        /// Minimum cumulative byte count that exceeded the maximum.
179        actual: usize,
180    },
181    /// I/O error.
182    #[error("I/O error: {0}")]
183    Io(#[from] std::io::Error),
184}
185
186#[cfg(any(feature = "xmldsig", test))]
187pub(crate) fn is_output_limit_error(error: &C14nError) -> bool {
188    matches!(
189        error,
190        C14nError::Io(error)
191            if error
192                .get_ref()
193                .is_some_and(|source| source.is::<CanonicalOutputLimitExceeded>())
194    )
195}
196
197/// Visibility contract for canonicalizing a precise XPath node-set.
198///
199/// XPath can select attributes and namespace nodes independently from their
200/// owner element. The public closure API predates that requirement and treats
201/// both categories as visible whenever their owner is visible; XMLDSig uses
202/// this richer crate-private contract for standards-compliant subsets.
203pub(crate) trait NodeVisibility {
204    fn contains_node(&self, node: Node<'_, '_>) -> bool;
205
206    fn contains_attribute(
207        &self,
208        owner: Node<'_, '_>,
209        namespace: Option<&str>,
210        local_name: &str,
211    ) -> bool;
212
213    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool;
214}
215
216struct ClosureVisibility<'a> {
217    predicate: &'a dyn Fn(Node<'_, '_>) -> bool,
218}
219
220impl NodeVisibility for ClosureVisibility<'_> {
221    fn contains_node(&self, node: Node<'_, '_>) -> bool {
222        (self.predicate)(node)
223    }
224
225    fn contains_attribute(
226        &self,
227        owner: Node<'_, '_>,
228        _namespace: Option<&str>,
229        _local_name: &str,
230    ) -> bool {
231        (self.predicate)(owner)
232    }
233
234    fn contains_namespace(&self, owner: Node<'_, '_>, _prefix: &str, _uri: &str) -> bool {
235        (self.predicate)(owner)
236    }
237}
238
239/// Canonicalize an XML document or document subset.
240///
241/// - `doc`: parsed roxmltree document (read-only DOM).
242/// - `node_set`: optional predicate controlling which nodes appear in output.
243///   `None` means the entire document.
244/// - `algo`: algorithm parameters (mode, comments, prefix list).
245/// - `output`: byte buffer receiving canonical XML.
246pub fn canonicalize(
247    doc: &Document,
248    node_set: Option<&dyn Fn(Node) -> bool>,
249    algo: &C14nAlgorithm,
250    output: &mut Vec<u8>,
251) -> Result<(), C14nError> {
252    let visibility = node_set.map(|predicate| ClosureVisibility { predicate });
253    canonicalize_with_visibility(
254        doc,
255        visibility
256            .as_ref()
257            .map(|visibility| visibility as &dyn NodeVisibility),
258        algo,
259        output,
260    )
261}
262
263#[cfg(any(feature = "xmldsig", test))]
264/// Canonicalize through the closure visibility API while enforcing both the
265/// output ceiling and the caller's operation-wide XML Base work budget.
266pub(crate) fn canonicalize_bounded_with_xml_base_budget(
267    doc: &Document,
268    node_set: Option<&dyn Fn(Node) -> bool>,
269    algo: &C14nAlgorithm,
270    max_output_bytes: usize,
271    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
272    output: &mut Vec<u8>,
273) -> Result<(), C14nError> {
274    let visibility = node_set.map(|predicate| ClosureVisibility { predicate });
275    canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
276        doc,
277        visibility
278            .as_ref()
279            .map(|visibility| visibility as &dyn NodeVisibility),
280        algo,
281        None,
282        max_output_bytes,
283        xml_base_resolution,
284        output,
285    )?;
286    Ok(())
287}
288
289pub(crate) fn canonicalize_with_visibility(
290    doc: &Document,
291    visibility: Option<&dyn NodeVisibility>,
292    algo: &C14nAlgorithm,
293    output: &mut Vec<u8>,
294) -> Result<(), C14nError> {
295    canonicalize_with_visibility_and_position(doc, visibility, algo, None, output)?;
296    Ok(())
297}
298
299pub(crate) fn canonicalize_with_visibility_and_position(
300    doc: &Document,
301    visibility: Option<&dyn NodeVisibility>,
302    algo: &C14nAlgorithm,
303    tracked_element: Option<NodeId>,
304    output: &mut Vec<u8>,
305) -> Result<Option<usize>, C14nError> {
306    canonicalize_with_visibility_and_position_impl(
307        doc,
308        visibility,
309        algo,
310        tracked_element,
311        None,
312        None,
313        output,
314    )
315}
316
317#[cfg(test)]
318pub(crate) fn canonicalize_with_visibility_and_position_bounded(
319    doc: &Document,
320    visibility: Option<&dyn NodeVisibility>,
321    algo: &C14nAlgorithm,
322    tracked_element: Option<NodeId>,
323    max_output_bytes: usize,
324    output: &mut Vec<u8>,
325) -> Result<Option<usize>, C14nError> {
326    canonicalize_with_visibility_and_position_impl(
327        doc,
328        visibility,
329        algo,
330        tracked_element,
331        Some(max_output_bytes),
332        None,
333        output,
334    )
335}
336
337#[cfg(any(feature = "xmldsig", test))]
338pub(crate) fn canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
339    doc: &Document,
340    visibility: Option<&dyn NodeVisibility>,
341    algo: &C14nAlgorithm,
342    tracked_element: Option<NodeId>,
343    max_output_bytes: usize,
344    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
345    output: &mut Vec<u8>,
346) -> Result<Option<usize>, C14nError> {
347    canonicalize_with_visibility_and_position_impl(
348        doc,
349        visibility,
350        algo,
351        tracked_element,
352        Some(max_output_bytes),
353        Some(xml_base_resolution),
354        output,
355    )
356}
357
358#[cfg(any(feature = "xmldsig", test))]
359pub(crate) fn canonicalize_with_visibility_and_positions_bounded_with_xml_base_budget(
360    doc: &Document,
361    visibility: Option<&dyn NodeVisibility>,
362    algo: &C14nAlgorithm,
363    tracked_elements: &[NodeId],
364    max_output_bytes: usize,
365    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
366    output: &mut Vec<u8>,
367) -> Result<Vec<(NodeId, usize)>, C14nError> {
368    let config = C14nConfig {
369        inherit_xml_attrs: !matches!(algo.mode, C14nMode::Exclusive1_0),
370        fixup_xml_base: matches!(algo.mode, C14nMode::Inclusive1_1),
371    };
372    let inclusive = InclusiveNsRenderer;
373    let exclusive = ExclusiveNsRenderer::new(&algo.inclusive_prefixes);
374    let renderer: &dyn serialize::NsRenderer = match algo.mode {
375        C14nMode::Inclusive1_0 | C14nMode::Inclusive1_1 => &inclusive,
376        C14nMode::Exclusive1_0 => &exclusive,
377    };
378    serialize_canonical_visible_with_positions_bounded(
379        doc,
380        visibility,
381        algo.with_comments,
382        renderer,
383        config,
384        CanonicalOutputOptions::bounded_many(
385            tracked_elements,
386            max_output_bytes,
387            xml_base_resolution,
388        ),
389        output,
390    )
391}
392
393fn canonicalize_with_visibility_and_position_impl(
394    doc: &Document,
395    visibility: Option<&dyn NodeVisibility>,
396    algo: &C14nAlgorithm,
397    tracked_element: Option<NodeId>,
398    max_output_bytes: Option<usize>,
399    xml_base_resolution: Option<&xml_base::XmlBaseResolutionBudget>,
400    output: &mut Vec<u8>,
401) -> Result<Option<usize>, C14nError> {
402    let default_xml_base_resolution = xml_base::XmlBaseResolutionBudget::default();
403    let xml_base_resolution = xml_base_resolution.unwrap_or(&default_xml_base_resolution);
404    // inherit_xml_attrs: Inclusive C14N inherits xml:* attrs from ancestors
405    // per §2.4. Exclusive C14N explicitly omits this per Exc-C14N §3.
406    // fixup_xml_base: C14N 1.1 resolves relative xml:base URIs via RFC 3986.
407    match algo.mode {
408        C14nMode::Inclusive1_0 => {
409            let renderer = InclusiveNsRenderer;
410            let config = C14nConfig {
411                inherit_xml_attrs: true,
412                fixup_xml_base: false,
413            };
414            serialize_canonical_visible_with_position_dispatch(
415                doc,
416                visibility,
417                algo.with_comments,
418                &renderer,
419                config,
420                tracked_element,
421                max_output_bytes,
422                xml_base_resolution,
423                output,
424            )
425        }
426        C14nMode::Inclusive1_1 => {
427            let renderer = InclusiveNsRenderer;
428            let config = C14nConfig {
429                inherit_xml_attrs: true,
430                fixup_xml_base: true,
431            };
432            serialize_canonical_visible_with_position_dispatch(
433                doc,
434                visibility,
435                algo.with_comments,
436                &renderer,
437                config,
438                tracked_element,
439                max_output_bytes,
440                xml_base_resolution,
441                output,
442            )
443        }
444        C14nMode::Exclusive1_0 => {
445            let renderer = ExclusiveNsRenderer::new(&algo.inclusive_prefixes);
446            let config = C14nConfig {
447                inherit_xml_attrs: false,
448                fixup_xml_base: false,
449            };
450            serialize_canonical_visible_with_position_dispatch(
451                doc,
452                visibility,
453                algo.with_comments,
454                &renderer,
455                config,
456                tracked_element,
457                max_output_bytes,
458                xml_base_resolution,
459                output,
460            )
461        }
462    }
463}
464
465#[allow(clippy::too_many_arguments)]
466fn serialize_canonical_visible_with_position_dispatch(
467    doc: &Document,
468    visibility: Option<&dyn NodeVisibility>,
469    with_comments: bool,
470    renderer: &dyn serialize::NsRenderer,
471    config: C14nConfig,
472    tracked_element: Option<NodeId>,
473    max_output_bytes: Option<usize>,
474    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
475    output: &mut Vec<u8>,
476) -> Result<Option<usize>, C14nError> {
477    match max_output_bytes {
478        Some(max_output_bytes) => serialize_canonical_visible_with_position_bounded(
479            doc,
480            visibility,
481            with_comments,
482            renderer,
483            config,
484            CanonicalOutputOptions::bounded(tracked_element, max_output_bytes, xml_base_resolution),
485            output,
486        ),
487        None => serialize_canonical_visible_with_position_with_xml_base_budget(
488            doc,
489            visibility,
490            with_comments,
491            renderer,
492            config,
493            tracked_element,
494            xml_base_resolution,
495            output,
496        ),
497    }
498}
499
500/// Convenience: parse XML bytes and canonicalize the whole document.
501///
502/// Input must be valid UTF-8 (XML 1.0 documents are UTF-8 or declare their
503/// encoding; roxmltree only accepts UTF-8). DTDs and external entity resolution
504/// are disabled, and the library's absolute XML byte, node, and depth ceilings apply.
505/// Returns `C14nError::Parse` for invalid UTF-8, malformed XML, or exceeded
506/// input ceilings.
507pub fn canonicalize_xml(xml: &[u8], algo: &C14nAlgorithm) -> Result<Vec<u8>, C14nError> {
508    canonicalize_xml_with_backend(xml, algo, crate::XmlBackend::default())
509}
510
511/// Parse and canonicalize a complete XML document with an explicit backend.
512pub fn canonicalize_xml_with_backend(
513    xml: &[u8],
514    algo: &C14nAlgorithm,
515    backend: crate::XmlBackend,
516) -> Result<Vec<u8>, C14nError> {
517    if xml.len() > crate::hard_limits::XML_DOCUMENT_BYTE_CEILING {
518        return Err(C14nError::Parse(format!(
519            "input exceeds maximum XML document size of {} bytes: got {}",
520            crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
521            xml.len()
522        )));
523    }
524    let xml_str =
525        std::str::from_utf8(xml).map_err(|e| C14nError::Parse(format!("invalid UTF-8: {e}")))?;
526    let document = crate::document::parse_borrowed_with_settings_and_budget(
527        xml_str,
528        crate::document::DocumentParseSettings::default().with_backend(backend),
529        None,
530    )
531    .map_err(|error| C14nError::Parse(error.to_string()))?;
532    let mut output = Vec::new();
533    canonicalize(&document, None, algo, &mut output)?;
534    Ok(output)
535}
536
537/// Canonicalize a retained owned document without reparsing it.
538pub fn canonicalize_document(
539    document: &crate::XmlDocument,
540    algo: &C14nAlgorithm,
541) -> Result<Vec<u8>, C14nError> {
542    let mut output = Vec::new();
543    document.with_view(|view| canonicalize(view.document(), None, algo, &mut output))?;
544    Ok(output)
545}
546
547#[cfg(test)]
548#[allow(clippy::unwrap_used)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn from_uri_roundtrip() {
554        let uris = [
555            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
556            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments",
557            "http://www.w3.org/2006/12/xml-c14n11",
558            "http://www.w3.org/2006/12/xml-c14n11#WithComments",
559            "http://www.w3.org/2001/10/xml-exc-c14n#",
560            "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
561        ];
562        for uri in uris {
563            let algo = C14nAlgorithm::from_uri(uri).expect(uri);
564            assert_eq!(algo.uri(), uri);
565        }
566    }
567
568    #[test]
569    fn unknown_uri_returns_none() {
570        assert!(C14nAlgorithm::from_uri("http://example.com/unknown").is_none());
571    }
572
573    #[test]
574    fn prefix_list_parsing() {
575        let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, false)
576            .with_prefix_list("foo bar #default baz");
577        assert!(algo.inclusive_prefixes.contains("foo"));
578        assert!(algo.inclusive_prefixes.contains("bar"));
579        assert!(algo.inclusive_prefixes.contains("baz"));
580        assert!(algo.inclusive_prefixes.contains("")); // #default → ""
581        assert_eq!(algo.inclusive_prefixes.len(), 4);
582    }
583
584    #[test]
585    fn canonicalize_xml_basic() {
586        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
587        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
588        let result = canonicalize_xml(xml, &algo).expect("c14n");
589        assert_eq!(
590            String::from_utf8(result).expect("utf8"),
591            r#"<root a="1" b="2"><empty></empty></root>"#
592        );
593    }
594
595    #[test]
596    fn canonicalize_xml_rejects_input_above_the_document_byte_ceiling_before_parsing() {
597        // The convenience parser accepts untrusted bytes, so allocation bounds
598        // must apply before UTF-8 or XML parsing can inspect the payload.
599        let xml = vec![b' '; crate::hard_limits::XML_DOCUMENT_BYTE_CEILING + 1];
600        let error = match canonicalize_xml(&xml, &C14nAlgorithm::new(C14nMode::Inclusive1_0, false))
601        {
602            Err(error) => error,
603            Ok(_) => panic!("oversized canonicalization input must be rejected"),
604        };
605        assert!(matches!(
606            error,
607            C14nError::Parse(message)
608                if message.contains("exceeds maximum XML document size")
609        ));
610    }
611
612    #[test]
613    fn canonicalize_xml_rejects_input_above_the_document_node_ceiling() {
614        // A compact document can otherwise force an effectively unbounded
615        // parser-node allocation despite staying below the byte ceiling.
616        let children = "<n/>".repeat(crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize);
617        let xml = format!("<root>{children}</root>");
618        let error = match canonicalize_xml(
619            xml.as_bytes(),
620            &C14nAlgorithm::new(C14nMode::Inclusive1_0, false),
621        ) {
622            Err(error) => error,
623            Ok(_) => panic!("excessive canonicalization nodes must be rejected"),
624        };
625        assert!(matches!(
626            error,
627            C14nError::Parse(message) if message.contains("nodes limit reached")
628        ));
629    }
630
631    #[test]
632    fn canonicalize_xml_rejects_input_above_the_document_depth_ceiling() {
633        // The convenience parser is a public untrusted-input boundary, so the
634        // absolute depth ceiling must apply before recursive C14N traversal.
635        let depth = crate::hard_limits::XML_DOCUMENT_DEPTH_CEILING + 1;
636        let xml = format!("{}{}", "<n>".repeat(depth), "</n>".repeat(depth));
637
638        let error = canonicalize_xml(
639            xml.as_bytes(),
640            &C14nAlgorithm::new(C14nMode::Inclusive1_0, false),
641        )
642        .expect_err("over-depth canonicalization input must be rejected");
643
644        assert!(matches!(
645            error,
646            C14nError::Parse(message) if message.contains("maximum element depth")
647        ));
648    }
649
650    #[test]
651    fn canonicalize_xml_does_not_enable_dtd_or_external_entity_resolution() {
652        // Whole-document C14N is a convenience API, not an implicit opt-in to
653        // DTD parsing or external resource access.
654        let xml = br#"<!DOCTYPE root [<!ENTITY value 'expanded'>]><root>&value;</root>"#;
655        let error = canonicalize_xml(xml, &C14nAlgorithm::new(C14nMode::Inclusive1_0, false))
656            .expect_err("DTD input must remain disabled");
657        assert!(matches!(
658            error,
659            C14nError::Parse(message) if message.contains("DTD detected")
660        ));
661    }
662
663    #[test]
664    fn c14n_1_1_basic() {
665        // C14N 1.1 serialization is identical to 1.0 for full documents.
666        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
667        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
668        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1");
669        assert_eq!(
670            String::from_utf8(result).expect("utf8"),
671            r#"<root a="1" b="2"><empty></empty></root>"#
672        );
673    }
674
675    #[test]
676    fn c14n_1_1_with_comments() {
677        let xml = b"<root><!-- comment -->text</root>";
678        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, true);
679        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 with comments");
680        assert_eq!(
681            String::from_utf8(result).expect("utf8"),
682            "<root><!-- comment -->text</root>"
683        );
684    }
685
686    #[test]
687    fn c14n_1_1_without_comments() {
688        let xml = b"<root><!-- comment -->text</root>";
689        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
690        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 without comments");
691        assert_eq!(
692            String::from_utf8(result).expect("utf8"),
693            "<root>text</root>"
694        );
695    }
696
697    #[test]
698    fn c14n_1_1_namespaces() {
699        // C14N 1.1 renders all in-scope namespaces like 1.0.
700        let xml = b"<root xmlns:a=\"http://a\" xmlns:b=\"http://b\"><child/></root>";
701        let algo_10 = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
702        let algo_11 = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
703        let result_10 = canonicalize_xml(xml, &algo_10).expect("1.0");
704        let result_11 = canonicalize_xml(xml, &algo_11).expect("1.1");
705        // For full documents, 1.0 and 1.1 produce identical output.
706        assert_eq!(result_10, result_11);
707    }
708
709    #[test]
710    fn c14n_1_1_xml_id_is_not_inherited_in_subset() {
711        // C14N 1.1 explicitly excludes xml:id from simple inheritable
712        // attributes, so omitting its owner must also omit the attribute.
713        use crate::xml::dom::Document;
714        use std::collections::HashSet;
715
716        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
717        let doc = Document::parse(xml).expect("parse");
718        let child = doc.root_element().first_element_child().expect("child");
719
720        // Build subset: child + its descendants, excluding root
721        let mut ids = HashSet::new();
722        let mut stack = vec![child];
723        while let Some(n) = stack.pop() {
724            ids.insert(n.id());
725            for c in n.children() {
726                stack.push(c);
727            }
728        }
729        let pred = move |n: crate::xml::dom::Node| ids.contains(&n.id());
730
731        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
732        let mut out = Vec::new();
733        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.1 subset");
734        let result = String::from_utf8(out).expect("utf8");
735
736        assert!(
737            !result.contains(r#"xml:id="r1""#),
738            "xml:id must not be inherited in C14N 1.1 subset; got: {result}"
739        );
740    }
741
742    #[test]
743    fn c14n_1_0_xml_id_is_inherited_in_subset() {
744        // C14N 1.0 predates the C14N 1.1 xml:id exception, so xml:id follows
745        // the general xml:* apex inheritance rule in a document subset.
746        use crate::xml::dom::Document;
747        use std::collections::HashSet;
748
749        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
750        let doc = Document::parse(xml).expect("parse");
751        let child = doc.root_element().first_element_child().expect("child");
752        let ids = child
753            .descendants()
754            .map(|node| node.id())
755            .collect::<HashSet<_>>();
756        let pred = move |node: crate::xml::dom::Node| ids.contains(&node.id());
757
758        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
759        let mut out = Vec::new();
760        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.0 subset");
761
762        assert_eq!(
763            String::from_utf8(out).expect("utf8"),
764            r#"<child xml:id="r1">text</child>"#
765        );
766    }
767
768    #[test]
769    fn bounded_canonicalization_stops_before_exceeding_the_limit() {
770        // XMLDSig applies a signature-wide output ceiling. The serializer must
771        // stop at that ceiling instead of allocating the complete hostile value
772        // and rejecting it only after serialization has finished.
773        let xml = format!("<root>{}</root>", "x".repeat(4_096));
774        let document = Document::parse(&xml).expect("fixed XML must parse");
775        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
776        let mut output = Vec::new();
777
778        let error = canonicalize_with_visibility_and_position_bounded(
779            &document,
780            None,
781            &algorithm,
782            None,
783            64,
784            &mut output,
785        )
786        .expect_err("canonicalization must stop at the caller's byte ceiling");
787
788        assert!(is_output_limit_error(&error));
789        assert!(
790            output.len() <= 64,
791            "bounded output grew to {} bytes",
792            output.len()
793        );
794    }
795
796    #[test]
797    fn c14n_1_1_bounds_inherited_xml_base_components() {
798        // C14N 1.1 subset fixup walks ancestors outside the selected node set.
799        // Bounding that walk prevents deeply nested xml:base chains from
800        // multiplying URI-resolution work during canonicalization.
801        let mut xml = String::new();
802        for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING {
803            xml.push_str(r#"<n xml:base="segment/">"#);
804        }
805        xml.push_str("<leaf/>");
806        for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING {
807            xml.push_str("</n>");
808        }
809        let document = Document::parse(&xml).expect("fixed XML must parse");
810        let leaf = document
811            .descendants()
812            .find(|node| node.has_tag_name("leaf"))
813            .expect("leaf");
814        let visible = |node: Node<'_, '_>| node == leaf;
815        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
816        let mut output = Vec::new();
817
818        let error = canonicalize_with_visibility(
819            &document,
820            Some(&ClosureVisibility {
821                predicate: &visible,
822            }),
823            &algorithm,
824            &mut output,
825        )
826        .expect_err("C14N must reject an overlong inherited xml:base chain");
827
828        assert!(
829            error.to_string().contains("XML Base"),
830            "unexpected C14N error: {error}"
831        );
832    }
833
834    #[test]
835    fn unbounded_output_preserves_the_callers_xml_base_budget() {
836        // Output capacity and XML Base work are independent limits. Omitting
837        // an output ceiling must not replace the caller's XML Base policy.
838        let document = Document::parse(
839            r#"<root xml:base="one/"><parent xml:base="two/"><leaf/></parent></root>"#,
840        )
841        .unwrap();
842        let leaf = document
843            .descendants()
844            .find(|node| node.has_tag_name("leaf"))
845            .unwrap();
846        let visible = |node: Node<'_, '_>| node == leaf;
847        let budget = xml_base::XmlBaseResolutionBudget::with_limits(1, usize::MAX);
848        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
849        let mut output = Vec::new();
850
851        let error = canonicalize_with_visibility_and_position_impl(
852            &document,
853            Some(&ClosureVisibility {
854                predicate: &visible,
855            }),
856            &algorithm,
857            None,
858            None,
859            Some(&budget),
860            &mut output,
861        )
862        .expect_err("the caller's component ceiling must survive unbounded dispatch");
863
864        assert!(matches!(
865            error,
866            C14nError::XmlBaseComponentsTooLarge { max: 1, actual: 2 }
867        ));
868    }
869}