Contents
Getting started
- Install — Cargo, source
- Quick Start — parse and query in ten lines
The oxml ecosystem (library + five satellite crates)
- The oxml ecosystem —
oxml,oxml-cli,oxml-lsp,oxml-mcp,oxml-wasm,xmlschemaat 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.6 — 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
- 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 questions, answered directly
- Development — building, testing, benchmarking
- Security — threat model
- Documentation
- Acknowledgements
- License
Install
[]
= "0.0.6"
Parsing only, without the XPath engine:
[]
= { = "0.0.6", = false, = ["std"] }
From source:
Minimum supported Rust version: 1.86.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 | Planned |
oxml-lsp |
Language server for XML documents | Planned |
oxml-mcp |
Model Context Protocol server | Planned |
oxml-wasm |
WebAssembly bindings | Planned |
xmlschema |
XSD validation | Planned |
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 empty 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.
The only XPath implementation on crates.io, sxd-xpath, last shipped
in 2018. XSLT and XSD validation have no pure-Rust implementation
at all. So a Rust project that needs to ask questions of an XML
document has three options: bind libxml2 through C-FFI, depend on a
crate abandoned seven years ago, 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.6
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
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
XPath 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
- Ten configurable bounds, in three profiles
- External entities never dereferenced
- Entity expansion bounded per document, not per reference
- Recursion bounded, so no input reaches a stack overflow
Verification
- 2,520 of 2,557 decided W3C conformance tests pass (98.6%), with 98.9% of the 2,585-test suite reaching a decision and zero panics
- 367 tests and 22 doctests; 97.3% line coverage, gated in CI
- Five fuzz targets, Miri, property tests, and a feature powerset build
Not yet: serialisation, mutation, XSD validation and XSLT. The external DTD subset is supported when the caller supplies it — see External entities and subsets — but is never fetched, and most remaining conformance failures need entity replacement text parsed as markup rather than substituted as text.
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 |
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. |
xpath |
✅ | The XPath 1.0 engine. Disable if you only need to parse. |
Both are additive. With neither, the crate is no_std and provides the
parser and tree 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!;
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 |
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 | 1000 | 1048576 |
max_nodes |
100000 | unbounded | unbounded |
max_text_length |
1048576 | 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 |
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 |
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, and xpath,libm — against
every bare-metal target: thumbv7em-none-eabihf,
riscv32imac-unknown-none-elf and aarch64-unknown-none. Nine 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 overrides a declaration that disagrees with it. UTF-8 input is borrowed rather than copied, so the common case costs nothing.
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. - You are streaming gigabytes. oxml builds a full tree in memory.
quick-xmlis the right tool and will stay so. - 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?
Not yet, and the README will say so until it is. On a 16.86 MB document,
measured on one machine: quick-xml 704 MB/s building no tree,
roxmltree 291 MB/s building a borrowed tree, oxml 123 MB/s. The gap is
allocation — oxml performs about two per node where a borrowed design
performs almost none — and closing it is in progress.
Any figure here states the machine and the method. During development the same binary measured 14.7 and 123.1 MB/s on a loaded host, which is why a number without its conditions is not a measurement.
How conformant is it?
98.6% 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.
Most of the 37 remaining failures need entity replacement text to be parsed as markup rather than substituted as text — a structural change to how expansion works, not a missing feature.
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 an external entity cannot be detected, and those account for most of the remaining conformance failures. 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 it prevents memory-unsafety. 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 to resolve external entities. If you are parsing multi-gigabyte documents where a DOM will not fit; a streaming API is planned and this is not it yet.
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?
No. Document is built by the parser and read afterwards. Mutation and
serialisation are the same feature -- there is little point in one
without the other -- and neither is implemented. If you need to produce
XML, build the string, or use quick-xml's writer.
How much memory does a document take?
Measured at 1.13 allocations per node — 18,065 allocations for a
16,002-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.
What remains is the owned Strings: every text node and attribute
value. Element and attribute names no longer allocate at all — they
are slices of the input until interning, and a repeated name costs a
map probe. Removing the last two means having the document own its
input so both become ranges into it, which is not done — so 1.13 is
the number.
That said: the whole document is in memory. See "When not to use oxml".
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; it is immutable after parsing, so any
number of threads can query one. 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
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)]rules out memory-corruption bugs, and CI checks the attribute is still there.- 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.