Query, validate and inspect XML from a shell — powered by
oxml, with zero
unsafe code.
Getting started
- Install — Cargo, from source
- Quick Start — a query in one line
The oxml ecosystem
- The oxml ecosystem — seven crates, one version
Reference
- Commands —
query,validate,stats,check - Options
- Exit status — and why it matters in a pipeline
- Reading from standard input
- Migration — from
xmllint,xq,xmlstarlet - Why this approach?
- Capabilities in 0.0.15
- Ecosystem comparison
- Benchmarks
Practical
- Examples — recipes that do real work
- Configuration
- When not to use oxml-cli
- FAQ
- Development
- Security
- Documentation
- Acknowledgements
- License
cargo install oxml-cli --version 0.0.15 --lockedFrom source:
git clone https://github.com/sebastienrousseau/oxml-cli
cd oxml-cli
cargo install --path .The binary is called oxml.
There are no C dependencies and no build toolchain requirements. If
cargo works, this installs.
$ oxml query -t '//title' catalogue.xml
Dune
Germinal$ oxml check catalogue.xml
well-formed (21 nodes)$ oxml stats catalogue.xml
nodes 21
elements 7
attributes 4
text 8
comments 1
max depth 4| Crate | What it is |
|---|---|
oxml |
The library: parser, tree, XPath 1.0 |
xmlschema |
XSD validation |
oxml-cli |
This crate — the command line |
oxml-wasm |
WebAssembly bindings |
oxml-mcp |
Model Context Protocol server |
oxml-lsp |
Language Server Protocol server |
oxml-json |
JSON support for the language server |
All seven ship one version number, moving in steps of 0.0.1. oxml-cli 0.0.15 goes with oxml 0.0.13, and no other combination is supported.
Evaluate an XPath 1.0 expression.
$ oxml query '//title' catalogue.xml
<title>Dune</title>
<title>Germinal</title>By default each match is printed as a summary of the node. -t prints
the text instead, and -c prints only how many matched.
Non-node results — numbers, strings, booleans — are printed directly:
$ oxml query 'count(//book)' catalogue.xml
2
$ oxml query 'sum(//price)' catalogue.xml
17.49Validate against an XML Schema.
$ oxml validate schema.xsd catalogue.xml
validExit status 0 means the document conforms, 1 means it does not. The schema comes first because the document is the argument that defaults to standard input.
Summarise the document: node counts by kind, and maximum depth.
Useful for finding out what is actually in a file before writing a query against it, and for spotting a document that is deeper or larger than you expected.
Report whether the document is well-formed.
$ oxml check broken.xml
not well-formed at 1:12: at byte 11: input ended unexpectedlyThe position is a line and column, counted in characters, so it points where an editor would.
| Option | Effect |
|---|---|
-t, --text |
Print matched nodes' text rather than a summary |
-c, --count |
Print only the number of matches |
-n, --ns P=URI |
Bind a namespace prefix for query; repeatable |
-h, --help |
Usage |
-V, --version |
Version |
-t and -c apply to query. Both accept the flag anywhere in the
argument list.
| Code | Meaning |
|---|---|
| 0 | Success; for validate, the document conforms |
| 1 | The document is invalid, or no nodes matched |
| 2 | A usage or I/O error |
The distinction between 1 and 2 is the point. A shell script that tests
if oxml query ... ; then wants to know "did anything match", and it
must not confuse that with "you passed a path that does not exist". A
tool that returns 1 for both makes set -e a liability.
if oxml query -c '//error' build.xml > /dev/null; then
echo "the build reported errors"
fiFILE defaults to standard input, so oxml composes:
curl -s https://example.com/feed.xml | oxml query -t '//item/title'unzip -p document.docx word/document.xml | oxml query -c '//w:p'find . -name '*.xml' -exec oxml check {} \;xmllint |
oxml |
|---|---|
xmllint --xpath '//title' f.xml |
oxml query '//title' f.xml |
xmllint --noout f.xml |
oxml check f.xml |
xmllint --schema s.xsd f.xml |
oxml validate s.xsd f.xml |
xmllint --format f.xml |
not available |
xmllint --xinclude |
not available |
xmllint exits 0 when --xpath matches nothing and prints an error to
stderr; oxml exits 1, which is usually what a script wants.
xmlstarlet |
oxml |
|---|---|
xmlstarlet sel -t -v '//title' f.xml |
oxml query -t '//title' f.xml |
xmlstarlet val f.xml |
oxml check f.xml |
xmlstarlet val -s s.xsd f.xml |
oxml validate s.xsd f.xml |
xmlstarlet ed … |
not available — oxml does not write |
xmlstarlet's template language is more capable and considerably
larger. oxml query is one expression and one output mode.
One binary, no dependencies. xmllint means libxml2, which means a
package manager, a shared library and a CVE stream. oxml is a static
binary produced by cargo install.
Exit codes that mean something. See Exit status.
Memory safety. The parser underneath forbids unsafe, and never
fetches external entities — so oxml cannot be made to read
/etc/passwd by a document that asks it to. For a tool routinely
pointed at files from elsewhere, that matters more than throughput.
Predictable output. One match per line, nothing else on stdout. Diagnostics go to stderr. It is meant to be piped.
- XPath 1.0 queries: ten axes, 25 functions, all four value types
- Text-only and count-only output modes
- XSD validation via
xmlschema - Well-formedness checking with line and column
- Document statistics by node kind, and maximum depth
- Standard input, so it composes with pipes
- Exit codes distinguishing "no match" from "usage error"
- UTF-8, UTF-16 and ISO-8859-1 input
- Namespace prefixes bound on the command line with
-n, --ns
Not yet: formatting or pretty-printing, editing, XInclude, XSLT.
oxml |
xmllint |
xmlstarlet |
xq |
|
|---|---|---|---|---|
| XPath 1.0 | Yes | Yes | Yes | Yes |
| XSD validation | Yes | Yes | Yes | No |
| Editing | No | No | Yes | No |
| Formatting | No | Yes | Yes | Yes |
| XSLT | No | Yes | Yes | No |
| Single static binary | Yes | No | No | Depends |
| C dependency | None | libxml2 | libxml2 | Varies |
| Fetches external entities | Never | Configurable | Configurable | Varies |
cargo bench --bench commandsPer invocation, with process spawn excluded — spawn is the operating
system's business and swamps everything else on a small document.
Argument handling and file I/O add roughly 9% over the bare parse on a
200 KB file; --help costs a fraction of a microsecond.
No absolute throughput figure is published, and that is deliberate: the same benchmark binary in this suite measured 14.7 and 123.1 MB/s on one machine on one day, at a load average above 30. A figure without its conditions is not a measurement, so the benchmark reports paired comparisons instead. See oxml's BENCHMARKS.md.
Runnable scripts live in examples/, each with the sample
document it operates on. They run in CI, so they cannot rot.
| Example | What it shows |
|---|---|
query-basics.sh |
Every output mode of query |
pipeline.sh |
Standard input, exit codes, composing with other tools |
inspect.sh |
stats and check, including a malformed document |
validate.sh |
XSD validation, valid and invalid |
There is none. No config file, no environment variables, no dotfile.
That is a decision rather than an omission: a command whose behaviour
depends on a file somewhere in your home directory behaves differently
in CI than on your laptop, and the difference surfaces as a confusing
failure. Everything oxml does is on the command line.
File and stdin reads stop at 8 MiB. Parsing and XPath compilation use
Limits::strict(); each query uses EvalLimits::default() to bound work,
intermediate nodes and strings. Exceeding a budget is an error, not a partial result.
- You need to edit XML.
xmlstarlet eddoes; this does not. - You need to format or pretty-print.
xmllint --formatdoes. - You need XSLT.
xsltproc. - You need XPath 2.0 or 3.1. This is 1.0, so no
matches(), nofor, no sequences. - The document is larger than memory. The whole tree is built.
Because oxml on crates.io is the library. The crate has to be
oxml-cli; the thing you type should be short.
So a shell script can test it. if oxml query -c '//error' log.xml
reads naturally and does the right thing. Exit 2 is reserved for "you
made a mistake", which is a different situation and needs a different
response.
Not directly. Pipe it: gunzip -c f.xml.gz | oxml query ….
Bind the prefix on the command line:
oxml query -n m=urn:example:meta -t '//m:item' catalogue.xmlThe binding lives with the query, not the document, so the same
command works against a document that spells the prefix differently —
only the URI has to match. -n is repeatable, and later bindings for
the same prefix win.
An unbound prefix is an error, not a silent match on the local part:
$ oxml query -t '//m:item' catalogue.xml
oxml: bad XPath: unbound namespace prefix `m`; bind it with --ns m=URI
An unprefixed name test matches only nodes in no namespace, which
is what XPath 1.0 specifies. If your document declares a default
namespace, //item matches nothing — bind a prefix to that URI.
namespace-uri() still works and needs no binding, which is useful
when you know the URI and not the prefix:
oxml query -t "//*[namespace-uri()='urn:example' and local-name()='item']" f.xmlquery -t and query -c are: one result per line, nothing else on
stdout. stats and the default query output are human-facing and may
change.
No. There is no network code anywhere in the dependency tree, and
external entities are never dereferenced — a document containing
<!ENTITY xxe SYSTEM "file:///etc/passwd"> cannot make it read that
file. See
oxml's SECURITY-MODEL.md.
The core supports serialization, but this CLI does not expose a format
subcommand. Use the core serializer or the MCP xml_format tool when
formatting is required.
It is rejected, with a line and column. There is no recovery mode. A parser that guesses at what a malformed document meant produces a tree that no two tools agree on.
That is the intended use.
- run: oxml check config.xml
- run: oxml validate schema.xsd config.xmlBoth exit non-zero on failure, which is all a CI step needs.
Yes. CI builds and tests on Linux, macOS and Windows.
./scripts/gate.shThat runs everything CI runs, in the order that fails fastest: format,
clippy, tests, rustdoc, the #![forbid(unsafe_code)] check, the
examples, the 95% coverage floor and an MSRV build. It pins the
toolchain rather than trusting rust-toolchain.toml, because a
RUSTUP_TOOLCHAIN in the environment silently overrides that file and
a lint that exists in one release and not another then makes a green
local run and a red CI one.
The individual steps, if you want them one at a time:
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all --check
cargo bench --bench commands
OXML="$PWD/target/release/oxml" ./examples/run-all.shCI runs the same set on Linux, macOS and Windows.
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.
The parser underneath never dereferences external entities, so XXE is
foreclosed structurally rather than by a default. Entity expansion is
bounded. #![forbid(unsafe_code)].
To report a vulnerability, see SECURITY.md. Please do not open a public issue.
- doc/ — the decisions, in longer form
- oxml's documentation
- CHANGELOG.md
- CONTRIBUTING.md
xmllint— the tool this is measured against, and the reason the exit-code behaviour is what it is.xmlstarlet— for showing how much a command-line XML tool can do.jq— for the standard a pipeline-friendly query tool is held to.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
