Skip to main content

Module xml

Module xml 

Source
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 &lt; &gt; &amp; &apos; &quot; 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’s check_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 reports format.attribute-dropped and format.namespace-dropped adjustments (spec Sec8.3.8, D-3) into report for every element that had an attribute or a namespace prefix discarded, mirroring the write-side report: Option<&mut WriteReport> pattern every writer in this crate already uses – see crate::report’s module doc. report: None behaves exactly like read_xml.
read_xml_with_schema
Parse XML text into a Doc with schema-guided pretyping of boolean, integer, and number scalar fields (spec §2.2 / issue #114).
write_xml
Project a Doc to XML text. See this module’s doc comment for the single-document-element requirement, sanitization, and depth-guard decisions.