Skip to main content

sup_xml_core/
canonical.rs

1#![forbid(unsafe_code)]
2
3//! Canonical XML serialization (W3C C14N) for the arena DOM.
4//!
5//! Arena-backed counterpart to [`crate::canonical`].  Same semantics, same
6//! options, same byte-for-byte output — but operates on
7//! [`sup_xml_tree::dom::Document`] / [`sup_xml_tree::dom::Node`].
8//!
9//! Two algorithms are supported:
10//!
11//! - **Canonical XML 1.0** — [W3C `xml-c14n`](https://www.w3.org/TR/xml-c14n).
12//! - **Exclusive Canonical XML 1.0** — [W3C `xml-exc-c14n`](https://www.w3.org/TR/xml-exc-c14n).
13//!
14//! See the legacy [`crate::canonical`] module docs for the full background and
15//! known divergences from libxml2.
16
17use std::collections::HashSet;
18use std::io::{self, Write};
19
20use sup_xml_tree::dom::{Attribute, Document, Node, NodeKind};
21
22// ── option types ─────────────────────────────────────────────────────────────
23
24/// Options controlling the canonicalization algorithm.
25#[derive(Debug, Clone)]
26pub struct CanonicalizeOptions {
27    /// Which canonicalization algorithm to use.
28    pub mode: C14nMode,
29    /// When `true`, comment nodes are included in the output (per
30    /// the `#WithComments` variant of each algorithm).  Default
31    /// `false` — comments are omitted, matching the most common
32    /// signature workflows.
33    pub with_comments: bool,
34}
35
36impl Default for CanonicalizeOptions {
37    fn default() -> Self {
38        Self {
39            mode: C14nMode::C14n10,
40            with_comments: false,
41        }
42    }
43}
44
45/// Selects which W3C canonicalization algorithm to apply.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum C14nMode {
48    /// Canonical XML 1.0 (`http://www.w3.org/TR/2001/REC-xml-c14n-20010315`).
49    /// Renders every namespace declaration in scope at the
50    /// canonicalization root.  Use for whole-document
51    /// canonicalization or fragments that should preserve their
52    /// inherited namespace context.
53    C14n10,
54    /// Exclusive Canonical XML 1.0
55    /// (`http://www.w3.org/2001/10/xml-exc-c14n#`).  Only renders
56    /// namespace declarations *visibly used* by the canonicalized
57    /// subtree, plus any prefixes in `inclusive_prefixes`.
58    /// Required by SAML, WS-Security, XAdES.
59    ExcC14n10 {
60        /// Namespace prefixes to render even when not visibly used —
61        /// the `InclusiveNamespaces PrefixList` from the spec.
62        /// Empty list (the default in most uses) means "exclusive
63        /// strictly per the algorithm."  Use the empty string `""`
64        /// to force the default namespace into the inclusive set.
65        inclusive_prefixes: Vec<String>,
66    },
67}
68
69// ── public surface ───────────────────────────────────────────────────────────
70
71/// What the [`canonicalize_with`] visibility predicate is being asked about.
72///
73/// Mirrors the targets libxml2's `xmlC14NIsVisibleCallback` is invoked
74/// against: element/text/comment/PI nodes, and individual attributes.
75/// Namespace declarations are emitted per the C14N algorithm rules and
76/// are not surfaced through this predicate.
77pub enum VisitTarget<'a, 'doc> {
78    /// An element, text, CDATA, comment, PI, or entity-reference node.
79    /// Returning `false` for an element causes the entire subtree to be
80    /// skipped (XML-DSig "subtree exclusion" semantics); returning
81    /// `false` for a non-element node skips just that node.
82    Node(&'a Node<'doc>),
83    /// An individual attribute on an element.  Returning `false`
84    /// causes that attribute to be omitted from the element's
85    /// canonical serialization.
86    Attribute(&'a Attribute<'doc>),
87}
88
89/// Predicate that includes every visited target.  The default used by
90/// [`canonicalize_to_bytes`] and [`canonicalize_node_to_bytes`].
91#[inline]
92pub fn include_all(_: VisitTarget<'_, '_>) -> bool {
93    true
94}
95
96/// Stream the canonical form of an entire [`Document`] into `out`.
97///
98/// Bytes are written incrementally as the walker produces them, so
99/// callers wiring a hash context (XML-DSig) or a network sink see
100/// each chunk without ever materializing the full canonical form.
101///
102/// `is_visible` filters which nodes and attributes appear in the
103/// output.  See [`VisitTarget`] for semantics; pass [`include_all`]
104/// to emit everything.
105pub fn canonicalize_with<W, F>(
106    doc: &Document,
107    opts: &CanonicalizeOptions,
108    out: &mut W,
109    is_visible: F,
110) -> io::Result<()>
111where
112    W: Write,
113    F: Fn(VisitTarget<'_, '_>) -> bool,
114{
115    let mut ctx = NsContext::new();
116    // Walk the document-level node chain: prolog comments/PIs, the
117    // document element, then epilogue comments/PIs (linked as the
118    // root's prev/next siblings).  Per the C14N spec, document-level
119    // comments/PIs preceding the document element are each followed
120    // by a newline, and those following it are each preceded by one;
121    // comments appear only when `with_comments` is set (PIs always).
122    let root = doc.root();
123    let root_ptr = root as *const Node<'_>;
124    let mut head = root;
125    while let Some(prev) = head.prev_sibling.get() {
126        head = prev;
127    }
128    let mut seen_root = false;
129    let mut cur = Some(head);
130    while let Some(n) = cur {
131        if std::ptr::eq(n as *const Node<'_>, root_ptr) {
132            write_node(out, n, &mut ctx, opts, &is_visible)?;
133            seen_root = true;
134        } else if !matches!(n.kind, NodeKind::Comment) || opts.with_comments {
135            if seen_root {
136                out.write_all(b"\n")?;
137                write_node(out, n, &mut ctx, opts, &is_visible)?;
138            } else {
139                write_node(out, n, &mut ctx, opts, &is_visible)?;
140                out.write_all(b"\n")?;
141            }
142        }
143        cur = n.next_sibling.get();
144    }
145    Ok(())
146}
147
148/// Stream the canonical form of a single subtree into `out`.  The
149/// supplied `node` is treated as the canonicalization root — no
150/// inherited namespace context from its ancestors is included.
151pub fn canonicalize_node_with<W, F>(
152    node: &Node<'_>,
153    opts: &CanonicalizeOptions,
154    out: &mut W,
155    is_visible: F,
156) -> io::Result<()>
157where
158    W: Write,
159    F: Fn(VisitTarget<'_, '_>) -> bool,
160{
161    let mut ctx = NsContext::new();
162    write_node(out, node, &mut ctx, opts, &is_visible)
163}
164
165/// Canonicalize an entire arena [`Document`] into an in-memory `Vec`.
166///
167/// Convenience wrapper around [`canonicalize_with`] for callers that
168/// want the full canonical form materialized in memory.  For streaming
169/// workloads (hashing, network I/O) use [`canonicalize_with`] directly.
170pub fn canonicalize_to_bytes(doc: &Document, opts: &CanonicalizeOptions) -> Vec<u8> {
171    let mut buf = Vec::with_capacity(estimate_capacity(doc));
172    canonicalize_with(doc, opts, &mut buf, include_all)
173        .expect("writes into Vec<u8> are infallible");
174    buf
175}
176
177/// Canonicalize a single arena node and its descendants into an
178/// in-memory `Vec`.  The node is treated as the canonicalization
179/// root — no inherited namespace context from ancestors is included.
180pub fn canonicalize_node_to_bytes(node: &Node<'_>, opts: &CanonicalizeOptions) -> Vec<u8> {
181    let mut buf = Vec::with_capacity(2048);
182    canonicalize_node_with(node, opts, &mut buf, include_all)
183        .expect("writes into Vec<u8> are infallible");
184    buf
185}
186
187// ── namespace context tracking ───────────────────────────────────────────────
188
189/// Stack of in-scope namespace bindings + record of which bindings have
190/// already been rendered in the output.  C14N de-dup works on the rendered
191/// set, not the in-scope set.
192struct NsContext {
193    frames: Vec<NsFrame>,
194}
195
196struct NsFrame {
197    /// (prefix, uri) bindings declared at this element.  `prefix == ""` means
198    /// the default namespace (`xmlns="…"`).
199    declared: Vec<(String, String)>,
200    /// (prefix, uri) bindings actually rendered to output at this element.
201    rendered: Vec<(String, String)>,
202}
203
204impl NsContext {
205    fn new() -> Self {
206        Self { frames: Vec::with_capacity(16) }
207    }
208
209    fn push_frame(&mut self) {
210        self.frames.push(NsFrame {
211            declared: Vec::new(),
212            rendered: Vec::new(),
213        });
214    }
215
216    fn pop_frame(&mut self) {
217        self.frames.pop();
218    }
219
220    fn declare(&mut self, prefix: &str, uri: &str) {
221        if let Some(frame) = self.frames.last_mut() {
222            frame.declared.push((prefix.to_string(), uri.to_string()));
223        }
224    }
225
226    fn record_rendered(&mut self, prefix: &str, uri: &str) {
227        if let Some(frame) = self.frames.last_mut() {
228            frame.rendered.push((prefix.to_string(), uri.to_string()));
229        }
230    }
231
232    /// Walk frame stack newest-to-oldest looking up `prefix`.
233    fn lookup(&self, prefix: &str) -> Option<&str> {
234        for frame in self.frames.iter().rev() {
235            for (p, u) in frame.declared.iter().rev() {
236                if p == prefix {
237                    return Some(u);
238                }
239            }
240        }
241        // Built-in: xml prefix.
242        if prefix == "xml" {
243            return Some("http://www.w3.org/XML/1998/namespace");
244        }
245        None
246    }
247
248    /// True if (prefix, uri) was already rendered above this element.
249    fn already_rendered(&self, prefix: &str, uri: &str) -> bool {
250        let last = self.frames.len();
251        if last == 0 {
252            return false;
253        }
254        for frame in &self.frames[..last - 1] {
255            for (p, u) in &frame.rendered {
256                if p == prefix && u == uri {
257                    return true;
258                }
259            }
260        }
261        false
262    }
263
264    /// True if `prefix` was rendered with a *different* URI in some ancestor —
265    /// the current binding overrides it and must be rendered.  C14N 1.0 § 2.3.
266    fn ancestor_rendered_different(&self, prefix: &str, uri: &str) -> bool {
267        let last = self.frames.len();
268        if last == 0 {
269            return false;
270        }
271        for frame in self.frames[..last - 1].iter().rev() {
272            for (p, u) in frame.rendered.iter().rev() {
273                if p == prefix {
274                    return u != uri;
275                }
276            }
277        }
278        false
279    }
280}
281
282// ── walker ───────────────────────────────────────────────────────────────────
283
284fn write_node(
285    out: &mut dyn Write,
286    node: &Node<'_>,
287    ctx: &mut NsContext,
288    opts: &CanonicalizeOptions,
289    is_visible: &dyn Fn(VisitTarget<'_, '_>) -> bool,
290) -> io::Result<()> {
291    if !is_visible(VisitTarget::Node(node)) {
292        // Subtree skip for elements; single-node skip for everything else.
293        return Ok(());
294    }
295    match node.kind {
296        NodeKind::Element => write_element(out, node, ctx, opts, is_visible)?,
297        NodeKind::Text => write_text_canonical(out, node.content())?,
298        NodeKind::CData => {
299            // CDATA sections become regular text in canonical form.
300            write_text_canonical(out, node.content())?;
301        }
302        NodeKind::Comment => {
303            if opts.with_comments {
304                out.write_all(b"<!--")?;
305                out.write_all(node.content().as_bytes())?;
306                out.write_all(b"-->")?;
307            }
308        }
309        // NodeKind::Attribute is the discriminant used on
310        // Attribute<'_>::kind (c-abi build) to satisfy libxml2's
311        // generic xmlNode/xmlAttr cross-casting.  It never appears
312        // as a real Node::kind — Attributes are walked through
313        // node.attributes(), not as children.
314        // The DTD (internal subset) is not part of the C14N document
315        // subset — neither the node itself nor its declarations are
316        // emitted in canonical form.
317        NodeKind::DtdDecl => {}
318        NodeKind::Dtd => {}
319        NodeKind::Attribute => unreachable!("Attribute kind never appears on a Node"),
320        NodeKind::Document  => unreachable!("Document kind never appears on a Node"),
321        // DocumentFragment is a transient container only produced by
322        // `xmlNewDocFragment` in the compat shim.  It never appears as
323        // an attached node in a real canonicalization target — if
324        // someone asks us to c14n it, walking its children directly is
325        // the closest sensible behaviour.
326        NodeKind::DocumentFragment => {
327            for c in node.children() {
328                write_node(out, c, ctx, opts, is_visible)?;
329            }
330        }
331        NodeKind::EntityRef => {
332            // C14N § 2.3 says entity references SHOULD be replaced by
333            // their replacement text before canonicalization (the
334            // canonicalization input is a post-expansion XPath data
335            // model).  If a doc reaches the canonicalizer with
336            // EntityRef nodes still in the tree (parsed with
337            // resolve_entities=false), emit the literal `&name;`
338            // form — best-effort byte-stable round-trip.  Callers
339            // who need spec-conformant C14N should re-parse with
340            // resolve_entities=true.
341            out.write_all(node.content().as_bytes())?;
342        }
343        NodeKind::Pi => {
344            out.write_all(b"<?")?;
345            out.write_all(node.name().as_bytes())?;
346            let content = node.content();
347            if !content.is_empty() {
348                out.write_all(b" ")?;
349                out.write_all(content.as_bytes())?;
350            }
351            out.write_all(b"?>")?;
352        }
353    }
354    Ok(())
355}
356
357fn write_element(
358    out: &mut dyn Write,
359    el: &Node<'_>,
360    ctx: &mut NsContext,
361    opts: &CanonicalizeOptions,
362    is_visible: &dyn Fn(VisitTarget<'_, '_>) -> bool,
363) -> io::Result<()> {
364    ctx.push_frame();
365
366    // Scan namespace declarations (always in scope) and regular
367    // attributes (filtered through is_visible).  In the c-abi build
368    // namespace declarations live on the element's `ns_def` chain
369    // (libxml2 convention) rather than mixed into the attribute list,
370    // so we read them from there too.
371    // Each entry: (ns_prefix, name, effective_prefix, local, value).
372    // `ns_prefix` is the namespace-carried prefix prepended to a local
373    // `name` (Some only in the c-abi/compat representation); `name` is
374    // `attr.name()` (local there, full QName otherwise); `effective_prefix`
375    // is for namespace lookup/visibility; `local` is the sort tiebreak.
376    let mut regular_attrs: Vec<(Option<&str>, &str, Option<&str>, &str, &str)> = Vec::new();
377    #[cfg(feature = "c-abi")]
378    {
379        let mut ns_cur = el.ns_def.get();
380        while let Some(ns) = ns_cur {
381            match ns.prefix() {
382                None    => ctx.declare("",  ns.href()),
383                Some(p) => ctx.declare(p,   ns.href()),
384            }
385            ns_cur = ns.next.get();
386        }
387    }
388    for attr in el.attributes() {
389        let aname: &str = attr.name();
390        if aname == "xmlns" {
391            ctx.declare("", attr.value());
392        } else if let Some(rest) = aname.strip_prefix("xmlns:") {
393            ctx.declare(rest, attr.value());
394        } else if is_visible(VisitTarget::Attribute(attr)) {
395            #[cfg(feature = "c-abi")]
396            let ns_prefix: Option<&str> = attr.namespace.get().and_then(|ns| ns.prefix());
397            #[cfg(not(feature = "c-abi"))]
398            let ns_prefix: Option<&str> = None;
399            let (name_prefix, local) = split_qname(aname);
400            let eff_prefix = ns_prefix.or(name_prefix);
401            regular_attrs.push((ns_prefix, aname, eff_prefix, local, attr.value()));
402        }
403    }
404
405    // Determine the element's prefix and the QName to serialize.  Two
406    // representations reach here:
407    //   * compat-parsed (c-abi, libxml2 convention): `name` is the local
408    //     part and the prefix lives on the attached namespace.
409    //   * core-parsed / non-c-abi: `name` already carries the prefix and
410    //     the namespace object may be absent.
411    // `ns_prefix` is the prefix to prepend to a *local* name (Some only
412    // when the namespace carries it); `effective_prefix` is the prefix
413    // for visible-namespace accounting, taken from the namespace or the
414    // QName itself.
415    let elem_name: &str = el.name();
416    #[cfg(feature = "c-abi")]
417    let ns_prefix: Option<&str> = el.namespace.get().and_then(|ns| ns.prefix());
418    #[cfg(not(feature = "c-abi"))]
419    let ns_prefix: Option<&str> = None;
420    let effective_prefix = ns_prefix.or_else(|| split_qname(elem_name).0);
421    let visibly_used = collect_visibly_used(effective_prefix, &regular_attrs, opts);
422
423    out.write_all(b"<")?;
424    write_qname(out, ns_prefix, elem_name)?;
425    write_namespace_decls(out, ctx, opts, &visibly_used)?;
426    write_attributes(out, ctx, &mut regular_attrs)?;
427    out.write_all(b">")?;
428
429    for child in el.children() {
430        write_node(out, child, ctx, opts, is_visible)?;
431    }
432
433    // Canonical form always uses an explicit end tag — never `<e/>`.
434    out.write_all(b"</")?;
435    write_qname(out, ns_prefix, elem_name)?;
436    out.write_all(b">")?;
437
438    ctx.pop_frame();
439    Ok(())
440}
441
442/// Compute the set of prefixes visibly used at this element.
443fn collect_visibly_used(
444    elem_prefix: Option<&str>,
445    attrs: &[(Option<&str>, &str, Option<&str>, &str, &str)],
446    opts: &CanonicalizeOptions,
447) -> HashSet<String> {
448    let mut used: HashSet<String> = HashSet::with_capacity(8);
449    // Element's own prefix.  Unprefixed name → default namespace, "".
450    used.insert(elem_prefix.unwrap_or("").to_string());
451
452    // Per XML Names: unprefixed attributes are NOT in the default namespace.
453    for (_ns_prefix, _name, eff_prefix, _local, _value) in attrs {
454        if let Some(p) = eff_prefix {
455            used.insert(p.to_string());
456        }
457    }
458
459    if let C14nMode::ExcC14n10 { inclusive_prefixes } = &opts.mode {
460        for p in inclusive_prefixes {
461            used.insert(p.clone());
462        }
463    }
464
465    used
466}
467
468/// Render namespace declarations.  C14N 1.0: every in-scope binding not yet
469/// rendered (or overridden).  Exc-c14n: only visibly-used prefixes.
470fn write_namespace_decls(
471    out: &mut dyn Write,
472    ctx: &mut NsContext,
473    opts: &CanonicalizeOptions,
474    visibly_used: &HashSet<String>,
475) -> io::Result<()> {
476    let mut to_render: Vec<(String, String)> = Vec::new();
477
478    match &opts.mode {
479        C14nMode::C14n10 => {
480            // C14N § 2.3: walk in-scope namespaces (most recent binding per
481            // prefix), render iff not already rendered above, or if it overrides.
482            let mut seen_prefixes: HashSet<String> = HashSet::new();
483            for frame in ctx.frames.iter().rev() {
484                for (prefix, uri) in &frame.declared {
485                    if seen_prefixes.insert(prefix.clone()) {
486                        if uri.is_empty() && prefix.is_empty() {
487                            // xmlns="" — only render if it overrides a non-empty
488                            // default rendered above.
489                            if ctx.ancestor_rendered_different(prefix, uri) {
490                                to_render.push((prefix.clone(), uri.clone()));
491                            }
492                        } else if !ctx.already_rendered(prefix, uri) {
493                            to_render.push((prefix.clone(), uri.clone()));
494                        }
495                    }
496                }
497            }
498        }
499        C14nMode::ExcC14n10 { .. } => {
500            // exc-c14n § 3: for each visibly-used prefix, render its current
501            // binding iff not already rendered above (or with a different URI).
502            for prefix in visibly_used {
503                let uri = ctx.lookup(prefix).unwrap_or("");
504                if prefix.is_empty() && uri.is_empty() {
505                    if ctx.ancestor_rendered_different(prefix, uri) {
506                        to_render.push((prefix.clone(), String::new()));
507                    }
508                    continue;
509                }
510                // Skip the built-in `xml` prefix.
511                if prefix == "xml" && uri == "http://www.w3.org/XML/1998/namespace" {
512                    continue;
513                }
514                if !ctx.already_rendered(prefix, uri) {
515                    to_render.push((prefix.clone(), uri.to_string()));
516                }
517            }
518        }
519    }
520
521    // Sort: default namespace ("") first, then by prefix lexicographically.
522    to_render.sort_by(|a, b| a.0.cmp(&b.0));
523
524    for (prefix, uri) in &to_render {
525        out.write_all(b" ")?;
526        if prefix.is_empty() {
527            out.write_all(b"xmlns=\"")?;
528        } else {
529            out.write_all(b"xmlns:")?;
530            out.write_all(prefix.as_bytes())?;
531            out.write_all(b"=\"")?;
532        }
533        write_attr_value_canonical(out, uri)?;
534        out.write_all(b"\"")?;
535        ctx.record_rendered(prefix, uri);
536    }
537    Ok(())
538}
539
540/// Render regular (non-xmlns) attributes in canonical sort order: namespace
541/// URI (empty first), then local name.
542fn write_attributes(
543    out: &mut dyn Write,
544    ctx: &NsContext,
545    attrs: &mut [(Option<&str>, &str, Option<&str>, &str, &str)],
546) -> io::Result<()> {
547    // C14N sorts attributes by (namespace URI, local name); attributes
548    // in no namespace (effective prefix None) sort before namespaced
549    // ones, matching an empty URI.
550    attrs.sort_by(|a, b| {
551        let a_ns = a.2.and_then(|p| ctx.lookup(p)).unwrap_or("");
552        let b_ns = b.2.and_then(|p| ctx.lookup(p)).unwrap_or("");
553        match a_ns.cmp(b_ns) {
554            std::cmp::Ordering::Equal => a.3.cmp(b.3),
555            ord => ord,
556        }
557    });
558
559    for (ns_prefix, name, _eff_prefix, _local, value) in attrs {
560        out.write_all(b" ")?;
561        write_qname(out, *ns_prefix, name)?;
562        out.write_all(b"=\"")?;
563        write_attr_value_canonical(out, value)?;
564        out.write_all(b"\"")?;
565    }
566    Ok(())
567}
568
569/// Write a qualified name.  `ns_prefix` is the prefix carried by the
570/// attached namespace and is prepended to a *local* `name`; callers pass
571/// `None` when `name` already includes any prefix (core / non-c-abi).
572fn write_qname(out: &mut dyn Write, ns_prefix: Option<&str>, name: &str) -> io::Result<()> {
573    if let Some(p) = ns_prefix {
574        out.write_all(p.as_bytes())?;
575        out.write_all(b":")?;
576    }
577    out.write_all(name.as_bytes())
578}
579
580// ── canonical character escaping ─────────────────────────────────────────────
581
582/// Escape text per C14N § 1.3.3: `&`, `<`, `>`, `\r`.
583fn write_text_canonical(out: &mut dyn Write, s: &str) -> io::Result<()> {
584    // Coalesce runs of pass-through bytes into a single write to keep
585    // the per-byte virtual-call cost off the hot path.
586    let bytes = s.as_bytes();
587    let mut start = 0;
588    for (i, &b) in bytes.iter().enumerate() {
589        let replacement: &[u8] = match b {
590            b'&'  => b"&amp;",
591            b'<'  => b"&lt;",
592            b'>'  => b"&gt;",
593            b'\r' => b"&#xD;",
594            _     => continue,
595        };
596        if start < i {
597            out.write_all(&bytes[start..i])?;
598        }
599        out.write_all(replacement)?;
600        start = i + 1;
601    }
602    if start < bytes.len() {
603        out.write_all(&bytes[start..])?;
604    }
605    Ok(())
606}
607
608/// Escape attribute value per C14N § 1.3.3: `&`, `<`, `"`, `\t`, `\n`, `\r`.
609fn write_attr_value_canonical(out: &mut dyn Write, s: &str) -> io::Result<()> {
610    let bytes = s.as_bytes();
611    let mut start = 0;
612    for (i, &b) in bytes.iter().enumerate() {
613        let replacement: &[u8] = match b {
614            b'&'  => b"&amp;",
615            b'<'  => b"&lt;",
616            b'"'  => b"&quot;",
617            b'\t' => b"&#x9;",
618            b'\n' => b"&#xA;",
619            b'\r' => b"&#xD;",
620            _     => continue,
621        };
622        if start < i {
623            out.write_all(&bytes[start..i])?;
624        }
625        out.write_all(replacement)?;
626        start = i + 1;
627    }
628    if start < bytes.len() {
629        out.write_all(&bytes[start..])?;
630    }
631    Ok(())
632}
633
634// ── small helpers ────────────────────────────────────────────────────────────
635
636/// Split an XML qualified name into (prefix, local).  Doesn't validate.
637fn split_qname(name: &str) -> (Option<&str>, &str) {
638    match name.find(':') {
639        Some(idx) => (Some(&name[..idx]), &name[idx + 1..]),
640        None => (None, name),
641    }
642}
643
644fn estimate_capacity(_doc: &Document) -> usize {
645    // Rough heuristic — canonical form is usually 1.0–1.5× source size.
646    4096
647}
648
649// ── tests ────────────────────────────────────────────────────────────────────
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::parser::parse_str;
655    use crate::options::ParseOptions;
656
657    fn c14n(xml: &str, mode: C14nMode, with_comments: bool) -> String {
658        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
659        let bytes = canonicalize_to_bytes(
660            &doc,
661            &CanonicalizeOptions { mode, with_comments },
662        );
663        String::from_utf8(bytes).expect("c14n produces UTF-8")
664    }
665
666    fn c14n10(xml: &str) -> String {
667        c14n(xml, C14nMode::C14n10, false)
668    }
669
670    fn exc_c14n10(xml: &str) -> String {
671        c14n(xml, C14nMode::ExcC14n10 { inclusive_prefixes: vec![] }, false)
672    }
673
674    // ── basic shape ──────────────────────────────────────────────────────────
675
676    #[test]
677    fn simple_element_round_trips_to_explicit_close() {
678        let out = c14n10("<r/>");
679        assert_eq!(out, "<r></r>");
680    }
681
682    #[test]
683    fn empty_element_with_attrs() {
684        let out = c14n10(r#"<r a="1"/>"#);
685        assert_eq!(out, r#"<r a="1"></r>"#);
686    }
687
688    #[test]
689    fn xml_decl_dropped() {
690        let out = c14n10(r#"<?xml version="1.0"?><r/>"#);
691        assert!(!out.contains("<?xml"));
692        assert_eq!(out, "<r></r>");
693    }
694
695    #[test]
696    fn attribute_value_quotes_canonical() {
697        let out = c14n10("<r a='1'/>");
698        assert_eq!(out, r#"<r a="1"></r>"#);
699    }
700
701    // ── attribute escaping ───────────────────────────────────────────────────
702
703    #[test]
704    fn attribute_value_escapes_quote_and_control() {
705        let out = c14n10("<r a=\"a&amp;b&lt;c&#x9;d\"/>");
706        assert_eq!(out, r#"<r a="a&amp;b&lt;c&#x9;d"></r>"#);
707    }
708
709    // ── text escaping ────────────────────────────────────────────────────────
710
711    #[test]
712    fn text_escapes_amp_lt_gt() {
713        let out = c14n10("<r>a&amp;b&lt;c&gt;d</r>");
714        assert_eq!(out, "<r>a&amp;b&lt;c&gt;d</r>");
715    }
716
717    #[test]
718    fn text_does_not_escape_tab_newline() {
719        let out = c14n10("<r>a\tb\nc</r>");
720        assert_eq!(out, "<r>a\tb\nc</r>");
721    }
722
723    // ── attribute sort order ─────────────────────────────────────────────────
724
725    #[test]
726    fn attributes_sorted_lexicographically_when_no_namespace() {
727        let out = c14n10(r#"<r z="1" a="2" m="3"/>"#);
728        assert_eq!(out, r#"<r a="2" m="3" z="1"></r>"#);
729    }
730
731    #[test]
732    fn namespace_decls_come_before_attributes() {
733        let out = c14n10(r#"<r a="1" xmlns:b="urn:b" b:c="2"/>"#);
734        assert_eq!(out, r#"<r xmlns:b="urn:b" a="1" b:c="2"></r>"#);
735    }
736
737    #[test]
738    fn default_namespace_sorts_before_prefixed_namespace() {
739        let out = c14n10(r#"<r xmlns:b="urn:b" xmlns="urn:default"/>"#);
740        assert_eq!(out, r#"<r xmlns="urn:default" xmlns:b="urn:b"></r>"#);
741    }
742
743    // ── namespace de-duplication ─────────────────────────────────────────────
744
745    #[test]
746    fn c14n10_does_not_repeat_inherited_namespace() {
747        let out = c14n10(r#"<outer xmlns:a="urn:a"><inner/></outer>"#);
748        assert_eq!(out, r#"<outer xmlns:a="urn:a"><inner></inner></outer>"#);
749    }
750
751    #[test]
752    fn c14n10_renders_inherited_when_subtree_uses_prefix() {
753        let out = c14n10(r#"<outer xmlns:a="urn:a"><a:inner/></outer>"#);
754        assert_eq!(out, r#"<outer xmlns:a="urn:a"><a:inner></a:inner></outer>"#);
755    }
756
757    // ── exc-c14n: only renders visibly-used prefixes ─────────────────────────
758
759    #[test]
760    fn exc_c14n_omits_unused_inherited_namespace() {
761        let out = exc_c14n10(r#"<a:outer xmlns:a="urn:a"><inner/></a:outer>"#);
762        assert_eq!(out, r#"<a:outer xmlns:a="urn:a"><inner></inner></a:outer>"#);
763    }
764
765    #[test]
766    fn exc_c14n_renders_namespace_for_used_prefix() {
767        let out = exc_c14n10(r#"<outer xmlns:a="urn:a"><a:inner/></outer>"#);
768        assert_eq!(out, r#"<outer><a:inner xmlns:a="urn:a"></a:inner></outer>"#);
769    }
770
771    #[test]
772    fn exc_c14n_inclusive_prefix_list() {
773        let doc = parse_str(
774            r#"<outer xmlns:a="urn:a"><inner/></outer>"#,
775            &ParseOptions::default(),
776        )
777        .unwrap();
778        let bytes = canonicalize_to_bytes(
779            &doc,
780            &CanonicalizeOptions {
781                mode: C14nMode::ExcC14n10 {
782                    inclusive_prefixes: vec!["a".into()],
783                },
784                with_comments: false,
785            },
786        );
787        let s = String::from_utf8(bytes).unwrap();
788        assert_eq!(
789            s,
790            r#"<outer xmlns:a="urn:a"><inner></inner></outer>"#,
791            "inclusive-prefixes adds `a` to the visibly-used set on both elements, but standard de-dup means inner doesn't re-render an inherited binding"
792        );
793    }
794
795    // ── comments ─────────────────────────────────────────────────────────────
796
797    #[test]
798    fn comments_omitted_by_default() {
799        let out = c14n10("<r><!-- hi --></r>");
800        assert_eq!(out, "<r></r>");
801    }
802
803    #[test]
804    fn comments_included_when_with_comments() {
805        let out = c14n("<r><!-- hi --></r>", C14nMode::C14n10, true);
806        assert_eq!(out, "<r><!-- hi --></r>");
807    }
808
809    // ── PIs ──────────────────────────────────────────────────────────────────
810
811    #[test]
812    fn processing_instruction_preserved() {
813        let out = c14n10(r#"<r><?target value?></r>"#);
814        assert_eq!(out, r#"<r><?target value?></r>"#);
815    }
816
817    // ── CDATA → text ─────────────────────────────────────────────────────────
818
819    #[test]
820    fn cdata_section_becomes_text() {
821        let out = c14n10("<r><![CDATA[<raw>&]]></r>");
822        assert_eq!(out, "<r>&lt;raw&gt;&amp;</r>");
823    }
824
825    // ── idempotency ──────────────────────────────────────────────────────────
826
827    #[test]
828    fn c14n_is_idempotent() {
829        let xml = r#"<r xmlns:b='urn:b' a='1' z='2' b:x="hi"><child/></r>"#;
830        let once = c14n10(xml);
831        let twice = c14n10(&once);
832        assert_eq!(once, twice, "c14n must be idempotent");
833    }
834
835    #[test]
836    fn exc_c14n_is_idempotent() {
837        let xml = r#"<r xmlns:b='urn:b' a='1' z='2' b:x="hi"><b:child/></r>"#;
838        let once = exc_c14n10(xml);
839        let twice = exc_c14n10(&once);
840        assert_eq!(once, twice, "exc-c14n must be idempotent");
841    }
842
843    // ── canonicalize_node_to_bytes ─────────────────────────────────────
844
845    #[test]
846    fn canonicalize_node_works_on_subtree() {
847        let doc = parse_str(
848            r#"<root><target a="1"><child/></target></root>"#,
849            &ParseOptions::default(),
850        )
851        .unwrap();
852        // Find <target> (first child of root) and canonicalize just its subtree.
853        let target = doc.root().first_child.get().expect("target child");
854        let bytes = canonicalize_node_to_bytes(target, &CanonicalizeOptions::default());
855        assert_eq!(
856            String::from_utf8(bytes).unwrap(),
857            r#"<target a="1"><child></child></target>"#
858        );
859    }
860
861    // ── streaming + visibility ────────────────────────────────────────
862
863    #[test]
864    fn canonicalize_with_matches_canonicalize_to_bytes() {
865        // Whatever shape the one-shot variant produces, the streaming
866        // variant with include_all must produce byte-identical output.
867        let xml = r#"<r xmlns:b='urn:b' a='1' z='2' b:x="hi"><c/><!--note--></r>"#;
868        let doc = parse_str(xml, &ParseOptions::default()).unwrap();
869        let opts = CanonicalizeOptions { mode: C14nMode::C14n10, with_comments: true };
870
871        let bulk = canonicalize_to_bytes(&doc, &opts);
872        let mut streamed = Vec::new();
873        canonicalize_with(&doc, &opts, &mut streamed, include_all).unwrap();
874        assert_eq!(bulk, streamed);
875    }
876
877    #[test]
878    fn canonicalize_with_skips_subtree_for_hidden_element() {
879        let doc = parse_str(
880            r#"<r><keep>x</keep><secret><nested/></secret><also/></r>"#,
881            &ParseOptions::default(),
882        )
883        .unwrap();
884        let mut out = Vec::new();
885        canonicalize_with(&doc, &CanonicalizeOptions::default(), &mut out, |t| {
886            match t {
887                VisitTarget::Node(n) => n.name() != "secret",
888                VisitTarget::Attribute(_) => true,
889            }
890        })
891        .unwrap();
892        let s = String::from_utf8(out).unwrap();
893        assert_eq!(s, "<r><keep>x</keep><also></also></r>");
894    }
895
896    #[test]
897    fn canonicalize_with_skips_individual_attribute() {
898        let doc = parse_str(
899            r#"<r keep="1" drop="2" also="3"/>"#,
900            &ParseOptions::default(),
901        )
902        .unwrap();
903        let mut out = Vec::new();
904        canonicalize_with(&doc, &CanonicalizeOptions::default(), &mut out, |t| {
905            match t {
906                VisitTarget::Attribute(a) => a.name() != "drop",
907                VisitTarget::Node(_) => true,
908            }
909        })
910        .unwrap();
911        let s = String::from_utf8(out).unwrap();
912        assert_eq!(s, r#"<r also="3" keep="1"></r>"#);
913    }
914
915    #[test]
916    fn canonicalize_with_streams_incrementally() {
917        // Sink that counts the number of distinct write() calls — proves
918        // the walker is producing bytes incrementally rather than buffering
919        // the full canonical form before handing it off.
920        struct CountingSink {
921            buf: Vec<u8>,
922            chunks: usize,
923        }
924        impl std::io::Write for CountingSink {
925            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
926                if !b.is_empty() {
927                    self.chunks += 1;
928                    self.buf.extend_from_slice(b);
929                }
930                Ok(b.len())
931            }
932            fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
933        }
934
935        let doc = parse_str(
936            r#"<r><a/><b/><c/></r>"#,
937            &ParseOptions::default(),
938        )
939        .unwrap();
940        let mut sink = CountingSink { buf: Vec::new(), chunks: 0 };
941        canonicalize_with(
942            &doc, &CanonicalizeOptions::default(), &mut sink, include_all,
943        )
944        .unwrap();
945        assert!(
946            sink.chunks > 1,
947            "expected multiple incremental writes, got {} for {:?}",
948            sink.chunks,
949            String::from_utf8_lossy(&sink.buf),
950        );
951        assert_eq!(
952            String::from_utf8(sink.buf).unwrap(),
953            "<r><a></a><b></b><c></c></r>",
954        );
955    }
956
957    #[test]
958    fn canonicalize_with_propagates_sink_errors() {
959        // Sink that fails on the second write — verifies the walker
960        // propagates io::Error rather than silently truncating.
961        struct FlakySink { remaining: usize }
962        impl std::io::Write for FlakySink {
963            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
964                if self.remaining == 0 {
965                    return Err(std::io::Error::other("boom"));
966                }
967                self.remaining -= 1;
968                Ok(b.len())
969            }
970            fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
971        }
972        let doc = parse_str(r#"<r><a/><b/></r>"#, &ParseOptions::default()).unwrap();
973        let mut sink = FlakySink { remaining: 1 };
974        let err = canonicalize_with(
975            &doc, &CanonicalizeOptions::default(), &mut sink, include_all,
976        )
977        .unwrap_err();
978        assert_eq!(err.to_string(), "boom");
979    }
980}