Expand description
XML codec. Ported from ~/dev/omnist/omnist/formats.py’s
read_xml/write_xml/check_xml.
§Structural difference from json.rs/yaml.rs/toml.rs
Per the Python module’s own top docstring: “XML uses repeated elements
directly, so it preserves interleaving on read and needs a single
document element on write.” Unlike the other three codecs, which all
project through crate::document::Doc::to_grouped (the JSON-shaped
“same-label edges become an array” representation), this module goes
through crate::document::Doc::from_raw/Doc::to_raw and
crate::document::RawNode instead – the same interleaving-preserving
path crate::oml uses, and for the same reason: XML element order can
interleave distinct labels arbitrarily (<b/><c/><b/>), which a
Value::Object’s IndexMap cannot represent (see RawNode’s own doc
comment in document.rs). Read builds a RawNode directly from the
parsed element tree; write walks Doc::to_raw() directly, never
to_grouped().
§Crate choice: quick-xml, advisory-checked
[quick_xml] is used for read-side tokenization only (default features,
no serde); the omnist-specific layer – interleaving/repetition
preservation, the depth guard, all-occurrences sanitization – is
hand-written, mirroring yaml.rs/toml.rs’s
crate-for-tokenization-plus-hand-written-logic pattern.
Checked per the omnist-ts#38 concern (an unfixable fast-xml-parser
GHSA advisory the TS port could not shed): cargo audit against this
crate’s full dependency tree (29 crates, including quick-xml 0.41.0)
on 2026-07-26 against the RustSec advisory database (1169 advisories
loaded) found zero matches – quick-xml has no open advisory at
this version, unlike TS’s situation. Additionally, quick-xml has no
DTD/external-entity expansion support at all (only the five predefined
XML entities < > & ' " are recognized; an
undefined entity reference is a parse error, not silently resolved) –
so, unlike Python’s read_xml (which specifically requires
defusedxml instead of the stdlib xml.etree.ElementTree, precisely to
shut off XXE/entity-expansion attacks), this port has no equivalent
opt-in needed: the crate is XXE-safe by construction, not by
configuration.
§Namespace handling: a disclosed simplification
Python’s read_xml uses ElementTree, which resolves a namespaced tag
into Clark notation ({uri}local) and _local() strips the {uri}
prefix to get the bare local name. quick_xml (used here in
non-namespace-aware mode) does not perform namespace URI resolution;
local_name instead strips a lexical prefix: (up to the last :)
from a tag, which coincides with Python’s behavior for the common case
(a declared, in-scope prefix) but does not resolve prefixes through
xmlns declarations the way real namespace-aware processing would.
Namespaces are outside this issue’s spec (the Python docstring never
mentions them), so this is a deliberate, disclosed simplification, not
a claimed parity guarantee.
§Text stays untyped until materialize (omnist-rs#86)
XML’s grammar carries no type information – <m>1</m> and <m>hi</m>
are syntactically identical, a bare text node. Per docs/formats/xml.md
(“Text is untyped … every leaf arrives as a string. Typing requires a
schema in stage 2.”), read_xml builds every leaf as Scalar::Str
unconditionally, with no int/float/bool inference at parse time –
confirmed against a live ~/dev/venvs/omnist read_xml: <m>1</m>
reads as Scalar::Str("1"), never Scalar::Int((1).into()).
An earlier version of this module ported a coerce() helper that
type-inferred leaf text (bool/int/float) at parse time, contradicting
the spec and diverging from Python’s reference read_xml – filed and
fixed as omnist-rs#86 (found via the conformance harness, vector
formats-xml/basic/interleaved-elements-preserve-order). Python fixed
the identical bug in its own read_xml as omnist#288
(_xml_to_node no longer infers scalar kind from text shape); this
module’s fix mirrors that commit.
§Schema-guided pretyping (issue #114)
Because XML text carries no native type information and materialize
intentionally never coerces plain strings to integer/number/boolean
scalars, read_xml_with_schema performs schema-guided pretyping of
boolean, integer, and number fields before materialization,
mirroring Python’s _xml_pretype. Fields typed any, date/time/datetime,
and mismatched text stay strings for normal stage-2 validation/materialization
reporting.
§All-occurrences sanitization (omnist-ts#36 regression)
omnist-ts#36: writeXml’s xmlSanitize used a non-global regex,
so only the first XML-illegal character in a string was replaced,
emitting malformed XML for any string with more than one. This module’s
xml_sanitize does not use a substitution regex at all – it maps
every char of the input through is_xml_illegal_char individually
(str::chars().map(...).collect()), so there is no “first occurrence
only” bug class available in the first place. See
sanitizes_every_illegal_character_not_just_the_first for the
regression test with multiple illegal characters in one string.
§Depth guard reuse
read_xml checks nesting depth itself, inline, during the
recursive-descent walk of quick_xml’s pull events (mirroring Python’s
own _xml_to_node, which inlines the identical check against the
shared _MAX_DEPTH constant rather than routing through
document.py’s write-side guard) – this is necessary because,
without it, an adversarially deep input could blow the native Rust call
stack in parse_content’s own recursion before Doc::from_raw
ever gets a chance to reject it via
crate::document::check_write_depth. Doc::from_raw then applies
its own guard a second time when building the final Doc – a harmless,
redundant confirmation of the same limit, not a second copy of the
guard’s logic (it calls the crate’s one shared check_write_depth).
write_xml/check_xml do not re-guard on the way out, matching
json.rs’s reasoning: they walk an already-Doc-validated
RawNode, so there is nothing left to guard.
§Single document element (write-side)
write_xml requires doc.to_raw() to be a RawNode::Edges with
exactly one edge – any other shape (a bare leaf-rooted Doc, zero
edges, or more than one top-level edge) is a crate::error::WriteError,
matching Python’s write_xml check
(if not (isinstance(node, list) and len(node) == 1): raise WriteError(...))
exactly, including that it fires outside crate::report::finish_write
– unconditionally, even under strict=false, and with no
crate::report::WriteReport attached (mirrors toml.rs’s identical
“non-table root” precedent).
Functions§
- check_
xml - Report what writing XML would adjust, without producing output. Unlike
write_xml, this does not enforce the single-document-element shape (mirrors Python’scheck_xml, which is just_scan_xml(node, "$", rep)with no root-shape guard of its own). - read_
xml - Parse XML text into a
Doc, preserving element order/interleaving exactly (see this module’s doc comment). - read_
xml_ report - Same as
read_xml, but also reportsformat.attribute-droppedandformat.namespace-droppedadjustments (spec Sec8.3.8, D-3) intoreportfor every element that had an attribute or a namespace prefix discarded, mirroring the write-sidereport: Option<&mut WriteReport>pattern every writer in this crate already uses – seecrate::report’s module doc.report: Nonebehaves exactly likeread_xml. - read_
xml_ with_ schema - Parse XML text into a
Docwith schema-guided pretyping of boolean, integer, and number scalar fields (spec §2.2 / issue #114). - write_
xml - Project a
Docto XML text. See this module’s doc comment for the single-document-element requirement, sanitization, and depth-guard decisions.