Contents
Getting started
- Install — Cargo, source
- Quick Start — parse and query in ten lines
The oxml ecosystem (library + six satellite crates)
- The oxml ecosystem —
oxml,oxml-cli,oxml-lsp,oxml-mcp,oxml-wasm,xmlschema,oxml-jsonat a glance
Library reference
- One-minute migration — name-for-name mapping from
sxd-xpath,roxmltree,quick-xml,libxml - Why this approach? — design rationale
- Capabilities in 0.0.11 — release inventory
- Ecosystem comparison — what each crate does and does not do
- Benchmarks — measured, with the method stated
- Features — cargo feature flags
Behaviour worth knowing
- Attributes are nodes — why
@langreturns what you expect - Namespaces resolve by URI — the element/attribute asymmetry
- XPath name tests resolve namespaces — and unprefixed names match no namespace
- Number formatting — why
sum()prints17.49 - Entity expansion is bounded, and external entities are never fetched — expansion happens, within a budget
Practical
- Library usage — the full surface
- Reading without a tree — events, and what streaming does and does not buy you
- Configuration — limits, profiles, cargo features
- Error reporting — offsets, line/column, carets
- External entities and subsets — supplied by the caller, never fetched
- Encodings — UTF-8, UTF-16, ISO-8859-1
- Examples — runnable programs
- When not to use oxml — honestly
- FAQ — twenty-one questions, answered directly
- Development — building, testing, benchmarking
- Security — threat model
- Documentation
- Acknowledgements
- License
Install
[]
= "0.0.11"
Parsing only, without the XPath engine:
[]
= { = "0.0.11", = false, = ["std"] }
From source:
Minimum supported Rust version: 1.89.0. Raising it is a breaking change and appears in the changelog.
Quick Start
use ;
let doc = parse.unwrap;
let titles = compile.unwrap;
assert_eq!;
XPath is optional. The tree stands on its own:
use parse;
let doc = parse.unwrap;
let root = doc.root_element.unwrap;
let b = doc.children;
assert_eq!;
assert_eq!;
The oxml ecosystem
Every member ships the same version number. If the core is at
0.0.X then so is every satellite, so there is never a compatibility
table to consult. Versions advance in 0.0.1 steps along the 0.0.x
line; 0.1.0 follows 0.0.999.
| Crate | What it is | Status |
|---|---|---|
oxml |
Core library — parser, tree, XPath 1.0 | Available |
oxml-cli |
Command-line querying and formatting | crates.io |
oxml-lsp |
Language server for XML documents | crates.io |
oxml-mcp |
Model Context Protocol server | crates.io |
oxml-wasm |
WebAssembly bindings | crates.io |
xmlschema |
XSD validation | crates.io |
oxml-json |
JSON support for the language server | crates.io |
xmlschema keeps its existing published name rather than being
repurposed into the core: the name means XSD validation, and that is
what it will be.
One-minute migration
From sxd-xpath
sxd-xpath has not shipped a release since 2018. The concepts map
directly.
sxd-xpath |
oxml |
|---|---|
sxd_document::parser::parse(s) |
oxml::parse(s) |
sxd_xpath::evaluate_xpath(&doc, "//x") |
XPath::compile("//x")?.evaluate(&doc) |
Value::Nodeset(set) |
Value::NodeSet(Vec<NodeId>) |
value.string() |
value.to_str(&doc) |
value.number() |
value.to_number(&doc) |
value.boolean() |
value.to_boolean() |
The one structural difference: oxml separates compiling an expression from evaluating it, so a query used against many documents is parsed once.
From roxmltree
roxmltree |
oxml |
|---|---|
Document::parse(s) |
oxml::parse(s) |
doc.root_element() |
doc.root_element() |
node.children() |
doc.children(id) |
node.attribute("k") |
doc.attribute(id, "k") |
node.text() |
doc.text(id) |
| — | XPath::compile(..) |
roxmltree returns node objects that borrow the document; oxml
returns NodeId handles and takes the document as a parameter. That
trade is what allows a node to know its parent without a lifetime
cycle.
From quick-xml
quick-xml is a streaming reader and writer, not a tree. If you are
matching on Event::Start and maintaining your own stack to find
elements, that is the loop oxml replaces:
let xml = "<list><item id='7'>found</item><item id='8'/></list>";
// quick-xml: match on Event::Start, maintain your own stack, track
// depth, and remember which element you are inside.
//
// oxml: say what you want.
let doc = parse.unwrap;
let hits = compile.unwrap.evaluate;
assert_eq!;
Keep quick-xml for gigabyte streams you never want fully in memory.
From libxml
libxml binds libxml2 through C-FFI. Migrating to oxml removes the C
toolchain, the unsafe blocks, and the libxml2 CVE stream — at the
cost of XSLT, which oxml does not yet have.
Why this approach?
Rust's XML ecosystem is strong at one end and specialised at the other.
Parsing is a solved problem: quick-xml has 379 million downloads and
is genuinely fast. What nothing maintained provides is the other half
of what lxml gives Python — querying combined with a mutable DOM.
For XPath 1.0, the earliest crate on crates.io, sxd-xpath, last shipped
in 2018. While xee targets XPath 3.1 and partial XSLT, teams needing
standard XPath 1.0 with a mutable DOM, WebAssembly, and schema validation
previously faced a fragmented choice: bind libxml2 through C-FFI (with its
memory-safety risks), depend on a dormant crate, or hand-roll traversal.
oxml closes the query gap first, because that is the one people hit.
Two architectural choices motivate the design:
-
An arena, not a pointer graph. Nodes live in a
Vecand are addressed by index. That is what lets every node know its parent withoutRc,RefCell, orunsafe— the parent link is an index, so there is no ownership cycle for the borrow checker to reject. The cost is that aNodeIdis only meaningful against the document that issued it; accessors returnNonerather than panicking when it is not. -
#![forbid(unsafe_code)], enforced twice. The attribute fails the build, and CI additionally greps for it — because the change that silently drops the guarantee is deleting the attribute, and a compile-time check cannot catch its own removal. Most XML crates with XPath support reach it through C-FFI; thoseunsafeblocks are usually well-vetted, but their existence makes a security-conscious downstream audit meaningfully harder.
Capabilities in 0.0.11
Parsing
- Elements, attributes, text, comments, processing instructions, CDATA
- XML declaration, and a full internal-subset
DOCTYPEparser:<!ELEMENT>,<!ATTLIST>,<!ENTITY>,<!NOTATION>, conditional sections, parameter-entity references and content models - Namespace resolution by URI, with the
xml:prefix bound implicitly - Internal general entities, the five predefined entities, and numeric character references
- XML 1.0 fourth and fifth edition name rules, selectable per parse
- XML 1.1, including the
RestrictedCharproduction - UTF-8, UTF-16 (both byte orders, with or without a BOM) and ISO-8859-1, chosen by BOM or declaration
- Adjacent character data merged, so a caller never sees two text siblings in a row
- A pull reader over the same scanner, for callers that want events rather than a tree
Tree
- Arena-backed, index-addressed nodes;
NodeIdisCopyand pointer-sized - Child and attribute lists as
(start, len)ranges into shared vectors; element and attribute names interned behind aNameId, which also records the prefix each was written with - Parent, children, descendants, text (XPath
string-valuesemantics) - Attributes and namespace declarations as first-class nodes
Send + Sync, so one document serves any number of threads
Serialisation
Document::to_xml()returns a document as XML;write_xml<W: Write>()writes it to any writer- Escaping is applied where it changes meaning:
&,<and>in text, plus", tab, newline and carriage return in attribute values, where an unescaped newline would normalise to a space on the way back in <?xml version="1.1"?>is emitted only when the document needs it -- a control character, or a prefix unbound withxmlns:p=""Document::try_to_xml()refuses, withSerialiseError, a document with no element or content only 1.1 can express beside a comment or PI only 1.0 keeps- Every document in the conformance corpus is a fixed point: parse, serialise, parse again, and the trees agree
Streaming
stream::Readeryields events without building a tree, over the same scanner the tree parser uses, so the two agree by constructionReader::from_readerreads from anyBufRead, so a document larger than memory is readable; memory stays bounded regardless of size- Characters and
\r\npairs split across a read are held back until whole, so the buffer size cannot change what a document means
XPath 1.0
- One documented extension:
*:local(XPath 2.0's production) matches a local name in any namespace, so deeply namespaced documents can be queried without binding every prefix. Everything else is strictly 1.0. - All thirteen axes:
child,descendant,descendant-or-self,parent,ancestor,ancestor-or-self,self,attribute,namespace,following,following-sibling,preceding,preceding-sibling - Abbreviations:
//,.,..,@ - Node tests: name,
*,text(),comment(),node() - Predicates, including positional (
[1]) and existential comparison - All four value types with the specified conversions
- All 27 functions:
last,position,count,id,local-name,namespace-uri,name,string,concat,starts-with,contains,substring-before,substring-after,substring,string-length,normalize-space,translate,boolean,not,true,false,lang,number,sum,floor,ceiling,round - Arithmetic, comparison, boolean and union operators
- An unknown function name, or the wrong number of arguments, is a compile error rather than a plausible-looking empty result
- Compiled once, evaluated many times;
Send + Sync
Safety and limits
- Configurable parsing and compilation bounds, in three profiles
- External entities never dereferenced
- Entity expansion bounded per document, not per reference
- Element and DTD recursion bounded by separate depth limits
- Opt-in XPath evaluation budgets for work, nodes and string bytes
Verification
- All 2,557 decided W3C conformance tests pass, with 98.9% of the 2,585-test suite reaching a decision and zero panics
- 669 tests and 35 doctests; line coverage gated at a 95% floor in CI
- Eight fuzz targets, Miri, property tests, and a feature powerset build
Not here: XSD validation (that is xmlschema),
XSLT, XPath 2.0 and later, and any I/O. The external DTD subset is
read when the caller supplies it — see External entities and
subsets — and never fetched.
Ecosystem comparison
| Crate | Downloads | Last release | Parse | Tree | XPath | XSLT | XSD | unsafe |
|---|---|---|---|---|---|---|---|---|
oxml |
— | active | ✅ | ✅ | ✅ | ✗ | ✗ | none |
quick-xml |
379M | active | ✅ | ✗ | ✗ | ✗ | ✗ | some |
xml-rs |
137M | active | ✅ | ✗ | ✗ | ✗ | ✗ | none |
roxmltree |
66M | active | ✅ | ✅ | ✗ | ✗ | ✗ | none |
xmltree |
19M | active | ✅ | ✅ | ✗ | ✗ | ✗ | none |
sxd-xpath |
2M | 2018 | ✅ | ✅ | ✅ | ✗ | ✗ | none |
libxml |
2M | active | ✅ | ✅ | ✅ | ✅ | ✅ | C-FFI |
xot |
218K | 2025 | ✅ | ✅ | ✗ | ✗ | ✗ | none |
Download figures from crates.io, August 2026.
Benchmarks
Run them yourself — the numbers below are from one machine and are useful as ratios, not absolutes:
No absolute figures are published here. The same binary on the same
machine on the same day measured 14.7 MB/s and 123.1 MB/s, the
difference being a load average above 30 on six cores. An 8× spread
means any number in that range is truthful and none is informative, so
doc/BENCHMARKS.md
states the method and the conditions a figure must carry, and this
table says what each benchmark isolates.
| Benchmark | What it isolates |
|---|---|
parse/wide_1000 |
1,000 sibling elements — sibling handling |
parse/deep_max |
Nesting to just inside MAX_DEPTH — recursion |
parse/attributes_1000 |
4 attributes each, namespaced — resolution |
xpath/compile |
Compiling //book[@lang='en']/title |
xpath/eval_descendant |
//title over 2,000 books |
xpath/eval_predicate |
//book[@lang='en']/title over 2,000 books |
xpath/eval_numeric_predicate |
A positional predicate over the same |
xpath/eval_following_from_every_element |
count(//*/following::*) over 6,000 elements |
xpath/eval_preceding_from_every_element |
count(//*/preceding::*) over 6,000 elements |
encoding/decode_utf8_borrowed |
The zero-copy path — nothing is transcoded |
encoding/decode_utf16_be |
UTF-16 big-endian, which must allocate |
encoding/decode_utf16_le |
UTF-16 little-endian |
encoding/decode_latin1 |
ISO-8859-1 |
encoding/parse_bytes_utf16_be |
Decode and parse together, as a caller pays it |
tree/descendants_2000 |
A full arena walk |
tree/text_of_root |
Gathering a subtree's character data |
tree/children_of_each |
Child lists through the shared arena |
tree/attribute_by_name |
Lookup by expanded name |
entities/expand_1000_references |
1,000 references to one declared entity |
entities/literal_equivalent |
The same output written out — the control |
entities/nested_four_levels |
Nested expansion, inside the budget |
Compilation and evaluation are timed separately on purpose: a caller
that compiles once and evaluates many times pays only the second, and
reporting a combined figure would hide that. entities carries its own
control for the same reason — expansion cost is only meaningful against
the cost of the characters it produces.
The benchmarks have already earned their place. The first
implementation deduplicated each step's results with a linear
contains scan, which is O(n²). //title over 2,000 elements took
10.8 ms; sorting instead brought it to 0.49 ms — a 22×
improvement with identical results. The benchmark was written before
the optimisation, not after it.
Features
| Flag | Default | What it does |
|---|---|---|
std |
✅ | Standard library integration, including std::error::Error and write_xml_to. |
xpath |
✅ | The XPath 1.0 engine. Disable if you only need to parse. |
libm |
Floating-point functions for xpath without std. |
|
async |
stream::AsyncReader over tokio::io::AsyncRead; implies std, adds tokio (io-util). |
|
tracing |
tracing spans around parsing, evaluation, serialisation and streaming. |
All are additive. With none, the crate is no_std and provides the
parser, tree and serialiser over alloc.
Attributes are nodes
XPath models attributes as nodes that are reachable from the
attribute:: axis but are not children of their element. oxml does
the same: attribute nodes live in the arena, know their parent, and are
absent from children().
use ;
let doc = parse.unwrap;
// The attribute's value, not the element's text.
let lang = compile.unwrap;
assert_eq!;
// And `child::` does not see it.
let root = doc.root_element.unwrap;
assert_eq!; // just <title>
The first implementation returned the owning element from the
attribute axis, which made string(//book/@lang) evaluate to the
book's text. It was wrong, and silently so — which is why the test that
pins this behaviour names the bug it prevents.
Namespaces resolve by URI
Two prefixes bound to the same URI name the same thing:
use parse;
let doc = parse.unwrap;
let root = doc.root_element.unwrap;
let kids = doc.children;
assert_eq!;
An unprefixed element takes the default namespace. An unprefixed attribute is in no namespace at all — not its element's. That asymmetry is the classic source of namespace bugs, so it is an explicit parameter in the parser rather than an assumption.
XPath name tests resolve namespaces
A prefix in an expression is resolved against the expression's own bindings, not the document's declarations. The same prefix can mean different things in the two, and resolving against the document would make an expression's meaning depend on which document it ran against.
use ;
let doc = parse.unwrap;
let q = compile_with_namespaces.unwrap;
assert_eq!;
// Only the URI matters. The expression may use a different prefix from
// the document, and a document that renames its prefixes still matches.
let q = compile_with_namespaces.unwrap;
assert_eq!;
An unbound prefix is a compile error, not a silent match:
use XPath;
let err = compile.unwrap_err;
assert!;
That matters more than it looks. Until 0.0.4 a prefixed name test
matched on its local part alone, so //m:a selected every a
whatever its namespace — a wrong answer with no error attached, which
is the worst way for a query engine to fail.
Unprefixed names match no namespace
use ;
let doc = parse.unwrap;
let q = compile.unwrap;
assert_eq!;
This is XPath 1.0's classic surprise and every conforming engine
behaves this way: a default namespace in the expression context does
not apply to node tests. If your document declares
xmlns="urn:u" on its root, //item matches nothing — bind a prefix
to that URI and write //x:item.
Number formatting
XPath has one numeric type: IEEE 754 double. sum() over 9.99 and
7.50 produces the float nearest 17.490000000000002, and printing
every digit needed to distinguish that value is what the
specification's wording literally asks for.
No other engine does that. libxml2, Xalan and Saxon all print 17.49,
because 15 significant digits is the point past which IEEE 754 noise
starts showing. oxml matches them:
use ;
let doc = parse.unwrap;
let sum = compile.unwrap;
assert_eq!;
Matching the ecosystem matters more here than matching the letter of a sentence written before shortest-round-trip float printing existed.
Entity expansion is bounded, and external entities are never fetched
Internal general entities are expanded — a conforming parser must, and a great many valid documents depend on it. External entities are never dereferenced, which forecloses XXE by construction rather than by configuration:
let src = r#"<!DOCTYPE a [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><a>&xxe;</a>"#;
let doc = parse.expect;
// No file is opened. The entity expands to nothing.
assert_eq!;
There is no flag to turn external fetching on, so there is no way to configure the vulnerability back in — the difference between a parser that is safe and one that is safe if you remember to set the option.
Expansion is bounded on two axes, because either alone is insufficient:
use ;
// Billion laughs: exponential nesting. Caught by the depth cap.
let mut src = Stringfrom;
for i in 1..=9
src.push_str;
assert!;
The second axis is a per-document character budget, which the depth
cap cannot substitute for. Referencing one 100 KB entity a thousand times
never exceeds depth 1, and an earlier per-reference budget let exactly
that through — 100 MB of text from 100 KB of input. Limits exposes
both max_entity_depth and max_entity_expansion.
Library usage
use ;
let doc = parse.unwrap;
// Navigation
let root = doc.root_element.unwrap;
let first = doc.children;
assert_eq!;
assert_eq!;
// Attributes
assert_eq!;
for attr in doc.attributes
// Node kinds
assert!;
// Text, with XPath string-value semantics: comments contribute nothing
assert_eq!;
// Queries, compiled once
let q = compile.unwrap;
assert_eq!;
Reading without a tree
parse builds a tree. When you only need to walk a document once —
counting, extracting, converting — stream::Reader hands you one
event at a time and builds nothing.
use ;
let mut reader = new.unwrap;
let mut names = Vecnew;
while let Some = reader.next_event.unwrap
assert_eq!;
It is the same scanner the tree parser runs, not a second implementation, so the two accept exactly the same documents and report the same error at the same byte offset when they don't. A test holds them to that.
What it saves. Reading the 16,004-node document from the allocation tests holds 191,967 bytes at peak against the tree's 1,809,822 — 89% less. It was 92% before the document began owning its input: the gap narrowed because the tree got cheaper, not because reading got dearer.
What it costs. Time. Reading a document as events takes about 1.9
times as long as parsing it into a tree when using owned Events,
because names and attribute values are copied out, where the tree keeps
a range into the input it already holds. Streaming trades time for
memory, and the trade is only worth making when the memory matters.
Zero-copy borrowing. For workloads sensitive to allocation overhead,
Reader::next_borrowed() returns BorrowedEvent<'a> items borrowing
string slices directly from the resident buffer without allocation.
Reading from a byte source. Reader::new takes a &str, so the
document is resident either way. Reader::from_reader takes anything
implementing BufRead and keeps only the construct it is reading,
dropping what it has passed — so a file larger than memory is
readable. Measured on a 185 KB document and a 1,929 KB one, ten times
the input, both held 34,722 bytes: the same figure to the byte.
use ;
let mut reader = from_reader?;
let mut items = 0;
while let Some = reader.next_event?
assert_eq!;
# Ok::
The events are the same events: a test holds from_reader and
Reader::new to an identical sequence at buffer sizes down to one
byte, because a bug here looks like a document that parses at one
buffer size and not another.
Events carry resolved names, so a prefix is looked up in the scopes
open at that point exactly as in the tree, and Limits apply
identically — including the depth limit, which here bounds a Vec
rather than the call stack.
Examples
Every example compiles and runs in CI, and a CI job checks that between them they call every public function in the crate — so an API added without an example fails the build rather than going undocumented.
| Example | What it shows |
|---|---|
parse_and_query |
XPath queries and direct tree traversal side by side |
walk_the_tree |
Node kinds, parents, children, descendants, and comparing expanded names |
read_attributes |
Attributes by local name and by namespace, and attributes as nodes |
xpath_values |
The four XPath value types, conversions between them, and evaluating from a context node |
handle_errors |
Matching on ErrorKind, and drawing a caret under the offending line |
apply_limits |
Billion laughs, XXE, depth bounds, and the cost of each profile |
external_entities |
Supplying external entities and subsets, an allow-list source, and why XXE cannot happen |
names_and_ids |
Qualified names, DTD-declared IDs, inherited xml:lang, and the functions that read them |
decode_bytes |
The same document in five encodings, and the two kinds of encoding failure |
stream_events |
Reading a document as events with no tree built, and the identical errors both entry points give |
stream_async |
The same events from a tokio reader (feature async) |
build_a_document |
Building and editing a tree: elements, attributes, namespaces, moves, and what the API refuses |
serialise_a_document |
to_xml, try_to_xml, write_xml_to, indentation options, and when the output declares XML 1.1 |
telemetry_tracing |
The spans the tracing feature emits (feature tracing) |
Configuration
Everything configurable lives in [Limits]. There is no global state,
no builder, and no environment variable: a Limits value is passed to
parse_with or parse_bytes_with, and two documents parsed on two
threads can use different ones.
use ;
// Start from a profile and adjust. `Limits` is `#[non_exhaustive]`,
// so it cannot be written out as a struct literal -- a bound added in
// a later version would otherwise be a breaking change for everyone
// who had.
let mut limits = default;
limits.max_depth = 32;
limits.max_attributes_per_element = 16;
assert!;
The three profiles
| Field | strict() |
default() |
permissive() |
|---|---|---|---|
max_depth |
64 | 256 | 256 |
max_attributes_per_element |
64 | 1000 | 100000 |
max_attribute_size |
8192 | 524288 | 67108864 |
max_name_length |
256 | 8192 | 1048576 |
max_nodes |
100000 | unbounded | unbounded |
max_text_length |
1048576 | unbounded | unbounded |
max_input_bytes |
8388608 | unbounded | unbounded |
max_document_bytes |
8388608 | unbounded | unbounded |
max_entity_depth |
4 | 10 | 40 |
max_entity_expansion |
100000 | 10000000 | 1000000000 |
max_xpath_depth |
32 | 256 | 1000 |
max_xpath_operators |
1000 | 10000 | 1000000 |
max_xpath_bytes |
65536 | 1048576 | unbounded |
max_xpath_nodes |
4000 | 40000 | 4000000 |
max_xpath_arguments |
1000 | 10000 | 1000000 |
max_dtd_bytes |
65536 | 16777216 | unbounded |
max_dtd_depth |
32 | 64 | 64 |
max_pe_expansion |
100000 | 10000000 | 1000000000 |
max_construct_bytes |
1048576 | 67108864 | unbounded |
The numbers are given unformatted because a test parses this table and compares every cell against the profile it names. A table of plausible figures is worse than no table: it is believed.
permissive() deliberately leaves max_depth where the default has
it. Depth is the one bound whose cost is stack rather than heap, and
the stack is the one resource a caller cannot recover from: overflow
aborts the process. Measured on this crate's own test binaries, a
2 MiB thread stack overflows at around 280 levels in a debug build, at
roughly 7,489 bytes per frame. Raising max_depth to 10,000 does not
give you deeper documents; it gives you a crash.
Which profile to choose
default()— a build tool, a test fixture, a document you wrote. Generous enough that no real document is refused.strict()— anything taking XML from the network. It costs you documents nested past 64 levels and entity expansions past 100 KB, which in practice means it costs you nothing and rejects attacks 2,600× faster.permissive()— a one-off conversion of a machine-generated document you already trust.
Cargo features
| Feature | Default | What it does |
|---|---|---|
std |
yes | std::error::Error for Error, and String/Vec from std |
xpath |
yes | The XPath 1.0 engine. Separable because the tree alone is useful |
libm |
no | floor/ceil/trunc from libm rather than std. Required to build xpath without std |
async |
no | stream::AsyncReader. Implies std; adds tokio with only io-util |
tracing |
no | tracing spans; adds tracing with no default features |
Building xpath with neither std nor libm is a compile error with
an explanation, not a link failure: XPath needs three floating-point
functions that core does not provide.
no_std is checked, not claimed
scripts/check-no-std.sh runs in CI and builds every feature
combination with std off: none, libm, xpath,libm, and each with
tracing, against every bare-metal target: thumbv7em-none-eabihf,
riscv32imac-unknown-none-elf and aarch64-unknown-none. Eighteen builds.
It also greps for std:: that is not behind #[cfg(feature = "std")],
which the builds cannot catch on their own: a std-only call in a
combination nothing builds sits there unnoticed until someone enables
that combination.
That exists because one configuration was not enough. A Vec that
resolved through the std prelude reached main and broke all three
bare-metal jobs at once — the build that would have caught it was not
being run locally.
Error reporting
An Error carries a byte offset and a kind, not a formatted string.
That is deliberate: the caller knows how it wants to present a
failure, and a pre-formatted message is a lossy version of the
structure it was built from.
use parse;
let input = "<config>\n <name>ok</name>\n <port>8080</hostname>\n</config>";
let error = parse.expect_err;
let = error.line_column;
assert_eq!;
// `ErrorKind` renders without the offset, for a caret that already
// shows the position.
let source = input.lines.nth.unwrap;
println!;
println!;
3 | <port>8080</hostname>
| ^ </hostname> closes <port>
line_column counts in characters rather than bytes, so the column it
reports is the one an editor shows. It never panics, including when
the offset lands inside a multi-byte character or when it is given a
different document from the one that produced the error — a wrong
column is a nuisance, but a panic in the error path takes down the
process.
External entities and subsets
oxml performs no I/O. A document that references an external entity or an external DTD subset names a location, and resolving it is the caller's decision — they have the permission model, the user, and the context to make it.
parse supplies
nothing, so an external reference expands to nothing. That is the
default and it is why XXE is structurally impossible rather than
switched off.
When you do have the content, hand it over:
use ;
let parts: & = &;
let doc = parse_with_external?;
assert_eq!;
# Ok::
Anything implementing ExternalSource works; a slice of
(system identifier, content) pairs is the simplest one. The parser
still never opens a file — the lookup happens in your code, where you
can see it and decide.
With content available, the rules only that content can settle are
checked: a text declaration must be well formed, must name an encoding,
must not claim standalone, and must not declare a version later than
the document's. Each external entity is line-ending–normalised
independently, by its own declared version.
The external subset works the same way. Given its content, its declarations are parsed and merged behind the internal subset's — "the first declaration binds", so anything declared in both takes the internal one.
A declaration whose text contains a parameter entity reference is
skipped as unknown rather than reported as malformed.
<!ELEMENT x (a,%choice;,c)> is legal in an external subset and oxml
does not expand parameter entities, so treating it as an error would
reject valid documents. The constraints such a declaration states go
unchecked.
An entity that is declared and never referenced is never read. A processor need not fetch what a document does not use, and validating eagerly rejected a valid document whose unused entity happened to omit its encoding.
Encodings
parse takes a &str, which means the caller has already decided the
encoding. parse_bytes reads the document's own declaration and
byte-order mark instead, which is what you want for a file or an HTTP
body.
use parse_bytes;
let utf8 = b"<?xml version='1.0'?><a>hi</a>";
let doc = parse_bytes.expect;
assert_eq!;
UTF-8, UTF-16 (both byte orders, with or without a BOM) and ISO-8859-1 are decoded. A BOM that contradicts a supported declared encoding is rejected. UTF-8 decoding borrows the input; constructing the document copies its normalized text into owned storage.
Two failures that look alike are kept apart, because they are not the same problem:
MalformedEncoding— the name breaks XML production 81 (encoding="UTF~8"), or the bytes are not valid in the encoding they claim. The document is malformed and every conforming parser must reject it.UnsupportedEncoding— the name is legal and names an encoding this crate does not implement (encoding="Shift_JIS"). The document may be perfectly well-formed. Decode it with a crate that knows the encoding and callparse.
When not to use oxml
Honestly:
- You need XSLT. oxml does not have it. Use
libxml. - You need complete XSD validation today.
xmlschemais published, but it is early: check its own README for what is implemented before depending on it. - Raw streaming speed is what you are buying.
stream::Reader::from_readerreads from anyBufReadand holds a bounded amount, so size is no longer the obstacle — but reading a document as events is about 1.9 times slower than parsing it into a tree here, because an event owns its text.quick-xmlis faster at this and stays the right tool when throughput is the point. - You need XPath 2.0 or 3.1. oxml implements 1.0.
- Raw parse throughput is your only metric.
quick-xmlis extremely well optimised and years ahead on that axis. oxml's argument is what you can do after parsing.
FAQ
Is it faster than quick-xml or roxmltree?
No, and the README will say so until it is. Measured against each on the job it actually does, on the same 855 KB document:
| oxml | reference | ratio | |
|---|---|---|---|
| Events, no tree | oxml::stream |
quick-xml |
0.09× |
| Tree | oxml::parse |
roxmltree |
0.32× |
So quick-xml reads events about eleven times faster, and
roxmltree builds a tree about three times faster. The gap is
allocation and copying: oxml performs about 0.50 allocations per
node where a borrowed design performs almost none, and it copies the
input once so the document can own it.
Reproduce it with cargo bench --bench comparison. Both crates are
dev-dependencies, so this is a measurement you can rerun rather than a
claim you have to take on trust — an earlier version of this section
quoted absolute MB/s from a comparison nothing in the repository could
regenerate.
Why a ratio and not MB/s. An absolute figure describes the machine as much as the code: the same binary measured 14.7 and 123.1 MB/s on one host on one day, and the difference was load. A ratio survives that, because contention slows both arms. The benchmark times each implementation against its reference back to back and takes the fastest run of each — the sample contention perturbed least. With ten CPU hogs competing on a six-core machine the ratios moved by 3% and 5%, while a median-based estimate of the same data halved. See doc/BENCHMARKS.md.
Can it stream?
Yes, both senses of the word.
stream::Reader::new reads a document as events without building a
tree — 89% less held at peak on a 16,000-node document — and
stream::Reader::from_reader reads from any BufRead, keeping only
the construct in hand. A 185 KB document and a 1,929 KB one both held
34,722 bytes, so size is bounded by the largest single construct
rather than by the file.
What it is not is faster. Reading as events costs about 1.9 times
what parsing into a tree costs, because an event owns its text where
the tree keeps ranges into input it already holds. Streaming here
trades time for memory. If throughput is what you mean by streaming,
quick-xml is the tool.
How conformant is it?
100% of decided tests in the W3C XML Conformance Test Suite, release
xmlts20130923, with 98.9% coverage of the 2,585 tests and zero panics.
Both numbers are published because either alone misleads: a pass rate
can be raised by skipping the hard tests.
There are no failures among the 2,557 decided cases. The 28 unsupported cases require namespace processing to be disabled (14), Namespaces 1.1 coverage outside the harness's scoring scope (8), or unsupported encodings (6). Type-aware attribute normalization is implemented.
Why does it not fetch external entities?
Because that is the XXE vulnerability, and a parser that cannot fetch cannot be made to leak a file. There is no option to enable it, which is the difference between safe and safe if you remember to set the flag.
The cost is real and is stated rather than hidden: an error that lives inside external content not supplied by the caller cannot be detected. The specification does not require a non-validating parser to read the external subset.
Does it expand entities at all?
Internal ones, yes — a conforming parser must, and many valid documents depend on it. Expansion is bounded on two axes, because either alone is insufficient: a depth cap stops the exponential "billion laughs" shape, and a per-document character budget stops the quadratic one. During development a per-reference budget let a single 100 KB entity referenced a thousand times produce 100 MB of text; both shapes are now rejected and both have tests.
Does it validate?
No. oxml checks well-formedness, including the constraints that live
inside the DTD. Validation against a schema is
xmlschema, which covers XSD.
Which XML version and edition?
XML 1.0 and 1.1, with namespaces. XML 1.0 has two incompatible editions
— the 5th relaxed the name rules and the two genuinely disagree — so
both are implemented and Limits::edition selects. The 5th is the
default.
Does it really work without std?
Yes, and CI builds for thumbv7em-none-eabihf,
riscv32imac-unknown-none-elf and aarch64-unknown-none to prove it.
cargo check --no-default-features on a host proves nothing, because
the host still has std — which is exactly how no_std support for
XPath regressed unnoticed once.
XPath additionally needs the libm feature, since floor, ceil and
trunc live in std rather than core. Requesting xpath without it
fails with one clear message rather than a dozen missing-method errors.
Is forbid(unsafe_code) just marketing?
It is CI-enforced and forbids unsafe code in this crate. Dependencies
and the Rust runtime remain part of the trust boundary. It does not
prevent a parser exhausting memory or spending ten minutes in a
quadratic loop — two HIGH-severity 2026 advisories against another Rust
XML crate were exactly that, in entirely safe code. Limits exists for
that class, and the fuzz targets exist to find it.
Why is my document rejected when another parser accepts it?
Most often one of: a -- inside a comment, a literal ]]> in content,
a missing space between attributes, < in an attribute value, or a
control character. All are forbidden by XML 1.0 and all are accepted by
some parsers. The error names the constraint and the byte offset.
When should I not use it?
If you need XSLT, XQuery, or XPath 2.0+, none of which exist here — see
Xee for XPath 3.1. If you need the
parser to fetch external entities itself. If you need a streaming
writer: the tree serialises, but there is no event writer yet. If raw
event throughput is the only thing that matters, quick-xml is faster.
Why is there a Limits argument instead of a builder?
Because a builder implies a default that someone chose for you and a
sequence of calls you have to remember. Limits is a plain value: you
can store one in a config struct, send it between threads, compare two,
and see the whole policy in one place. parse uses Limits::default()
so the common case stays a single call.
Why does parse take &str when XML is bytes?
Because deciding the encoding is a separate job from parsing, and
conflating them is how parsers end up with an encoding layer that
cannot be tested or replaced. parse_bytes does both when you want
that; parse is for when you already know.
It also keeps the fast path honest. A &str is already valid UTF-8,
so parse does no transcoding and no validation pass over the bytes.
Is the tree mutable?
Yes, since 0.0.8. Document::empty() and the Builder create one;
append_element, append_text, set_attribute, declare_namespace,
insert_at, reparent, detach and remove edit one; to_xml and
write_xml_to serialise it, and parsing the output gives the same tree
back. Names, namespace URIs and text placement are validated on the
way in. An incomplete document without a root element, or a combination
no XML version can write (a control character beside a comment holding
U+0085), is refused by try_to_xml. See the
build_a_document
example.
How much memory does a document take?
Measured at 0.50 allocations per node — 8,076 allocations for a
16,004-node document, counted with a wrapping global allocator over the
whole of parse and divided by Document::len(). A test holds that
figure to a ceiling, so it cannot drift upward unnoticed.
The structure is a flat arena rather than a graph of Rcs. Child and
attribute lists are (start, len) ranges into shared vectors and
names are interned, so traversal is index arithmetic rather than
pointer chasing and the document is Send + Sync for free. That work
took the figure from 4.13 to 1.13.
The step from 1.13 to 0.50 is the document owning its input. Text nodes, comments and attribute values are ranges into that string rather than strings of their own, so a document that expands no entities allocates nothing at all for its character data. Names had already stopped allocating — they are slices of the input until interning, and a repeated name costs a map probe.
Fewer than one allocation per node means most nodes cost none: what remains is the arenas themselves, which grow by doubling and so cost a handful of allocations for the whole document.
The trade is one copy of the input, because the document owns the
decoded and line-ending-normalised text rather than borrowing yours.
That is what lets Document have no lifetime parameter and
parse_bytes decode UTF-16, where there is no caller string to borrow
from. See "When not to use oxml" — the whole document is in memory.
Why NodeId rather than references?
Because a reference into the tree borrows the tree, and almost every
useful traversal wants to hold a position while looking at something
else. NodeId is Copy and pointer-sized, and outlives any particular
borrow, so you can collect ids into a Vec, store them in a struct, or
return them from a function without a lifetime parameter spreading
through your code.
The cost is that a NodeId from one document means something different
in another. They are indices, and nothing stops you mixing them.
Does it allocate during XPath evaluation?
Yes. Node-sets are Vec<NodeId>, and intermediate values are built and
dropped as the expression evaluates. Compiling is where the one-time
cost sits: XPath::compile parses to a syntax tree once, and the
result is document-independent, so a server can compile every
expression it knows at startup and evaluate them per request.
What happens on a document that is not well-formed?
It is rejected, with the byte offset of the problem. There is no recovery mode and no "lenient" flag. A parser that guesses at what a malformed document meant produces a tree that no two implementations agree on, and the disagreement surfaces later as a bug somewhere else.
Is it thread-safe?
Document is Send and Sync; shared references support concurrent
queries, while mutation requires an exclusive reference.
XPath is likewise Send and Sync,
which is what makes compile-once-evaluate-many work across a thread
pool.
Why is unsafe forbidden rather than just avoided?
Because "we don't use unsafe" is a claim about the present and
#![forbid(unsafe_code)] is a property of the build. CI checks the
attribute is still in the source, so removing it is a visible change in
a diff rather than a quiet one.
It does cost something. Some of the tricks that make the fastest parsers fast are unavailable, and this crate is slower than it could be because of it. That trade is stated rather than hidden.
How do I report a security issue?
See SECURITY.md. Please do not open a public issue for a vulnerability.
Development
CI runs all of the above on Linux, macOS and Windows, plus an MSRV
build and a check that #![forbid(unsafe_code)] is still present.
Security
Scorecard scans use a digest-pinned upstream image. Results are retained as CI artifacts and repository posture findings in GitHub code scanning, not published to Scorecard's public service. The badge shows workflow status, not a security score; a successful scan can still report findings.
See SECURITY.md for reporting and the full threat model.
In summary:
- External entities are never dereferenced. There is no code that opens a file or a socket on a document's behalf, so XXE is foreclosed by construction rather than by a default that can be changed.
- Entity expansion is bounded per document, not per reference. A per-reference budget still lets a quadratic blowup through: a thousand small expansions cost a thousand times the budget.
#![forbid(unsafe_code)]forbids unsafe code in this crate, and CI checks the attribute is still there. Dependencies and the Rust runtime remain part of the memory-safety trust boundary.- Recursion is bounded by
Limits::max_depth, because a stack overflow aborts the process rather than unwinding and no caller can catch it. - The defaults are generous. They accept every real document we
have found, which means they are not the tightest bound available.
A service parsing untrusted XML under load should use
Limits::strict: it rejects a nine-level billion-laughs document in 25 µs where the defaults take 66 ms.
Documentation
Acknowledgements
oxml exists because of work that came before it:
- lxml — the reference for what an XML toolkit should offer.
- libxml2 — decades of hard-won correctness, and the yardstick for behaviour.
sxd-xpath— the first XPath implementation in Rust.quick-xmlandroxmltree— proof that Rust XML parsing can be both fast and safe.- W3C — for the XPath 1.0 specification, which this implementation follows.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be dual licensed as above, without any additional terms or conditions.