Dependencies

No C, no XSLT, no JVM

The conventional way to run Schematron is to compile the schema into XSLT using the ISO reference stylesheets, then run that XSLT. That works, and it means a Rust project acquires either a libxml2 build, an FFI boundary and a system package to install, or a Saxon process and a JVM to start.

This crate implements the standard directly. The XML parser, the XPath 1.0 engine, the schema compiler, and the validator are all Rust, in this crate, with no unsafe. The build story is cargo add, on every platform Rust targets — musl static binaries and cross-compiles included.

Interpreting rather than transpiling has a second benefit: error messages point at your schema, not at generated XSLT. A mistake in a @context is reported as a mistake in a @context.

Security

XXE is structurally impossible

The XML parser never resolves an external entity and never processes a DTD's entity declarations. That is not a switch that defaults to off — the code path does not exist. A reference to a DTD-declared entity is an error.

The default resolver reads local files and refuses http: and https: URIs. Network access is something an application opts into by supplying its own Resolver, never something the library does behind your back. The command line tool has no network flag at all.

Parse depth, include depth, and expression nesting are all bounded, and exceeding a bound returns an error rather than exhausting the stack. Four cargo-fuzz targets exist to keep that true.

Performance

Compile once, validate many

Compiling is the expensive step: it resolves includes, expands abstractions, and parses every XPath expression in the schema. It happens once. The compiled schema is then reused across documents and across threads.

Rule contexts are evaluated once per document rather than tested node by node, so matching is linear rather than quadratic in document size.

Schema is immutable and Send + Sync:

use std::sync::Arc;

let schema = Arc::new(Schema::from_path("rules.sch")?);

for path in paths {
    let schema = Arc::clone(&schema);
    std::thread::spawn(move || {
        let document = Document::from_path(path)?;
        schema.validate(&document)
    });
}

Patterns are independent, so a single schema with several of them can evaluate them on separate threads. The report is identical either way — this is a performance switch, not a behaviour switch:

let options = ValidateOptions::new().with_parallel_patterns(true);
let report = schema.validate_with(&document, &options)?;

Measure before turning it on: on a small document the threads cost more than they save. Indicative figures and the benchmarks that produce them live in spec/testing/; run cargo bench yourself, because the numbers in any README are somebody else's hardware.

Stability

What you can depend on

  • MSRV of 1.96, under a stated policy: current stable minus two. Not a number that moves when a dependency feels like it.
  • A normative specification. If the code and spec/ disagree, that is a defect in one of them — and the test suite checks the docs against the code, so it is usually caught.
  • Divergences stated up front. Every place this crate differs from the ISO reference implementation is written down, with the measurement that established it.
  • Unsupported constructs error by name. Outside the implemented XPath 2.0 and 3.0 subsets you get a hard error naming the construct, never a plausible wrong answer.

See exactly what is implemented Read the source