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 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_bounded,
43    serialize_canonical_visible_with_position_with_xml_base_budget,
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    /// The inherited `xml:base` chain exceeds the configured component limit.
162    #[error("XML Base resolution exceeds maximum of {max} inherited components: got {actual}")]
163    XmlBaseComponentsTooLarge {
164        /// Configured maximum inherited components.
165        max: usize,
166        /// Number of inherited components encountered.
167        actual: usize,
168    },
169    /// Cumulative `xml:base` resolution work exceeds the configured byte limit.
170    #[error("XML Base resolution exceeds maximum of {max_bytes} bytes: got at least {actual}")]
171    XmlBaseResolutionTooLarge {
172        /// Configured maximum cumulative bytes.
173        max_bytes: usize,
174        /// Minimum cumulative byte count that exceeded the maximum.
175        actual: usize,
176    },
177    /// I/O error.
178    #[error("I/O error: {0}")]
179    Io(#[from] std::io::Error),
180}
181
182#[cfg(any(feature = "xmldsig", test))]
183pub(crate) fn is_output_limit_error(error: &C14nError) -> bool {
184    matches!(
185        error,
186        C14nError::Io(error)
187            if error
188                .get_ref()
189                .is_some_and(|source| source.is::<CanonicalOutputLimitExceeded>())
190    )
191}
192
193/// Visibility contract for canonicalizing a precise XPath node-set.
194///
195/// XPath can select attributes and namespace nodes independently from their
196/// owner element. The public closure API predates that requirement and treats
197/// both categories as visible whenever their owner is visible; XMLDSig uses
198/// this richer crate-private contract for standards-compliant subsets.
199pub(crate) trait NodeVisibility {
200    fn contains_node(&self, node: Node<'_, '_>) -> bool;
201
202    fn contains_attribute(
203        &self,
204        owner: Node<'_, '_>,
205        namespace: Option<&str>,
206        local_name: &str,
207    ) -> bool;
208
209    fn contains_namespace(&self, owner: Node<'_, '_>, prefix: &str, uri: &str) -> bool;
210}
211
212struct ClosureVisibility<'a> {
213    predicate: &'a dyn Fn(Node<'_, '_>) -> bool,
214}
215
216impl NodeVisibility for ClosureVisibility<'_> {
217    fn contains_node(&self, node: Node<'_, '_>) -> bool {
218        (self.predicate)(node)
219    }
220
221    fn contains_attribute(
222        &self,
223        owner: Node<'_, '_>,
224        _namespace: Option<&str>,
225        _local_name: &str,
226    ) -> bool {
227        (self.predicate)(owner)
228    }
229
230    fn contains_namespace(&self, owner: Node<'_, '_>, _prefix: &str, _uri: &str) -> bool {
231        (self.predicate)(owner)
232    }
233}
234
235/// Canonicalize an XML document or document subset.
236///
237/// - `doc`: parsed roxmltree document (read-only DOM).
238/// - `node_set`: optional predicate controlling which nodes appear in output.
239///   `None` means the entire document.
240/// - `algo`: algorithm parameters (mode, comments, prefix list).
241/// - `output`: byte buffer receiving canonical XML.
242pub fn canonicalize(
243    doc: &Document,
244    node_set: Option<&dyn Fn(Node) -> bool>,
245    algo: &C14nAlgorithm,
246    output: &mut Vec<u8>,
247) -> Result<(), C14nError> {
248    let visibility = node_set.map(|predicate| ClosureVisibility { predicate });
249    canonicalize_with_visibility(
250        doc,
251        visibility
252            .as_ref()
253            .map(|visibility| visibility as &dyn NodeVisibility),
254        algo,
255        output,
256    )
257}
258
259#[cfg(any(feature = "xmldsig", test))]
260/// Canonicalize through the closure visibility API while enforcing both the
261/// output ceiling and the caller's operation-wide XML Base work budget.
262pub(crate) fn canonicalize_bounded_with_xml_base_budget(
263    doc: &Document,
264    node_set: Option<&dyn Fn(Node) -> bool>,
265    algo: &C14nAlgorithm,
266    max_output_bytes: usize,
267    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
268    output: &mut Vec<u8>,
269) -> Result<(), C14nError> {
270    let visibility = node_set.map(|predicate| ClosureVisibility { predicate });
271    canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
272        doc,
273        visibility
274            .as_ref()
275            .map(|visibility| visibility as &dyn NodeVisibility),
276        algo,
277        None,
278        max_output_bytes,
279        xml_base_resolution,
280        output,
281    )?;
282    Ok(())
283}
284
285pub(crate) fn canonicalize_with_visibility(
286    doc: &Document,
287    visibility: Option<&dyn NodeVisibility>,
288    algo: &C14nAlgorithm,
289    output: &mut Vec<u8>,
290) -> Result<(), C14nError> {
291    canonicalize_with_visibility_and_position(doc, visibility, algo, None, output)?;
292    Ok(())
293}
294
295pub(crate) fn canonicalize_with_visibility_and_position(
296    doc: &Document,
297    visibility: Option<&dyn NodeVisibility>,
298    algo: &C14nAlgorithm,
299    tracked_element: Option<NodeId>,
300    output: &mut Vec<u8>,
301) -> Result<Option<usize>, C14nError> {
302    canonicalize_with_visibility_and_position_impl(
303        doc,
304        visibility,
305        algo,
306        tracked_element,
307        None,
308        None,
309        output,
310    )
311}
312
313#[cfg(test)]
314pub(crate) fn canonicalize_with_visibility_and_position_bounded(
315    doc: &Document,
316    visibility: Option<&dyn NodeVisibility>,
317    algo: &C14nAlgorithm,
318    tracked_element: Option<NodeId>,
319    max_output_bytes: usize,
320    output: &mut Vec<u8>,
321) -> Result<Option<usize>, C14nError> {
322    canonicalize_with_visibility_and_position_impl(
323        doc,
324        visibility,
325        algo,
326        tracked_element,
327        Some(max_output_bytes),
328        None,
329        output,
330    )
331}
332
333#[cfg(any(feature = "xmldsig", test))]
334pub(crate) fn canonicalize_with_visibility_and_position_bounded_with_xml_base_budget(
335    doc: &Document,
336    visibility: Option<&dyn NodeVisibility>,
337    algo: &C14nAlgorithm,
338    tracked_element: Option<NodeId>,
339    max_output_bytes: usize,
340    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
341    output: &mut Vec<u8>,
342) -> Result<Option<usize>, C14nError> {
343    canonicalize_with_visibility_and_position_impl(
344        doc,
345        visibility,
346        algo,
347        tracked_element,
348        Some(max_output_bytes),
349        Some(xml_base_resolution),
350        output,
351    )
352}
353
354fn canonicalize_with_visibility_and_position_impl(
355    doc: &Document,
356    visibility: Option<&dyn NodeVisibility>,
357    algo: &C14nAlgorithm,
358    tracked_element: Option<NodeId>,
359    max_output_bytes: Option<usize>,
360    xml_base_resolution: Option<&xml_base::XmlBaseResolutionBudget>,
361    output: &mut Vec<u8>,
362) -> Result<Option<usize>, C14nError> {
363    let default_xml_base_resolution = xml_base::XmlBaseResolutionBudget::default();
364    let xml_base_resolution = xml_base_resolution.unwrap_or(&default_xml_base_resolution);
365    // inherit_xml_attrs: Inclusive C14N inherits xml:* attrs from ancestors
366    // per §2.4. Exclusive C14N explicitly omits this per Exc-C14N §3.
367    // fixup_xml_base: C14N 1.1 resolves relative xml:base URIs via RFC 3986.
368    match algo.mode {
369        C14nMode::Inclusive1_0 => {
370            let renderer = InclusiveNsRenderer;
371            let config = C14nConfig {
372                inherit_xml_attrs: true,
373                fixup_xml_base: false,
374            };
375            serialize_canonical_visible_with_position_dispatch(
376                doc,
377                visibility,
378                algo.with_comments,
379                &renderer,
380                config,
381                tracked_element,
382                max_output_bytes,
383                xml_base_resolution,
384                output,
385            )
386        }
387        C14nMode::Inclusive1_1 => {
388            let renderer = InclusiveNsRenderer;
389            let config = C14nConfig {
390                inherit_xml_attrs: true,
391                fixup_xml_base: true,
392            };
393            serialize_canonical_visible_with_position_dispatch(
394                doc,
395                visibility,
396                algo.with_comments,
397                &renderer,
398                config,
399                tracked_element,
400                max_output_bytes,
401                xml_base_resolution,
402                output,
403            )
404        }
405        C14nMode::Exclusive1_0 => {
406            let renderer = ExclusiveNsRenderer::new(&algo.inclusive_prefixes);
407            let config = C14nConfig {
408                inherit_xml_attrs: false,
409                fixup_xml_base: false,
410            };
411            serialize_canonical_visible_with_position_dispatch(
412                doc,
413                visibility,
414                algo.with_comments,
415                &renderer,
416                config,
417                tracked_element,
418                max_output_bytes,
419                xml_base_resolution,
420                output,
421            )
422        }
423    }
424}
425
426#[allow(clippy::too_many_arguments)]
427fn serialize_canonical_visible_with_position_dispatch(
428    doc: &Document,
429    visibility: Option<&dyn NodeVisibility>,
430    with_comments: bool,
431    renderer: &dyn serialize::NsRenderer,
432    config: C14nConfig,
433    tracked_element: Option<NodeId>,
434    max_output_bytes: Option<usize>,
435    xml_base_resolution: &xml_base::XmlBaseResolutionBudget,
436    output: &mut Vec<u8>,
437) -> Result<Option<usize>, C14nError> {
438    match max_output_bytes {
439        Some(max_output_bytes) => serialize_canonical_visible_with_position_bounded(
440            doc,
441            visibility,
442            with_comments,
443            renderer,
444            config,
445            CanonicalOutputOptions::bounded(tracked_element, max_output_bytes, xml_base_resolution),
446            output,
447        ),
448        None => serialize_canonical_visible_with_position_with_xml_base_budget(
449            doc,
450            visibility,
451            with_comments,
452            renderer,
453            config,
454            tracked_element,
455            xml_base_resolution,
456            output,
457        ),
458    }
459}
460
461/// Convenience: parse XML bytes and canonicalize the whole document.
462///
463/// Input must be valid UTF-8 (XML 1.0 documents are UTF-8 or declare their
464/// encoding; roxmltree only accepts UTF-8). Returns `C14nError::Parse` for
465/// invalid UTF-8 or malformed XML.
466pub fn canonicalize_xml(xml: &[u8], algo: &C14nAlgorithm) -> Result<Vec<u8>, C14nError> {
467    let xml_str =
468        std::str::from_utf8(xml).map_err(|e| C14nError::Parse(format!("invalid UTF-8: {e}")))?;
469    let doc = Document::parse(xml_str).map_err(|e| C14nError::Parse(e.to_string()))?;
470    let mut output = Vec::new();
471    canonicalize(&doc, None, algo, &mut output)?;
472    Ok(output)
473}
474
475#[cfg(test)]
476#[allow(clippy::unwrap_used)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn from_uri_roundtrip() {
482        let uris = [
483            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
484            "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments",
485            "http://www.w3.org/2006/12/xml-c14n11",
486            "http://www.w3.org/2006/12/xml-c14n11#WithComments",
487            "http://www.w3.org/2001/10/xml-exc-c14n#",
488            "http://www.w3.org/2001/10/xml-exc-c14n#WithComments",
489        ];
490        for uri in uris {
491            let algo = C14nAlgorithm::from_uri(uri).expect(uri);
492            assert_eq!(algo.uri(), uri);
493        }
494    }
495
496    #[test]
497    fn unknown_uri_returns_none() {
498        assert!(C14nAlgorithm::from_uri("http://example.com/unknown").is_none());
499    }
500
501    #[test]
502    fn prefix_list_parsing() {
503        let algo = C14nAlgorithm::new(C14nMode::Exclusive1_0, false)
504            .with_prefix_list("foo bar #default baz");
505        assert!(algo.inclusive_prefixes.contains("foo"));
506        assert!(algo.inclusive_prefixes.contains("bar"));
507        assert!(algo.inclusive_prefixes.contains("baz"));
508        assert!(algo.inclusive_prefixes.contains("")); // #default → ""
509        assert_eq!(algo.inclusive_prefixes.len(), 4);
510    }
511
512    #[test]
513    fn canonicalize_xml_basic() {
514        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
515        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
516        let result = canonicalize_xml(xml, &algo).expect("c14n");
517        assert_eq!(
518            String::from_utf8(result).expect("utf8"),
519            r#"<root a="1" b="2"><empty></empty></root>"#
520        );
521    }
522
523    #[test]
524    fn c14n_1_1_basic() {
525        // C14N 1.1 serialization is identical to 1.0 for full documents.
526        let xml = b"<root b=\"2\" a=\"1\"><empty/></root>";
527        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
528        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1");
529        assert_eq!(
530            String::from_utf8(result).expect("utf8"),
531            r#"<root a="1" b="2"><empty></empty></root>"#
532        );
533    }
534
535    #[test]
536    fn c14n_1_1_with_comments() {
537        let xml = b"<root><!-- comment -->text</root>";
538        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, true);
539        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 with comments");
540        assert_eq!(
541            String::from_utf8(result).expect("utf8"),
542            "<root><!-- comment -->text</root>"
543        );
544    }
545
546    #[test]
547    fn c14n_1_1_without_comments() {
548        let xml = b"<root><!-- comment -->text</root>";
549        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
550        let result = canonicalize_xml(xml, &algo).expect("c14n 1.1 without comments");
551        assert_eq!(
552            String::from_utf8(result).expect("utf8"),
553            "<root>text</root>"
554        );
555    }
556
557    #[test]
558    fn c14n_1_1_namespaces() {
559        // C14N 1.1 renders all in-scope namespaces like 1.0.
560        let xml = b"<root xmlns:a=\"http://a\" xmlns:b=\"http://b\"><child/></root>";
561        let algo_10 = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
562        let algo_11 = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
563        let result_10 = canonicalize_xml(xml, &algo_10).expect("1.0");
564        let result_11 = canonicalize_xml(xml, &algo_11).expect("1.1");
565        // For full documents, 1.0 and 1.1 produce identical output.
566        assert_eq!(result_10, result_11);
567    }
568
569    #[test]
570    fn c14n_1_1_xml_id_is_not_inherited_in_subset() {
571        // C14N 1.1 explicitly excludes xml:id from simple inheritable
572        // attributes, so omitting its owner must also omit the attribute.
573        use roxmltree::Document;
574        use std::collections::HashSet;
575
576        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
577        let doc = Document::parse(xml).expect("parse");
578        let child = doc.root_element().first_element_child().expect("child");
579
580        // Build subset: child + its descendants, excluding root
581        let mut ids = HashSet::new();
582        let mut stack = vec![child];
583        while let Some(n) = stack.pop() {
584            ids.insert(n.id());
585            for c in n.children() {
586                stack.push(c);
587            }
588        }
589        let pred = move |n: roxmltree::Node| ids.contains(&n.id());
590
591        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
592        let mut out = Vec::new();
593        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.1 subset");
594        let result = String::from_utf8(out).expect("utf8");
595
596        assert!(
597            !result.contains(r#"xml:id="r1""#),
598            "xml:id must not be inherited in C14N 1.1 subset; got: {result}"
599        );
600    }
601
602    #[test]
603    fn c14n_1_0_xml_id_is_inherited_in_subset() {
604        // C14N 1.0 predates the C14N 1.1 xml:id exception, so xml:id follows
605        // the general xml:* apex inheritance rule in a document subset.
606        use roxmltree::Document;
607        use std::collections::HashSet;
608
609        let xml = r#"<root xml:id="r1"><child>text</child></root>"#;
610        let doc = Document::parse(xml).expect("parse");
611        let child = doc.root_element().first_element_child().expect("child");
612        let ids = child
613            .descendants()
614            .map(|node| node.id())
615            .collect::<HashSet<_>>();
616        let pred = move |node: roxmltree::Node| ids.contains(&node.id());
617
618        let algo = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
619        let mut out = Vec::new();
620        canonicalize(&doc, Some(&pred), &algo, &mut out).expect("c14n 1.0 subset");
621
622        assert_eq!(
623            String::from_utf8(out).expect("utf8"),
624            r#"<child xml:id="r1">text</child>"#
625        );
626    }
627
628    #[test]
629    fn bounded_canonicalization_stops_before_exceeding_the_limit() {
630        // XMLDSig applies a signature-wide output ceiling. The serializer must
631        // stop at that ceiling instead of allocating the complete hostile value
632        // and rejecting it only after serialization has finished.
633        let xml = format!("<root>{}</root>", "x".repeat(4_096));
634        let document = Document::parse(&xml).expect("fixed XML must parse");
635        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_0, false);
636        let mut output = Vec::new();
637
638        let error = canonicalize_with_visibility_and_position_bounded(
639            &document,
640            None,
641            &algorithm,
642            None,
643            64,
644            &mut output,
645        )
646        .expect_err("canonicalization must stop at the caller's byte ceiling");
647
648        assert!(is_output_limit_error(&error));
649        assert!(
650            output.len() <= 64,
651            "bounded output grew to {} bytes",
652            output.len()
653        );
654    }
655
656    #[test]
657    fn c14n_1_1_bounds_inherited_xml_base_components() {
658        // C14N 1.1 subset fixup walks ancestors outside the selected node set.
659        // Bounding that walk prevents deeply nested xml:base chains from
660        // multiplying URI-resolution work during canonicalization.
661        let mut xml = String::new();
662        for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING {
663            xml.push_str(r#"<n xml:base="segment/">"#);
664        }
665        xml.push_str("<leaf/>");
666        for _ in 0..=crate::hard_limits::XML_BASE_COMPONENT_CEILING {
667            xml.push_str("</n>");
668        }
669        let document = Document::parse(&xml).expect("fixed XML must parse");
670        let leaf = document
671            .descendants()
672            .find(|node| node.has_tag_name("leaf"))
673            .expect("leaf");
674        let visible = |node: Node<'_, '_>| node == leaf;
675        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
676        let mut output = Vec::new();
677
678        let error = canonicalize_with_visibility(
679            &document,
680            Some(&ClosureVisibility {
681                predicate: &visible,
682            }),
683            &algorithm,
684            &mut output,
685        )
686        .expect_err("C14N must reject an overlong inherited xml:base chain");
687
688        assert!(
689            error.to_string().contains("XML Base"),
690            "unexpected C14N error: {error}"
691        );
692    }
693
694    #[test]
695    fn unbounded_output_preserves_the_callers_xml_base_budget() {
696        // Output capacity and XML Base work are independent limits. Omitting
697        // an output ceiling must not replace the caller's XML Base policy.
698        let document = Document::parse(
699            r#"<root xml:base="one/"><parent xml:base="two/"><leaf/></parent></root>"#,
700        )
701        .unwrap();
702        let leaf = document
703            .descendants()
704            .find(|node| node.has_tag_name("leaf"))
705            .unwrap();
706        let visible = |node: Node<'_, '_>| node == leaf;
707        let budget = xml_base::XmlBaseResolutionBudget::with_limits(1, usize::MAX);
708        let algorithm = C14nAlgorithm::new(C14nMode::Inclusive1_1, false);
709        let mut output = Vec::new();
710
711        let error = canonicalize_with_visibility_and_position_impl(
712            &document,
713            Some(&ClosureVisibility {
714                predicate: &visible,
715            }),
716            &algorithm,
717            None,
718            None,
719            Some(&budget),
720            &mut output,
721        )
722        .expect_err("the caller's component ceiling must survive unbounded dispatch");
723
724        assert!(matches!(
725            error,
726            C14nError::XmlBaseComponentsTooLarge { max: 1, actual: 2 }
727        ));
728    }
729}