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