A safe, fast XML toolkit for Rust that behaves like libxml2.
- Safe:
#![forbid(unsafe_code)], one dependency (memchr), every resource bounded by default, and nothing loaded from disk or the network unless you pass a resolver. - Compatible: libxml2 2.15.4 is the reference. Parse results, error codes and messages, serialized bytes and validation verdicts are tested against it, and option flags keep libxml2's names and numbers.
- Fast: faster than libxml2 on all 65 DTD-free DOM and streaming benchmark workloads (benchmarks).
- Broad: borrowed and editable DOMs; pull, push and
xmlTextReaderstreaming; XPath 1.0; DTD, XML Schema, RELAX NG and Schematron validation; C14N, XInclude, catalogs, HTML and writers.
[dependencies]
yaxml = "0.1"Requires Rust 1.97 or later.
use yaxml::{Document, xpath};
let document = Document::parse("<shop><item price='3'/><item price='4'/></shop>")?;
for item in document.root_element().children() {
println!("{:?}", item.attribute("price"));
}
let total = xpath::XPath::compile("sum(//item/@price)")?;
let value = total.evaluate(&document, document.root().id(), &xpath::Context::new())?;
assert_eq!(value, xpath::Value::Number(7.0));
# Ok::<(), Box<dyn std::error::Error>>(())Document borrows its input and never copies text that needs no decoding;
for UTF-16 or single-byte input, Transcoded decodes it once and
Document::parse_transcoded borrows that. OwnedDocument owns its strings,
decodes any input itself, and can be edited.
Pull events from a slice, push byte chunks of any size, or use libxml2's
xmlTextReader cursor (TextReader) over any BufRead. Memory stays bounded
however large the document is.
use std::ops::ControlFlow;
use yaxml::{Event, PushParser, Reader};
let xml = b"<log><entry>a</entry><entry>b</entry></log>";
let mut reader = Reader::new(xml)?;
let mut entries = 0;
while let Some(event) = reader.read_event()? {
if let Event::Start(element) = event {
entries += usize::from(element.name.local() == "entry");
}
}
assert_eq!(entries, 2);
let mut parser = PushParser::new();
for chunk in xml.chunks(5) {
parser.feed(chunk, |_event, _origin| ControlFlow::Continue(()))?;
}
parser.finish(|_, _| ControlFlow::Continue(()))?;
# Ok::<(), yaxml::Error>(())use yaxml::{EditLimits, OwnedDocument, save};
let mut document = OwnedDocument::parse("<config><port>80</port></config>")?;
let root = document.root_element().id();
document.edit(EditLimits::default())?.append_comment(root, "edited")?;
let bytes = save::to_vec(&document, &Default::default())?;
assert_eq!(bytes, b"<?xml version=\"1.0\"?>\n<config><port>80</port><!--edited--></config>\n");
# Ok::<(), Box<dyn std::error::Error>>(())use yaxml::{Document, xsd};
let schema = xsd::Schema::parse(br#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="n" type="xs:int"/></xs:schema>"#, "n.xsd")?;
assert!(xsd::is_valid(&schema.validate(&Document::parse("<n>42</n>")?)));
assert!(!xsd::is_valid(&schema.validate(&Document::parse("<n>x</n>")?)));
# Ok::<(), Box<dyn std::error::Error>>(())DTD (Document::validate, valid), RELAX NG (relaxng) and Schematron
(schematron) work the same way. DTD, XML Schema and RELAX NG can also
validate a stream without building a tree.
ParseOptions takes libxml2's XML_PARSE_* flags. A surface that does not
support a flag rejects it rather than ignoring it. An Error is small (a
kind and a byte offset), and describe expands it into libxml2's domain,
code, line, column and message. As in libxml2, a namespace error (an unbound
prefix, say) does not stop a parse: the name stays in no namespace and the
error is listed in errors(). RECOVER works on the DOMs and every stream,
which delivers the recovered tree's events and the same errors().
use yaxml::{Document, LineIndex, ParseOptions, options};
let input = b"<r><a>text</b></r>";
let document = Document::parse_with(input, ParseOptions::new(options::RECOVER))?;
let messages: Vec<_> = LineIndex::new(input)
.describe_all(document.errors())
.into_iter()
.map(|diagnostic| diagnostic.message)
.collect();
assert_eq!(messages, ["Opening and ending tag mismatch: a line 1 and b"]);
# Ok::<(), yaxml::Error>(())| Threat | Default |
|---|---|
| External entities (XXE), external DTDs | Loaded only through a Resolver you supply, and only when the flags ask for them |
| Entity expansion ("billion laughs") | Amplification and total-expansion limits, as in libxml2 |
| Deep nesting; huge names, attributes or text | Depth, size and count limits on every surface |
| Quadratic and backtracking inputs | Linear algorithms; regular expressions and RELAX NG run on bounded automata |
| Memory | Every allocation that the input controls is charged to a budget |
Each limit is a field of a Limits struct, and options::HUGE relaxes the
limits as libxml2's XML_PARSE_HUGE does. docs/security.md
has the full table alongside libxml2's values. Every parser, validator and
writer has fuzz targets.
yaxml reproduces libxml2's behavior, not its C API. For each feature, compatibility records how it was checked against libxml2 and every known difference. The API mapping has one row per libxml2 function, and conformance covers the W3C test suite results.
Not yet supported: XML 1.1, encodings that libxml2 reads only through iconv or ICU (Shift_JIS, EUC-JP, GBK and others), and the SAX2 handler table. The remaining work lists what is left.
Everything is on by default (full). With default-features = false you get
the core: Reader, PushParser, BufferedReader, Document,
OwnedDocument and Editor, DTD processing and validation (valid),
diagnostics, save, Writer, uri and chars (about 37,600 of the
70,000 lines in src/). Then add what you need:
[dependencies]
yaxml = { version = "0.1", default-features = false, features = ["xpath"] }| Feature | Enables | Implies | Lines |
|---|---|---|---|
xpath |
xpath compile and evaluation, xpointer, pattern |
3,400 | |
c14n |
c14n |
xpath |
650 |
xinclude |
xinclude, the XINCLUDE parser flag |
xpath |
1,150 |
catalog |
catalog, the CATALOG_PI parser flag |
1,250 | |
schemas |
xsd, relaxng, schematron, schema_types, regexp |
xpath |
16,800 |
html |
html, save::AS_HTML |
6,250 | |
textreader |
TextReader (its RELAX NG and XSD hooks need schemas) |
schemas |
1,700 |
textwriter |
TextWriter |
1,200 | |
full |
all of the above (default) | ||
allocator_api |
Rust 1.100+: OwnedDocument storage in any Allocator (OwnedDocument::parse_in); not in full |
Lines are the feature's own src/ files, without what it implies. A flag
whose implementation is compiled out (XINCLUDE, CATALOG_PI,
save::AS_HTML) is ErrorKind::UnsupportedOption, and
features::has answers for the build. The yaxmllint binary needs full.
cargo install yaxml installs yaxmllint, which accepts xmllint's options
and matches its output (yaxmllint).
bindings/ holds a C ABI (yaxml-capi), a Go package and an
lxml.etree-style Python package. They are developed alongside the crate but
not published yet.
Everything lives in this one repository. Plain cargo test needs only stable
Rust: no C compiler, libxml2, test suites or nightly. The rest is driven by
scripts at the root, which build two separate Cargo workspaces that cargo test never touches: harness/ (benchmarks, libxml2 differentials, the W3C
XML Test Suite run) and fuzz/ (cargo-fuzz targets).
git clone https://github.com/pbdeuchler/yaxml && cd yaxml
cargo test # the library's own tests
./setup.sh # once: libxml2 2.15.4 oracle and the XML test suite
./oracle.sh # every differential against libxml2, then XMLTS
./bench.sh tiny mixed # yaxml vs libxml2 parse times
./fuzz.sh # list the fuzz targets
./fuzz.sh dom 600 # fuzz one for 10 minutes
./coverage.sh dom # line/branch coverage of src/ from the corpusPrerequisites: Rust via rustup (rust-toolchain.toml pins
1.97.0). setup.sh, oracle.sh and bench.sh also need curl, cmake and a C
compiler. fuzz.sh and coverage.sh need rustup toolchain install nightly-2026-10-18 --component llvm-tools-preview and cargo install cargo-fuzz --version 0.13.2 --locked; coverage.sh also needs
uv. pgo.sh needs llvm-profdata (rustup component add llvm-tools, or Xcode's). Every script takes --help.
./setup.sh downloads and checks (sha256) libxml2 2.15.4 and the W3C XML Test
Suite 20130923, builds the static libxml2 oracle in ../yaxml-oracle (beside
the checkout; no iconv/ICU/zlib) and writes the suite catalog in
../yaxml-suites. Set YAXML_ORACLE or YAXML_SUITES to use other
locations; anything already present is reused.
| Command | What it does | Results |
|---|---|---|
./oracle.sh [step...] |
yaxml's ignored and env-gated differentials, the harness oracle tests, fuzz-seed replay, structural edits, and the conformance run, which fails on any regression of harness/tests/fixtures/xmlconf-20130923-required.tsv |
harness/results/oracle-<time>/: one log per step, xmlconf-results.tsv |
./bench.sh [fixture...] |
Matched parse benchmark, yaxml vs libxml2, in five modes; prints libxml2/yaxml time ratios (above 1 favors yaxml) | table on stdout, raw samples in harness/results/bench-<time>.txt |
./pgo.sh |
Profile-guided yaxmllint: instrumented build trained on the benchmark fixtures under 12 option sets, then rebuilt with the merged profile; prints instruction deltas against the plain build (macOS) |
harness/target/pgo/use/release/yaxmllint |
./fuzz.sh [target [seconds]] |
Lists the fuzz targets, or fuzzes one (default 60 s) | new inputs in the corpus, crashes in fuzz/artifacts/<target>/ |
./coverage.sh [target...] |
Line/branch coverage of src/ from the corpus |
fuzz/coverage/report.txt, merged.lcov |
The repository carries a minimized corpus per target in fuzz/corpus/
(1.65 MB, 23,244 inputs). The full corpus is a separate repository: ./setup.sh corpus
clones it into fuzz/corpus-full/ (YAXML_CORPUS_URL overrides the
location), and fuzz.sh and coverage.sh use it, adding new inputs there,
whenever it is present. Without it, new inputs land in fuzz/corpus/; don't
commit those. Build output goes to harness/target/ (CARGO_TARGET_DIR
overrides it) and fuzz/target/ (FUZZ_TARGET_DIR).
Fuzz logic lives in the library's tests (tests/support/fuzz/, shared with
tests/fuzz_smoke.rs); fuzz/README.md lists the targets and
campaign notes. To add a target:
- Write the checks as a
pub fn exercise_<name>(data: &[u8])intests/support/fuzz/(a new module goes indriver.rswith a#[path]line), and call it fromtests/fuzz_smoke.rswith a few inputs. - Add
fuzz/fuzz_targets/<name>.rs, a copy of any other target calling your function, and a[[bin]]entry for it infuzz/Cargo.toml. - Optionally add seeds to
fuzz/corpus/<name>/, then./fuzz.sh <name> 600. - Fixed crashes: a minimized input goes to
tests/fixtures/fuzz-regressions/<name>/(fuzz/regressionslinks there; map the directory to the function infuzz_regressions_replayif it is not a plain XML target).
MIT or Apache-2.0, at your option.