omnist/formats/xml.rs
1//! XML codec. Ported from `~/dev/omnist/omnist/formats.py`'s
2//! `read_xml`/`write_xml`/`check_xml`.
3//!
4//! ## Structural difference from `json.rs`/`yaml.rs`/`toml.rs`
5//!
6//! Per the Python module's own top docstring: "XML uses repeated elements
7//! directly, so it preserves interleaving on read and needs a single
8//! document element on write." Unlike the other three codecs, which all
9//! project through [`crate::document::Doc::to_grouped`] (the JSON-shaped
10//! "same-label edges become an array" representation), this module goes
11//! through [`crate::document::Doc::from_raw`]/[`Doc::to_raw`] and
12//! [`crate::document::RawNode`] instead -- the same interleaving-preserving
13//! path `crate::oml` uses, and for the same reason: XML element order can
14//! interleave distinct labels arbitrarily (`<b/><c/><b/>`), which a
15//! `Value::Object`'s `IndexMap` cannot represent (see `RawNode`'s own doc
16//! comment in `document.rs`). Read builds a `RawNode` directly from the
17//! parsed element tree; write walks `Doc::to_raw()` directly, never
18//! `to_grouped()`.
19//!
20//! ## Crate choice: `quick-xml`, advisory-checked
21//!
22//! [`quick_xml`] is used for read-side tokenization only (default features,
23//! no `serde`); the omnist-specific layer -- interleaving/repetition
24//! preservation, the depth guard, all-occurrences sanitization -- is
25//! hand-written, mirroring `yaml.rs`/`toml.rs`'s
26//! crate-for-tokenization-plus-hand-written-logic pattern.
27//!
28//! Checked per the omnist-ts#38 concern (an unfixable `fast-xml-parser`
29//! GHSA advisory the TS port could not shed): `cargo audit` against this
30//! crate's full dependency tree (29 crates, including `quick-xml` 0.41.0)
31//! on 2026-07-26 against the RustSec advisory database (1169 advisories
32//! loaded) found **zero** matches -- `quick-xml` has no open advisory at
33//! this version, unlike TS's situation. Additionally, `quick-xml` has no
34//! DTD/external-entity expansion support at all (only the five predefined
35//! XML entities `< > & ' "` are recognized; an
36//! undefined entity reference is a parse error, not silently resolved) --
37//! so, unlike Python's `read_xml` (which specifically requires
38//! `defusedxml` instead of the stdlib `xml.etree.ElementTree`, precisely to
39//! shut off XXE/entity-expansion attacks), this port has no equivalent
40//! opt-in needed: the crate is XXE-safe by construction, not by
41//! configuration.
42//!
43//! ## Namespace handling: a disclosed simplification
44//!
45//! Python's `read_xml` uses `ElementTree`, which resolves a namespaced tag
46//! into Clark notation (`{uri}local`) and `_local()` strips the `{uri}`
47//! prefix to get the bare local name. `quick_xml` (used here in
48//! non-namespace-aware mode) does not perform namespace URI resolution;
49//! `local_name` instead strips a lexical `prefix:` (up to the last `:`)
50//! from a tag, which coincides with Python's behavior for the common case
51//! (a declared, in-scope prefix) but does not resolve prefixes through
52//! `xmlns` declarations the way real namespace-aware processing would.
53//! Namespaces are outside this issue's spec (the Python docstring never
54//! mentions them), so this is a deliberate, disclosed simplification, not
55//! a claimed parity guarantee.
56//!
57//! ## Text stays untyped until materialize (omnist-rs#86)
58//!
59//! XML's grammar carries no type information -- `<m>1</m>` and `<m>hi</m>`
60//! are syntactically identical, a bare text node. Per `docs/formats/xml.md`
61//! ("Text is untyped ... every leaf arrives as a string. Typing requires a
62//! schema in stage 2."), [`read_xml`] builds every leaf as `Scalar::Str`
63//! unconditionally, with no int/float/bool inference at parse time --
64//! confirmed against a live `~/dev/venvs/omnist` `read_xml`: `<m>1</m>`
65//! reads as `Scalar::Str("1")`, never `Scalar::Int((1).into())`.
66//!
67//! An earlier version of this module ported a `coerce()` helper that
68//! type-inferred leaf text (bool/int/float) at parse time, contradicting
69//! the spec and diverging from Python's reference `read_xml` -- filed and
70//! fixed as omnist-rs#86 (found via the conformance harness, vector
71//! `formats-xml/basic/interleaved-elements-preserve-order`). Python fixed
72//! the identical bug in its own `read_xml` as `omnist#288`
73//! (`_xml_to_node` no longer infers scalar kind from text shape); this
74//! module's fix mirrors that commit.
75//!
76//! ## Schema-guided pretyping (issue #114)
77//!
78//! Because XML text carries no native type information and `materialize`
79//! intentionally never coerces plain strings to `integer`/`number`/`boolean`
80//! scalars, [`read_xml_with_schema`] performs schema-guided pretyping of
81//! `boolean`, `integer`, and `number` fields before materialization,
82//! mirroring Python's `_xml_pretype`. Fields typed `any`, date/time/datetime,
83//! and mismatched text stay strings for normal stage-2 validation/materialization
84//! reporting.
85//!
86//! ## Illegal characters fail the write (no substitution)
87//!
88//! XML 1.0 cannot represent a raw C0 control character other than tab, LF and
89//! CR (nor U+FFFE/U+FFFF), and there is no substitute spelling, so a string
90//! holding one fails the write unconditionally with `write.unsupported-value`
91//! at that string's path (spec section 8.3.8) -- it is never dropped or
92//! replaced. `scan_xml_cursor` checks every leaf through
93//! `is_xml_illegal_char` before any output exists.
94//!
95//! ## Depth guard reuse
96//!
97//! [`read_xml`] checks nesting depth itself, inline, during the
98//! recursive-descent walk of `quick_xml`'s pull events (mirroring Python's
99//! own `_xml_to_node`, which inlines the identical check against the
100//! shared `_MAX_DEPTH` constant rather than routing through
101//! `document.py`'s write-side guard) -- this is necessary because,
102//! without it, an adversarially deep input could blow the native Rust call
103//! stack in `parse_content`'s own recursion *before* [`Doc::from_raw`]
104//! ever gets a chance to reject it via
105//! `crate::document::check_write_depth`. [`Doc::from_raw`] then applies
106//! its own guard a second time when building the final `Doc` -- a harmless,
107//! redundant confirmation of the same limit, not a second copy of the
108//! guard's logic (it calls the crate's one shared `check_write_depth`).
109//! [`write_xml`]/[`check_xml`] do not re-guard on the way out, matching
110//! `json.rs`'s reasoning: they walk an already-`Doc`-validated
111//! [`RawNode`], so there is nothing left to guard.
112//!
113//! ## Single document element (write-side)
114//!
115//! [`write_xml`] requires `doc.to_raw()` to be a [`RawNode::Edges`] with
116//! **exactly one** edge -- any other shape (a bare leaf-rooted `Doc`, zero
117//! edges, or more than one top-level edge) is a [`crate::error::WriteError`],
118//! matching Python's `write_xml` check
119//! (`if not (isinstance(node, list) and len(node) == 1): raise WriteError(...)`)
120//! exactly, including that it fires *outside* [`crate::report::finish_write`]
121//! -- unconditionally, even under `strict=false`, and with no
122//! [`crate::report::WriteReport`] attached (mirrors `toml.rs`'s identical
123//! "non-table root" precedent).
124
125use crate::WriteError;
126use crate::document::{Cursor, Doc, RawNode, Scalar};
127use crate::error::{DocumentError, OmnistError, ParseError};
128use crate::formats::float_fmt;
129use crate::formats::textpos::line_col_bytes;
130use crate::limits::{Limits, Resolved as ResolvedLimits};
131use crate::report::{Severity, WriteReport};
132use crate::schema::{FieldType, Resolved, ScalarKind, Schema};
133use indexmap::IndexMap;
134use quick_xml::Reader;
135use quick_xml::events::Event;
136
137// ============================================================== Reader
138
139static XML_INT_RE: std::sync::LazyLock<regex::Regex> =
140 std::sync::LazyLock::new(|| regex::Regex::new(r"^-?(0|[1-9]\d*)$").unwrap());
141
142static XML_NUM_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
143 regex::Regex::new(r"^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$").unwrap()
144});
145
146fn xml_pretype_scalar(
147 node: RawNode,
148 s: &crate::schema::Scalar,
149 guard: &mut crate::limits::IntGuard,
150) -> RawNode {
151 let RawNode::Leaf(Scalar::Str(ref val)) = node else {
152 return node;
153 };
154 match s.kind() {
155 ScalarKind::Boolean => {
156 if val == "true" {
157 RawNode::Leaf(Scalar::Bool(true))
158 } else if val == "false" {
159 RawNode::Leaf(Scalar::Bool(false))
160 } else {
161 node
162 }
163 }
164 ScalarKind::Integer => {
165 if XML_INT_RE.is_match(val) {
166 let digits = if let Some(stripped) = val.strip_prefix('-') {
167 stripped
168 } else {
169 val.as_str()
170 };
171 if digits.len() <= guard.max() {
172 let i: num_bigint::BigInt = val
173 .parse()
174 .expect("XML_INT_RE guarantees valid integer literal");
175 return RawNode::Leaf(Scalar::Int(i));
176 }
177 // Over the D-9 digit limit (counted on the decimal digits of
178 // the value, D-28): the placeholder is itself over the cap, so
179 // the Document constructor refuses it with
180 // `document.limit.int-digits` at this leaf's Document path
181 // (E-10 index included), without the superlinear conversion.
182 return RawNode::Leaf(Scalar::Int(guard.placeholder()));
183 }
184 node
185 }
186 ScalarKind::Number => {
187 // D-28: a `number` is exempt from the integer digit limit however
188 // long its integer part; D-29: past binary64 it reads as an
189 // infinity (underflow as a zero), so the parse never fails.
190 if XML_NUM_RE.is_match(val) {
191 let f: f64 = val
192 .parse()
193 .expect("XML_NUM_RE guarantees valid float literal");
194 return RawNode::Leaf(Scalar::Float(f));
195 }
196 node
197 }
198 _ => node,
199 }
200}
201
202fn xml_pretype(
203 node: RawNode,
204 schema: &Schema,
205 ty: &FieldType,
206 guard: &mut crate::limits::IntGuard,
207) -> RawNode {
208 let d = schema.resolve(ty);
209 match d {
210 Resolved::Any => node,
211 Resolved::Scalar(s) => xml_pretype_scalar(node, &s, guard),
212 Resolved::Record(rec) => {
213 let RawNode::Edges(edges) = node else {
214 return node;
215 };
216 let mut out = Vec::with_capacity(edges.len());
217 for (label, child) in edges {
218 let pretyped_child = if let Some(field) = rec.field(&label) {
219 xml_pretype(child, schema, &field.ty, guard)
220 } else {
221 child
222 };
223 out.push((label, pretyped_child));
224 }
225 RawNode::Edges(out)
226 }
227 }
228}
229
230/// A data-XML profile refusal (`docs/formats/xml.md`, "The data-XML
231/// profile"): the input is well-formed XML that Omnist declines to read.
232///
233/// A refusal is only RECORDED when met (the first one in document order
234/// wins) and raised once the whole document has proved well-formed, so
235/// malformed XML is always `parse.codec-syntax` and never misreported as a
236/// profile refusal (E-24).
237type Refusal = Option<(&'static str, String)>;
238
239fn note_refusal(slot: &mut Refusal, code: &'static str, message: impl Into<String>) {
240 if slot.is_none() {
241 *slot = Some((code, message.into()));
242 }
243}
244
245fn read_xml_raw(
246 text: &str,
247 mut report: Option<&mut WriteReport>,
248 limits: &ResolvedLimits,
249) -> Result<RawNode, OmnistError> {
250 // D-23: first, on the input as received (BOM counted).
251 limits.check_input_size(text.len())?;
252 // D-15/D-21: one leading BOM is stripped, a second is rejected at 1:1
253 // (`parse.codec-syntax`, E-24). XML 1.0 admits a leading BOM, so
254 // quick_xml would otherwise discard a second one silently.
255 let text = crate::bom::strip_leading_bom(text)
256 .map_err(|_| ParseError::codec_syntax(1, 1, crate::bom::DOUBLED_BOM_MESSAGE))?;
257 let mut refusal: Refusal = None;
258 let normalized = normalize_line_endings(text);
259 // C-16: XML 1.0 `Char` only. A character outside it, anywhere in the
260 // document (text, CDATA, an attribute value, a comment, a name), is
261 // `parse.codec-syntax` at its position.
262 if let Some((at, c)) = normalized
263 .char_indices()
264 .find(|&(_, c)| is_xml_illegal_char(c))
265 {
266 let (line, col) = line_col_bytes(&normalized, at);
267 return Err(ParseError::codec_syntax(
268 line,
269 col,
270 format!(
271 "invalid XML: character U+{:04X} is not allowed in XML 1.0",
272 c as u32
273 ),
274 )
275 .into());
276 }
277 let mut reader = Reader::from_str(&normalized);
278 reader.config_mut().trim_text(false);
279 let mut buf = Vec::new();
280 let root_node: RawNode = loop {
281 buf.clear();
282 let ev = reader
283 .read_event_into(&mut buf)
284 .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
285 match ev {
286 Event::Start(e) => {
287 // The document root is the first node; an element becomes a
288 // node (a container) once it has a child element.
289 let mut budget = NodeBudget {
290 limits,
291 node_count: 1,
292 };
293 let tag = local_name(e.name());
294 let path = crate::report::child_path("$", &tag, None);
295 check_attribute_char_refs(&reader, &normalized, &e)?;
296 refuse_attribute_entities(&e, &mut refusal);
297 record_elem_diagnostics(&e, &path, report.as_deref_mut());
298 let content = parse_content(
299 &mut reader,
300 &normalized,
301 1,
302 &mut budget,
303 &path,
304 &mut refusal,
305 report,
306 )?;
307 break RawNode::Edges(vec![(tag, content)]);
308 }
309 Event::Empty(e) => {
310 let tag = local_name(e.name());
311 let path = crate::report::child_path("$", &tag, None);
312 check_attribute_char_refs(&reader, &normalized, &e)?;
313 refuse_attribute_entities(&e, &mut refusal);
314 record_elem_diagnostics(&e, &path, report.as_deref_mut());
315 break RawNode::Edges(vec![(tag, RawNode::Leaf(Scalar::Str(String::new())))]);
316 }
317 Event::Eof => {
318 return Err(located_error(
319 &reader,
320 &normalized,
321 "invalid XML: no root element found",
322 ));
323 }
324 Event::Text(t) => {
325 if !t.iter().all(|b| b.is_ascii_whitespace()) {
326 return Err(located_error(
327 &reader,
328 &normalized,
329 "invalid XML: unexpected text outside root element",
330 ));
331 }
332 }
333 Event::CData(t) => {
334 if !t.iter().all(|b| b.is_ascii_whitespace()) {
335 return Err(located_error(
336 &reader,
337 &normalized,
338 "invalid XML: unexpected text outside root element",
339 ));
340 }
341 }
342 Event::GeneralRef(_) => {
343 return Err(located_error(
344 &reader,
345 &normalized,
346 "invalid XML: unexpected text outside root element",
347 ));
348 }
349 Event::DocType(_) => note_refusal(
350 &mut refusal,
351 "format.dtd-forbidden",
352 "a DOCTYPE declaration is outside the data-XML profile (refused on sight)",
353 ),
354 Event::Decl(_) | Event::Comment(_) | Event::PI(_) | Event::End(_) => {
355 // Legal prolog events: skip.
356 }
357 }
358 };
359
360 loop {
361 buf.clear();
362 let ev = reader
363 .read_event_into(&mut buf)
364 .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
365 match ev {
366 Event::Eof => break,
367 Event::Text(t) => {
368 if !t.iter().all(|b| b.is_ascii_whitespace()) {
369 return Err(located_error(
370 &reader,
371 &normalized,
372 "invalid XML: unexpected text after root element",
373 ));
374 }
375 }
376 Event::CData(t) => {
377 if !t.iter().all(|b| b.is_ascii_whitespace()) {
378 return Err(located_error(
379 &reader,
380 &normalized,
381 "invalid XML: unexpected text after root element",
382 ));
383 }
384 }
385 Event::GeneralRef(_) => {
386 return Err(located_error(
387 &reader,
388 &normalized,
389 "invalid XML: unexpected text after root element",
390 ));
391 }
392 Event::DocType(_) => note_refusal(
393 &mut refusal,
394 "format.dtd-forbidden",
395 "a DOCTYPE declaration is outside the data-XML profile (refused on sight)",
396 ),
397 Event::Comment(_) | Event::PI(_) | Event::Decl(_) | Event::End(_) => {
398 // Legal epilog events: skip.
399 }
400 Event::Start(_) | Event::Empty(_) => {
401 return Err(located_error(
402 &reader,
403 &normalized,
404 "invalid XML: multiple root elements found",
405 ));
406 }
407 }
408 }
409
410 // Well-formedness has been established for the whole document; only now
411 // is a recorded profile refusal raised. Path `$`: the refusal is about
412 // the input as a whole, not one edge (spec, xml profile vectors).
413 if let Some((code, message)) = refusal {
414 return Err(DocumentError::with_code("$", code, message).into());
415 }
416 Ok(root_node)
417}
418
419/// Parse XML text into a [`Doc`], preserving element order/interleaving
420/// exactly (see this module's doc comment).
421pub fn read_xml(text: &str) -> Result<Doc, OmnistError> {
422 let raw = read_xml_raw(text, None, &ResolvedLimits::DEFAULT)?;
423 Doc::from_raw(raw).map_err(Into::into)
424}
425
426/// [`read_xml`] under explicit [`Limits`].
427///
428/// Returns the [`Limits::validate`] error unchanged when the limits are out
429/// of range. A limit crossed is a [`crate::error::DocumentError`]
430/// (`document.limit.depth` / `document.limit.nodes` at `$`). An element's
431/// text is never an integer literal here (XML is untyped), so
432/// `max_int_digits` only governs the schema-guided pretyping of
433/// [`read_xml_with_schema_and_limits`]: a schema-typed `integer` leaf over
434/// it is refused with `document.limit.int-digits` at the leaf's path.
435pub fn read_xml_with(text: &str, limits: &Limits) -> Result<Doc, OmnistError> {
436 let resolved = limits.resolve()?;
437 let raw = read_xml_raw(text, None, &resolved)?;
438 Doc::from_raw_resolved(raw, resolved).map_err(Into::into)
439}
440
441/// Same as [`read_xml`], but also reports `format.attribute-dropped` and
442/// `format.namespace-dropped` adjustments (spec Sec8.3.8, D-3) into
443/// `report` for every element that had an attribute or a namespace prefix
444/// discarded, mirroring the write-side `report: Option<&mut WriteReport>`
445/// pattern every writer in this crate already uses -- see
446/// `crate::report`'s module doc. `report: None` behaves exactly like
447/// [`read_xml`].
448pub fn read_xml_report(text: &str, report: Option<&mut WriteReport>) -> Result<Doc, OmnistError> {
449 let raw = read_xml_raw(text, report, &ResolvedLimits::DEFAULT)?;
450 Doc::from_raw(raw).map_err(Into::into)
451}
452
453/// Parse XML text into a [`Doc`] with schema-guided pretyping of boolean,
454/// integer, and number scalar fields (spec ยง2.2 / issue #114).
455pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError> {
456 read_xml_with_schema_and_limits(text, schema, &Limits::default())
457}
458
459/// [`read_xml_with_schema`] under explicit [`Limits`] (see [`read_xml_with`]
460/// for what each limit governs here).
461pub fn read_xml_with_schema_and_limits(
462 text: &str,
463 schema: &Schema,
464 limits: &Limits,
465) -> Result<Doc, OmnistError> {
466 let resolved = limits.resolve()?;
467 let raw = read_xml_raw(text, None, &resolved)?;
468 let mut guard = crate::limits::IntGuard::new(resolved.max_int_digits);
469 let pretyped = xml_pretype(
470 raw,
471 schema,
472 &FieldType::Ref(schema.root().clone()),
473 &mut guard,
474 );
475 Doc::from_raw_resolved(pretyped, resolved).map_err(Into::into)
476}
477
478/// An element is a node (a container) from its first child element on:
479/// charges that one node against the cap when `first_child` is set.
480fn charge_container(first_child: bool, budget: &mut NodeBudget) -> Result<(), OmnistError> {
481 if first_child {
482 budget.node_count += 1;
483 if budget.node_count > budget.limits.max_nodes {
484 return Err(budget.limits.nodes_error().into());
485 }
486 }
487 Ok(())
488}
489
490/// The limits in force and the nodes (containers) counted so far.
491struct NodeBudget<'a> {
492 limits: &'a ResolvedLimits,
493 node_count: usize,
494}
495
496/// Reads the content of an already-opened element (the matching `Start`
497/// event has already been consumed by the caller) up to and including its
498/// `End` event, returning either an internal node ([`RawNode::Edges`], if
499/// it has child elements) or a leaf ([`RawNode::Leaf`], its untyped text
500/// verbatim as a [`Scalar::Str`]), mirroring Python's `_xml_to_node`.
501fn parse_content(
502 reader: &mut Reader<&[u8]>,
503 source: &str,
504 depth: usize,
505 budget: &mut NodeBudget,
506 path: &str,
507 refusal: &mut Refusal,
508 mut report: Option<&mut WriteReport>,
509) -> Result<RawNode, OmnistError> {
510 if depth > budget.limits.max_depth {
511 return Err(budget.limits.depth_error().into());
512 }
513 let mut text = String::new();
514 let mut children: Vec<(String, RawNode)> = Vec::new();
515 // Same-label occurrence counts, tracked incrementally (O(1) amortized
516 // per child) rather than by rescanning children on every element via
517 // children.iter().filter(...).count(), which is O(n) per element and
518 // made a MAX_NODES-sized sibling run O(n^2).
519 let mut label_counts: std::collections::HashMap<String, usize> =
520 std::collections::HashMap::new();
521 // E-10: a label occurring more than once is indexed on every occurrence,
522 // the first included, but the first occurrence is read before a sibling
523 // shows the label repeats. Its diagnostics are recorded under the bare
524 // path and re-indexed after the loop: label -> (report range of the
525 // first occurrence's subtree, its bare path).
526 let mut firsts: std::collections::HashMap<String, (std::ops::Range<usize>, String)> =
527 std::collections::HashMap::new();
528 let mut buf = Vec::new();
529 loop {
530 buf.clear();
531 let ev = reader
532 .read_event_into(&mut buf)
533 .map_err(|e| xml_parse_error(reader, source, &e))?;
534 match ev {
535 Event::Start(e) => {
536 charge_container(children.is_empty(), budget)?;
537 let tag = local_name(e.name());
538 let index = *label_counts
539 .entry(tag.clone())
540 .and_modify(|n| *n += 1)
541 .or_insert(0);
542 let child_path =
543 crate::report::child_path(path, &tag, (index > 0).then_some(index));
544 let start = report.as_deref().map_or(0, WriteReport::len);
545 check_attribute_char_refs(reader, source, &e)?;
546 refuse_attribute_entities(&e, refusal);
547 record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
548 let child = parse_content(
549 reader,
550 source,
551 depth + 1,
552 budget,
553 &child_path,
554 refusal,
555 report.as_deref_mut(),
556 )?;
557 if index == 0 && report.is_some() {
558 let end = report.as_deref().map_or(0, WriteReport::len);
559 firsts.insert(tag.clone(), (start..end, child_path));
560 }
561 children.push((tag, child));
562 }
563 Event::Empty(e) => {
564 charge_container(children.is_empty(), budget)?;
565 let tag = local_name(e.name());
566 let index = *label_counts
567 .entry(tag.clone())
568 .and_modify(|n| *n += 1)
569 .or_insert(0);
570 let child_path =
571 crate::report::child_path(path, &tag, (index > 0).then_some(index));
572 let start = report.as_deref().map_or(0, WriteReport::len);
573 check_attribute_char_refs(reader, source, &e)?;
574 refuse_attribute_entities(&e, refusal);
575 record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
576 if index == 0 && report.is_some() {
577 let end = report.as_deref().map_or(0, WriteReport::len);
578 firsts.insert(tag.clone(), (start..end, child_path));
579 }
580 children.push((tag, RawNode::Leaf(Scalar::Str(String::new()))));
581 }
582 Event::End(_) => break,
583 Event::Text(e) => {
584 // `quick_xml` splits entity/character references out into
585 // their own `GeneralRef` events (see the arm below) --
586 // a `Text` event's content never itself contains an
587 // unresolved `&...;` sequence, so only charset decoding
588 // (never entity unescaping) is needed here. `decode()`
589 // cannot fail for a `Reader::from_str`-backed reader (the
590 // crate's own source notes the decoder is fixed to UTF-8
591 // automatically in that case -- there is no declared-
592 // encoding-vs-actual-bytes mismatch possible when the
593 // input was already a Rust `&str`), so this is an
594 // `.expect()`, not a propagated error path.
595 let decoded = e
596 .decode()
597 .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
598 text.push_str(&decoded);
599 }
600 Event::GeneralRef(e) => {
601 if let Some(c) = resolve_general_ref(reader, source, &e, refusal)? {
602 text.push(c);
603 }
604 }
605 // C-9 audit: the reader is `Reader::from_str` over a valid `&str`
606 // and a CDATA body is cut at ASCII delimiters, so the lossy form
607 // below never inserts a U+FFFD (docs/limitations.md,
608 // omnist/tests/c9_vacuous.rs).
609 Event::CData(e) => {
610 text.push_str(&String::from_utf8_lossy(e.as_ref()));
611 }
612 Event::Eof => {
613 return Err(located_error(
614 reader,
615 source,
616 "invalid XML: unexpected end of document",
617 ));
618 }
619 // Comments/PIs inside an element body: skip.
620 _ => {}
621 }
622 }
623 if let Some(rep) = report {
624 for (label, (range, bare)) in firsts {
625 if label_counts[&label] > 0 {
626 rep.reindex_prefix(range, &bare, &format!("{bare}[0]"));
627 }
628 }
629 }
630 if !children.is_empty() {
631 if !text.trim().is_empty() {
632 note_refusal(
633 refusal,
634 "format.mixed-content",
635 "mixed content (text alongside child elements) is outside the data-XML profile",
636 );
637 }
638 Ok(RawNode::Edges(children))
639 } else {
640 Ok(RawNode::Leaf(Scalar::Str(text)))
641 }
642}
643
644/// Builds a [`ParseError`] located at the reader's current byte position
645/// (converted to a 1-based line/column via [`line_col_bytes`]), for the error
646/// conditions this module detects itself rather than receiving from
647/// `quick_xml` (unexpected EOF, mixed content).
648fn located_error(reader: &Reader<&[u8]>, source: &str, message: &str) -> OmnistError {
649 let pos = (reader.buffer_position() as usize).min(source.len());
650 let (line, col) = line_col_bytes(source, pos);
651 ParseError::codec_syntax(line, col, message).into()
652}
653
654/// Records `format.attribute-dropped` and `format.namespace-dropped`
655/// (spec Sec8.3.8, D-3) for one just-opened element (`Start`/`Empty`
656/// event), at `path` -- the path of the element itself, matching the
657/// vectors' convention (the element the attribute/prefix was lost *from*,
658/// not its parent or child). A no-op when `report` is `None`, matching
659/// every other `Option<&mut WriteReport>` consumer in this crate.
660fn record_elem_diagnostics(
661 e: &quick_xml::events::BytesStart<'_>,
662 path: &str,
663 report: Option<&mut WriteReport>,
664) {
665 let Some(rep) = report else { return };
666 if e.attributes().next().is_some() {
667 rep.add(
668 path,
669 "format.attribute-dropped",
670 "an XML attribute was discarded on read",
671 Severity::Warning,
672 );
673 }
674 let name = e.name();
675 let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
676 if raw.contains(':') {
677 rep.add(
678 path,
679 "format.namespace-dropped",
680 "an XML namespace prefix was discarded on read",
681 Severity::Warning,
682 );
683 }
684}
685
686/// The character a strict XML numeric character reference body names (the
687/// text between `&` and `;`, so `#65` or `#x41`), or `None` when the body is
688/// malformed (no digits, `#X`, a sign, a non-digit, a value that does not fit
689/// `u32`) or names a code point outside XML 1.0's `Char` (C-16).
690fn numeric_ref_char(body: &str) -> Option<char> {
691 let digits = body.strip_prefix('#')?;
692 let (radix, digits) = match digits.strip_prefix('x') {
693 Some(hex) => (16, hex),
694 None => (10, digits),
695 };
696 if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
697 return None;
698 }
699 char::from_u32(u32::from_str_radix(digits, radix).ok()?).filter(|&c| !is_xml_illegal_char(c))
700}
701
702/// C-16 for attribute values, which quick_xml does not tokenize into
703/// reference events: a malformed numeric character reference, or one naming a
704/// code point outside `Char`, is `parse.codec-syntax`.
705fn check_attribute_char_refs(
706 reader: &Reader<&[u8]>,
707 source: &str,
708 e: &quick_xml::events::BytesStart<'_>,
709) -> Result<(), OmnistError> {
710 for attr in e.attributes().filter_map(Result::ok) {
711 // An attribute value is a slice of the valid `&str` source cut at
712 // quotes, so it is always UTF-8.
713 let value = std::str::from_utf8(&attr.value).unwrap_or_default();
714 let mut rest: &str = value;
715 while let Some(at) = rest.find("&#") {
716 rest = &rest[at + 1..];
717 let body = rest.find(';').map(|end| &rest[..end]);
718 if body.and_then(numeric_ref_char).is_none() {
719 let shown: String = match body {
720 Some(b) => format!("&{b};"),
721 None => format!(
722 "&{}",
723 rest.chars()
724 .take_while(|c| c.is_ascii_alphanumeric() || *c == '#')
725 .collect::<String>()
726 ),
727 };
728 return Err(located_error(
729 reader,
730 source,
731 &format!(
732 "invalid XML: bad character reference '{shown}' in an attribute value"
733 ),
734 ));
735 }
736 }
737 }
738 Ok(())
739}
740
741/// The data-XML profile refuses an entity reference other than the five
742/// predefined ones "on sight, not on use", wherever it stands -- an
743/// attribute value included, although attributes are otherwise dropped on
744/// read. quick_xml reports entity references as their own events only in
745/// text, so attribute values are scanned here. A numeric character reference
746/// and the five predefined entities stay legal. The refusal is only
747/// RECORDED (raised after well-formedness, like the others); an attribute
748/// quick_xml cannot parse is ignored here because the element's own
749/// well-formedness is checked by the reader.
750fn refuse_attribute_entities(e: &quick_xml::events::BytesStart<'_>, refusal: &mut Refusal) {
751 for attr in e.attributes().filter_map(Result::ok) {
752 // C-9 audit: an attribute value is a slice of the valid `&str`
753 // source cut at quotes, so this never inserts a U+FFFD.
754 let value = String::from_utf8_lossy(&attr.value);
755 let mut rest: &str = &value;
756 while let Some(at) = rest.find('&') {
757 rest = &rest[at + 1..];
758 let Some(end) = rest.find(';') else { break };
759 let name = &rest[..end];
760 if !name.starts_with('#') && !matches!(name, "lt" | "gt" | "amp" | "apos" | "quot") {
761 note_refusal(
762 refusal,
763 "format.entity-forbidden",
764 format!(
765 "entity reference '&{name};' in an attribute value is outside the \
766 data-XML profile (only the five predefined XML entities and numeric \
767 character references are read)"
768 ),
769 );
770 return;
771 }
772 rest = &rest[end + 1..];
773 }
774 }
775}
776
777/// The local (unprefixed) part of a tag name -- see this module's doc
778/// comment on namespace handling.
779fn local_name(name: quick_xml::name::QName) -> String {
780 let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
781 match raw.rsplit_once(':') {
782 Some((_, local)) => local.to_string(),
783 None => raw.to_string(),
784 }
785}
786
787/// XML mandates line-ending normalization on parse (any of `"\r\n"`,
788/// a lone `"\r"`) to a bare `"\n"` -- live-confirmed against
789/// `defusedxml.ElementTree` (see this module's tests).
790fn normalize_line_endings(s: &str) -> String {
791 s.replace("\r\n", "\n").replace('\r', "\n")
792}
793
794fn xml_parse_error(reader: &Reader<&[u8]>, source: &str, e: &quick_xml::Error) -> OmnistError {
795 let pos = (reader.buffer_position() as usize).min(source.len());
796 let (line, col) = line_col_bytes(source, pos);
797 ParseError::codec_syntax(line, col, format!("invalid XML: {e}")).into()
798}
799
800/// Resolves an `Event::GeneralRef` (a `&...;` entity or character
801/// reference `quick_xml` tokenizes as its own event, separate from
802/// `Text`) to the single `char` it denotes. Handles a numeric character
803/// reference (`A`/`A`) via the crate's own [`quick_xml::events::BytesRef::resolve_char_ref`],
804/// and the five predefined XML entities by name -- `quick_xml` has no
805/// DTD support, so no other named entity can ever legitimately appear
806/// (see this module's doc comment on why that's a security feature, not
807/// a gap).
808///
809/// An entity reference outside XML's five predefined ones is a data-XML
810/// profile refusal (`format.entity-forbidden`), not malformed input: it is
811/// recorded in `refusal` (raised once the document proves well-formed) and
812/// `Ok(None)` is returned so the scan continues.
813fn resolve_general_ref(
814 reader: &Reader<&[u8]>,
815 source: &str,
816 e: &quick_xml::events::BytesRef<'_>,
817 refusal: &mut Refusal,
818) -> Result<Option<char>, OmnistError> {
819 if let Some(ch) = e
820 .resolve_char_ref()
821 .map_err(|err| xml_parse_error(reader, source, &err))?
822 {
823 if is_xml_illegal_char(ch) {
824 return Err(located_error(
825 reader,
826 source,
827 "invalid XML: character reference names a character outside XML 1.0 Char",
828 ));
829 }
830 return Ok(Some(ch));
831 }
832 let name = e
833 .decode()
834 .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
835 match name.as_ref() {
836 "lt" => Ok(Some('<')),
837 "gt" => Ok(Some('>')),
838 "amp" => Ok(Some('&')),
839 "apos" => Ok(Some('\'')),
840 "quot" => Ok(Some('"')),
841 other => {
842 note_refusal(
843 refusal,
844 "format.entity-forbidden",
845 format!(
846 "entity reference '&{other};' is outside the data-XML profile (only the \
847 five predefined XML entities and numeric character references are read)"
848 ),
849 );
850 Ok(None)
851 }
852 }
853}
854
855// ============================================================== Writer
856
857/// Project a [`Doc`] to XML text. See this module's doc comment for the
858/// single-document-element requirement, sanitization, and depth-guard
859/// decisions.
860pub fn write_xml(
861 doc: &Doc,
862 strict: bool,
863 report: Option<&mut WriteReport>,
864) -> Result<String, WriteError> {
865 let root = doc.root();
866 let Ok(edges) = root.internal_edges() else {
867 return Err(single_root_error());
868 };
869 if edges.len() > 1 {
870 return Err(multiple_roots_error());
871 }
872 if edges.len() != 1 {
873 return Err(single_root_error());
874 }
875 let mut rep = WriteReport::new();
876 scan_xml_cursor(&root, &mut String::from("$"), &mut rep, true)?;
877 let (tag, child_id) = &edges[0];
878 let child_cursor = root.seek(*child_id);
879 let mut out = String::new();
880 write_element(tag, &child_cursor, 0, &mut out);
881 if !matches!(child_cursor.internal_edges(), Ok(e) if !e.is_empty()) && out.ends_with('\n') {
882 out.pop();
883 }
884 crate::report::finish_write(out, rep, strict, report)
885}
886
887/// `format.multiple-roots` (spec section 8.3.8): a Document with several
888/// top-level edges cannot be written as XML. Path `$`, the whole Document.
889fn multiple_roots_error() -> WriteError {
890 WriteError::with_diagnostic(
891 "$",
892 "format.multiple-roots",
893 "XML needs exactly one document element; the root node has several top-level edges (a multi-rooted Document)",
894 )
895}
896
897fn single_root_error() -> WriteError {
898 WriteError::new(
899 "XML needs exactly one document element; the root node must have a single top-level \
900 edge (a single-rooted Document)",
901 )
902}
903
904/// Report what writing XML would adjust, without producing output. Unlike
905/// [`write_xml`], this does not enforce the single-document-element shape
906/// (mirrors Python's `check_xml`, which is just `_scan_xml(node, "$", rep)`
907/// with no root-shape guard of its own).
908pub fn check_xml(doc: &Doc) -> WriteReport {
909 let mut rep = WriteReport::new();
910 // `fail_fast: false` -- `scan_xml_cursor` never returns `Err` on this
911 // path, it only records the same conditions as `write.unsupported-value`
912 // `Severity::Error` adjustments for preview purposes (`check_xml` never
913 // produces output to begin with, so there is nothing to fail).
914 scan_xml_cursor(&doc.root(), &mut String::from("$"), &mut rep, false)
915 .expect("fail_fast: false never returns Err");
916 rep
917}
918
919/// Marker type implementing [`crate::formats::Codec`] for XML -- adapts
920/// [`read_xml`]/[`write_xml`]/[`check_xml`] to the registry's uniform
921/// shape with the documented defaults (`strict: false`, no report). The
922/// single-document-element root-shape error `write_xml` raises fires from
923/// inside `write_xml` itself, outside `finish_write` and before any
924/// scanning, exactly as before -- this impl only calls `write_xml`, it
925/// doesn't reimplement it.
926pub(crate) struct Xml;
927
928impl crate::formats::Codec for Xml {
929 const NAME: &'static str = "xml";
930
931 fn read(text: &str) -> Result<Doc, OmnistError> {
932 read_xml(text)
933 }
934
935 fn write(doc: &Doc) -> Result<String, OmnistError> {
936 write_xml(doc, false, None).map_err(Into::into)
937 }
938
939 fn check(doc: &Doc) -> WriteReport {
940 check_xml(doc)
941 }
942}
943
944/// Scans a subtree for every write-time adjustment/failure XML has, mirrored
945/// against `check_xml`'s preview-only need via `fail_fast`.
946///
947/// Two conditions -- an XML-illegal label and an empty internal node --
948/// fail the write unconditionally (`write.unsupported-value`, spec
949/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) rather than sanitizing/substituting
950/// and reporting a warning; retired the `key.sanitized`/`shape.empty_ambiguous`
951/// codes (see this module's doc comment and `write_xml`'s). With
952/// `fail_fast: true` (the real [`write_xml`] path), this function returns
953/// `Err` the moment either condition is found, before any output is
954/// produced -- so `write_element`'s own `xml_name`-sanitizing branch is now
955/// unreachable in practice and has been removed; every label `write_element`
956/// ever sees has already been confirmed a valid XML name here. With
957/// `fail_fast: false` ([`check_xml`]'s preview-only path), both conditions
958/// are instead recorded as `write.unsupported-value`/`Severity::Error`
959/// adjustments and scanning continues, so `check_xml` can report every
960/// occurrence in one pass rather than just the first.
961fn scan_xml_cursor(
962 cursor: &Cursor,
963 path: &mut String,
964 rep: &mut WriteReport,
965 fail_fast: bool,
966) -> Result<(), WriteError> {
967 match cursor.internal_edges() {
968 Ok(edges) => {
969 if edges.is_empty() {
970 let detail = "empty internal node (no edges) has no XML representation -- it \
971 would read back as the empty-string leaf '', indistinguishable \
972 from a genuine empty string";
973 if fail_fast {
974 return Err(crate::report::unsupported_value_error(path, detail));
975 }
976 rep.add(
977 path.as_str(),
978 "write.unsupported-value",
979 detail,
980 Severity::Error,
981 );
982 return Ok(());
983 }
984 let mut totals: IndexMap<&str, usize> = IndexMap::new();
985 for (label, _) in edges {
986 *totals.entry(label.as_str()).or_insert(0) += 1;
987 }
988 let mut seen: IndexMap<&str, usize> = IndexMap::new();
989 for (label, child_id) in edges {
990 let i = *seen
991 .entry(label.as_str())
992 .and_modify(|n| *n += 1)
993 .or_insert(0);
994 let base = path.len();
995 crate::report::push_child_path(
996 path,
997 label,
998 crate::report::occurrence_index(i, totals[label.as_str()]),
999 );
1000 if !is_valid_xml_name(label) {
1001 let detail =
1002 format!("label {label:?} is not a valid XML name and cannot be written");
1003 if fail_fast {
1004 return Err(crate::report::unsupported_value_error(path, detail));
1005 }
1006 rep.add(
1007 path.as_str(),
1008 "write.unsupported-value",
1009 detail,
1010 Severity::Error,
1011 );
1012 }
1013 let child = cursor.seek(*child_id);
1014 scan_xml_cursor(&child, path, rep, fail_fast)?;
1015 path.truncate(base);
1016 }
1017 }
1018 Err(_) => {
1019 let scalar = cursor.value().unwrap();
1020 // A string holding a character XML 1.0 cannot represent (a C0
1021 // control other than tab/LF/CR, U+FFFE, U+FFFF) fails the write
1022 // unconditionally: there is no substitute spelling (spec
1023 // section 8.3.8, the string-illegal-char condition).
1024 if let Scalar::Str(s) = scalar
1025 && let Some(bad) = s.chars().find(|&c| is_xml_illegal_char(c))
1026 {
1027 let detail = format!(
1028 "string contains U+{:04X}, which XML 1.0 cannot represent and has no \
1029 substitute spelling",
1030 bad as u32
1031 );
1032 if fail_fast {
1033 return Err(crate::report::unsupported_value_error(path, detail));
1034 }
1035 rep.add(
1036 path.as_str(),
1037 "write.unsupported-value",
1038 detail,
1039 Severity::Error,
1040 );
1041 return Ok(());
1042 }
1043 // C-10: XML has no null token, and the empty element `<a/>` reads
1044 // back as the empty string, so a written null would be
1045 // indistinguishable from the different, valid Document holding
1046 // "". Fails unconditionally, like TOML's null.
1047 if matches!(scalar, Scalar::Null) {
1048 let detail = "null has no XML representation (XML has no null token, and an \
1049 empty element reads back as the empty string)";
1050 if fail_fast {
1051 return Err(crate::report::unsupported_value_error(path, detail));
1052 }
1053 rep.add(
1054 path.as_str(),
1055 "write.unsupported-value",
1056 detail,
1057 Severity::Error,
1058 );
1059 return Ok(());
1060 }
1061 scan_leaf(scalar, path, rep);
1062 }
1063 }
1064 Ok(())
1065}
1066
1067fn scan_leaf(scalar: &Scalar, path: &str, rep: &mut WriteReport) {
1068 match scalar {
1069 // A null never reaches this function: `scan_xml_cursor` fails the
1070 // write (C-10), or records the failure for `check_xml`, first.
1071 // omnist-rs#86: read_xml no longer infers scalar kind from
1072 // element-text shape, so a non-string scalar written to XML (XML
1073 // has no native typed literals -- everything is text) now reads
1074 // back as a string, not its original type. Previously silent
1075 // (the old shape-based coercion happened to undo this on read);
1076 // now reported like every other type-losing write, matching
1077 // Python's identical fix (`omnist#288`, `value.stringified`).
1078 Scalar::Bool(_) | Scalar::Int(_) | Scalar::Float(_) => rep.add(
1079 path,
1080 "format.value-stringified",
1081 "non-string scalar written as text (reads back as a string)",
1082 Severity::Warning,
1083 ),
1084 // E-5: a leaf takes one of the two codes, the temporal one for the
1085 // three temporal kinds and the value one for the rest -- never both.
1086 Scalar::Date(_) | Scalar::Time(_) | Scalar::Datetime(_) => rep.add(
1087 path,
1088 "format.temporal-stringified",
1089 "date/time/datetime written as its ISO-8601 text (reads back as a string)",
1090 Severity::Warning,
1091 ),
1092 Scalar::Null | Scalar::Str(_) => {}
1093 }
1094 // `string.cr_normalized` retired (spec Sec8.3.8, issue #162): a
1095 // literal CR is no longer written raw and reported lossy -- it is
1096 // escaped as the numeric character reference ` `, which is exempt
1097 // from XML's mandatory line-ending normalization on parse and
1098 // round-trips losslessly. See `xml_escape_text`. Nothing left to
1099 // report here for CR. A string holding a character XML cannot
1100 // represent never reaches this function: `scan_xml_cursor` fails the
1101 // write (or records it, for `check_xml`) before calling it.
1102}
1103
1104/// `tag` is always already a valid XML name by the time this runs -- the
1105/// only two callers are `write_xml` (which fails via `scan_xml_cursor`'s
1106/// `fail_fast: true` pass, before this function is ever reached, on any
1107/// label that isn't) and this function's own recursive call on a child
1108/// label already scanned the same way. No sanitization happens here
1109/// anymore -- see `scan_xml_cursor`'s doc comment on why the write now
1110/// fails unconditionally on an XML-illegal label instead.
1111fn write_element(tag: &str, content: &Cursor, level: usize, out: &mut String) {
1112 let indent = " ".repeat(level);
1113 out.push_str(&indent);
1114 out.push('<');
1115 out.push_str(tag);
1116 match content.internal_edges() {
1117 Ok(edges) if !edges.is_empty() => {
1118 out.push_str(">\n");
1119 for (label, child_id) in edges {
1120 let child = content.seek(*child_id);
1121 write_element(label, &child, level + 1, out);
1122 }
1123 out.push_str(&indent);
1124 out.push_str("</");
1125 out.push_str(tag);
1126 out.push_str(">\n");
1127 }
1128 // Unreachable via the real `write_xml` path: `scan_xml_cursor`'s
1129 // `fail_fast: true` pass already returned `Err` for any empty
1130 // internal node anywhere in the tree before `write_element` is
1131 // ever called (spec Sec8.3.8/Sec8.3.9, issue #161) -- see that
1132 // function's doc comment. White-box confirmed directly below
1133 // (`write_element_panics_on_empty_internal_node`), same rationale
1134 // as `toml.rs`'s/`yaml.rs`'s identical `unreachable!()` precedents.
1135 Ok(_) => unreachable!(
1136 "write_element is never called on an empty internal node -- scan_xml_cursor \
1137 already failed the write"
1138 ),
1139 Err(_) => {
1140 let scalar = content.value().unwrap();
1141 let text = xml_text(scalar);
1142 if text.is_empty() {
1143 out.push_str(" />\n");
1144 } else {
1145 out.push('>');
1146 out.push_str(&xml_escape_text(&text));
1147 out.push_str("</");
1148 out.push_str(tag);
1149 out.push_str(">\n");
1150 }
1151 }
1152 }
1153}
1154
1155/// A valid XML 1.0 `Name`, simplified to the ASCII-friendly subset Python's
1156/// own `_XML_NAME` regex accepts (`^[A-Za-z_][A-Za-z0-9_.\-]*$`).
1157fn is_valid_xml_name(name: &str) -> bool {
1158 let mut chars = name.chars();
1159 match chars.next() {
1160 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1161 _ => return false,
1162 }
1163 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
1164}
1165
1166fn xml_text(scalar: &Scalar) -> String {
1167 match scalar {
1168 Scalar::Null => String::new(),
1169 Scalar::Bool(b) => {
1170 if *b {
1171 "true".to_string()
1172 } else {
1173 "false".to_string()
1174 }
1175 }
1176 Scalar::Int(i) => i.to_string(),
1177 Scalar::Float(x) => write_float_text(*x),
1178 Scalar::Str(s) | Scalar::Date(s) => s.clone(),
1179 Scalar::Time(s) => super::temporal_text(s, false).into_owned(),
1180 Scalar::Datetime(s) => super::temporal_text(s, true).into_owned(),
1181 }
1182}
1183
1184/// Same float-formatting convention as `toml.rs`'s `write_float`: `nan`/
1185/// `inf`/`-inf` lowercase for special values, an explicit `.0` appended
1186/// whenever the default `Display` rendering doesn't already contain a
1187/// `.`/`e`/`E` marker (matching Python's `str(float)`, which always emits
1188/// one of those markers; Rust's bare `{}` formatter does not, and for
1189/// integral values >= 1e17 it doesn't even include a decimal point -- see
1190/// issue #46).
1191fn write_float_text(x: f64) -> String {
1192 float_fmt::float_to_string(x, "nan", "inf", "-inf")
1193}
1194
1195/// XML 1.0's character-data legality rule (tab/LF/CR plus U+0020-U+D7FF,
1196/// U+E000-U+FFFD, U+10000-U+10FFFF are legal; everything else, including
1197/// the C0 controls other than tab/LF/CR and the BMP noncharacters
1198/// U+FFFE/U+FFFF, is not) -- a Rust `char` can never be a UTF-16 surrogate,
1199/// so that illegal range from Python's version is unreachable here and
1200/// intentionally omitted.
1201fn is_xml_illegal_char(c: char) -> bool {
1202 let cp = c as u32;
1203 (0x00..=0x08).contains(&cp)
1204 || (0x0B..=0x0C).contains(&cp)
1205 || (0x0E..=0x1F).contains(&cp)
1206 || (0xFFFE..=0xFFFF).contains(&cp)
1207}
1208
1209/// Escapes the three characters XML text content requires escaped
1210/// (`&`, `<`, `>`), plus (spec Sec8.3.8, issue #162) a literal carriage
1211/// return as the numeric character reference ` ` -- matching
1212/// `ElementTree.tostring`'s text-escaping for the first three (quotes are
1213/// left literal; they only need escaping in attribute values, which this
1214/// module never writes), and going beyond it for CR: XML mandates
1215/// line-ending normalization on parse, so a raw CR byte and a raw LF byte
1216/// read back identical (confirmed live) -- a numeric character reference
1217/// is exempt from that normalization and round-trips intact, so a string
1218/// containing a bare CR writes as `a b`, and a string containing a
1219/// CRLF sequence writes as `a \nb`, not the raw bytes.
1220fn xml_escape_text(text: &str) -> String {
1221 let mut out = String::with_capacity(text.len());
1222 for c in text.chars() {
1223 match c {
1224 '&' => out.push_str("&"),
1225 '<' => out.push_str("<"),
1226 '>' => out.push_str(">"),
1227 '\r' => out.push_str(" "),
1228 c => out.push(c),
1229 }
1230 }
1231 out
1232}
1233
1234#[cfg(test)]
1235mod tests;