diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..ba8c3927
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,4 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ path_filters:
+ - "!crates/xml-sec-xslt/tests/fixtures/libxslt-1.1.45/upstream/**"
diff --git a/.gitattributes b/.gitattributes
index a1926bc9..90f4b693 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,3 +3,5 @@ tests/fixtures/xmlenc/01-phaos-xmlenc-3/** -text whitespace=-trailing-space,-spa
tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/** -text whitespace=-blank-at-eof
tests/fixtures/xmldsig/phaos-xmldsig-three/** -text whitespace=-trailing-space,-space-before-tab,-blank-at-eof
tests/fixtures/xmldsig/external-data/rfc3161.txt -text
+crates/xml-sec-xslt/tests/fixtures/libxslt-1.1.45/upstream/** -text whitespace=-trailing-space,-space-before-tab,-blank-at-eof linguist-generated
+vendor/sxd-xpath-no-unsafe/** -text whitespace=-trailing-space,-space-before-tab,-blank-at-eof
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f09c4c8..a7eeb2bb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -93,6 +93,23 @@ jobs:
steps:
- run: test "${{ needs.build-matrix.result }}" = "success"
+ alloc-only:
+ name: alloc-only XML input
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: "1.92.0"
+ targets: thumbv7em-none-eabihf
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo +1.92.0 check -p xml-sec-xml-input --no-default-features
+ - run: >-
+ cargo +1.92.0 check -p xml-sec-xml-input --no-default-features
+ --target thumbv7em-none-eabihf
+
test-matrix:
name: test-matrix (${{ matrix.rust }}, ${{ matrix.xml-backend }})
runs-on: ubuntu-latest
@@ -148,15 +165,49 @@ jobs:
scripts/install-xmlsec1.sh
"$XMLSEC1_BIN" --version
- uses: Swatinem/rust-cache@v2
- - run: cargo nextest run --workspace ${{ matrix.cargo-args }}
+ - run: >-
+ cargo nextest run --workspace ${{ matrix.cargo-args }}
+ -E 'not binary(libxslt_oracle)'
- run: cargo test --doc --workspace ${{ matrix.cargo-args }}
test:
runs-on: ubuntu-latest
- needs: test-matrix
+ needs: [test-matrix, xslt-oracle]
if: ${{ always() }}
steps:
- run: test "${{ needs.test-matrix.result }}" = "success"
+ - run: test "${{ needs.xslt-oracle.result }}" = "success"
+
+ xslt-oracle:
+ name: XSLT oracle (libxslt 1.1.45)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - name: Read pinned XSLT oracle revisions
+ id: xslt-oracle-revisions
+ run: |
+ libxslt_commit="$(cat compatibility/libxslt-1.1.45-donor-commit.txt)"
+ [[ "$libxslt_commit" =~ ^[0-9a-f]{40}$ ]]
+ echo "libxslt=$libxslt_commit" >> "$GITHUB_OUTPUT"
+ - uses: actions/checkout@v7
+ with:
+ repository: GNOME/libxslt
+ ref: ${{ steps.xslt-oracle-revisions.outputs.libxslt }}
+ path: donors/libxslt
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: "1.92.0"
+ - uses: taiki-e/install-action@nextest
+ - name: Verify vendored oracle fixtures
+ env:
+ LIBXSLT_SOURCE_DIR: ${{ github.workspace }}/donors/libxslt
+ run: scripts/import-libxslt-oracle-fixtures.sh --check
+ - uses: Swatinem/rust-cache@v2
+ - name: Run Rust engine against pinned libxslt golden corpus
+ run: cargo nextest run -p xml-sec-xslt --test libxslt_oracle --no-fail-fast
clippy-matrix:
name: clippy (${{ matrix.xml-backend }})
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 270a1822..402feb1d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,6 +17,23 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: rust-lang/crates-io-auth-action@v1
id: auth
+ # Workspace path dependencies must exist in the registry before their consumers.
+ - name: Publish document support crate to crates.io
+ run: bash .github/scripts/publish-crate.sh xml-sec-sxd-document
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+ - name: Publish XPath support crate to crates.io
+ run: bash .github/scripts/publish-crate.sh xml-sec-sxd-xpath
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+ - name: Publish XML input crate to crates.io
+ run: bash .github/scripts/publish-crate.sh xml-sec-xml-input
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+ - name: Publish XSLT crate to crates.io
+ run: bash .github/scripts/publish-crate.sh xml-sec-xslt
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
- name: Publish xml-sec to crates.io
run: bash .github/scripts/publish-crate.sh xml-sec
env:
diff --git a/.greptile/config.json b/.greptile/config.json
new file mode 100644
index 00000000..9c8c9fcb
--- /dev/null
+++ b/.greptile/config.json
@@ -0,0 +1,3 @@
+{
+ "ignorePatterns": "crates/xml-sec-xslt/tests/fixtures/libxslt-1.1.45/upstream/**"
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..a00eb983
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,128 @@
+# xml-sec Agent Rules
+
+These repository-specific rules supplement the active global agent instructions.
+
+## Unified Policy Architecture (CRITICAL)
+
+Before implementing roadmap work, read the policy tasks in `arch/ROADMAP.md` (`P4-012a`
+through `P4-012d`) and the request/evidence task (`P4-015a`). The project is converging on
+one typed immutable policy domain; do not extend the current scattered configuration.
+
+- Never add standalone policy booleans, allowlists, minima/maxima, modes, or legacy
+ exceptions to `VerifyContext`, `SignContext`, `KeyResolverConfig`, XMLEnc builders or
+ options, transform options, or parser options.
+- Never introduce deployment-selectable policy as a hard-coded constant near an
+ enforcement point. Algorithm acceptance, key/HMAC minima, key-source trust,
+ URI/transform acceptance, Manifest behavior, XML allowances, external-resource limits,
+ and aggregate work/output limits belong to typed policy.
+- Wire/spec constants may remain local. Absolute non-configurable safety ceilings must be
+ centralized and may only tighten compiled policy. Do not misclassify configurable
+ defaults as hard safety ceilings.
+- Verify/sign/encrypt/decrypt contexts receive one immutable compiled policy snapshot and
+ report typed policy violations. Shared policy concepts have one source of truth rather
+ than operation-specific duplicates.
+- Trusted keys, expected signed targets, tenant/correlation identity, and caller-provided
+ external bytes are request context, not static policy.
+- The `xml-sec` core performs no implicit config discovery, filesystem/environment reads,
+ network access, or hot reload. Versioned external configuration and atomic policy-store
+ integration belong to the separate `xml-sec-config` boundary.
+- Never select a permissive profile from untrusted document content.
+- Standalone mechanism crates may accept typed enforcement limits so they remain reusable and
+ bounded without depending on `xml-sec` policy types. XML-security adapters must derive every
+ such limit from the operation's one compiled policy snapshot and must not expose a parallel
+ caller-configurable policy surface.
+
+For `/next-task`, inventory every policy decision and enforcement point in the mandatory
+research summary before editing code. If full implementation would require a temporary
+one-off knob or policy constant before the unified policy refactor exists, treat the task
+as implicitly blocked: reorder the dependency or choose another unblocked task. Do not
+ship a partial feature or add a temporary configuration path.
+
+Before completion, inspect the diff for newly introduced policy-like fields and numeric
+limits and prove each is correctly classified. Public-path tests must demonstrate that
+the compiled policy reaches every affected enforcement point.
+
+## `no_std + alloc` Compatibility Gate (CRITICAL)
+
+The `no_std + alloc` configuration is a required product path, not an optional follow-up.
+Every production-code or dependency change must preserve it once introduced, including
+changes whose primary scope is the default `std` build.
+
+- Before editing, identify whether affected crates and dependencies are compiled by the
+ alloc-only feature set. Do not introduce unconditional filesystem, process, networking,
+ environment, thread, clock, synchronization, or other `std` APIs into that graph.
+- Keep capability boundaries explicit: functionality requiring `std` must be behind the
+ documented `std` feature and must not leak types or trait bounds into alloc-only APIs.
+- Dependencies used by the alloc-only graph must disable default features where necessary;
+ verify their actual feature graph rather than assuming that a crate is `no_std` capable.
+- Before declaring any task or review cycle complete, run the repository's canonical
+ alloc-only check for every affected crate, in addition to the default/all-feature build,
+ tests, and lint. A passing default build never substitutes for this check.
+- Also validate a representative target without `std` whenever the toolchain target is
+ available. Host-only `--no-default-features` is insufficient if it can accidentally link
+ `std` through the target environment.
+- If the canonical command or CI job does not yet exist while implementing the no-std path,
+ add a durable repository command and CI matrix entry first, then use that same path locally.
+ Do not rely on a one-off local invocation that future tasks cannot reproduce.
+- Treat an alloc-only compile failure, accidental `std` feature activation, or unvalidated
+ affected crate as a completion blocker. Do not push or report the work complete.
+
+## Standards Decide Format Semantics (CRITICAL)
+
+This repository implements specified formats and protocols, including XML, Namespaces in XML,
+XPath, XSLT, C14N, XMLDSig, XMLEnc, XInclude, X.509, and their referenced cryptographic formats.
+When review feedback or implementation behavior turns on what input is valid or what a processor
+must do, the primary normative specification decides rather than reviewer or author preference.
+
+- Cite the primary document and exact section for claims that input must be accepted, rejected,
+ ordered, bounded, or ignored. Quote the decisive normative wording when `must`, `should`, or
+ `may` changes the conclusion.
+- State explicitly when the standard is silent; reporting, resource limits, API shape, and stricter
+ security policy are then product decisions and must not be presented as conformance mandates.
+- Put a stable link and exact section in a local code comment at the enforcement point whenever a
+ standards rule is ambiguous, surprising, or has already caused review disagreement.
+- Document intentional interoperability or security-policy departures honestly, including why the
+ project diverges. If the normative text contradicts the implementation, change the code and
+ replace the obsolete comment.
+
+### Pinned Normative Baseline (checked 2026-09-07)
+
+Use the following editions, their applicable updates, and verified errata. A newer language
+version is not a silent replacement for the version selected by the document/algorithm contract.
+These are reference requirements, not a claim that every feature of every document is implemented.
+
+| Contract | Edition |
+| --- | --- |
+| XML / Namespaces | XML 1.0 Fifth Edition (2008-11-26); Namespaces in XML 1.0 Third Edition (2009-12-08) |
+| XML 1.1 comparison | XML 1.1 and Namespaces in XML 1.1 Second Editions (2006-08-16); do not apply 1.1 character or namespace rules to a 1.0 document |
+| XPath / XSLT | 1.0 Recommendations (1999-11-16), including errata; XPath 3.1 (2017-03-21) and XSLT 3.0 (2017-06-08) are newer languages, not this engine's contract |
+| XInclude / XML Base / xml:id | XInclude 1.0 Second Edition (2006-11-15); XML Base Second Edition (2009-01-28); xml:id (2005-09-09) |
+| XPointer | Framework and element() Scheme (2003-03-25) |
+| Canonicalization | C14N 1.0 (2001-03-15), C14N 1.1 (2008-05-02), Exclusive C14N 1.0 (2002-07-18), selected by algorithm URI |
+| XML Security | XMLDSig 1.1 and XMLEnc 1.1 (2013-04-11); XPath Filter 2.0 (2002-11-08); RFC 9231 (2022-07), which obsoletes RFC 6931 |
+| XSLT serialization / EXSLT date types | HTML 4.01 (1999-12-24); XML Schema Datatypes 1.0 Second Edition (2004-10-28); do not substitute XSD 1.1 year semantics |
+| ASN.1 BER/CER/DER | ITU-T X.690 (2021-02), identical to ISO/IEC 8825-1:2021, plus Erratum 1 (2021-09) |
+| Certificates | RFC 5280 (2008-05), with Updates 6818, 9549 (replaces 8399), 9598 (replaces 8398), 9608, 9618, 9925, 10007; apply each only to its defined scope |
+| Crypto encodings | RFC 3394, 4055 + 5756, 5758, 5958, 7468, 8017 (PKCS #1 v2.2), 8018 + 9879 (replaces 9579; PKCS #5 v2.1 / PBMAC1), 8410 + 9295 |
+| URI / text encodings | RFC 3986 + 8820 (supersedes 7320), RFC 2781, RFC 4648; use historical RFC 2396 only where an older normative contract explicitly incorporates it |
+| Names / requirements | RFC 4514, RFC 9525; BCP 14 = RFC 2119 + RFC 8174 |
+
+Exact dated publisher URLs are in [`docs/standards-sources.tsv`](docs/standards-sources.tsv).
+Run `bash scripts/fetch-standards.sh` to download publisher texts and RFC status metadata into
+gitignored `.refs/standards/`, with retrieval time and SHA-256 checksums. Preserve notices; do not
+commit third-party full texts. Check `obsoleted_by`, `updated_by`, and errata status before relying
+on an RFC: a reported erratum is not automatically a normative correction.
+
+Charset editions include ISO/IEC 8859-1:1998, 8859-2:1999, 8859-9:1999, and 8859-11:2001.
+Their ISO full texts were not obtained (the publisher catalogue returned HTTP 403; this
+does not establish the reason for the access failure). Open Unicode Consortium mapping tables
+can verify byte-to-Unicode mappings but are not substitutes for the full normative text:
+https://www.unicode.org/Public/MAPPINGS/ISO8859/DatedVersions/ .
+Do not claim that the ISO text has been audited. The freely
+published ITU-T X.690 text and its erratum are available for ASN.1 auditing.
+
+## Review Fixture Scope
+
+Treat imported fixture payloads as test data, not review context. Do not read or review individual
+fixture files unless a changed importer, manifest/hash, failing test, or concrete finding requires
+that exact payload; review provenance, selection metadata, and harness behavior instead.
diff --git a/Cargo.toml b/Cargo.toml
index 8aaa7103..3c05a1e9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,9 +13,28 @@ categories = ["cryptography", "web-programming", "authentication"]
readme = "README.md"
[workspace]
-members = [".", "tools/capability-ledger"]
+members = [
+ ".",
+ "crates/xml-sec-xml-input",
+ "crates/xml-sec-xslt",
+ "tools/capability-ledger",
+ "vendor/sxd-document-no-unsafe",
+ "vendor/sxd-xpath-no-unsafe",
+]
resolver = "3"
+# The complete libxslt oracle includes multi-megabyte DocBook transforms. Keep
+# production-equivalent XSLT/XPath code generation in the test profile so the
+# per-case watchdog measures algorithmic stalls rather than debug arithmetic.
+[profile.dev.package.xml-sec-xslt]
+opt-level = 2
+
+[profile.dev.package.xml-sec-sxd-document]
+opt-level = 2
+
+[profile.dev.package.xml-sec-sxd-xpath]
+opt-level = 2
+
[[bin]]
name = "xmlsec1"
path = "tools/xmlsec1/src/main.rs"
@@ -44,11 +63,11 @@ required-features = ["xmlenc"]
# XML parsing
roxmltree = { version = "0.21", features = ["positions"], optional = true }
xmloxide = { version = "0.5", default-features = false, optional = true }
-quick-xml = "0.41"
self_cell = "1.3"
+xml-sec-xml-input = { version = "0.1.0", path = "crates/xml-sec-xml-input" }
# Crypto
-rsa = { package = "sad-rsa", version = "0.2.3", features = ["sha1", "sha2"], optional = true }
+rsa = { package = "sad-rsa", version = "0.10.2", features = ["sha1", "sha2"], optional = true }
sha1 = { version = "0.11", features = ["oid"], optional = true }
sha2 = { version = "0.11", features = ["oid"], optional = true }
p256 = { version = "0.14", features = ["ecdsa"], optional = true }
@@ -62,8 +81,8 @@ signature = { version = "3", optional = true }
subtle = { version = "2", optional = true }
getrandom = { version = "0.4", features = ["sys_rng"], optional = true }
zeroize = { version = "1", optional = true }
-sxd-document-no-unsafe = { version = "0.4.1", default-features = false, features = ["no-unsafe"], optional = true }
-sxd-xpath-no-unsafe = { version = "0.5.1", default-features = false, features = ["no-unsafe"], optional = true }
+sxd-document-no-unsafe = { package = "xml-sec-sxd-document", version = "0.1.0", path = "vendor/sxd-document-no-unsafe", default-features = false, features = ["no-unsafe"], optional = true }
+sxd-xpath-no-unsafe = { package = "xml-sec-sxd-xpath", version = "0.1.0", path = "vendor/sxd-xpath-no-unsafe", default-features = false, features = ["no-unsafe"], optional = true }
aes = { version = "0.9.2", optional = true }
aes-gcm = { version = "0.11.1", optional = true }
aes-kw = { version = "0.3.1", optional = true }
@@ -76,6 +95,7 @@ pem = { version = "4", optional = true }
x509-parser = { version = "0.18", features = ["verify"], optional = true }
x509-cert = { version = "0.3", default-features = false, optional = true }
x520-stringprep = { version = "1", features = ["alloc"], optional = true }
+tinyvec = { version = "1.13", optional = true }
der = { version = "0.8", optional = true }
crypto-bigint = { version = "0.7", optional = true }
@@ -126,6 +146,7 @@ xmldsig = [ # XML Digital Signatures (sign + verify)
"dep:x509-parser",
"dep:x509-cert",
"dep:x520-stringprep",
+ "dep:tinyvec",
"dep:zeroize",
]
xmlenc = [ # XML Encryption (encrypt + decrypt)
diff --git a/README.md b/README.md
index e00dce4c..0273831f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# xml-sec
+# xml-sec: Pure Rust XML Security
[](https://crates.io/crates/xml-sec)
[](https://docs.rs/xml-sec)
@@ -6,170 +6,88 @@
[](https://www.rust-lang.org)
[](https://github.com/structured-world/xml-sec/blob/main/LICENSE)
-XML Security in pure Rust, built to replace libxmlsec1.
+Pure Rust XML Security for **XMLDSig**, **XML Encryption**, **C14N**, **SAML 2.0**, and
+**X.509**, built to replace libxmlsec1 workflows without a C toolchain or system libraries.
-**No C dependencies. No cmake. No system libraries. Just `cargo add xml-sec`.**
+**No C dependencies. No CMake. No system XML or crypto packages. Just `cargo add xml-sec`.**
> [!WARNING]
> Early-stage pre-release. The API is unstable, XMLDSig/XMLEnc coverage is still incomplete,
> and this crate should not yet be used in production.
-## Features
-
-- **C14N** — XML Canonicalization (inclusive + exclusive, W3C compliant)
-- **XMLDSig** — XML Digital Signatures (verify and signing pipelines, X.509 `KeyInfo`, and xmlsec1 CLI interoperability)
-- **XMLEnc** — XML Encryption encrypt/decrypt pipelines (direct, RSA-OAEP, and AES-KW keys)
-- **X.509** — Certificate-based key extraction and validation
-- **Native CLI** — `xmlsec1` command surface backed by the same Rust policy and provider pipelines
-- **Provider-neutral crypto** — typed capabilities and opaque key handles with RustCrypto as the pure-Rust default
-- **Reusable XML documents** — policy-aware retained parsing, stable semantic identities, shared indexes, and generation-safe mutation across C14N, XMLDSig, and XMLEnc
-- **Compiled operations** — deterministic dependency plans keep policy, cumulative budgets, resolver/cache state, authenticated identities, and mutation gates in one operation context
-- **Selectable XML backend** — `xmloxide` and `roxmltree` are interchangeable compile-time parsers behind one backend-neutral semantic DOM
-
-## Why?
-
-libxmlsec1 is the established XML Security implementation, but its native dependency stack adds
-libxml2, a crypto backend, platform packages, and cross-compilation work to every deployment.
-
-`xml-sec` rebuilds that functionality on memory-safe Rust foundations: a bounded `quick-xml`
-preflight before DOM allocation, one feature-selected XML parser projected into a shared semantic
-arena for C14N/XPath/mutation, `quick-xml` for writing, RustCrypto for cryptography, and
-`x509-parser` for certificates. One Cargo dependency, no system XML or crypto libraries.
+## Why xml-sec?
+
+- **End-to-end XML security**: sign, verify, encrypt, and decrypt through public Rust APIs.
+- **SAML-ready primitives**: enveloped signatures, encrypted assertions, X.509 keys, XPath,
+ canonicalization, and strict same-document ID handling.
+- **Pure Rust deployment**: RustCrypto, `x509-parser`, and selectable Rust XML backends replace
+ the libxml2/OpenSSL-style native dependency stack.
+- **Fail-closed security policy**: typed immutable policy controls algorithms, trust, XML parsing,
+ transforms, external resources, and cumulative operation budgets.
+- **xmlsec1 interoperability**: a native `xmlsec1` CLI surface plus deterministic offline
+ compatibility corpora and generated parity ledgers.
+- **Encoding-aware XML input**: strict bounded decoding for UTF-8, UTF-16, UTF-32, and supported
+ legacy XML encodings before backend-independent semantic parsing.
+- **Reusable safe-Rust XSLT 1.0 engine**: an isolated workspace crate with explicit resolver,
+ clock, extension, and resource capabilities.
## Install
-Use the library from Rust code:
-
```sh
cargo add xml-sec
```
-Default features provide C14N, XMLDSig, and XMLEnc. Applications that need a
-smaller dependency graph can select only the required library capabilities:
+Default features provide C14N, XMLDSig, XMLEnc, and the `xmloxide` XML backend. Adding the crate as
+a dependency builds the library; install the command-line tool from the same package:
-```toml
-xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "c14n", "xml-backend-xmloxide"] }
+```sh
+cargo install xml-sec
+xmlsec1 version
```
-Select `xml-backend-roxmltree` instead for a thin build containing only `roxmltree`, or compile
-`xml-backends-all` when the application must select `Xmloxide`, `Roxmltree`, or fail-closed
-`Differential` parsing at runtime. Compiled implementations and runtime selection are separate:
-selecting an implementation absent from a thin build returns a typed error and never falls back.
-Both adapters populate the same source-preserving semantic arena, and no C14N, XPath, signature,
-encryption, or mutation code branches on parser type. A bounded streaming preflight
-rejects byte, node, and depth limits before either backend allocates its DOM; stack-safe
-internal-entity traversal consumes the same cumulative parse-work budget. The `xmloxide` adapter
-adds a lexical position sidecar because its native tree does not retain the source ranges required
-for namespace-correct mutation.
+Applications can build only the capabilities they need:
```toml
-xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "c14n", "xml-backend-roxmltree"] }
-```
-
-```toml
-# Fat build: xmloxide remains the default; applications select per operation.
-xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "xmlenc", "c14n", "xml-backends-all"] }
-```
-
-```rust
-use xml_sec::XmlBackend;
-use xml_sec::xmldsig::VerifyContext;
-
-# let xml = " ";
-let result = VerifyContext::new()
- .xml_backend(XmlBackend::Roxmltree)
- .verify(xml);
-# let _ = result;
-```
-
-`xml-backend-differential` remains a compatibility feature for CI and fuzzing: it compiles both
-adapters and selects `Differential` by default. Differential parsing fails closed unless the full
-semantic arenas agree, including topology, expanded names, attributes, namespace axes, character
-data, comments, processing instructions, semantic order, and source ranges. It is an explicit
-diagnostic mode, not a production fallback. Both implementations are checked against the same
-per-backend parser-work allowance, so differential validation does not halve the operation budget.
-
-Cryptographic implementation and runtime selection follow the same separation through the
-`CryptoProvider` contract: operation contexts receive one provider explicitly. The current package
-ships the RustCrypto provider; a future AWS-LC feature can add another compiled implementation
-without changing signing, verification, encryption, or decryption policy semantics. Crypto has no
-differential mode: a fat crypto build selects exactly one provider for each operation.
-
-Install the `xmlsec1` command from the same package:
-
-```sh
-cargo install xml-sec
-xmlsec1 verify --xml-backend xmloxide signed.xml
+xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "c14n", "xml-backend-xmloxide"] }
```
-Adding `xml-sec` as a dependency builds its library target, not the executable.
-`cargo install` builds and installs the binary target.
-The CLI accepts `--xml-backend xmloxide|roxmltree|differential` on every XML
-Security operation. A thin binary rejects a backend that was not compiled;
-install a fat build with `--features xml-backends-all` when runtime switching is required.
-
## Capabilities
| Area | Available today |
|------|-----------------|
-| Canonicalization | C14N 1.0, C14N 1.1, Exclusive C14N, comments and document subsets |
-| Signatures | End-to-end XMLDSig signing and verification, same-document and caller-provided references, XPath transforms, `Manifest`, `KeyInfo`, and X.509 validation |
-| Encryption | AES-CBC/GCM, RSA-OAEP, AES Key Wrap, multiple recipients, and Element/Content replacement |
-| Policy | Typed immutable policies for algorithms, trust, parsing, external resources, transforms, and work limits |
-| Providers | Provider-neutral crypto contracts with a pure-Rust RustCrypto implementation |
+| Canonicalization | Canonical XML 1.0/1.1, Exclusive C14N, comments, and document subsets |
+| XML signatures | XMLDSig signing and verification, RSA/DSA/ECDSA/HMAC, XPath transforms, `Manifest`, `KeyInfo`, and caller-provided references |
+| XML encryption | AES-CBC/GCM, RSA-OAEP, AES Key Wrap, multiple recipients, and Element/Content replacement |
+| X.509 | Certificate key extraction, chain validation, CRLs, and policy-controlled trust |
+| SAML 2.0 | Signed assertions and encrypted-assertion workflows covered by integration tests |
+| XML input | Strict bounded byte decoding, entity/depth/node limits, stable node identities, and generation-safe mutation |
+| Crypto | Provider-neutral contracts and opaque key handles with pure-Rust RustCrypto as the default implementation |
| CLI | Native `xmlsec1` process interface for sign, verify, encrypt, decrypt, keys, and capability discovery |
-The implementation is fail-closed: unsupported algorithms, unavailable provider
-capabilities, untrusted key sources, implicit external I/O, and exhausted resource
-budgets produce explicit errors rather than compatibility fallbacks.
-XML parsing work is cumulative per operation: initial input, recursive transform
-adapters, staged mutations, dependency levels, and decryption retries share one
-policy allowance rather than resetting limits inside helpers.
-
-Interoperability evidence is deterministic and offline. The complete Phaos
-XMLDSig 3, XMLDSig 1.1, and XMLDSig Second Edition interoperability corpora
-are executed through the public sign/verify APIs with exact valid, invalid,
-and fail-closed classifications; the generated
-[compatibility ledger](docs/compatibility-ledger.md) keeps remaining
-libxmlsec1 parity work explicit.
-
-## Native CLI
-
-Inspect the installed binary's runtime capability registry:
-
-```sh
-xmlsec1 version
-xmlsec1 list-transforms
-xmlsec1 list-key-data
-```
-
-The native binary covers sign/verify, template-preserving encrypt/decrypt, AES
-key generation, capability queries, donor option syntax, and deterministic
-process statuses through the same policy and provider pipelines as the library.
-Unsupported algorithms, formats, providers, and policy controls fail closed;
-document-selected certificates require explicit trust unless `--insecure` is
-chosen. Selected unmodified upstream DSig, Enc, and Keys scenarios run against
-the Rust binary without network access or a system `xmlsec1`. See the
-[CLI compatibility guide](docs/cli.md) for exact commands, formats, key lookup,
-diagnostics, and interoperability boundaries.
+Unsupported algorithms, unavailable provider capabilities, untrusted key sources, implicit external
+I/O, malformed encodings, and exhausted budgets return explicit errors. Initial parsing, recursive
+transforms, resolver work, staged mutations, and decryption retries share one operation-wide budget
+instead of resetting limits inside helpers.
-## XMLDSig Usage
+## XMLDSig
-`examples/sign.rs` builds an enveloped RSA-SHA256 signature and `examples/verify.rs`
-verifies it through the embedded X.509 certificate:
+`examples/sign.rs` creates an enveloped RSA-SHA256 signature and `examples/verify.rs` verifies it
+through the embedded X.509 certificate:
```sh
cargo run --example sign > signed.xml
cargo run --example verify -- signed.xml
```
-See [XML Digital Signatures](docs/xmldsig.md) for supported algorithms, transform
-semantics, key-resolution policy, and validation failure handling.
+The signing and verification pipelines support same-document and caller-provided references,
+XPath 1.0 and XPath Filter 2 transforms, `Manifest`, structured `KeyInfo`, and policy-controlled
+X.509 validation. See [XML Digital Signatures](docs/xmldsig.md) for algorithms, transform semantics,
+key resolution, failure handling, and current interoperability boundaries.
-## XMLEnc Usage
+## XML Encryption
-Enable the `xmlenc` feature. `EncryptedDataBuilder` supports direct symmetric keys,
-RSA-OAEP recipients, AES Key Wrap recipients, and Element/Content document replacement:
+`EncryptedDataBuilder` supports direct symmetric keys, RSA-OAEP recipients, AES Key Wrap
+recipients, and Element/Content document replacement:
```rust
use xml_sec::xmlenc::{DataEncryptionAlgorithm, EncryptedDataBuilder};
@@ -186,31 +104,100 @@ fn example() -> Result<(), Box> {
}
```
-See [XML Encryption](docs/xmlenc.md) for reciprocal decryption, recipient transport,
-document replacement, input bounds, and parser security policy.
+See [XML Encryption](docs/xmlenc.md) for reciprocal decryption, key transport, recipient selection,
+document replacement, and parser policy.
+
+## XML Backends
+
+`xmloxide` and `roxmltree` are independent parsers behind one source-preserving semantic DOM.
+C14N, XPath, XMLDSig, XMLEnc, and mutation code do not branch on parser-specific tree types.
+
+Use `xml-backend-roxmltree` for a thin alternative build, or compile `xml-backends-all` to select
+`Xmloxide`, `Roxmltree`, or fail-closed `Differential` parsing per operation:
+
+```toml
+xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "xmlenc", "c14n", "xml-backends-all"] }
+```
+
+```rust
+use xml_sec::XmlBackend;
+use xml_sec::xmldsig::VerifyContext;
+
+# let xml = " ";
+let result = VerifyContext::new()
+ .xml_backend(XmlBackend::Roxmltree)
+ .verify(xml);
+# let _ = result;
+```
+
+Runtime selection never falls back to an implementation absent from the build. Differential mode
+requires both complete semantic arenas to agree on topology, expanded names, attributes,
+namespaces, text, processing instructions, source order, and ranges. Each backend receives the
+full configured allowance; the mode does not split one parser budget in half.
+
+Raw-byte APIs select XML encoding from the BOM, byte signature, declaration, or trusted resolver
+metadata. UTF-8 remains borrowed when possible; other supported encodings are strictly transcoded
+under the same materialization ceiling. Conflicting declarations, malformed byte sequences,
+ambiguous BOM-less UTF-16/UTF-32, and unsupported EBCDIC variants fail explicitly.
+
+## Native xmlsec1 CLI
+
+```sh
+xmlsec1 list-transforms
+xmlsec1 list-key-data
+xmlsec1 verify --xml-backend xmloxide signed.xml
+```
+
+The binary covers sign/verify, template-preserving encrypt/decrypt, AES key generation, capability
+queries, donor option syntax, and deterministic process statuses through the same policy and
+provider pipelines as the library. A fat build accepts
+`--xml-backend xmloxide|roxmltree|differential`; a thin build rejects unavailable backends.
+
+See the [CLI compatibility guide](docs/cli.md) for commands, formats, key lookup, diagnostics, and
+interoperability boundaries.
+
+## Interoperability
-## Project Status
+Interoperability evidence is deterministic and offline. The complete Phaos XMLDSig 3, XMLDSig 1.1,
+and XMLDSig Second Edition corpora execute through public sign/verify APIs with exact valid,
+invalid, and fail-closed classifications. Selected unmodified upstream XMLDSig, XML Encryption,
+and key-management scenarios execute against the Rust `xmlsec1` binary without network access or
+a system libxmlsec1 installation.
-Current development focuses on remaining XMLDSig/XMLEnc algorithms, complete
-upstream conformance classification, fuzzing, benchmarks, hardening, and API
-stabilization.
+The generated [compatibility ledger](docs/compatibility-ledger.md) tracks libxmlsec1 1.3.13 public
+surface and operation behavior with source and test evidence.
-The [compatibility ledgers](docs/compatibility-ledger.md) track libxmlsec1 1.3.13
-public surface and operation-level behavior with source and test evidence. See
-the [XMLDSig guide](docs/xmldsig.md), [XMLEnc guide](docs/xmlenc.md), and
-[CLI compatibility guide](docs/cli.md) for detailed contracts and limitations.
+## Safe-Rust XSLT
-The project tracks stable Rust and supports Rust 1.92 or newer.
+[`xml-sec-xslt`](crates/xml-sec-xslt) is an XML-security-neutral XSLT 1.0 compiler and runtime. It
+owns stylesheet compilation, XPath/XSLT semantics, template execution, result-tree construction,
+deterministic budgets, and XML/HTML/text serialization. Its `ExecutionEnvironment` makes resolver
+access, operation time, and extensions explicit, allowing fixed clocks or disabled nondeterministic
+EXSLT date functions.
+
+```sh
+cargo add xml-sec-xslt
+```
+
+The engine remains a separate architectural boundary. The main crate continues to reject XMLDSig
+XSLT transforms until the policy, resource identity, and node-set adapter contracts are connected.
+[`xml-sec-xml-input`](crates/xml-sec-xml-input) supplies the shared strict byte-decoding and lexical
+boundary used by core and XSLT paths.
## Specifications
-| Spec | Status |
-|------|--------|
+| Specification | Status |
+|---------------|--------|
| [Canonical XML 1.0](https://www.w3.org/TR/xml-c14n/) | Implemented; full-document and document-subset vectors |
| [Canonical XML 1.1](https://www.w3.org/TR/xml-c14n11/) | Implemented; `xml:id` and `xml:base` subset rules |
| [Exclusive C14N](https://www.w3.org/TR/xml-exc-c14n/) | Implemented; `InclusiveNamespaces PrefixList` support |
-| [XMLDSig 1.0/1.1](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines; complete Merlin, Phaos 3, 2012 XMLDSig 1.1, and Second Edition interop corpora classified and executed |
-| [XMLEnc](https://www.w3.org/TR/xmlenc-core1/) | Core AES-CBC/GCM encrypt/decrypt with RSA-OAEP and AES-KW implemented; broader conformance coverage in progress |
+| [XMLDSig 1.0/1.1](https://www.w3.org/TR/xmldsig-core1/) | Core sign/verify pipelines; complete Merlin, Phaos 3, XMLDSig 1.1, and Second Edition corpora classified and executed |
+| [XML Encryption](https://www.w3.org/TR/xmlenc-core1/) | Core AES-CBC/GCM, RSA-OAEP, and AES-KW pipelines implemented; broader conformance work continues |
+| [XSLT 1.0](https://www.w3.org/TR/xslt-10/) | Separate safe-Rust engine implemented; XMLDSig transform adapter not yet connected |
+
+The project tracks stable Rust and supports Rust 1.92 or newer. Detailed contracts and limitations
+live in the [XMLDSig guide](docs/xmldsig.md), [XML Encryption guide](docs/xmlenc.md), and
+[CLI guide](docs/cli.md).
## License
diff --git a/compatibility/libxml2-2.15.1-xslt-oracle-commit.txt b/compatibility/libxml2-2.15.1-xslt-oracle-commit.txt
new file mode 100644
index 00000000..90dc56fa
--- /dev/null
+++ b/compatibility/libxml2-2.15.1-xslt-oracle-commit.txt
@@ -0,0 +1 @@
+b7fa62cbe8ef0df5869e000d5b690bdedd07f33e
diff --git a/compatibility/libxslt-1.1.45-donor-commit.txt b/compatibility/libxslt-1.1.45-donor-commit.txt
new file mode 100644
index 00000000..cfb4afcf
--- /dev/null
+++ b/compatibility/libxslt-1.1.45-donor-commit.txt
@@ -0,0 +1 @@
+35323d6a15f6e63c9919ddbc0abe64c90a0dd88a
diff --git a/crates/xml-sec-xml-input/Cargo.toml b/crates/xml-sec-xml-input/Cargo.toml
new file mode 100644
index 00000000..f9ae2417
--- /dev/null
+++ b/crates/xml-sec-xml-input/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "xml-sec-xml-input"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.92"
+license = "Apache-2.0"
+description = "Strict XML byte encoding detection and transcoding for xml-sec"
+repository = "https://github.com/structured-world/xml-sec"
+
+[features]
+default = ["std"]
+std = ["thiserror/std", "xmlparser/std"]
+
+[dependencies]
+encoding_rs = { version = "0.8", default-features = false, features = ["alloc"] }
+thiserror = { version = "2", default-features = false }
+xmlparser = { version = "0.13.6", default-features = false }
diff --git a/crates/xml-sec-xml-input/src/lexical.rs b/crates/xml-sec-xml-input/src/lexical.rs
new file mode 100644
index 00000000..2f061439
--- /dev/null
+++ b/crates/xml-sec-xml-input/src/lexical.rs
@@ -0,0 +1,1293 @@
+//! Zero-copy lexical XML events and deterministic XML serialization.
+//!
+//! This module is the parser-neutral boundary used before semantic DOM
+//! construction. The public event model intentionally does not expose the
+//! implementation tokenizer, so consumers can share source ranges, escaping,
+//! and serialization without inheriting a parser's tree semantics.
+
+use alloc::{
+ borrow::{Cow, ToOwned},
+ format,
+ string::{String, ToString},
+ vec::Vec,
+};
+use core::ops::Range;
+
+#[cfg(feature = "std")]
+use std::{
+ collections::HashSet,
+ io::{Error as IoError, ErrorKind, Write},
+};
+
+/// A lexical XML failure with a source position.
+#[derive(Debug, thiserror::Error)]
+#[error("XML lexical error: {message}")]
+pub struct Error {
+ message: String,
+}
+
+impl Error {
+ fn tokenizer(error: xmlparser::Error) -> Self {
+ Self {
+ message: error.to_string(),
+ }
+ }
+
+ fn malformed(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+}
+
+/// A borrowed qualified XML name.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Name<'a> {
+ prefix: &'a str,
+ local: &'a str,
+}
+
+impl<'a> Name<'a> {
+ /// Namespace prefix, or `None` for an unprefixed name.
+ #[must_use]
+ pub fn prefix(self) -> Option<&'a str> {
+ (!self.prefix.is_empty()).then_some(self.prefix)
+ }
+
+ /// Local component of the name.
+ #[must_use]
+ pub const fn local(self) -> &'a str {
+ self.local
+ }
+
+ /// Compare the lexical qualified name without allocating.
+ #[must_use]
+ pub fn is_qualified(self, value: &str) -> bool {
+ match self.prefix() {
+ Some(prefix) => {
+ value
+ .strip_prefix(prefix)
+ .and_then(|suffix| suffix.strip_prefix(':'))
+ == Some(self.local)
+ }
+ None => value == self.local,
+ }
+ }
+
+ /// Materialize the lexical qualified name.
+ #[must_use]
+ pub fn qualified(self) -> Cow<'a, str> {
+ self.prefix().map_or_else(
+ || Cow::Borrowed(self.local),
+ |prefix| Cow::Owned(format!("{prefix}:{}", self.local)),
+ )
+ }
+}
+
+/// A borrowed attribute from one start tag.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Attribute<'a> {
+ /// Lexical attribute name.
+ pub name: Name<'a>,
+ /// Raw value before entity/reference expansion.
+ pub value: &'a str,
+ /// Complete source range of the attribute.
+ pub range: Range,
+}
+
+/// A complete start or empty-element tag.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct StartTag<'a> {
+ /// Lexical element name.
+ pub name: Name<'a>,
+ /// Attributes in source order.
+ pub attributes: Vec>,
+ /// Complete source range including `<` and `>`.
+ pub range: Range,
+}
+
+/// One lexical XML event.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Event<'a> {
+ /// XML declaration.
+ Declaration {
+ /// Declared XML version.
+ version: &'a str,
+ /// Standalone declaration, if supplied; significant for XML entity constraints.
+ standalone: Option,
+ /// Complete declaration range.
+ range: Range,
+ },
+ /// Processing instruction.
+ ProcessingInstruction {
+ /// PI target.
+ target: &'a str,
+ /// Optional PI value without separator whitespace.
+ content: Option<&'a str>,
+ /// Complete source range.
+ range: Range,
+ },
+ /// XML comment.
+ Comment {
+ /// Comment content.
+ text: &'a str,
+ /// Complete source range.
+ range: Range,
+ },
+ /// Complete document type declaration.
+ DocType {
+ /// Declared document element lexical qualified name.
+ name: &'a str,
+ /// Complete source range.
+ range: Range,
+ },
+ /// Opening element tag.
+ Start(StartTag<'a>),
+ /// Empty-element tag.
+ Empty(StartTag<'a>),
+ /// Closing element tag.
+ End {
+ /// Lexical closing name.
+ name: Name<'a>,
+ /// Complete source range.
+ range: Range,
+ },
+ /// Character data without general references.
+ Text {
+ /// Raw text.
+ text: &'a str,
+ /// Source range equal to the text.
+ range: Range,
+ },
+ /// CDATA content.
+ CData {
+ /// Unwrapped CDATA content.
+ text: &'a str,
+ /// Complete source range including delimiters.
+ range: Range,
+ },
+ /// A character or entity reference from character data.
+ Reference {
+ /// Reference body without `&` and `;`.
+ name: &'a str,
+ /// Complete reference range.
+ range: Range,
+ },
+}
+
+/// Pull-based, zero-copy lexical scanner.
+pub struct Scanner<'a> {
+ input: &'a str,
+ tokenizer: xmlparser::Tokenizer<'a>,
+ pending_text: Option>,
+ pending_start: Option>,
+ dtd_start: Option<(usize, &'a str)>,
+}
+
+struct PendingStart<'a> {
+ name: Name<'a>,
+ attributes: Vec>,
+ start: usize,
+}
+
+struct PendingText<'a> {
+ text: &'a str,
+ range: Range,
+ offset: usize,
+}
+
+fn validate_unique_attributes(attributes: &mut [Attribute<'_>]) -> Result<(), Error> {
+ const SMALL_TAG_ATTRIBUTES: usize = 8;
+
+ if attributes.len() <= SMALL_TAG_ATTRIBUTES {
+ for index in 1..attributes.len() {
+ let name = attributes[index].name;
+ if attributes[..index]
+ .iter()
+ .any(|attribute| attribute.name == name)
+ {
+ return Err(Error::malformed(format!(
+ "duplicate attribute `{}`",
+ name.qualified()
+ )));
+ }
+ }
+ return Ok(());
+ }
+
+ attributes.sort_unstable_by_key(|attribute| attribute.name);
+ if let Some(name) = attributes
+ .windows(2)
+ .find_map(|pair| (pair[0].name == pair[1].name).then_some(pair[0].name))
+ {
+ return Err(Error::malformed(format!(
+ "duplicate attribute `{}`",
+ name.qualified()
+ )));
+ }
+ attributes.sort_unstable_by_key(|attribute| attribute.range.start);
+ Ok(())
+}
+
+impl<'a> Scanner<'a> {
+ /// Scan a complete XML document.
+ #[must_use]
+ pub fn new(input: &'a str) -> Self {
+ Self {
+ input,
+ tokenizer: xmlparser::Tokenizer::from(input),
+ pending_text: None,
+ pending_start: None,
+ dtd_start: None,
+ }
+ }
+
+ /// Return the next event, or `None` at end of input.
+ pub fn next_event(&mut self) -> Result>, Error> {
+ if let Some(event) = self.next_text_event()? {
+ return Ok(Some(event));
+ }
+ loop {
+ let Some(token) = self.tokenizer.next() else {
+ if self.pending_start.is_some() {
+ return Err(Error::malformed("unterminated element start tag"));
+ }
+ return Ok(None);
+ };
+ let token = token.map_err(Error::tokenizer)?;
+ use xmlparser::{ElementEnd, Token};
+ match &token {
+ Token::DtdStart { name, span, .. } => {
+ self.dtd_start = Some((span.range().start, name.as_str()));
+ continue;
+ }
+ Token::DtdEnd { span } if self.dtd_start.is_some() => {
+ let (start, name) = self.dtd_start.take().expect("DTD start is present");
+ return Ok(Some(Event::DocType {
+ name,
+ range: start..span.range().end,
+ }));
+ }
+ _ if self.dtd_start.is_some() => {
+ // Internal-subset tokens belong to the DocType event and
+ // must never be projected as document-tree nodes.
+ continue;
+ }
+ _ => {}
+ }
+ match token {
+ Token::Declaration {
+ version,
+ standalone,
+ span,
+ ..
+ } => {
+ // XML 1.0 Fifth Edition section 2.8 allows later 1.x declarations only when
+ // the processor implements that version's complete syntax and character
+ // model. This scanner currently implements XML 1.0, so accepting XML 1.1
+ // here would silently apply the wrong character rules downstream.
+ // https://www.w3.org/TR/xml/#sec-prolog-dtd
+ if version.as_str() != "1.0" {
+ return Err(Error::malformed("unsupported XML version"));
+ }
+ return Ok(Some(Event::Declaration {
+ version: version.as_str(),
+ standalone,
+ range: span.range(),
+ }));
+ }
+ Token::ProcessingInstruction {
+ target,
+ content,
+ span,
+ } => {
+ return Ok(Some(Event::ProcessingInstruction {
+ target: target.as_str(),
+ content: content.map(|value| value.as_str()),
+ range: span.range(),
+ }));
+ }
+ Token::Comment { text, span } => {
+ return Ok(Some(Event::Comment {
+ text: text.as_str(),
+ range: span.range(),
+ }));
+ }
+ Token::DtdStart { .. } => unreachable!("DTD start is handled above"),
+ Token::EmptyDtd { name, span, .. } => {
+ return Ok(Some(Event::DocType {
+ name: name.as_str(),
+ range: span.range(),
+ }));
+ }
+ Token::DtdEnd { .. } => {
+ return Err(Error::malformed("DOCTYPE end without a start"));
+ }
+ Token::EntityDeclaration { .. } => {}
+ Token::ElementStart {
+ prefix,
+ local,
+ span,
+ } => {
+ validate_qualified_lexeme(&self.input[span.range()])?;
+ if self.pending_start.is_some() {
+ return Err(Error::malformed("nested element start tokens"));
+ }
+ self.pending_start = Some(PendingStart {
+ name: Name {
+ prefix: prefix.as_str(),
+ local: local.as_str(),
+ },
+ attributes: Vec::new(),
+ start: span.range().start,
+ });
+ }
+ Token::Attribute {
+ prefix,
+ local,
+ value,
+ span,
+ } => {
+ validate_qualified_lexeme(&self.input[span.range()])?;
+ let start = self
+ .pending_start
+ .as_mut()
+ .ok_or_else(|| Error::malformed("attribute outside a start tag"))?;
+ let name = Name {
+ prefix: prefix.as_str(),
+ local: local.as_str(),
+ };
+ start.attributes.push(Attribute {
+ name,
+ value: value.as_str(),
+ range: span.range(),
+ });
+ }
+ Token::ElementEnd { end, span } => match end {
+ ElementEnd::Open | ElementEnd::Empty => {
+ let mut start = self
+ .pending_start
+ .take()
+ .ok_or_else(|| Error::malformed("element end without a start"))?;
+ validate_unique_attributes(&mut start.attributes)?;
+ let tag = StartTag {
+ name: start.name,
+ attributes: start.attributes,
+ range: start.start..span.range().end,
+ };
+ return Ok(Some(if end == ElementEnd::Open {
+ Event::Start(tag)
+ } else {
+ Event::Empty(tag)
+ }));
+ }
+ ElementEnd::Close(prefix, local) => {
+ validate_qualified_lexeme(&self.input[span.range()])?;
+ return Ok(Some(Event::End {
+ name: Name {
+ prefix: prefix.as_str(),
+ local: local.as_str(),
+ },
+ range: span.range(),
+ }));
+ }
+ },
+ Token::Text { text } => {
+ self.pending_text = Some(PendingText {
+ text: text.as_str(),
+ range: text.range(),
+ offset: 0,
+ });
+ if let Some(event) = self.next_text_event()? {
+ return Ok(Some(event));
+ }
+ }
+ Token::Cdata { text, span } => {
+ return Ok(Some(Event::CData {
+ text: text.as_str(),
+ range: span.range(),
+ }));
+ }
+ }
+ }
+ }
+
+ fn next_text_event(&mut self) -> Result >, Error> {
+ let Some(mut pending) = self.pending_text.take() else {
+ return Ok(None);
+ };
+ let tail = &pending.text[pending.offset..];
+ let Some(relative) = tail.find('&') else {
+ return Ok((!tail.is_empty()).then_some(Event::Text {
+ text: tail,
+ range: pending.range.start + pending.offset..pending.range.end,
+ }));
+ };
+ let start = pending.offset + relative;
+ if start > pending.offset {
+ let event = Event::Text {
+ text: &pending.text[pending.offset..start],
+ range: pending.range.start + pending.offset..pending.range.start + start,
+ };
+ pending.offset = start;
+ self.pending_text = Some(pending);
+ return Ok(Some(event));
+ }
+ let Some(relative_end) = pending.text[start + 1..].find(';') else {
+ return Err(Error::malformed(
+ "unterminated XML reference in character data",
+ ));
+ };
+ let end = start + 1 + relative_end;
+ let event = Event::Reference {
+ name: &pending.text[start + 1..end],
+ range: pending.range.start + start..pending.range.start + end + 1,
+ };
+ pending.offset = end + 1;
+ if pending.offset < pending.text.len() {
+ self.pending_text = Some(pending);
+ }
+ Ok(Some(event))
+ }
+
+ /// Original scanner input.
+ #[must_use]
+ pub const fn input(&self) -> &'a str {
+ self.input
+ }
+}
+
+fn validate_qualified_lexeme(source: &str) -> Result<(), Error> {
+ // XML 1.0 productions [40] and [42] place Name immediately after `<` or ``; applying
+ // Unicode whitespace trimming here would erase legal non-ASCII NameStartChar values.
+ // https://www.w3.org/TR/xml/#NT-STag
+ let source = source
+ .strip_prefix("")
+ .or_else(|| source.strip_prefix('<'))
+ .unwrap_or(source);
+ let name = source
+ .split(|character: char| {
+ character.is_ascii_whitespace() || matches!(character, '=' | '/' | '>')
+ })
+ .next()
+ .unwrap_or_default();
+ let mut parts = name.split(':');
+ let first = parts.next().unwrap_or_default();
+ let second = parts.next();
+ if first.is_empty() || second.is_some_and(str::is_empty) || parts.next().is_some() {
+ return Err(Error::malformed("invalid qualified XML name"));
+ }
+ Ok(())
+}
+
+/// Return whether `value` is an XML Namespaces 1.0 `QName`.
+#[must_use]
+pub fn is_qname(value: &str) -> bool {
+ let mut parts = value.split(':');
+ let first = parts.next().unwrap_or_default();
+ !first.is_empty()
+ && is_ncname(first)
+ && parts.next().is_none_or(is_ncname)
+ && parts.next().is_none()
+}
+
+fn is_ncname(value: &str) -> bool {
+ let mut characters = value.chars();
+ let Some(first) = characters.next() else {
+ return false;
+ };
+ is_ncname_start(first) && characters.all(is_ncname_char)
+}
+
+fn is_ncname_start(character: char) -> bool {
+ matches!(
+ character,
+ 'A'..='Z'
+ | '_'
+ | 'a'..='z'
+ | '\u{C0}'..='\u{D6}'
+ | '\u{D8}'..='\u{F6}'
+ | '\u{F8}'..='\u{2FF}'
+ | '\u{370}'..='\u{37D}'
+ | '\u{37F}'..='\u{1FFF}'
+ | '\u{200C}'..='\u{200D}'
+ | '\u{2070}'..='\u{218F}'
+ | '\u{2C00}'..='\u{2FEF}'
+ | '\u{3001}'..='\u{D7FF}'
+ | '\u{F900}'..='\u{FDCF}'
+ | '\u{FDF0}'..='\u{FFFD}'
+ | '\u{10000}'..='\u{EFFFF}'
+ )
+}
+
+fn is_ncname_char(character: char) -> bool {
+ is_ncname_start(character)
+ || matches!(
+ character,
+ '-' | '.' | '0'..='9' | '\u{B7}' | '\u{0300}'..='\u{036F}' | '\u{203F}'..='\u{2040}'
+ )
+}
+
+/// Namespace prefixes declared directly by one lexical opening tag.
+///
+/// The default namespace is represented by an empty string.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeclaredNamespacePrefixes(Vec);
+
+impl DeclaredNamespacePrefixes {
+ /// Return whether the opening tag declares `prefix` directly.
+ #[must_use]
+ pub fn contains(&self, prefix: &str) -> bool {
+ self.0.iter().any(|candidate| candidate == prefix)
+ }
+}
+
+/// Parse the namespace prefixes declared directly by one lexical opening tag.
+pub fn declared_namespace_prefixes(opening: &str) -> Result {
+ let standalone = format!("{} />", opening.trim_end_matches('/'));
+ let mut scanner = Scanner::new(&standalone);
+ let Some(Event::Start(tag) | Event::Empty(tag)) = scanner.next_event()? else {
+ return Err(Error::malformed("expected one opening element tag"));
+ };
+ Ok(DeclaredNamespacePrefixes(
+ tag.attributes
+ .iter()
+ .filter_map(
+ |attribute| match (attribute.name.prefix(), attribute.name.local()) {
+ (None, "xmlns") => Some(String::new()),
+ (Some("xmlns"), prefix) => Some(prefix.to_owned()),
+ _ => None,
+ },
+ )
+ .collect(),
+ ))
+}
+
+/// Expand the five predefined entities and XML character references.
+pub fn decode_references(value: &str) -> Result, Error> {
+ if !value.contains('&') {
+ return Ok(Cow::Borrowed(value));
+ }
+ let mut output = String::with_capacity(value.len());
+ let mut offset = 0;
+ while let Some(relative) = value[offset..].find('&') {
+ let start = offset + relative;
+ output.push_str(&value[offset..start]);
+ let end = value[start + 1..]
+ .find(';')
+ .map(|relative| start + 1 + relative)
+ .ok_or_else(|| Error::malformed("unterminated XML reference"))?;
+ let name = &value[start + 1..end];
+ let character = match name {
+ "amp" => '&',
+ "apos" => '\'',
+ "gt" => '>',
+ "lt" => '<',
+ "quot" => '"',
+ value if value.starts_with('#') => decode_numeric_character_reference(&value[1..])?,
+ _ => {
+ return Err(Error::malformed(format!(
+ "unresolved entity reference &{name};"
+ )));
+ }
+ };
+ output.push(character);
+ offset = end + 1;
+ }
+ output.push_str(&value[offset..]);
+ Ok(Cow::Owned(output))
+}
+
+/// Decode the portion of an XML numeric character reference after ``.
+///
+/// XML 1.0 section 4.1 permits exactly decimal digits or a lowercase `x` followed by
+/// hexadecimal digits: https://www.w3.org/TR/xml/#sec-references
+pub fn decode_numeric_character_reference(reference: &str) -> Result {
+ let (digits, radix) = reference
+ .strip_prefix('x')
+ .map_or((reference, 10), |digits| (digits, 16));
+ let valid_digits = if radix == 16 {
+ digits.bytes().all(|byte| byte.is_ascii_hexdigit())
+ } else {
+ digits.bytes().all(|byte| byte.is_ascii_digit())
+ };
+ if digits.is_empty() || !valid_digits {
+ return Err(Error::malformed("invalid XML character reference"));
+ }
+ u32::from_str_radix(digits, radix)
+ .ok()
+ .and_then(char::from_u32)
+ .filter(|character| is_xml_1_0_character(*character))
+ .ok_or_else(|| Error::malformed("invalid XML character reference"))
+}
+
+fn is_xml_1_0_character(character: char) -> bool {
+ matches!(character, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
+}
+
+/// Escape XML character data.
+#[must_use]
+pub fn escape_text(value: &str) -> Cow<'_, str> {
+ escape(value, false)
+}
+
+/// Escape a double-quoted XML attribute value.
+#[must_use]
+pub fn escape_attribute(value: &str) -> Cow<'_, str> {
+ escape(value, true)
+}
+
+fn escape(value: &str, attribute: bool) -> Cow<'_, str> {
+ if !value.bytes().any(|byte| {
+ matches!(byte, b'&' | b'<' | b'>' | b'\r')
+ || (attribute && matches!(byte, b'"' | b'\n' | b'\t'))
+ }) {
+ return Cow::Borrowed(value);
+ }
+ let mut output = String::with_capacity(value.len());
+ for character in value.chars() {
+ match character {
+ '&' => output.push_str("&"),
+ '<' => output.push_str("<"),
+ '>' => output.push_str(">"),
+ '"' if attribute => output.push_str("""),
+ '\t' if attribute => output.push_str(" "),
+ '\n' if attribute => output.push_str("
"),
+ '\r' => output.push_str("
"),
+ _ => output.push(character),
+ }
+ }
+ Cow::Owned(output)
+}
+
+/// Deterministic UTF-8 XML writer for generated markup.
+#[cfg(feature = "std")]
+pub struct Writer {
+ output: W,
+ frames: Vec,
+}
+
+#[cfg(feature = "std")]
+struct WriterFrame {
+ element_name: String,
+ namespaces: Vec<(String, String)>,
+}
+
+#[cfg(feature = "std")]
+impl Writer {
+ /// Wrap an output sink.
+ #[must_use]
+ pub const fn new(output: W) -> Self {
+ Self {
+ output,
+ frames: Vec::new(),
+ }
+ }
+
+ /// Write an opening tag and escaped attributes.
+ pub fn start<'a>(
+ &mut self,
+ name: &str,
+ attributes: impl IntoIterator- ,
+ ) -> std::io::Result<()> {
+ self.tag(name, attributes, false)
+ }
+
+ /// Write an empty-element tag and escaped attributes.
+ pub fn empty<'a>(
+ &mut self,
+ name: &str,
+ attributes: impl IntoIterator
- ,
+ ) -> std::io::Result<()> {
+ self.tag(name, attributes, true)
+ }
+
+ fn tag<'a>(
+ &mut self,
+ name: &str,
+ attributes: impl IntoIterator
- ,
+ empty: bool,
+ ) -> std::io::Result<()> {
+ validate_writer_qname(name)?;
+ let attributes = attributes.into_iter().collect::
>();
+ validate_writer_element_namespace(name, &attributes, &self.frames)?;
+ validate_writer_attributes(&attributes, &self.frames)?;
+ write!(self.output, "<{name}")?;
+ for (attribute, value) in &attributes {
+ write!(self.output, " {attribute}=\"{}\"", escape_attribute(value))?;
+ }
+ self.output.write_all(if empty { b"/>" } else { b">" })?;
+ if !empty {
+ self.frames.push(WriterFrame {
+ element_name: name.to_owned(),
+ namespaces: attributes
+ .iter()
+ .filter_map(|(name, uri)| namespace_declaration(name).map(|name| (name, *uri)))
+ .map(|(name, uri)| (name.to_owned(), uri.to_owned()))
+ .collect(),
+ });
+ }
+ Ok(())
+ }
+
+ /// Write a closing tag.
+ pub fn end(&mut self, name: &str) -> std::io::Result<()> {
+ validate_writer_qname(name)?;
+ // XML 1.0 section 3.1 requires the end-tag Name to match the corresponding start-tag:
+ // https://www.w3.org/TR/xml/#sec-starttags
+ let frame = self.frames.last().ok_or_else(|| {
+ IoError::new(ErrorKind::InvalidInput, "XML end tag has no open element")
+ })?;
+ if frame.element_name != name {
+ return Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!(
+ "XML end tag `{name}` does not match open element `{}`",
+ frame.element_name
+ ),
+ ));
+ }
+ write!(self.output, "{name}>")?;
+ self.frames.pop();
+ Ok(())
+ }
+
+ /// Write escaped character data.
+ pub fn text(&mut self, value: &str) -> std::io::Result<()> {
+ validate_writer_characters(value)?;
+ write!(self.output, "{}", escape_text(value))
+ }
+
+ /// Write caller-validated XML markup unchanged.
+ pub fn raw(&mut self, value: &str) -> std::io::Result<()> {
+ self.output.write_all(value.as_bytes())
+ }
+
+ /// Return the wrapped sink.
+ #[must_use]
+ pub fn into_inner(self) -> W {
+ self.output
+ }
+}
+
+#[cfg(feature = "std")]
+fn validate_writer_attributes(
+ attributes: &[(&str, &str)],
+ frames: &[WriterFrame],
+) -> std::io::Result<()> {
+ const SMALL_TAG_ATTRIBUTES: usize = 8;
+
+ for (name, value) in attributes {
+ validate_writer_qname(name)?;
+ validate_writer_characters(value)?;
+ validate_writer_namespace_declaration(name, value)?;
+ }
+ if attributes.len() <= SMALL_TAG_ATTRIBUTES {
+ for index in 1..attributes.len() {
+ if attributes[..index]
+ .iter()
+ .any(|(name, _)| *name == attributes[index].0)
+ {
+ return Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("duplicate XML attribute `{}`", attributes[index].0),
+ ));
+ }
+ }
+ } else {
+ let mut names = HashSet::with_capacity(attributes.len());
+ if let Some((duplicate, _)) = attributes.iter().find(|(name, _)| !names.insert(*name)) {
+ return Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("duplicate XML attribute `{duplicate}`"),
+ ));
+ }
+ }
+
+ let mut expanded = HashSet::with_capacity(attributes.len());
+ for (name, _) in attributes {
+ let Some((prefix, local)) = name.split_once(':') else {
+ if name != &"xmlns" && !expanded.insert((None, *name)) {
+ return duplicate_expanded_attribute(name);
+ }
+ continue;
+ };
+ if prefix == "xmlns" {
+ continue;
+ }
+ let namespace = resolve_writer_prefix(prefix, attributes, frames).ok_or_else(|| {
+ IoError::new(
+ ErrorKind::InvalidInput,
+ format!("unbound XML namespace prefix `{prefix}`"),
+ )
+ })?;
+ if namespace.is_empty() {
+ return Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("unbound XML namespace prefix `{prefix}`"),
+ ));
+ }
+ if !expanded.insert((Some(namespace), local)) {
+ return duplicate_expanded_attribute(name);
+ }
+ }
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn validate_writer_element_namespace(
+ name: &str,
+ attributes: &[(&str, &str)],
+ frames: &[WriterFrame],
+) -> std::io::Result<()> {
+ let Some((prefix, _)) = name.split_once(':') else {
+ return Ok(());
+ };
+ // Namespaces in XML 1.0 sections 2.2 and 5 require a non-empty namespace binding for every
+ // prefixed element name.
+ // https://www.w3.org/TR/xml-names/#iri-use https://www.w3.org/TR/xml-names/#ns-using
+ if prefix == "xmlns"
+ || resolve_writer_prefix(prefix, attributes, frames).is_none_or(str::is_empty)
+ {
+ return Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("unbound XML namespace prefix `{prefix}`"),
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(feature = "std")]
+fn validate_writer_namespace_declaration(name: &str, uri: &str) -> std::io::Result<()> {
+ const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace";
+ const XMLNS_NAMESPACE: &str = "http://www.w3.org/2000/xmlns/";
+
+ let Some(prefix) = namespace_declaration(name) else {
+ return Ok(());
+ };
+ // Namespaces in XML 1.0 section 3 reserves both namespace names and their prefixes. Only the
+ // fixed xml -> XML namespace binding is legal; xmlns itself cannot be declared.
+ // https://www.w3.org/TR/xml-names/#ns-decl
+ let valid = if prefix == "xml" {
+ uri == XML_NAMESPACE
+ } else if prefix == "xmlns" {
+ false
+ } else {
+ uri != XML_NAMESPACE && uri != XMLNS_NAMESPACE && (prefix.is_empty() || !uri.is_empty())
+ };
+ if valid {
+ Ok(())
+ } else {
+ Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("invalid XML namespace declaration `{name}={uri}`"),
+ ))
+ }
+}
+
+#[cfg(feature = "std")]
+fn namespace_declaration(name: &str) -> Option<&str> {
+ if name == "xmlns" {
+ Some("")
+ } else {
+ name.strip_prefix("xmlns:")
+ }
+}
+
+#[cfg(feature = "std")]
+fn resolve_writer_prefix<'a>(
+ prefix: &str,
+ attributes: &'a [(&str, &str)],
+ frames: &'a [WriterFrame],
+) -> Option<&'a str> {
+ if prefix == "xml" {
+ return Some("http://www.w3.org/XML/1998/namespace");
+ }
+ attributes
+ .iter()
+ .find_map(|(name, uri)| (namespace_declaration(name) == Some(prefix)).then_some(*uri))
+ .or_else(|| {
+ frames.iter().rev().find_map(|frame| {
+ frame
+ .namespaces
+ .iter()
+ .rev()
+ .find_map(|(name, uri)| (name == prefix).then_some(uri.as_str()))
+ })
+ })
+}
+
+#[cfg(feature = "std")]
+fn duplicate_expanded_attribute(name: &str) -> std::io::Result<()> {
+ Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!("duplicate expanded XML attribute `{name}`"),
+ ))
+}
+
+#[cfg(feature = "std")]
+fn validate_writer_qname(name: &str) -> std::io::Result<()> {
+ // Namespaces in XML 1.0 section 3 production [6] permits exactly one optional prefix.
+ // https://www.w3.org/TR/xml-names/#NT-QName
+ if is_qname(name) {
+ Ok(())
+ } else {
+ Err(IoError::new(ErrorKind::InvalidInput, "invalid XML QName"))
+ }
+}
+
+#[cfg(feature = "std")]
+fn validate_writer_characters(value: &str) -> std::io::Result<()> {
+ // XML 1.0 section 2.2 production [2] is the character repertoire for parsed entities;
+ // escaping markup delimiters cannot make a forbidden control character legal.
+ // https://www.w3.org/TR/xml/#charsets
+ if let Some(character) = value
+ .chars()
+ .find(|character| !is_xml_1_0_character(*character))
+ {
+ Err(IoError::new(
+ ErrorKind::InvalidInput,
+ format!(
+ "character U+{:04X} is forbidden by XML 1.0",
+ u32::from(character)
+ ),
+ ))
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(all(test, feature = "std"))]
+mod tests {
+ #[test]
+ fn xml_version_requires_digits_after_the_period() {
+ // The scanner implements the XML 1.0 character and grammar contract only.
+ assert!(
+ super::Scanner::new(" ")
+ .next_event()
+ .is_err()
+ );
+ assert!(
+ super::Scanner::new(" ")
+ .next_event()
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn scanner_rejects_xml_11_until_character_rules_are_version_aware() {
+ // Accepting an XML version means applying that version's complete character model.
+ assert!(
+ super::Scanner::new(" ")
+ .next_event()
+ .is_err()
+ );
+ }
+ use super::*;
+
+ #[test]
+ fn scanner_groups_start_tags_and_splits_references() {
+ let xml = "a&b
";
+ let mut scanner = Scanner::new(xml);
+ let events = std::iter::from_fn(|| scanner.next_event().transpose())
+ .collect::, _>>()
+ .expect("fixture must scan");
+ assert!(matches!(&events[0], Event::Start(tag) if tag.attributes.len() == 2));
+ assert!(matches!(&events[1], Event::Text { text: "a", .. }));
+ assert!(matches!(&events[2], Event::Reference { name: "amp", .. }));
+ assert!(matches!(&events[3], Event::Reference { name: "#x62", .. }));
+ assert!(matches!(&events[4], Event::CData { text: "c", .. }));
+ assert!(matches!(&events[5], Event::End { .. }));
+ }
+
+ #[test]
+ fn scanner_streams_dense_reference_runs_without_buffering_events() {
+ // One borrowed lexical event must be produced per pull. A source-sized reference run
+ // cannot amplify into an event queue before the consumer sees its first reference.
+ let xml = format!("{} ", "&".repeat(4096));
+ let mut scanner = Scanner::new(&xml);
+ assert!(matches!(
+ scanner.next_event().expect("scan root start"),
+ Some(Event::Start(_))
+ ));
+ assert!(matches!(
+ scanner.next_event().expect("scan first reference"),
+ Some(Event::Reference { name: "amp", .. })
+ ));
+ assert_eq!(
+ scanner.pending_text.as_ref().map(|pending| pending.offset),
+ Some("&".len()),
+ "the scanner retains only a cursor into the source token"
+ );
+ assert_eq!(
+ std::iter::from_fn(|| scanner.next_event().transpose())
+ .collect::, _>>()
+ .expect("remaining references scan")
+ .len(),
+ 4096
+ );
+ }
+
+ #[test]
+ fn scanner_rejects_unterminated_references_in_character_data() {
+ // XML 1.0 productions [66]-[68] require every reference opened by `&` to end with `;`.
+ // https://www.w3.org/TR/xml/#NT-Reference
+ for xml in ["AT&T ", "&bad "] {
+ let mut scanner = Scanner::new(xml);
+ let result = std::iter::from_fn(|| scanner.next_event().transpose())
+ .collect::, _>>();
+ assert!(result.is_err(), "accepted {xml}");
+ }
+ }
+
+ #[test]
+ fn scanner_keeps_the_internal_subset_as_one_doctype_event() {
+ // DTD comments and declarations are not document-tree nodes. Keeping
+ // them inside one event prevents semantic sidecars from diverging.
+ let xml = r#" -->]> "#;
+ let mut scanner = Scanner::new(xml);
+ assert!(matches!(
+ scanner.next_event().expect("scan DTD"),
+ Some(Event::DocType { .. })
+ ));
+ assert!(matches!(
+ scanner.next_event().expect("scan root"),
+ Some(Event::Empty(_))
+ ));
+ assert!(scanner.next_event().expect("scan EOF").is_none());
+ }
+
+ #[test]
+ fn scanner_rejects_empty_and_repeated_prefix_components() {
+ // The tokenizer exposes split names but accepts these malformed QName
+ // spellings, so the shared lexical contract must reject them itself.
+ for xml in ["<:root/>", " ", " "] {
+ assert!(Scanner::new(xml).next_event().is_err(), "accepted {xml}");
+ }
+ }
+
+ #[test]
+ fn scanner_preserves_non_ascii_name_start_characters() {
+ // XML 1.0 production [4] admits U+1680 as NameStartChar; it is not markup whitespace.
+ // https://www.w3.org/TR/xml/#NT-NameStartChar
+ let Some(Event::Empty(tag)) = Scanner::new("<\u{1680}/>")
+ .next_event()
+ .expect("valid non-ASCII name scans")
+ else {
+ panic!("empty element must produce one empty-tag event");
+ };
+ assert_eq!(tag.name.local(), "\u{1680}");
+ }
+
+ #[test]
+ fn scanner_rejects_duplicate_lexical_attributes() {
+ // Namespace declarations are attributes under XML Namespaces 1.0 and
+ // may not be repeated even though they do not enter the XPath axis.
+ for xml in [
+ " ",
+ " ",
+ " ",
+ ] {
+ assert!(Scanner::new(xml).next_event().is_err(), "accepted {xml}");
+ }
+ }
+
+ #[test]
+ fn scanner_validates_wide_attributes_without_changing_source_order() {
+ // Wide tags use the allocation-free sorted duplicate check. Restoring source order is part
+ // of the public lexical event contract and keeps downstream namespace processing stable.
+ let attributes = (0..4096)
+ .rev()
+ .map(|index| format!(" a{index}='{index}'"))
+ .collect::();
+ let xml = format!(" ");
+ let Some(Event::Empty(tag)) = Scanner::new(&xml)
+ .next_event()
+ .expect("wide start tag scans")
+ else {
+ panic!("wide empty element must produce one empty-tag event");
+ };
+ assert_eq!(tag.attributes.len(), 4096);
+ assert_eq!(tag.attributes[0].name.local(), "a4095");
+ assert_eq!(tag.attributes[4095].name.local(), "a0");
+
+ let unique = (0..4096)
+ .map(|index| format!(" a{index}='x'"))
+ .collect::();
+ let duplicate = format!(" ");
+ assert!(Scanner::new(&duplicate).next_event().is_err());
+ }
+
+ #[test]
+ fn writer_escapes_text_and_attributes_by_context() {
+ let mut writer = Writer::new(Vec::new());
+ writer
+ .start("p", [("a", "<&\"")])
+ .expect("start tag must serialize");
+ writer.text("<&\"").expect("text must serialize");
+ writer.end("p").expect("end tag must serialize");
+ assert_eq!(
+ String::from_utf8(writer.into_inner()).expect("writer emits UTF-8"),
+ "<&\"
"
+ );
+ }
+
+ #[test]
+ fn writer_preserves_normalized_whitespace_across_reparse() {
+ // Literal XML whitespace is normalized differently in character data
+ // and attributes, so numeric references preserve the semantic value.
+ let mut writer = Writer::new(Vec::new());
+ writer
+ .empty("root", [("value", "tab\tline\nreturn\r")])
+ .expect("empty tag must serialize");
+ assert_eq!(
+ String::from_utf8(writer.into_inner()).expect("writer emits UTF-8"),
+ ""
+ );
+
+ let mut writer = Writer::new(Vec::new());
+ writer.text("line\rbreak\n").expect("text must serialize");
+ assert_eq!(
+ String::from_utf8(writer.into_inner()).expect("writer emits UTF-8"),
+ "line
break\n"
+ );
+ }
+
+ #[test]
+ fn writer_rejects_invalid_element_and_attribute_qnames() {
+ for name in ["", ":root", "root:", "a:b:c", "root> ");
+ }
+
+ #[test]
+ fn numeric_character_references_follow_the_exact_xml_grammar() {
+ for valid in ["65", "x41", "x10FFFF"] {
+ let reference = format!("{valid};");
+ assert!(decode_references(&reference).is_ok(), "rejected {valid}");
+ }
+ for malformed in ["", "x", "+65", "-1", "X41", "x+41", " 65"] {
+ let reference = format!("{malformed};");
+ assert!(
+ decode_references(&reference).is_err(),
+ "accepted {malformed}"
+ );
+ }
+ }
+
+ #[test]
+ fn writer_rejects_duplicate_attributes_before_emitting_markup() {
+ // XML 1.0 section 3.1 forbids an attribute name from appearing more than once in the
+ // same start-tag: https://www.w3.org/TR/xml/#sec-starttags
+ let mut writer = Writer::new(Vec::new());
+ assert!(
+ writer
+ .empty("root", [("id", "one"), ("id", "two")])
+ .is_err()
+ );
+ assert!(writer.into_inner().is_empty());
+ }
+
+ #[test]
+ fn writer_rejects_duplicate_expanded_attribute_names() {
+ // Namespaces in XML 1.0 section 6.3 makes expanded names, not lexical prefixes, unique.
+ // https://www.w3.org/TR/xml-names/#uniqAttrs
+ let mut writer = Writer::new(Vec::new());
+ assert!(
+ writer
+ .empty(
+ "root",
+ [
+ ("xmlns:a", "urn:shared"),
+ ("xmlns:b", "urn:shared"),
+ ("a:id", "one"),
+ ("b:id", "two"),
+ ],
+ )
+ .is_err()
+ );
+ assert!(writer.into_inner().is_empty());
+
+ let mut writer = Writer::new(Vec::new());
+ writer
+ .empty(
+ "root",
+ [
+ ("xmlns:a", "urn:first"),
+ ("xmlns:b", "urn:second"),
+ ("a:id", "one"),
+ ("b:id", "two"),
+ ],
+ )
+ .expect("different expanded names remain legal");
+ }
+
+ #[test]
+ fn writer_rejects_invalid_element_namespace_bindings_before_output() {
+ // Namespaces in XML 1.0 sections 3 and 5 reserve xml/xmlns and require every other
+ // element prefix to be declared: https://www.w3.org/TR/xml-names/#ns-decl and
+ // https://www.w3.org/TR/xml-names/#ns-using
+ for (name, attributes) in [
+ ("p:root", Vec::new()),
+ ("root", vec![("xmlns:xml", "urn:wrong")]),
+ ("root", vec![("xmlns:xmlns", "urn:wrong")]),
+ ] {
+ let mut writer = Writer::new(Vec::new());
+ assert!(writer.empty(name, attributes).is_err(), "accepted {name}");
+ assert!(writer.into_inner().is_empty());
+ }
+
+ let mut writer = Writer::new(Vec::new());
+ writer
+ .empty("p:root", [("xmlns:p", "urn:bound")])
+ .expect("a same-tag namespace declaration binds the element prefix");
+
+ let mut writer = Writer::new(Vec::new());
+ writer
+ .start("root", [("xmlns:p", "urn:bound")])
+ .expect("parent namespace declaration is valid");
+ writer
+ .empty("p:child", [])
+ .expect("an inherited namespace declaration binds the child prefix");
+ }
+
+ #[test]
+ fn writer_rejects_characters_forbidden_by_xml_1_0() {
+ let mut writer = Writer::new(Vec::new());
+ assert!(writer.text("before\0after").is_err());
+ assert!(writer.into_inner().is_empty());
+
+ let mut writer = Writer::new(Vec::new());
+ assert!(
+ writer
+ .empty("root", [("value", "before\u{1}after")])
+ .is_err()
+ );
+ }
+}
diff --git a/crates/xml-sec-xml-input/src/lib.rs b/crates/xml-sec-xml-input/src/lib.rs
new file mode 100644
index 00000000..7647a6b3
--- /dev/null
+++ b/crates/xml-sec-xml-input/src/lib.rs
@@ -0,0 +1,1178 @@
+//! Backend-neutral XML byte encoding detection and strict transcoding.
+//!
+//! Disable the default `std` feature for an alloc-only decoder and lexical scanner. The
+//! `std::io::Write`-based lexical writer is available only when `std` is enabled.
+
+#![cfg_attr(not(feature = "std"), no_std)]
+#![deny(unsafe_code)]
+
+extern crate alloc;
+
+use alloc::{borrow::Cow, string::String};
+use core::ops::Range;
+
+pub mod lexical;
+
+/// Failure while converting external bytes into the Unicode XML parser contract.
+#[derive(Debug, thiserror::Error)]
+#[non_exhaustive]
+pub enum Error {
+ /// The byte signature selected an encoding that this implementation cannot decode.
+ #[error("unsupported XML byte encoding `{0}`")]
+ UnsupportedByteEncoding(&'static str),
+ /// An encoding label was not recognized.
+ #[error("unsupported XML encoding `{0}`")]
+ UnsupportedEncoding(String),
+ /// Resolver metadata, a byte signature, and the XML declaration disagreed.
+ #[error("XML byte encoding conflicts with declared or selected encoding `{0}`")]
+ ConflictingEncoding(String),
+ /// The XML declaration was malformed before parsing could begin.
+ #[error("malformed XML encoding declaration: {0}")]
+ MalformedDeclaration(&'static str),
+ /// The selected decoder rejected malformed input instead of replacing it.
+ #[error("XML input contains invalid {0} bytes")]
+ InvalidBytes(&'static str),
+ /// A BOM-less UTF-16 document did not identify its byte order.
+ #[error("BOM-less UTF-16 XML input requires an explicit UTF-16LE or UTF-16BE declaration")]
+ MissingUtf16ByteOrder,
+ /// A UTF-32 document did not identify its byte order through metadata or its signature.
+ #[error("UTF-32 XML input requires a UTF-32LE/UTF-32BE encoding or byte-order signature")]
+ MissingUtf32ByteOrder,
+ /// A non-UTF-8/UTF-16 entity omitted both external encoding metadata and its declaration.
+ #[error("{0} XML input requires an encoding declaration or trusted external encoding metadata")]
+ MissingEncodingDeclaration(&'static str),
+ /// A UTF-16 code unit was truncated.
+ #[error("{0} XML input has an odd byte length")]
+ InvalidUtf16Length(&'static str),
+ /// A UTF-32 code unit was truncated.
+ #[error("{0} XML input byte length is not divisible by four")]
+ InvalidUtf32Length(&'static str),
+ /// Decoded UTF-8 would exceed the caller's materialization ceiling.
+ #[error("decoded XML exceeds the maximum size of {maximum} bytes: at least {actual} bytes")]
+ DecodedLimit { maximum: usize, actual: usize },
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SelectedEncoding {
+ Standard(&'static encoding_rs::Encoding),
+ Utf32Le,
+ Utf32Be,
+ Ascii,
+ Registered(IanaSingleByteEncoding),
+}
+
+impl SelectedEncoding {
+ fn name(self) -> &'static str {
+ match self {
+ Self::Standard(encoding) => encoding.name(),
+ Self::Utf32Le => "UTF-32LE",
+ Self::Utf32Be => "UTF-32BE",
+ Self::Ascii => "US-ASCII",
+ Self::Registered(encoding) => encoding.name(),
+ }
+ }
+
+ fn is_utf8(self) -> bool {
+ matches!(self, Self::Standard(encoding) if encoding == encoding_rs::UTF_8)
+ }
+}
+
+/// Strict IANA single-byte repertoire shared by XML input and XSLT output.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IanaSingleByteEncoding {
+ /// ISO-8859-1 (Latin-1).
+ Latin1,
+ /// ISO-8859-9 (Latin-5).
+ Latin5,
+ /// ISO-8859-11 Thai encoding.
+ Iso8859_11,
+ /// TIS-620 Thai encoding.
+ Tis620,
+}
+
+impl IanaSingleByteEncoding {
+ #[must_use]
+ pub const fn name(self) -> &'static str {
+ match self {
+ Self::Latin1 => "ISO-8859-1",
+ Self::Latin5 => "ISO-8859-9",
+ Self::Iso8859_11 => "ISO-8859-11",
+ Self::Tis620 => "TIS-620",
+ }
+ }
+
+ #[must_use]
+ pub fn decode_byte(self, byte: u8) -> Option {
+ match self {
+ Self::Latin1 => Some(char::from(byte)),
+ Self::Latin5 => Some(match byte {
+ 0xD0 => '\u{011E}',
+ 0xDD => '\u{0130}',
+ 0xDE => '\u{015E}',
+ 0xF0 => '\u{011F}',
+ 0xFD => '\u{0131}',
+ 0xFE => '\u{015F}',
+ _ => char::from(byte),
+ }),
+ Self::Iso8859_11 | Self::Tis620 => match byte {
+ 0x00..=0x7F => Some(char::from(byte)),
+ 0xA0 if self == Self::Iso8859_11 => Some('\u{00A0}'),
+ 0xA1..=0xDA | 0xE0..=0xFB => char::from_u32(u32::from(byte) + 0x0D60),
+ 0xDF => Some('\u{0E3F}'),
+ _ => None,
+ },
+ }
+ }
+
+ #[must_use]
+ pub fn encode_char(self, character: char) -> Option {
+ match self {
+ Self::Latin1 => u8::try_from(u32::from(character)).ok(),
+ Self::Latin5 => match character {
+ '\u{011E}' => Some(0xD0),
+ '\u{0130}' => Some(0xDD),
+ '\u{015E}' => Some(0xDE),
+ '\u{011F}' => Some(0xF0),
+ '\u{0131}' => Some(0xFD),
+ '\u{015F}' => Some(0xFE),
+ _ => u8::try_from(u32::from(character))
+ .ok()
+ .filter(|byte| !matches!(byte, 0xD0 | 0xDD | 0xDE | 0xF0 | 0xFD | 0xFE)),
+ },
+ Self::Iso8859_11 | Self::Tis620 => match u32::from(character) {
+ value @ 0x00..=0x7F => Some(value as u8),
+ 0xA0 if self == Self::Iso8859_11 => Some(0xA0),
+ value @ 0x0E01..=0x0E3A | value @ 0x0E40..=0x0E5B => {
+ u8::try_from(value - 0x0D60).ok()
+ }
+ 0x0E3F => Some(0xDF),
+ _ => None,
+ },
+ }
+ }
+}
+
+/// Resolve labels whose IANA meaning differs from WHATWG-compatible decoders.
+#[must_use]
+pub fn registered_single_byte_encoding(label: &str) -> Option {
+ if is_latin1_encoding_label(label) {
+ return Some(IanaSingleByteEncoding::Latin1);
+ }
+ if matches_ascii_case(
+ label,
+ &[
+ "iso-ir-148",
+ "iso88599",
+ "iso-8859-9",
+ "iso_8859-9",
+ "latin5",
+ "csisolatin5",
+ "iso_8859-9:1989",
+ ],
+ ) {
+ return Some(IanaSingleByteEncoding::Latin5);
+ }
+ if matches_ascii_case(label, &["iso8859-11", "iso-8859-11"]) {
+ return Some(IanaSingleByteEncoding::Iso8859_11);
+ }
+ label
+ .eq_ignore_ascii_case("tis-620")
+ .then_some(IanaSingleByteEncoding::Tis620)
+}
+
+/// Return whether a WHATWG label lookup preserves the caller's requested legacy encoding.
+///
+/// WHATWG redirects many ISO labels to Windows code pages. Callers that promise exact IANA
+/// semantics must either implement those repertoires explicitly or reject the redirected label.
+#[must_use]
+pub fn legacy_label_matches_encoding(
+ label: &str,
+ encoding: &'static encoding_rs::Encoding,
+) -> bool {
+ let canonical = encoding.name();
+ let Some(code_page) = canonical.strip_prefix("windows-") else {
+ return true;
+ };
+ label.eq_ignore_ascii_case(canonical)
+ || label
+ .get(2..)
+ .is_some_and(|suffix| label[..2].eq_ignore_ascii_case("cp") && suffix == code_page)
+ || label
+ .get(4..)
+ .is_some_and(|suffix| label[..4].eq_ignore_ascii_case("x-cp") && suffix == code_page)
+}
+
+/// Decode XML bytes according to XML 1.0 encoding detection rules.
+///
+/// `explicit_encoding` is trusted resolver metadata. It is checked against the
+/// byte signature and XML declaration rather than silently overriding either.
+/// UTF-8 input is borrowed when no declaration rewrite is required; other
+/// encodings are strictly transcoded and their declaration is normalized.
+pub fn decode_xml<'a>(
+ bytes: &'a [u8],
+ explicit_encoding: Option<&str>,
+) -> Result, Error> {
+ decode_xml_bounded(bytes, explicit_encoding, usize::MAX)
+}
+
+/// Decode XML while preventing either the transcoded or normalized UTF-8
+/// representation from growing beyond `maximum_decoded_bytes`.
+pub fn decode_xml_bounded<'a>(
+ bytes: &'a [u8],
+ explicit_encoding: Option<&str>,
+ maximum_decoded_bytes: usize,
+) -> Result, Error> {
+ let physical = physical_encoding(bytes)?;
+ let ascii_declaration = if physical.is_none() {
+ declaration_from_ascii_bytes(bytes)?
+ } else {
+ None
+ };
+ let explicit_utf16 = explicit_encoding.is_some_and(is_generic_utf16);
+ let explicit_utf32 = explicit_encoding.is_some_and(is_generic_utf32);
+ let explicit = explicit_encoding
+ .filter(|_| !explicit_utf16 && !explicit_utf32)
+ .map(parse_encoding)
+ .transpose()?;
+ if physical.is_some_and(|(_, bom_len)| bom_len > 0)
+ && let Some(explicit) = explicit
+ && is_utf16_encoding(explicit)
+ {
+ // RFC 2781 section 4.3 forbids a byte-order signature when the charset label fixes the
+ // UTF-16 byte order. Reject it before the common decoder path can consume the signature.
+ // https://www.rfc-editor.org/rfc/rfc2781#section-4.3
+ return Err(Error::ConflictingEncoding(explicit.name().into()));
+ }
+ if explicit_utf16
+ && !physical.is_some_and(|(encoding, bom_len)| is_utf16_encoding(encoding) && bom_len > 0)
+ {
+ // XML 1.0 section 4.3.3 requires an entity labeled as generic UTF-16 to begin with a BOM;
+ // a declaration discovered after decoding cannot replace that byte-order signature.
+ // https://www.w3.org/TR/xml/#charencoding
+ return Err(Error::MissingUtf16ByteOrder);
+ }
+ if explicit_utf32 && !physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
+ return Err(Error::MissingUtf32ByteOrder);
+ }
+ let declared_before_decode = ascii_declaration
+ .as_ref()
+ .map(|(_, label)| parse_encoding(label))
+ .transpose()?;
+ let selected = explicit
+ .or(physical.map(|(encoding, _)| encoding))
+ .or(declared_before_decode)
+ .unwrap_or(SelectedEncoding::Standard(encoding_rs::UTF_8));
+
+ if let Some((physical, _)) = physical
+ && !encodings_compatible(selected, physical, true)
+ {
+ return Err(Error::ConflictingEncoding(selected.name().into()));
+ }
+ if let Some(declared) = declared_before_decode
+ && !encodings_compatible(selected, declared, false)
+ {
+ return Err(Error::ConflictingEncoding(declared.name().into()));
+ }
+
+ let bom_len = physical.map_or(0, |(_, bom_len)| bom_len);
+ let mut decoded = decode_selected(&bytes[bom_len..], selected, maximum_decoded_bytes)?;
+ let declaration = declaration_from_text(&decoded)?;
+ if explicit_encoding.is_none()
+ && declaration.is_none()
+ && physical.is_some_and(|(encoding, bom_len)| {
+ is_utf32_encoding(encoding) || (bom_len == 0 && is_utf16_encoding(encoding))
+ })
+ {
+ // XML 1.0 section 4.3.3 permits declarationless entities only for UTF-8 and UTF-16. A
+ // UTF-32 BOM identifies byte order but does not make UTF-32 one of those two exceptions.
+ // https://www.w3.org/TR/xml/#charencoding
+ return Err(
+ if physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
+ Error::MissingEncodingDeclaration("UTF-32")
+ } else {
+ Error::MissingUtf16ByteOrder
+ },
+ );
+ }
+ if let Some(range) = &declaration {
+ let label = &decoded[range.clone()];
+ if is_generic_utf16(label) {
+ let has_utf16_bom = physical.is_some_and(|(encoding, bom_len)| {
+ bom_len > 0
+ && matches!(encoding, SelectedEncoding::Standard(value)
+ if value == encoding_rs::UTF_16LE || value == encoding_rs::UTF_16BE)
+ });
+ if !has_utf16_bom {
+ return Err(Error::MissingUtf16ByteOrder);
+ }
+ } else if is_generic_utf32(label) {
+ if !physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
+ return Err(Error::MissingUtf32ByteOrder);
+ }
+ } else {
+ let declared = parse_encoding(label)?;
+ if physical.is_some_and(|(_, bom_len)| bom_len > 0) && is_utf16_encoding(declared) {
+ // RFC 2781 section 4.3 applies equally when the endian-specific charset label is
+ // carried by the XML declaration rather than external metadata.
+ // https://www.rfc-editor.org/rfc/rfc2781#section-4.3
+ return Err(Error::ConflictingEncoding(declared.name().into()));
+ }
+ if !encodings_compatible(selected, declared, false) {
+ return Err(Error::ConflictingEncoding(label.into()));
+ }
+ }
+ }
+
+ if !selected.is_utf8()
+ && let Some(range) = declaration
+ {
+ let normalized_len = decoded
+ .len()
+ .saturating_sub(range.len())
+ .saturating_add("UTF-8".len());
+ if normalized_len > maximum_decoded_bytes {
+ return Err(Error::DecodedLimit {
+ maximum: maximum_decoded_bytes,
+ actual: normalized_len,
+ });
+ }
+ decoded.to_mut().replace_range(range, "UTF-8");
+ }
+ Ok(decoded)
+}
+
+/// Decode a non-XML text resource using an explicit character encoding.
+pub fn decode_text<'a>(bytes: &'a [u8], encoding: &str) -> Result, Error> {
+ decode_text_bounded(bytes, encoding, usize::MAX)
+}
+
+/// Decode a non-XML text resource under a retained-byte ceiling.
+pub fn decode_text_bounded<'a>(
+ bytes: &'a [u8],
+ encoding: &str,
+ maximum_decoded_bytes: usize,
+) -> Result, Error> {
+ if is_generic_utf16(encoding) {
+ // RFC 2781 section 3.3 requires a byte-order signature when the generic UTF-16
+ // label is used. Consume that signature before exposing text to the caller.
+ // https://www.rfc-editor.org/rfc/rfc2781.html#section-3.3
+ if let Some(payload) = bytes.strip_prefix(&[0xFF, 0xFE]) {
+ return decode_selected(
+ payload,
+ SelectedEncoding::Standard(encoding_rs::UTF_16LE),
+ maximum_decoded_bytes,
+ );
+ }
+ if let Some(payload) = bytes.strip_prefix(&[0xFE, 0xFF]) {
+ return decode_selected(
+ payload,
+ SelectedEncoding::Standard(encoding_rs::UTF_16BE),
+ maximum_decoded_bytes,
+ );
+ }
+ return Err(Error::MissingUtf16ByteOrder);
+ }
+ let selected = parse_encoding(encoding)?;
+ if is_utf16_encoding(selected)
+ && (bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]))
+ {
+ // RFC 2781 section 4.3 forbids a byte-order signature when the charset label fixes the
+ // UTF-16 byte order. https://www.rfc-editor.org/rfc/rfc2781#section-4.3
+ return Err(Error::ConflictingEncoding(selected.name().into()));
+ }
+ decode_selected(bytes, selected, maximum_decoded_bytes)
+}
+
+fn physical_encoding(bytes: &[u8]) -> Result, Error> {
+ let prefix = bytes.get(..4).unwrap_or(bytes);
+ // XML 1.0 Appendix F defines UCS-4 BOMs and initial `<` signatures. The two
+ // unusual octet orders are recognized but intentionally unsupported.
+ // https://www.w3.org/TR/xml/#sec-guessing
+ match prefix {
+ [0x00, 0x00, 0xFE, 0xFF] => return Ok(Some((SelectedEncoding::Utf32Be, 4))),
+ [0xFF, 0xFE, 0x00, 0x00] => return Ok(Some((SelectedEncoding::Utf32Le, 4))),
+ [0x00, 0x00, 0x00, b'<'] => return Ok(Some((SelectedEncoding::Utf32Be, 0))),
+ [b'<', 0x00, 0x00, 0x00] => return Ok(Some((SelectedEncoding::Utf32Le, 0))),
+ [0x00, 0x00, 0xFF, 0xFE]
+ | [0xFE, 0xFF, 0x00, 0x00]
+ | [0x00, 0x00, b'<', 0x00]
+ | [0x00, b'<', 0x00, 0x00] => {
+ return Err(Error::UnsupportedByteEncoding("UTF-32 unusual byte order"));
+ }
+ _ => {}
+ }
+ if prefix == [0x4C, 0x6F, 0xA7, 0x94] {
+ return Err(Error::UnsupportedByteEncoding("EBCDIC"));
+ }
+ if let Some((encoding, length)) = encoding_rs::Encoding::for_bom(bytes) {
+ return Ok(Some((SelectedEncoding::Standard(encoding), length)));
+ }
+ Ok(match prefix {
+ [0x00, b'<', 0x00, b'?'] => Some((SelectedEncoding::Standard(encoding_rs::UTF_16BE), 0)),
+ [b'<', 0x00, b'?', 0x00] => Some((SelectedEncoding::Standard(encoding_rs::UTF_16LE), 0)),
+ _ => None,
+ })
+}
+
+fn parse_encoding(label: &str) -> Result {
+ if matches_ascii_case(label, &["utf-32le", "utf32le"]) {
+ return Ok(SelectedEncoding::Utf32Le);
+ }
+ if matches_ascii_case(label, &["utf-32be", "utf32be"]) {
+ return Ok(SelectedEncoding::Utf32Be);
+ }
+ if matches_ascii_case(label, &["us-ascii", "ascii"]) {
+ return Ok(SelectedEncoding::Ascii);
+ }
+ // The IANA-registered labels below name the same ISO-8859-1 repertoire;
+ // WHATWG-style lookup would incorrectly map them to Windows-1252. `latin-1`
+ // is retained as the already-supported punctuation variant.
+ // https://www.iana.org/assignments/character-sets/character-sets.xhtml
+ if let Some(encoding) = registered_single_byte_encoding(label) {
+ return Ok(SelectedEncoding::Registered(encoding));
+ }
+ // `encoding_rs` exposes some registered ISO repertoires directly (including
+ // ISO-8859-2). Reject only lookups whose canonical result proves that the
+ // requested IANA label was redirected to a Windows extension with different C1 bytes.
+ // XML 1.0 section 4.3.3 requires registered labels to retain their IANA meaning.
+ // https://www.w3.org/TR/xml/#charencoding
+ // XInclude 1.0 sections 4.2-4.3 make an unsupported text encoding a resource error.
+ // Decoder-only WHATWG labels must therefore not select the replacement decoder.
+ // https://www.w3.org/TR/xinclude/#text_included
+ let encoding = encoding_rs::Encoding::for_label_no_replacement(label.as_bytes())
+ .ok_or_else(|| Error::UnsupportedEncoding(label.into()))?;
+ if !legacy_label_matches_encoding(label, encoding) {
+ return Err(Error::UnsupportedEncoding(label.into()));
+ }
+ Ok(SelectedEncoding::Standard(encoding))
+}
+
+/// Return whether `label` selects strict ISO-8859-1 semantics.
+///
+/// This intentionally does not use WHATWG label matching, which maps these
+/// XML encoding names to Windows-1252 instead of the registered repertoire.
+#[must_use]
+pub fn is_latin1_encoding_label(label: &str) -> bool {
+ matches_ascii_case(
+ label,
+ &[
+ "iso_8859-1:1987",
+ "iso-ir-100",
+ "iso_8859-1",
+ "iso-8859-1",
+ "latin1",
+ "latin-1",
+ "l1",
+ "ibm819",
+ "cp819",
+ "csisolatin1",
+ ],
+ )
+}
+
+fn decode_selected<'a>(
+ bytes: &'a [u8],
+ encoding: SelectedEncoding,
+ maximum: usize,
+) -> Result, Error> {
+ if matches!(
+ encoding,
+ SelectedEncoding::Utf32Le | SelectedEncoding::Utf32Be
+ ) {
+ return decode_utf32(bytes, encoding, maximum).map(Cow::Owned);
+ }
+ if encoding == SelectedEncoding::Ascii {
+ if bytes.iter().any(|byte| !byte.is_ascii()) {
+ return Err(Error::InvalidBytes("US-ASCII"));
+ }
+ let decoded =
+ core::str::from_utf8(bytes).expect("seven-bit US-ASCII is always valid UTF-8");
+ if decoded.len() > maximum {
+ return Err(Error::DecodedLimit {
+ maximum,
+ actual: decoded.len(),
+ });
+ }
+ return Ok(Cow::Borrowed(decoded));
+ }
+ if matches!(encoding, SelectedEncoding::Registered(_)) {
+ return decode_registered_single_byte(bytes, encoding, maximum).map(Cow::Owned);
+ }
+ let SelectedEncoding::Standard(encoding) = encoding else {
+ unreachable!("special-case encodings returned above")
+ };
+ if encoding == encoding_rs::UTF_8 {
+ let decoded = core::str::from_utf8(bytes).map_err(|_| Error::InvalidBytes("UTF-8"))?;
+ if decoded.len() > maximum {
+ return Err(Error::DecodedLimit {
+ maximum,
+ actual: decoded.len(),
+ });
+ }
+ return Ok(Cow::Borrowed(decoded));
+ }
+ if (encoding == encoding_rs::UTF_16LE || encoding == encoding_rs::UTF_16BE)
+ && !bytes.len().is_multiple_of(2)
+ {
+ return Err(Error::InvalidUtf16Length(encoding.name()));
+ }
+
+ let mut decoder = encoding.new_decoder_without_bom_handling();
+ let mut remaining = bytes;
+ let mut decoded = String::with_capacity(bytes.len().min(maximum));
+ let mut buffer = [0_u8; 4096];
+ loop {
+ let (result, read, written) =
+ decoder.decode_to_utf8_without_replacement(remaining, &mut buffer, true);
+ let actual = decoded.len().saturating_add(written);
+ if actual > maximum {
+ return Err(Error::DecodedLimit { maximum, actual });
+ }
+ decoded.push_str(
+ core::str::from_utf8(&buffer[..written])
+ .expect("encoding_rs emits valid UTF-8 into the output buffer"),
+ );
+ remaining = &remaining[read..];
+ match result {
+ encoding_rs::DecoderResult::InputEmpty => return Ok(Cow::Owned(decoded)),
+ encoding_rs::DecoderResult::OutputFull => {}
+ encoding_rs::DecoderResult::Malformed(_, _) => {
+ return Err(Error::InvalidBytes(encoding.name()));
+ }
+ }
+ }
+}
+
+fn decode_registered_single_byte(
+ bytes: &[u8],
+ encoding: SelectedEncoding,
+ maximum: usize,
+) -> Result {
+ let mut decoded = String::with_capacity(bytes.len().min(maximum));
+ for &byte in bytes {
+ let character = match encoding {
+ SelectedEncoding::Registered(encoding) => encoding
+ .decode_byte(byte)
+ .ok_or_else(|| Error::InvalidBytes(encoding.name()))?,
+ _ => unreachable!("registered single-byte decoder receives a matching encoding"),
+ };
+ let actual = decoded.len().saturating_add(character.len_utf8());
+ if actual > maximum {
+ return Err(Error::DecodedLimit { maximum, actual });
+ }
+ decoded.push(character);
+ }
+ Ok(decoded)
+}
+
+fn decode_utf32(bytes: &[u8], encoding: SelectedEncoding, maximum: usize) -> Result {
+ if !bytes.len().is_multiple_of(4) {
+ return Err(Error::InvalidUtf32Length(encoding.name()));
+ }
+ let mut decoded = String::with_capacity(bytes.len().min(maximum));
+ let (units, remainder) = bytes.as_chunks::<4>();
+ debug_assert!(remainder.is_empty());
+ for &unit in units {
+ let scalar = match encoding {
+ SelectedEncoding::Utf32Le => u32::from_le_bytes(unit),
+ SelectedEncoding::Utf32Be => u32::from_be_bytes(unit),
+ _ => unreachable!("UTF-32 decoder receives an explicit byte order"),
+ };
+ let character = char::from_u32(scalar).ok_or(Error::InvalidBytes(encoding.name()))?;
+ let actual = decoded.len().saturating_add(character.len_utf8());
+ if actual > maximum {
+ return Err(Error::DecodedLimit { maximum, actual });
+ }
+ decoded.push(character);
+ }
+ Ok(decoded)
+}
+
+fn encodings_compatible(
+ selected: SelectedEncoding,
+ candidate: SelectedEncoding,
+ physical: bool,
+) -> bool {
+ selected == candidate
+ || (physical
+ && matches!(selected, SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8)
+ && matches!(candidate, SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8))
+}
+
+fn is_utf16_encoding(encoding: SelectedEncoding) -> bool {
+ matches!(encoding, SelectedEncoding::Standard(value)
+ if value == encoding_rs::UTF_16LE || value == encoding_rs::UTF_16BE)
+}
+
+fn is_utf32_encoding(encoding: SelectedEncoding) -> bool {
+ matches!(
+ encoding,
+ SelectedEncoding::Utf32Le | SelectedEncoding::Utf32Be
+ )
+}
+
+// XML 1.0 section 2.3 production [3] defines S as exactly these four bytes.
+// https://www.w3.org/TR/xml/#NT-S
+const fn is_xml_s_byte(byte: &u8) -> bool {
+ matches!(*byte, b' ' | b'\t' | b'\r' | b'\n')
+}
+
+fn declaration_from_ascii_bytes(bytes: &[u8]) -> Result, &str)>, Error> {
+ let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
+ if !bytes.starts_with(b"")
+ .map(|index| index + 2)
+ .ok_or(Error::MalformedDeclaration("unterminated declaration"))?;
+ // XML encoding declarations are ASCII for every supported
+ // ASCII-compatible encoding, regardless of the following document bytes.
+ let prefix = core::str::from_utf8(&bytes[..end])
+ .map_err(|_| Error::MalformedDeclaration("declaration is not ASCII-compatible"))?;
+ declaration_from_text(prefix).map(|range| {
+ range.map(|range| {
+ let label = &prefix[range.clone()];
+ (range, label)
+ })
+ })
+}
+
+fn declaration_from_text(xml: &str) -> Result >, Error> {
+ let Some(rest) = xml.strip_prefix("")
+ .ok_or(Error::MalformedDeclaration("unterminated declaration"))?;
+ let declaration = &rest.as_bytes()[..end];
+ let mut cursor = 0;
+ let mut encoding_range = None;
+ while cursor < declaration.len() {
+ while declaration.get(cursor).is_some_and(is_xml_s_byte) {
+ cursor += 1;
+ }
+ if cursor == declaration.len() {
+ break;
+ }
+ let name_start = cursor;
+ while declaration.get(cursor).is_some_and(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':' | b'-' | b'.')
+ }) {
+ cursor += 1;
+ }
+ if cursor == name_start {
+ return Err(Error::MalformedDeclaration("invalid pseudo-attribute"));
+ }
+ let name = &declaration[name_start..cursor];
+ while declaration.get(cursor).is_some_and(is_xml_s_byte) {
+ cursor += 1;
+ }
+ if declaration.get(cursor) != Some(&b'=') {
+ return Err(Error::MalformedDeclaration("missing `=`"));
+ }
+ cursor += 1;
+ while declaration.get(cursor).is_some_and(is_xml_s_byte) {
+ cursor += 1;
+ }
+ let "e @ (b'\'' | b'"') = declaration
+ .get(cursor)
+ .ok_or(Error::MalformedDeclaration("missing quoted value"))?
+ else {
+ return Err(Error::MalformedDeclaration("value is not quoted"));
+ };
+ let value_start = cursor + 1;
+ let value_end = value_start
+ + declaration[value_start..]
+ .iter()
+ .position(|byte| *byte == quote)
+ .ok_or(Error::MalformedDeclaration("unterminated value"))?;
+ if name == b"encoding" {
+ let value = &declaration[value_start..value_end];
+ // XML 1.0 section 4.3.3 production [81] requires EncName to start with an ASCII
+ // letter and limits the remaining characters. Validate before normalization erases
+ // the declaration. https://www.w3.org/TR/xml/#NT-EncName
+ if !is_xml_encoding_name_bytes(value) {
+ return Err(Error::MalformedDeclaration("invalid encoding name"));
+ }
+ encoding_range = Some((5 + value_start)..(5 + value_end));
+ }
+ cursor = value_end + 1;
+ if declaration
+ .get(cursor)
+ .is_some_and(|byte| !is_xml_s_byte(byte))
+ {
+ return Err(Error::MalformedDeclaration("missing whitespace"));
+ }
+ }
+ // XML 1.0 section 2.8 production [23] makes EncodingDecl part of one complete XMLDecl;
+ // selection is valid only after every following pseudo-attribute has been checked.
+ // https://www.w3.org/TR/xml/#NT-XMLDecl
+ Ok(encoding_range)
+}
+
+/// Return whether a label satisfies XML 1.0's `EncName` production.
+///
+/// See XML 1.0 section 4.3.3, production [81]:
+/// https://www.w3.org/TR/xml/#NT-EncName
+#[must_use]
+pub fn is_xml_encoding_name(value: &str) -> bool {
+ is_xml_encoding_name_bytes(value.as_bytes())
+}
+
+fn is_xml_encoding_name_bytes(value: &[u8]) -> bool {
+ value.first().is_some_and(u8::is_ascii_alphabetic)
+ && value[1..]
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
+}
+
+fn is_generic_utf16(label: &str) -> bool {
+ label.eq_ignore_ascii_case("UTF-16") || label.eq_ignore_ascii_case("UTF16")
+}
+
+fn is_generic_utf32(label: &str) -> bool {
+ label.eq_ignore_ascii_case("UTF-32") || label.eq_ignore_ascii_case("UTF32")
+}
+
+fn matches_ascii_case(value: &str, candidates: &[&str]) -> bool {
+ candidates
+ .iter()
+ .any(|candidate| value.eq_ignore_ascii_case(candidate))
+}
+
+#[cfg(all(test, feature = "std"))]
+mod tests {
+ use std::borrow::Cow;
+
+ use super::{
+ Error, declaration_from_ascii_bytes, declaration_from_text, decode_text, decode_xml,
+ decode_xml_bounded,
+ };
+
+ fn encode_utf32(source: &str, little_endian: bool) -> Vec {
+ source
+ .chars()
+ .flat_map(|character| {
+ let value = u32::from(character);
+ if little_endian {
+ value.to_le_bytes()
+ } else {
+ value.to_be_bytes()
+ }
+ })
+ .collect()
+ }
+
+ #[test]
+ fn utf8_without_a_rewritten_declaration_stays_borrowed() {
+ let bytes = b"ok ";
+ assert!(matches!(decode_xml(bytes, None), Ok(Cow::Borrowed(_))));
+ }
+
+ #[test]
+ fn generic_utf16_uses_the_bom_byte_order() {
+ let source = "lambda ";
+ let mut bytes = vec![0xFE, 0xFF];
+ bytes.extend(source.encode_utf16().flat_map(u16::to_be_bytes));
+ let decoded = decode_xml(&bytes, Some("UTF-16")).expect("BOM selects UTF-16BE");
+ assert!(decoded.contains("encoding=\"UTF-8\""));
+ assert!(decoded.contains("lambda "));
+ }
+
+ #[test]
+ fn xml_declaration_rejects_values_outside_the_enc_name_grammar() {
+ // XML 1.0 section 4.3.3 production [81] excludes `:` from EncName.
+ // https://www.w3.org/TR/xml/#NT-EncName
+ let bytes = b" ";
+ assert!(matches!(
+ decode_xml(bytes, None),
+ Err(Error::MalformedDeclaration("invalid encoding name"))
+ ));
+ }
+
+ #[test]
+ fn encoding_selection_requires_a_complete_well_formed_declaration() {
+ // XML 1.0 section 2.8 production [23] requires the declaration to match XMLDecl in full;
+ // finding EncodingDecl is not permission to ignore malformed trailing pseudo-attributes.
+ // https://www.w3.org/TR/xml/#NT-XMLDecl
+ let malformed = " ";
+ assert!(matches!(
+ declaration_from_text(malformed),
+ Err(Error::MalformedDeclaration("missing `=`"))
+ ));
+ assert!(matches!(
+ declaration_from_ascii_bytes(malformed.as_bytes()),
+ Err(Error::MalformedDeclaration("missing `=`"))
+ ));
+
+ let mut utf16 = vec![0xFF, 0xFE];
+ utf16.extend(malformed.encode_utf16().flat_map(u16::to_le_bytes));
+ assert!(matches!(
+ decode_xml(&utf16, None),
+ Err(Error::MalformedDeclaration("missing `=`"))
+ ));
+ }
+
+ #[test]
+ fn xml_declaration_rejects_non_xml_ascii_whitespace() {
+ // XML 1.0 section 2.3 production [3] limits S to space, tab, CR, and LF.
+ // https://www.w3.org/TR/xml/#NT-S
+ for whitespace in *b"\x0b\x0c" {
+ let source = [
+ b"caf\xe9 ".as_slice(),
+ ]
+ .concat();
+ assert!(matches!(
+ decode_xml(&source, None),
+ Err(Error::InvalidBytes("UTF-8"))
+ ));
+ }
+ }
+
+ #[test]
+ fn xml_prefixed_processing_instruction_is_not_a_declaration() {
+ // XML 1.0 sections 2.6 and 2.8 distinguish PI targets from XMLDecl by the mandatory
+ // whitespace after `xml`: https://www.w3.org/TR/xml/#sec-prolog-dtd
+ let bytes = b" ";
+ assert_eq!(
+ decode_xml(bytes, Some("windows-1252")).expect("PI follows resolver encoding"),
+ " "
+ );
+ }
+
+ #[test]
+ fn utf32_requires_a_bom_metadata_or_encoding_declaration() {
+ // XML 1.0 Appendix F defines both UCS-4 BOMs and the BOM-less `<` signatures.
+ // https://www.w3.org/TR/xml/#sec-guessing
+ let source = "lambda ";
+ for (little_endian, bom) in [
+ (false, [0x00, 0x00, 0xFE, 0xFF]),
+ (true, [0xFF, 0xFE, 0x00, 0x00]),
+ ] {
+ let mut bytes = bom.to_vec();
+ bytes.extend(encode_utf32(source, little_endian));
+ let decoded = decode_xml(&bytes, None).expect("UTF-32 BOM selects byte order");
+ assert!(decoded.contains("encoding=\"UTF-8\""));
+ assert!(decoded.contains("lambda "));
+
+ let mut declarationless = bom.to_vec();
+ declarationless.extend(encode_utf32("lambda ", little_endian));
+ assert!(
+ decode_xml(&declarationless, None).is_err(),
+ "a UTF-32 BOM identifies byte order but does not replace the encoding declaration"
+ );
+ }
+
+ for little_endian in [false, true] {
+ let declaration = encode_utf32(
+ "lambda ",
+ little_endian,
+ );
+ assert!(
+ decode_xml(&declaration, None)
+ .expect("the initial signature and declaration identify UTF-32")
+ .contains("lambda ")
+ );
+ let declarationless = encode_utf32("lambda ", little_endian);
+ assert!(matches!(
+ decode_xml(&declarationless, None),
+ Err(Error::MissingEncodingDeclaration("UTF-32"))
+ ));
+ assert_eq!(
+ decode_xml(
+ &declarationless,
+ Some(if little_endian {
+ "UTF-32LE"
+ } else {
+ "UTF-32BE"
+ })
+ )
+ .expect("trusted external metadata supplies the encoding"),
+ "lambda "
+ );
+ }
+ }
+
+ #[test]
+ fn utf32_rejects_truncation_invalid_scalars_and_conflicting_metadata() {
+ let mut truncated = vec![0x00, 0x00, 0xFE, 0xFF];
+ truncated.extend([0x00, 0x00, 0x00]);
+ assert!(decode_xml(&truncated, None).is_err());
+
+ let mut surrogate = vec![0x00, 0x00, 0xFE, 0xFF];
+ surrogate.extend(0xD800_u32.to_be_bytes());
+ assert!(matches!(
+ decode_xml(&surrogate, None),
+ Err(Error::InvalidBytes("UTF-32BE"))
+ ));
+
+ let mut little_endian = vec![0xFF, 0xFE, 0x00, 0x00];
+ little_endian.extend(encode_utf32(" ", true));
+ assert!(matches!(
+ decode_xml(&little_endian, Some("UTF-32BE")),
+ Err(Error::ConflictingEncoding(_))
+ ));
+ }
+
+ #[test]
+ fn utf32_decoding_obeys_the_utf8_materialization_limit() {
+ let mut bytes = vec![0x00, 0x00, 0xFE, 0xFF];
+ bytes.extend(encode_utf32("lambda ", false));
+ assert!(matches!(
+ decode_xml_bounded(&bytes, None, 8),
+ Err(Error::DecodedLimit { maximum: 8, .. })
+ ));
+ }
+
+ #[test]
+ fn bomless_generic_utf16_is_rejected_as_ambiguous() {
+ let bytes = " "
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::>();
+ assert!(matches!(
+ decode_xml(&bytes, Some("UTF-16")),
+ Err(Error::MissingUtf16ByteOrder)
+ ));
+ }
+
+ #[test]
+ fn generic_utf16_metadata_does_not_replace_the_required_bom() {
+ // XML 1.0 section 4.3.3 requires every UTF-16 entity to begin with a BOM; a generic
+ // transport label supplies no byte order and cannot relax that document constraint.
+ // https://www.w3.org/TR/xml/#charencoding
+ let bytes = " "
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::>();
+ assert!(matches!(
+ decode_xml(&bytes, Some("UTF-16")),
+ Err(Error::MissingUtf16ByteOrder)
+ ));
+ }
+
+ #[test]
+ fn generic_utf16_metadata_cannot_bypass_the_bom_via_a_specific_declaration() {
+ // XML 1.0 section 4.3.3 requires generic UTF-16 entities to begin with a BOM even when
+ // their declaration reveals the byte order: https://www.w3.org/TR/xml/#charencoding
+ let bytes = " "
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::>();
+ assert!(matches!(
+ decode_xml(&bytes, Some("UTF-16")),
+ Err(Error::MissingUtf16ByteOrder)
+ ));
+ }
+
+ #[test]
+ fn explicit_utf16_byte_order_decodes_a_declarationless_resource() {
+ let bytes = "lambda "
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::>();
+ assert_eq!(
+ decode_xml(&bytes, Some("UTF-16LE")).unwrap(),
+ "lambda "
+ );
+ }
+
+ #[test]
+ fn endian_specific_utf16_rejects_a_byte_order_mark() {
+ // RFC 2781 section 4.3 requires a byte-order signature to be absent when the charset
+ // label itself fixes the byte order. https://www.rfc-editor.org/rfc/rfc2781#section-4.3
+ let mut metadata = vec![0xFF, 0xFE];
+ metadata.extend(" ".encode_utf16().flat_map(u16::to_le_bytes));
+ assert!(matches!(
+ decode_xml(&metadata, Some("UTF-16LE")),
+ Err(Error::ConflictingEncoding(label)) if label == "UTF-16LE"
+ ));
+ assert!(matches!(
+ decode_text(&metadata, "UTF-16LE"),
+ Err(Error::ConflictingEncoding(label)) if label == "UTF-16LE"
+ ));
+
+ let mut declaration = vec![0xFE, 0xFF];
+ declaration.extend(
+ " "
+ .encode_utf16()
+ .flat_map(u16::to_be_bytes),
+ );
+ assert!(matches!(
+ decode_xml(&declaration, None),
+ Err(Error::ConflictingEncoding(label)) if label == "UTF-16BE"
+ ));
+ }
+
+ #[test]
+ fn generic_utf16_text_requires_and_consumes_a_byte_order_mark() {
+ let text = "lambda";
+ let mut little = vec![0xFF, 0xFE];
+ little.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
+ let mut big = vec![0xFE, 0xFF];
+ big.extend(text.encode_utf16().flat_map(u16::to_be_bytes));
+
+ assert_eq!(decode_text(&little, "UTF-16").unwrap(), text);
+ assert_eq!(decode_text(&big, "UTF-16").unwrap(), text);
+ assert!(matches!(
+ decode_text(&little[2..], "UTF-16"),
+ Err(Error::MissingUtf16ByteOrder)
+ ));
+ }
+
+ #[test]
+ fn trusted_metadata_cannot_conflict_with_a_bom() {
+ assert!(matches!(
+ decode_xml(&[0xFF, 0xFE, b'A', 0], Some("UTF-8")),
+ Err(Error::ConflictingEncoding(_))
+ ));
+ }
+
+ #[test]
+ fn latin1_and_windows_1252_keep_distinct_c1_semantics() {
+ // Every IANA label must select the exact registered repertoire rather than the
+ // WHATWG replacement decoder used for HTML compatibility.
+ for alias in [
+ "ISO_8859-1:1987",
+ "iso-ir-100",
+ "ISO_8859-1",
+ "ISO-8859-1",
+ "latin1",
+ "l1",
+ "IBM819",
+ "CP819",
+ "csISOLatin1",
+ ] {
+ assert_eq!(
+ decode_text(&[0x80], alias).unwrap(),
+ "\u{80}",
+ "IANA alias {alias} must retain ISO-8859-1 C1 semantics"
+ );
+ }
+ assert_eq!(decode_text(&[0x80], "windows-1252").unwrap(), "€");
+ }
+
+ #[test]
+ fn iana_single_byte_encodings_do_not_inherit_windows_extensions() {
+ // XML 1.0 section 4.3.3 requires IANA labels to retain their registered meaning.
+ // https://www.w3.org/TR/xml/#charencoding
+ assert_eq!(decode_text(&[0x80], "ISO-8859-9").unwrap(), "\u{80}");
+ assert_eq!(decode_text(&[0x80], "iso88599").unwrap(), "\u{80}");
+ assert_eq!(decode_text(&[0xD0, 0xFD], "ISO-8859-9").unwrap(), "Ğı");
+ assert_eq!(decode_text(&[0x80], "windows-1254").unwrap(), "€");
+
+ assert_eq!(decode_text(&[0xA0], "ISO-8859-11").unwrap(), "\u{A0}");
+ assert!(matches!(
+ decode_text(&[0x80], "TIS-620"),
+ Err(Error::InvalidBytes("TIS-620"))
+ ));
+ assert!(matches!(
+ decode_text(&[0xA0], "TIS-620"),
+ Err(Error::InvalidBytes("TIS-620"))
+ ));
+ assert_eq!(decode_text(&[0xA1, 0xFB], "TIS-620").unwrap(), "ก๛");
+ assert_eq!(decode_text(&[0x80], "windows-874").unwrap(), "€");
+ }
+
+ #[test]
+ fn registered_iana_labels_retain_their_declared_repertoires() {
+ // XML 1.0 section 4.3.3 requires an IANA encoding name to retain its registered
+ // semantics; ISO-8859-2 therefore must not be confused with Windows-1250.
+ // https://www.w3.org/TR/xml/#charencoding
+ assert_eq!(decode_text(&[0x80], "ISO-8859-2").unwrap(), "\u{80}");
+ assert_eq!(decode_text(&[0xA1], "ISO-8859-2").unwrap(), "Ą");
+ assert_eq!(decode_text(&[0x80], "windows-1250").unwrap(), "€");
+
+ let source = b"\xA1 ";
+ assert!(decode_xml(source, None).unwrap().contains("Ą "));
+ }
+
+ #[test]
+ fn encoding_lookup_selects_the_strict_iso_8859_2_codec() {
+ // Keep the dependency contract explicit: this label is not a WHATWG redirect in the
+ // encoding_rs release used by the parser, so the standard decoder is the strict codec.
+ assert_eq!(
+ encoding_rs::Encoding::for_label(b"ISO-8859-2"),
+ Some(encoding_rs::ISO_8859_2)
+ );
+ assert_eq!(encoding_rs::ISO_8859_2.name(), "ISO-8859-2");
+ }
+
+ #[test]
+ fn decoder_only_labels_are_reported_as_unsupported() {
+ // XInclude 1.0 sections 4.2-4.3 classify an unsupported text encoding as a resource
+ // error, so decoder-only WHATWG labels must not reach the replacement decoder.
+ // https://www.w3.org/TR/xinclude/#text_included
+ for label in ["replacement", "ISO-2022-KR"] {
+ assert!(matches!(
+ decode_text(b"", label),
+ Err(Error::UnsupportedEncoding(rejected)) if rejected == label
+ ));
+ }
+ }
+
+ #[test]
+ fn us_ascii_rejects_non_ascii_bytes() {
+ // WHATWG aliases US-ASCII to Windows-1252, but XML's declared encoding
+ // contract permits only seven-bit bytes for this label.
+ assert!(matches!(
+ decode_text(&[0x80], "US-ASCII"),
+ Err(Error::InvalidBytes("US-ASCII"))
+ ));
+ assert_eq!(
+ decode_text(b"plain ASCII", "US-ASCII").unwrap(),
+ "plain ASCII"
+ );
+ }
+
+ #[test]
+ fn transcoded_and_normalized_representations_are_both_bounded() {
+ let bytes = b" ";
+ let exact = decode_xml(bytes, None).expect("GBK declaration is supported");
+ assert!(matches!(
+ decode_xml_bounded(bytes, None, exact.len() - 1),
+ Err(Error::DecodedLimit { .. })
+ ));
+ assert_eq!(decode_xml_bounded(bytes, None, exact.len()).unwrap(), exact);
+ }
+
+ #[test]
+ fn declaration_detection_is_not_limited_to_a_short_prefix() {
+ let whitespace = " ".repeat(2_048);
+ let source = format!("caf\u{e9} ");
+ let bytes = source
+ .chars()
+ .map(|character| u8::try_from(u32::from(character)).unwrap())
+ .collect::>();
+ assert!(decode_xml(&bytes, None).unwrap().contains("café"));
+ }
+
+ #[test]
+ fn declaration_allows_whitespace_before_its_terminator() {
+ let source = b"caf\xe9 ";
+ assert!(decode_xml(source, None).unwrap().contains("café"));
+ }
+
+ #[test]
+ fn unsupported_xml_signatures_fail_explicitly() {
+ assert!(matches!(
+ decode_xml(&[0x4C, 0x6F, 0xA7, 0x94], None),
+ Err(Error::UnsupportedByteEncoding("EBCDIC"))
+ ));
+ }
+
+ #[test]
+ fn truncated_utf16_reports_the_code_unit_boundary() {
+ assert!(matches!(
+ decode_xml(&[0xff, 0xfe, 0], None),
+ Err(Error::InvalidUtf16Length("UTF-16LE"))
+ ));
+ }
+}
diff --git a/crates/xml-sec-xslt/Cargo.toml b/crates/xml-sec-xslt/Cargo.toml
new file mode 100644
index 00000000..856190ef
--- /dev/null
+++ b/crates/xml-sec-xslt/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "xml-sec-xslt"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.92"
+license = "Apache-2.0"
+description = "Safe Rust XSLT 1.0 compiler and runtime"
+repository = "https://github.com/structured-world/xml-sec"
+readme = "README.md"
+
+[dependencies]
+encoding_rs = "0.8"
+icu_collator = "2.3.1"
+icu_locale = "2.3.1"
+md-5 = "0.11"
+roxmltree = { version = "0.21", features = ["positions"] }
+sha1 = "0.11"
+sxd-document-no-unsafe = { package = "xml-sec-sxd-document", version = "0.1.0", path = "../../vendor/sxd-document-no-unsafe", default-features = false, features = ["no-unsafe"] }
+sxd-xpath-no-unsafe = { package = "xml-sec-sxd-xpath", version = "0.1.0", path = "../../vendor/sxd-xpath-no-unsafe", default-features = false, features = ["no-unsafe"] }
+thiserror = "2"
+time = { version = "0.3.55", features = ["local-offset"] }
+url = "2"
+xml-sec-xml-input = { version = "0.1.0", path = "../xml-sec-xml-input" }
+
+[dev-dependencies]
+pretty_assertions = "1"
diff --git a/crates/xml-sec-xslt/README.md b/crates/xml-sec-xslt/README.md
new file mode 100644
index 00000000..f1e5d31c
--- /dev/null
+++ b/crates/xml-sec-xslt/README.md
@@ -0,0 +1,97 @@
+# xml-sec-xslt
+
+Safe-Rust XSLT 1.0 compiler and runtime for parser-independent XML processing.
+The crate is an independent engine: it has no XMLDSig, XMLEnc, key-provider,
+filesystem, network, environment, or global registry coupling. Pure EXSLT digest
+functions are implemented locally and do not expose or depend on XML-security key
+handling.
+
+Compilation produces an immutable `Stylesheet` that can be shared and executed
+repeatedly. Callers provide explicit compile/execution budgets and a resolver
+contract; `NoResolver` denies external access.
+
+Execution capabilities are explicit through `ExecutionEnvironment`: a caller-owned
+resolver, clock, and typed extension policy. The compatibility entry point uses the
+host local clock. Security-sensitive or reproducible transforms can inject
+`FixedClock`, while `ExtensionPolicy::Deterministic` rejects zero-argument EXSLT date
+functions that depend on ambient time. Extension capability therefore does not imply
+permission to use nondeterministic behavior.
+
+The engine implements template matching and precedence, modes, named templates,
+variables and parameters, keys, whitespace declarations, decimal formats,
+namespace aliases, attribute sets, XSLT instruction execution, result-tree
+construction, numbering, sorting, and XML/HTML/text serialization.
+
+XPath node sets retain element, attribute, and namespace identities. Converting a
+public `Value` to a string therefore requires the associated `Document`; result-tree
+fragments remain owned temporary trees rather than flattened text.
+
+Stylesheet modules and runtime `document()` resources use the same explicit
+resolver contract with purpose, base URI, stable resource identity, and byte
+budgeting. `ResolveRequest` also carries XInclude `accept` and `accept-language`
+preferences without allocating or interpreting transport policy inside the engine.
+Both preferences must contain only printable ASCII (XInclude 1.0 section 3.1);
+invalid values are fatal before resolution and cannot select fallback content.
+The XMLDSig transform adapter remains outside this crate so no XML-security types
+enter the reusable engine.
+
+Execution budgets cover internal work, not just output: node-set string traversal,
+EXSLT date parsing and URI conversion, and both small and large XPath projections
+are metered, including runtime identity paths and optimized result-fragment scans.
+Cached stylesheet-function results also charge replay copies, identity resolution,
+and node-set growth. `lang()` ancestor visits and `str:align()` Unicode scans consume
+their respective XPath and extension work allowances before execution.
+`current()` reuses indexed projection identity rather than rescanning sibling lists.
+Existing string arguments are borrowed rather than copied for conversion.
+Result-fragment string projections are shared immutably across XPath evaluations;
+the fragment's tree identity is retained independently. Actual value copies and
+node-set/container capacity growth are reserved before allocation. Namespace-axis
+and name-function evaluation visit borrowed declarations with shadowing preserved,
+without first materializing every in-scope namespace. String construction, number
+formatting, set membership, and default numbering also consume work allowances.
+
+Resolver resources are byte-oriented. XML declarations, BOMs, UTF-16/UTF-32 initial
+patterns, and explicit resolver encoding metadata are honored without lossy
+fallback; unsupported labels and malformed byte sequences are rejected. XML,
+HTML, and text output supports registered `encoding_rs` labels, including UTF-8,
+UTF-16LE/BE, and ISO-8859-1, with the method-specific escaping or rejection
+required for unrepresentable characters.
+`Document::parse` accepts trusted, already-decoded Rust text. Untrusted callers use
+`Document::parse_with_budget` or `Document::parse_bytes_with_budget` to bound decoded
+bytes, semantic nodes, and element depth before arena growth; `Compiler::compile_bytes`
+applies the same strict XML byte-decoding boundary used by resolver resources. The
+production semantic tree always uses one iterative
+lexical-event path, so attacker-controlled document depth cannot select a different
+parser implementation. Stylesheet compilation uses a `roxmltree` frontend and
+immediately projects it into the engine's owned compiler IR; source documents and
+runtime trees do not retain that DOM. The event tokenizer and compiler frontend are
+private implementation details and do not define the engine's semantic or encoding
+contract.
+
+The crate exposes policy-neutral compile and execution limits. An embedding security protocol
+must derive those limits, source-processing permission, and extension permission from one compiled
+operation-policy snapshot at its adapter boundary; they are not a second policy domain. The main
+`xml-sec` crate intentionally keeps its XSLT transform adapter disconnected until that typed
+policy, resource-identity, and node-set translation is available.
+
+## Compatibility oracle
+
+The repository vendors the complete libxslt 1.1.45 test tree and registers all
+554 transformations driven by its core, REC/REC2, EXSLT, XSLTMark, DocBook,
+multiple-output, XInclude, and xmlspec suites. Every registered case executes
+through this engine. Portable output is compared byte-for-byte; comparisons are
+normalized only where the donor output is stale or XSLT permits
+implementation-defined lexical values. The harness projects donor DTD defaults,
+ID and tokenized attributes, entities, and unparsed-entity system identifiers
+without granting production code implicit filesystem access.
+
+`files.sha256` accounts for all 2,021 files in the pinned upstream source tree, so
+the importer and CI detect fixture omissions and byte drift independently of
+engine behavior.
+
+Refresh or verify the snapshot from a matching donor checkout with:
+
+```console
+LIBXSLT_SOURCE_DIR=/path/to/libxslt scripts/import-libxslt-oracle-fixtures.sh
+LIBXSLT_SOURCE_DIR=/path/to/libxslt scripts/import-libxslt-oracle-fixtures.sh --check
+```
diff --git a/crates/xml-sec-xslt/src/budget.rs b/crates/xml-sec-xslt/src/budget.rs
new file mode 100644
index 00000000..8d8e13b6
--- /dev/null
+++ b/crates/xml-sec-xslt/src/budget.rs
@@ -0,0 +1,634 @@
+use std::collections::{HashMap, HashSet};
+use std::hash::{BuildHasher, Hash};
+
+use crate::{Error, Result};
+
+pub(crate) const ENTITY_EXPANSION_DEPTH_CEILING: usize = 256;
+// This is an absolute parser safety ceiling, not a deployment policy default.
+// Caller budgets may reject smaller documents at the compile/execution boundary.
+pub(crate) const ENTITY_EXPANSION_BYTE_CEILING: usize = 16 * 1024 * 1024;
+// Compilation still uses bounded native recursion while borrowing frontend nodes. This absolute
+// process-safety ceiling only tightens caller policy until module and instruction traversal are
+// represented entirely by explicit work stacks.
+pub(crate) const COMPILE_RECURSION_DEPTH_CEILING: usize = 256;
+// XInclude resolution currently retains one small native frame per nested acquired document.
+// This absolute process-safety ceiling only tightens the caller's execution policy.
+pub(crate) const XINCLUDE_RECURSION_DEPTH_CEILING: usize = 256;
+// Global initialization, capture constructors, attribute-set expansion, and stylesheet-defined
+// functions retain native Rust frames. This process-safety ceiling only tightens caller policy;
+// iterative template depth is independent of the count of live native sequence frames.
+pub(crate) const EXECUTION_RECURSION_DEPTH_CEILING: usize = 256;
+
+/// Independently metered XSLT resource dimensions.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[non_exhaustive]
+pub enum BudgetKind {
+ StylesheetBytes,
+ SourceBytes,
+ SourceNodes,
+ EntityReferences,
+ EntityExpansionBytes,
+ NamespaceScopeBytes,
+ ImportedModules,
+ ExternalDocuments,
+ RecursionDepth,
+ XPathEvaluations,
+ XPathOperations,
+ ExtensionOperations,
+ PatternEvaluations,
+ TemplateApplications,
+ SortComparisons,
+ KeyEntries,
+ ResultNodes,
+ SerializedBytes,
+ Messages,
+ OwnedBytes,
+}
+
+/// Policy-neutral enforcement limits for parsing one caller-supplied XML document.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ParseBudget {
+ /// Maximum decoded XML bytes accepted for one document.
+ pub source_bytes: usize,
+ /// Maximum semantic nodes, including the synthetic document root.
+ pub source_nodes: usize,
+ /// Maximum element nesting depth.
+ pub recursion_depth: usize,
+ /// Maximum declared general and parameter entity-reference occurrences expanded.
+ pub entity_references: usize,
+ /// Maximum aggregate bytes materialized while expanding entities.
+ pub entity_expansion_bytes: usize,
+ /// Maximum peak bytes used to materialize inherited namespace scopes and their indexes.
+ pub namespace_scope_bytes: usize,
+}
+
+impl ParseBudget {
+ #[must_use]
+ pub const fn new(
+ source_bytes: usize,
+ source_nodes: usize,
+ recursion_depth: usize,
+ entity_references: usize,
+ entity_expansion_bytes: usize,
+ namespace_scope_bytes: usize,
+ ) -> Self {
+ Self {
+ source_bytes,
+ source_nodes,
+ recursion_depth,
+ entity_references,
+ entity_expansion_bytes,
+ namespace_scope_bytes,
+ }
+ }
+
+ pub(crate) const UNBOUNDED: Self = Self::new(
+ usize::MAX,
+ usize::MAX,
+ usize::MAX,
+ usize::MAX,
+ usize::MAX,
+ usize::MAX,
+ );
+}
+
+/// Policy-neutral immutable enforcement limits for compiling a stylesheet graph.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CompileBudget {
+ pub stylesheet_bytes: usize,
+ pub imported_modules: usize,
+ pub recursion_depth: usize,
+ pub owned_bytes: usize,
+}
+
+impl CompileBudget {
+ #[must_use]
+ pub const fn new(
+ stylesheet_bytes: usize,
+ imported_modules: usize,
+ recursion_depth: usize,
+ owned_bytes: usize,
+ ) -> Self {
+ Self {
+ stylesheet_bytes,
+ imported_modules,
+ recursion_depth,
+ owned_bytes,
+ }
+ }
+}
+
+/// Policy-neutral immutable enforcement limits shared by one transformation execution.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ExecutionBudget {
+ pub source_bytes: usize,
+ pub external_documents: usize,
+ pub recursion_depth: usize,
+ pub xpath_evaluations: usize,
+ /// Maximum primitive traversal, predicate, comparison, and node-set insertion operations.
+ pub xpath_operations: usize,
+ /// Internal work performed by extension functions after one XPath call is dispatched.
+ pub extension_operations: usize,
+ pub pattern_evaluations: usize,
+ pub template_applications: usize,
+ pub sort_comparisons: usize,
+ pub key_entries: usize,
+ pub result_nodes: usize,
+ pub serialized_bytes: usize,
+ pub messages: usize,
+ pub owned_bytes: usize,
+}
+
+#[derive(Debug)]
+pub(crate) struct Meter {
+ limits: ExecutionBudget,
+ xpath_evaluations: usize,
+ xpath_operations: usize,
+ extension_operations: usize,
+ pattern_evaluations: usize,
+ template_applications: usize,
+ sort_comparisons: usize,
+ key_entries: usize,
+ result_nodes: usize,
+ serialized_bytes: usize,
+ messages: usize,
+ owned_bytes: usize,
+ external_documents: usize,
+}
+
+impl Meter {
+ pub(crate) fn new(limits: ExecutionBudget, source_bytes: usize) -> Result {
+ ensure(BudgetKind::SourceBytes, limits.source_bytes, source_bytes)?;
+ ensure(BudgetKind::OwnedBytes, limits.owned_bytes, source_bytes)?;
+ Ok(Self {
+ limits,
+ xpath_evaluations: 0,
+ xpath_operations: 0,
+ extension_operations: 0,
+ pattern_evaluations: 0,
+ template_applications: 0,
+ sort_comparisons: 0,
+ key_entries: 0,
+ result_nodes: 0,
+ serialized_bytes: 0,
+ messages: 0,
+ owned_bytes: source_bytes,
+ external_documents: 0,
+ })
+ }
+
+ pub(crate) fn recursion(&self, depth: usize) -> Result<()> {
+ ensure(
+ BudgetKind::RecursionDepth,
+ self.limits.recursion_depth,
+ depth,
+ )
+ }
+
+ pub(crate) fn recursion_with_ceiling(&self, depth: usize, ceiling: usize) -> Result<()> {
+ ensure(
+ BudgetKind::RecursionDepth,
+ self.limits.recursion_depth.min(ceiling),
+ depth,
+ )
+ }
+
+ pub(crate) fn charge(&mut self, kind: BudgetKind, amount: usize) -> Result<()> {
+ let (used, limit) = match kind {
+ BudgetKind::ExternalDocuments => {
+ (&mut self.external_documents, self.limits.external_documents)
+ }
+ BudgetKind::XPathEvaluations => {
+ (&mut self.xpath_evaluations, self.limits.xpath_evaluations)
+ }
+ BudgetKind::XPathOperations => {
+ (&mut self.xpath_operations, self.limits.xpath_operations)
+ }
+ BudgetKind::ExtensionOperations => (
+ &mut self.extension_operations,
+ self.limits.extension_operations,
+ ),
+ BudgetKind::PatternEvaluations => (
+ &mut self.pattern_evaluations,
+ self.limits.pattern_evaluations,
+ ),
+ BudgetKind::TemplateApplications => (
+ &mut self.template_applications,
+ self.limits.template_applications,
+ ),
+ BudgetKind::SortComparisons => {
+ (&mut self.sort_comparisons, self.limits.sort_comparisons)
+ }
+ BudgetKind::KeyEntries => (&mut self.key_entries, self.limits.key_entries),
+ BudgetKind::ResultNodes => (&mut self.result_nodes, self.limits.result_nodes),
+ BudgetKind::SerializedBytes => {
+ (&mut self.serialized_bytes, self.limits.serialized_bytes)
+ }
+ BudgetKind::Messages => (&mut self.messages, self.limits.messages),
+ BudgetKind::OwnedBytes => (&mut self.owned_bytes, self.limits.owned_bytes),
+ other => {
+ return Err(Error::Dynamic(format!(
+ "{other:?} cannot be charged during execution"
+ )));
+ }
+ };
+ let Some(actual) = used.checked_add(amount) else {
+ *used = usize::MAX;
+ return Err(Error::Budget {
+ kind,
+ limit,
+ actual: usize::MAX,
+ });
+ };
+ ensure(kind, limit, actual)?;
+ *used = actual;
+ Ok(())
+ }
+
+ pub(crate) fn check_additional(&self, kind: BudgetKind, amount: usize) -> Result<()> {
+ let (used, limit) = self.usage(kind)?;
+ ensure(kind, limit, used.saturating_add(amount))
+ }
+
+ pub(crate) fn release_owned_bytes(&mut self, amount: usize) {
+ self.owned_bytes = self
+ .owned_bytes
+ .checked_sub(amount)
+ .expect("released owned-byte reservation was previously charged");
+ }
+
+ pub(crate) fn usage(&self, kind: BudgetKind) -> Result<(usize, usize)> {
+ match kind {
+ BudgetKind::ExternalDocuments => {
+ Ok((self.external_documents, self.limits.external_documents))
+ }
+ BudgetKind::XPathEvaluations => {
+ Ok((self.xpath_evaluations, self.limits.xpath_evaluations))
+ }
+ BudgetKind::XPathOperations => {
+ Ok((self.xpath_operations, self.limits.xpath_operations))
+ }
+ BudgetKind::ExtensionOperations => {
+ Ok((self.extension_operations, self.limits.extension_operations))
+ }
+ BudgetKind::PatternEvaluations => {
+ Ok((self.pattern_evaluations, self.limits.pattern_evaluations))
+ }
+ BudgetKind::TemplateApplications => Ok((
+ self.template_applications,
+ self.limits.template_applications,
+ )),
+ BudgetKind::SortComparisons => {
+ Ok((self.sort_comparisons, self.limits.sort_comparisons))
+ }
+ BudgetKind::KeyEntries => Ok((self.key_entries, self.limits.key_entries)),
+ BudgetKind::ResultNodes => Ok((self.result_nodes, self.limits.result_nodes)),
+ BudgetKind::SerializedBytes => {
+ Ok((self.serialized_bytes, self.limits.serialized_bytes))
+ }
+ BudgetKind::Messages => Ok((self.messages, self.limits.messages)),
+ BudgetKind::OwnedBytes => Ok((self.owned_bytes, self.limits.owned_bytes)),
+ other => Err(Error::Dynamic(format!(
+ "{other:?} cannot be checked during execution"
+ ))),
+ }
+ }
+
+ pub(crate) fn remaining_owned_bytes(&self) -> usize {
+ self.limits.owned_bytes.saturating_sub(self.owned_bytes)
+ }
+
+ pub(crate) const fn recursion_limit(&self) -> usize {
+ self.limits.recursion_depth
+ }
+}
+
+pub(crate) fn reserve_temporary_vec_slot(
+ items: &mut Vec,
+ meter: &mut Meter,
+ reserved_owned_bytes: &mut usize,
+) -> Result<()> {
+ if std::mem::size_of::() == 0 || items.len() < items.capacity() {
+ return Ok(());
+ }
+ let old_capacity = items.capacity();
+ let requested_slots = old_capacity.max(4);
+ let target_capacity = old_capacity.saturating_add(requested_slots);
+ let requested_bytes = target_capacity.saturating_mul(std::mem::size_of::());
+ meter.charge(BudgetKind::OwnedBytes, requested_bytes)?;
+ let mut replacement = Vec::new();
+ if let Err(error) = replacement.try_reserve_exact(target_capacity) {
+ meter.release_owned_bytes(requested_bytes);
+ return Err(Error::Dynamic(format!(
+ "failed to reserve temporary execution storage: {error}"
+ )));
+ }
+ let actual_bytes = replacement
+ .capacity()
+ .saturating_mul(std::mem::size_of::());
+ reconcile_replacement_growth(meter, requested_bytes, actual_bytes)?;
+
+ replacement.append(items);
+ std::mem::swap(items, &mut replacement);
+ let old_bytes = replacement
+ .capacity()
+ .saturating_mul(std::mem::size_of::());
+ *reserved_owned_bytes = reserved_owned_bytes
+ .checked_sub(old_bytes)
+ .expect("temporary vector capacity was previously charged")
+ .saturating_add(actual_bytes);
+ meter.release_owned_bytes(old_bytes);
+ Ok(())
+}
+
+pub(crate) fn append_metered_string(
+ current: &mut String,
+ suffix: &str,
+ meter: &mut Meter,
+) -> Result<()> {
+ let required = current
+ .len()
+ .checked_add(suffix.len())
+ .filter(|length| *length <= isize::MAX as usize)
+ .ok_or_else(|| Error::Dynamic("text value is too large".into()))?;
+ if required <= current.capacity() {
+ current.push_str(suffix);
+ return Ok(());
+ }
+
+ meter.check_additional(BudgetKind::OwnedBytes, required)?;
+ debug_assert!(current.capacity() <= isize::MAX as usize);
+ let target_capacity = (current.capacity() * 2)
+ .max(required)
+ .min(isize::MAX as usize)
+ .min(meter.remaining_owned_bytes());
+ meter.charge(BudgetKind::OwnedBytes, target_capacity)?;
+ let mut replacement = String::new();
+ if let Err(error) = replacement.try_reserve_exact(target_capacity) {
+ meter.release_owned_bytes(target_capacity);
+ return Err(Error::Dynamic(format!(
+ "failed to reserve metered text storage: {error}"
+ )));
+ }
+ let actual_capacity = replacement.capacity();
+ reconcile_replacement_growth(meter, target_capacity, actual_capacity)?;
+ replacement.push_str(current);
+ replacement.push_str(suffix);
+ let old_capacity = current.capacity();
+ *current = replacement;
+ meter.release_owned_bytes(old_capacity);
+ Ok(())
+}
+
+fn reconcile_replacement_growth(
+ meter: &mut Meter,
+ requested_bytes: usize,
+ actual_bytes: usize,
+) -> Result<()> {
+ if actual_bytes < requested_bytes {
+ meter.release_owned_bytes(requested_bytes - actual_bytes);
+ } else if actual_bytes > requested_bytes {
+ let shortfall = actual_bytes - requested_bytes;
+ if let Err(error) = meter.check_additional(BudgetKind::OwnedBytes, shortfall) {
+ meter.release_owned_bytes(requested_bytes);
+ return Err(error);
+ }
+ let charged_before_shortfall = meter.owned_bytes;
+ if let Err(error) = meter.charge(BudgetKind::OwnedBytes, shortfall) {
+ meter.owned_bytes = charged_before_shortfall;
+ meter.release_owned_bytes(requested_bytes);
+ return Err(error);
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn retained_hash_storage(capacity: usize) -> usize {
+ capacity
+ .saturating_mul(std::mem::size_of::())
+ .saturating_mul(2)
+}
+
+pub(crate) fn reserve_retained_hash_set_slot(
+ items: &mut HashSet,
+ meter: &mut Meter,
+ reserved_owned_bytes: &mut usize,
+) -> Result<()>
+where
+ T: Eq + Hash,
+ S: BuildHasher + Clone,
+{
+ if items.len() < items.capacity() {
+ return Ok(());
+ }
+ let old_capacity = items.capacity();
+ let target_capacity = old_capacity.saturating_add(old_capacity.max(4));
+ let requested_bytes = retained_hash_storage::(target_capacity);
+ meter.charge(BudgetKind::OwnedBytes, requested_bytes)?;
+ let mut replacement = HashSet::with_hasher(items.hasher().clone());
+ if let Err(error) = replacement.try_reserve(target_capacity) {
+ meter.release_owned_bytes(requested_bytes);
+ return Err(Error::Dynamic(format!(
+ "failed to reserve retained hash-set storage: {error}"
+ )));
+ }
+ let actual_bytes = retained_hash_storage::(replacement.capacity());
+ reconcile_replacement_growth(meter, requested_bytes, actual_bytes)?;
+ replacement.extend(items.drain());
+ std::mem::swap(items, &mut replacement);
+ let old_bytes = retained_hash_storage::(replacement.capacity());
+ *reserved_owned_bytes = reserved_owned_bytes
+ .checked_sub(old_bytes)
+ .expect("retained hash-set capacity was previously charged")
+ .saturating_add(actual_bytes);
+ meter.release_owned_bytes(old_bytes);
+ Ok(())
+}
+
+/// Grow retained vector storage through the same peak-allocation gate as temporary vectors.
+pub(crate) fn reserve_retained_vec_slot(items: &mut Vec, meter: &mut Meter) -> Result<()> {
+ let mut reserved_owned_bytes = items.capacity().saturating_mul(std::mem::size_of::());
+ reserve_temporary_vec_slot(items, meter, &mut reserved_owned_bytes)
+}
+
+pub(crate) fn reserve_retained_hash_map_slot(
+ items: &mut HashMap,
+ meter: &mut Meter,
+ reserved_owned_bytes: &mut usize,
+) -> Result<()>
+where
+ K: Eq + Hash,
+ S: BuildHasher + Clone,
+{
+ if items.len() < items.capacity() {
+ return Ok(());
+ }
+ let old_capacity = items.capacity();
+ let target_capacity = old_capacity.saturating_add(old_capacity.max(4));
+ let requested_bytes = retained_hash_storage::<(K, V)>(target_capacity);
+ meter.charge(BudgetKind::OwnedBytes, requested_bytes)?;
+ let mut replacement = HashMap::with_hasher(items.hasher().clone());
+ if let Err(error) = replacement.try_reserve(target_capacity) {
+ meter.release_owned_bytes(requested_bytes);
+ return Err(Error::Dynamic(format!(
+ "failed to reserve retained hash-map storage: {error}"
+ )));
+ }
+ let actual_bytes = retained_hash_storage::<(K, V)>(replacement.capacity());
+ reconcile_replacement_growth(meter, requested_bytes, actual_bytes)?;
+ replacement.extend(items.drain());
+ std::mem::swap(items, &mut replacement);
+ let old_bytes = retained_hash_storage::<(K, V)>(replacement.capacity());
+ *reserved_owned_bytes = reserved_owned_bytes
+ .checked_sub(old_bytes)
+ .expect("retained hash-map capacity was previously charged")
+ .saturating_add(actual_bytes);
+ meter.release_owned_bytes(old_bytes);
+ Ok(())
+}
+
+pub(crate) fn ensure(kind: BudgetKind, limit: usize, actual: usize) -> Result<()> {
+ if actual > limit {
+ return Err(Error::Budget {
+ kind,
+ limit,
+ actual,
+ });
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn execution_budget(owned_bytes: usize) -> ExecutionBudget {
+ ExecutionBudget {
+ source_bytes: 64,
+ external_documents: 0,
+ recursion_depth: 1,
+ xpath_evaluations: 0,
+ xpath_operations: 0,
+ extension_operations: 0,
+ pattern_evaluations: 0,
+ template_applications: 0,
+ sort_comparisons: 0,
+ key_entries: 0,
+ result_nodes: 0,
+ serialized_bytes: 0,
+ messages: 0,
+ owned_bytes,
+ }
+ }
+
+ #[test]
+ fn meter_rejects_initial_source_ownership_above_its_limit() {
+ assert!(Meter::new(execution_budget(8), 8).is_ok());
+ assert!(matches!(
+ Meter::new(execution_budget(7), 8),
+ Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: 7,
+ actual: 8,
+ })
+ ));
+ }
+
+ #[test]
+ fn failed_vec_replacement_shortfall_rolls_back_accounting() {
+ // An allocator may grant more capacity than Vec::try_reserve_exact requests. Rejecting
+ // that temporary replacement must restore the meter before the replacement is dropped.
+ let mut meter = Meter::new(execution_budget(10), 0).expect("meter initializes");
+ meter
+ .charge(BudgetKind::OwnedBytes, 8)
+ .expect("requested growth fits");
+ assert!(matches!(
+ reconcile_replacement_growth(&mut meter, 8, 12),
+ Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: 10,
+ actual: 12,
+ })
+ ));
+ assert_eq!(
+ meter
+ .usage(BudgetKind::OwnedBytes)
+ .expect("owned-byte usage is available"),
+ (0, 10)
+ );
+ meter
+ .charge(BudgetKind::OwnedBytes, 10)
+ .expect("failed replacement leaves the original allowance available");
+ }
+
+ #[test]
+ fn overflowing_replacement_shortfall_releases_the_provisional_charge() {
+ // check_additional saturates at usize::MAX, while charge detects the arithmetic overflow.
+ // The replacement's provisional reservation must still be released on that error path.
+ let mut meter = Meter::new(execution_budget(usize::MAX), 0).expect("meter initializes");
+ meter
+ .charge(BudgetKind::OwnedBytes, usize::MAX - 2)
+ .expect("baseline fits");
+ meter
+ .charge(BudgetKind::OwnedBytes, 1)
+ .expect("provisional replacement charge fits");
+
+ assert!(matches!(
+ reconcile_replacement_growth(&mut meter, 1, 3),
+ Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: usize::MAX,
+ actual: usize::MAX,
+ })
+ ));
+ assert_eq!(
+ meter
+ .usage(BudgetKind::OwnedBytes)
+ .expect("owned-byte usage is available"),
+ (usize::MAX - 2, usize::MAX)
+ );
+ }
+
+ #[test]
+ fn metered_string_growth_fails_before_replacing_the_live_buffer() {
+ let mut value = String::with_capacity(8);
+ value.push_str("12345678");
+ let original_capacity = value.capacity();
+ let mut meter =
+ Meter::new(execution_budget(original_capacity + 4), 0).expect("meter initializes");
+ meter
+ .charge(BudgetKind::OwnedBytes, original_capacity)
+ .expect("existing allocation is retained");
+
+ assert!(matches!(
+ append_metered_string(&mut value, "5678", &mut meter),
+ Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ ..
+ })
+ ));
+ assert_eq!(value, "12345678");
+ assert_eq!(value.capacity(), original_capacity);
+ }
+
+ #[test]
+ fn overflowing_owned_charge_leaves_the_meter_fail_closed() {
+ let mut meter = Meter::new(execution_budget(usize::MAX), 1).expect("meter initializes");
+
+ assert!(matches!(
+ meter.charge(BudgetKind::OwnedBytes, usize::MAX),
+ Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: usize::MAX,
+ actual: usize::MAX,
+ })
+ ));
+ assert_eq!(
+ meter
+ .usage(BudgetKind::OwnedBytes)
+ .expect("owned-byte usage is available"),
+ (usize::MAX, usize::MAX)
+ );
+ assert!(meter.charge(BudgetKind::OwnedBytes, 1).is_err());
+ }
+}
diff --git a/crates/xml-sec-xslt/src/compiler.rs b/crates/xml-sec-xslt/src/compiler.rs
new file mode 100644
index 00000000..c8a8afaf
--- /dev/null
+++ b/crates/xml-sec-xslt/src/compiler.rs
@@ -0,0 +1,5432 @@
+use std::borrow::Cow;
+use std::cell::{Cell, RefCell};
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::rc::Rc;
+use std::sync::{Arc, Weak};
+
+use crate::budget::{COMPILE_RECURSION_DEPTH_CEILING, ensure};
+use crate::lexical::{
+ ValidatedXPointerFragment, is_ncname, is_ncname_char, is_ncname_start, is_xml_whitespace,
+ strip_xpath_attribute_axis, trim_xml_whitespace, unicode_decimal_value, xpath_string_literal,
+};
+use crate::model::{normalized_xml_id, parser_workspace_bytes, prepare_xml_frontend_bounded};
+use crate::resolver::decode_resource;
+use crate::{
+ BudgetKind, CompileBudget, Document, Error, ExpandedName, Namespace, OutputDefinition,
+ OutputMethod, ResolvePurpose, ResolvedResource, Resolver, ResourceIdentity, Result,
+};
+
+pub(crate) const XSLT_NS: &str = "http://www.w3.org/1999/XSL/Transform";
+pub(crate) const EXSLT_FUNCTIONS_NS: &str = "http://exslt.org/functions";
+const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
+const SAXON_NS: &str = "http://icl.com/saxon";
+const XT_NS: &str = "http://www.jclark.com/xt";
+const XALAN_REDIRECT_NS: &str = "org.apache.xalan.xslt.extensions.Redirect";
+const LIBXSLT_TEST_NS: &str = "http://xmlsoft.org/XSLT/";
+const LIBXSLT_TEST_PLUGIN_NS: &str = "http://xmlsoft.org/xslt/testplugin";
+
+/// XSLT compiler with an explicit resource boundary and compile budget.
+pub struct Compiler {
+ resolver: Arc,
+ budget: CompileBudget,
+}
+
+impl Compiler {
+ #[must_use]
+ pub fn new(resolver: Arc, budget: CompileBudget) -> Self {
+ Self { resolver, budget }
+ }
+
+ /// Compile a complete stylesheet graph into immutable executable IR.
+ pub fn compile(&self, xml: &str, base_uri: Option<&str>) -> Result {
+ self.compile_with_workspace(xml, base_uri, 0)
+ }
+
+ fn compile_with_workspace(
+ &self,
+ xml: &str,
+ base_uri: Option<&str>,
+ decoded_workspace: usize,
+ ) -> Result {
+ ensure(
+ BudgetKind::StylesheetBytes,
+ self.budget.stylesheet_bytes,
+ xml.len(),
+ )?;
+ let mut state = CompileState::new(self.budget, xml.len());
+ state.charge_owned(decoded_workspace)?;
+ self.compile_module(xml, base_uri, None, None, &mut state, 1)?;
+ let principal_document = parse_semantic_document_metered(xml, base_uri, &mut state)?;
+ let principal_base_uri = clone_compile_string(base_uri, &mut state)?;
+ let mut stylesheet = state.finish()?;
+ stylesheet.principal_document = principal_document;
+ stylesheet.principal_base_uri = principal_base_uri;
+ Ok(stylesheet)
+ }
+
+ /// Decode and compile a complete stylesheet graph from external XML bytes.
+ pub fn compile_bytes(&self, bytes: &[u8], base_uri: Option<&str>) -> Result {
+ ensure(
+ BudgetKind::StylesheetBytes,
+ self.budget.stylesheet_bytes,
+ bytes.len(),
+ )?;
+ let decoded_limit = self.budget.stylesheet_bytes.min(self.budget.owned_bytes);
+ let xml =
+ xml_sec_xml_input::decode_xml_bounded(bytes, None, decoded_limit).map_err(|error| {
+ match error {
+ xml_sec_xml_input::Error::DecodedLimit { actual, .. }
+ if self.budget.owned_bytes <= self.budget.stylesheet_bytes =>
+ {
+ Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: self.budget.owned_bytes,
+ actual,
+ }
+ }
+ xml_sec_xml_input::Error::DecodedLimit { actual, .. } => Error::Budget {
+ kind: BudgetKind::StylesheetBytes,
+ limit: self.budget.stylesheet_bytes,
+ actual,
+ },
+ error => Error::Xml(error.to_string()),
+ }
+ })?;
+ let (xml, decoded_workspace) = classify_decoded_workspace(xml);
+ self.compile_with_workspace(&xml, base_uri, decoded_workspace)
+ }
+
+ fn compile_module(
+ &self,
+ xml: &str,
+ base_uri: Option<&str>,
+ fragment: Option<&str>,
+ inherited_precedence: Option,
+ state: &mut CompileState,
+ depth: usize,
+ ) -> Result<()> {
+ ensure(
+ BudgetKind::RecursionDepth,
+ state.budget.recursion_depth,
+ depth,
+ )?;
+ with_compiler_document(xml, base_uri, state, |document, state| {
+ let root = stylesheet_module_root(document, fragment)?;
+ let StylesheetModuleKind::Standard { forward } = stylesheet_module_kind(root)? else {
+ let precedence = inherited_precedence.unwrap_or_else(|| state.next_precedence());
+ return self
+ .compile_literal_result_stylesheet(root, base_uri, precedence, state, depth);
+ };
+ validate_standard_stylesheet_content(root)?;
+ validate_top_level_declaration_attributes(root, forward)?;
+ validate_namespace_prefix_attributes(root, forward)?;
+ let mut saw_non_import = false;
+ self.compile_effective_imports(root, base_uri, state, depth, &mut saw_non_import)?;
+ let local_precedence = inherited_precedence.unwrap_or_else(|| state.next_precedence());
+ self.compile_effective_declarations(
+ root,
+ base_uri,
+ local_precedence,
+ forward,
+ state,
+ depth,
+ )
+ })
+ }
+
+ fn compile_effective_imports(
+ &self,
+ root: roxmltree::Node<'_, '_>,
+ base_uri: Option<&str>,
+ state: &mut CompileState,
+ depth: usize,
+ saw_non_import: &mut bool,
+ ) -> Result<()> {
+ for child in root.children().filter(roxmltree::Node::is_element) {
+ let is_import = child.has_tag_name((XSLT_NS, "import"));
+ if is_import && *saw_non_import {
+ return Err(Error::Static(
+ "xsl:import must precede all other top-level declarations".into(),
+ ));
+ }
+ if is_import {
+ ensure(
+ BudgetKind::RecursionDepth,
+ state.budget.recursion_depth,
+ depth + 1,
+ )?;
+ let module = self.resolve_module(child, base_uri, ResolvePurpose::Import, state)?;
+ self.enter_resource(&module.resource, module.fragment, state, |state| {
+ let source = resource_source(&module.resource, state)?;
+ self.compile_module(
+ source.as_str(),
+ Some(&module.resource.canonical_uri),
+ module.fragment,
+ None,
+ state,
+ depth + 1,
+ )
+ })?;
+ } else if child.has_tag_name((XSLT_NS, "include")) {
+ ensure(
+ BudgetKind::RecursionDepth,
+ state.budget.recursion_depth,
+ depth + 1,
+ )?;
+ let module =
+ self.resolve_module(child, base_uri, ResolvePurpose::Include, state)?;
+ self.enter_resource(&module.resource, module.fragment, state, |state| {
+ let source = resource_source(&module.resource, state)?;
+ with_frontend_document(source.as_str(), state, |document, state| {
+ let included_root = stylesheet_module_root(document, module.fragment)?;
+ match stylesheet_module_kind(included_root)? {
+ StylesheetModuleKind::Standard { forward } => {
+ validate_standard_stylesheet_content(included_root)?;
+ validate_top_level_declaration_attributes(included_root, forward)?;
+ validate_namespace_prefix_attributes(included_root, forward)?;
+ self.compile_effective_imports(
+ included_root,
+ Some(&module.resource.canonical_uri),
+ state,
+ depth + 1,
+ saw_non_import,
+ )
+ }
+ StylesheetModuleKind::Simplified => Ok(()),
+ }
+ })
+ })?;
+ // XSLT 1.0 section 2.6.2 requires imports to precede every other top-level
+ // element, explicitly including xsl:include after its imports are expanded.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#import
+ *saw_non_import = true;
+ } else {
+ *saw_non_import = true;
+ }
+ }
+ Ok(())
+ }
+
+ fn compile_effective_declarations(
+ &self,
+ root: roxmltree::Node<'_, '_>,
+ base_uri: Option<&str>,
+ precedence: usize,
+ forward: bool,
+ state: &mut CompileState,
+ depth: usize,
+ ) -> Result<()> {
+ for child in root.children().filter(roxmltree::Node::is_element) {
+ if child.has_tag_name((XSLT_NS, "import")) {
+ continue;
+ }
+ if child.has_tag_name((XSLT_NS, "include")) {
+ ensure(
+ BudgetKind::RecursionDepth,
+ state.budget.recursion_depth,
+ depth + 1,
+ )?;
+ let module =
+ self.resolve_module(child, base_uri, ResolvePurpose::Include, state)?;
+ self.enter_resource(&module.resource, module.fragment, state, |state| {
+ let source = resource_source(&module.resource, state)?;
+ with_compiler_document(
+ source.as_str(),
+ Some(&module.resource.canonical_uri),
+ state,
+ |document, state| {
+ let included_root = stylesheet_module_root(document, module.fragment)?;
+ match stylesheet_module_kind(included_root)? {
+ StylesheetModuleKind::Standard {
+ forward: included_forward,
+ } => self.compile_effective_declarations(
+ included_root,
+ Some(&module.resource.canonical_uri),
+ precedence,
+ included_forward,
+ state,
+ depth + 1,
+ ),
+ StylesheetModuleKind::Simplified => self
+ .compile_literal_result_stylesheet(
+ included_root,
+ Some(&module.resource.canonical_uri),
+ precedence,
+ state,
+ depth + 1,
+ ),
+ }
+ },
+ )
+ })?;
+ continue;
+ }
+ self.compile_top_level(child, base_uri, precedence, forward, state, depth)?;
+ }
+ Ok(())
+ }
+
+ fn resolve_module<'input>(
+ &self,
+ node: roxmltree::Node<'input, 'input>,
+ base_uri: Option<&str>,
+ purpose: ResolvePurpose,
+ state: &mut CompileState,
+ ) -> Result> {
+ // XSLT 1.0 sections 2.6.1 and 2.6.2 define include/import as EMPTY. Validate the
+ // declaration before URI resolution so malformed syntax cannot cause external access.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#include
+ require_empty_instruction(node)?;
+ let href = required_attr(node, "href")?;
+ let (resource_href, fragment) = href
+ .split_once('#')
+ .map_or((href, None), |(resource, fragment)| {
+ (resource, Some(fragment))
+ });
+ if fragment == Some("") {
+ return Err(Error::Static(
+ "stylesheet module fragment identifier must not be empty".into(),
+ ));
+ }
+ let effective_base = effective_base_uri(node, base_uri)?;
+ let request_owned_bytes =
+ resolve_request_retained_bytes(resource_href, effective_base.as_deref());
+ state.charge_owned(request_owned_bytes)?;
+ let request = ResolveRequest {
+ href: resource_href.to_owned(),
+ base_uri: effective_base.clone(),
+ purpose,
+ };
+ if let Some(resource) = state.resolved_requests.get(&request) {
+ let resource = Arc::clone(resource);
+ state.release_owned(request_owned_bytes);
+ return Ok(ResolvedModule { resource, fragment });
+ }
+ state.imported_modules = state.imported_modules.saturating_add(1);
+ ensure(
+ BudgetKind::ImportedModules,
+ state.budget.imported_modules,
+ state.imported_modules,
+ )?;
+ let resolved = match self.resolver.resolve(crate::ResolveRequest::new(
+ resource_href,
+ effective_base.as_deref(),
+ purpose,
+ )) {
+ Ok(resource) => resource,
+ Err(error) => {
+ state.release_owned(request_owned_bytes);
+ return Err(error);
+ }
+ };
+ let (resource, document_id, new_identity) =
+ if let Some(previous) = state.resolved_identities.get(&resolved.identity) {
+ if previous.resource.as_ref() != &resolved {
+ state.release_owned(request_owned_bytes);
+ return Err(Error::StaleResource {
+ identity: resolved.identity,
+ });
+ }
+ (Arc::clone(&previous.resource), previous.document_id, false)
+ } else {
+ state.check_stylesheet(resolved.bytes.len())?;
+ state.charge_owned(new_resolved_identity_retained_bytes(&resolved))?;
+ let document_id = StylesheetDocumentId(state.module_documents.len() + 1);
+ (Arc::new(resolved), document_id, true)
+ };
+ if new_identity {
+ state.charge_owned(module_document_cache_entry_bytes())?;
+ let source = resource_source(&resource, state)?;
+ let document = parse_semantic_document_metered(
+ source.as_str(),
+ Some(&resource.canonical_uri),
+ state,
+ )?;
+ state.module_documents.insert(
+ document_id,
+ ModuleDocument {
+ id: document_id,
+ document,
+ },
+ );
+ state.resolved_identities.insert(
+ resource.identity.clone(),
+ ResolvedIdentity {
+ resource: Arc::clone(&resource),
+ document_id,
+ },
+ );
+ state.resources.push(resource.identity.clone());
+ }
+ state
+ .resolved_requests
+ .insert(request, Arc::clone(&resource));
+ Ok(ResolvedModule { resource, fragment })
+ }
+
+ fn enter_resource(
+ &self,
+ resource: &Arc,
+ fragment: Option<&str>,
+ state: &mut CompileState,
+ compile: impl FnOnce(&mut CompileState) -> Result,
+ ) -> Result {
+ if state.active_resources.iter().any(|active| {
+ active.resource.identity == resource.identity && active.fragment.as_deref() == fragment
+ }) {
+ return Err(Error::Static(format!(
+ "stylesheet include/import cycle at {}{}",
+ resource.canonical_uri,
+ fragment.map_or(String::new(), |fragment| format!("#{fragment}"))
+ )));
+ }
+ // XSLT 1.0 section 2.7 makes the fragment part of an embedded stylesheet module's
+ // identity. Distinct modules in one XML resource must not alias in the active set.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#embedded
+ let fragment_bytes = fragment.map_or(0, str::len);
+ state.charge_owned(fragment_bytes)?;
+ if let Err(error) = state.push_active_resource(ActiveResource {
+ resource: Arc::clone(resource),
+ fragment: fragment.map(Into::into),
+ }) {
+ state.release_owned(fragment_bytes);
+ return Err(error);
+ }
+ let result = compile(state);
+ let active = state
+ .active_resources
+ .pop()
+ .expect("the active stylesheet module was pushed before compilation");
+ let fragment_bytes = active.fragment.as_ref().map_or(0, |value| value.len());
+ drop(active);
+ state.release_owned(fragment_bytes);
+ result
+ }
+
+ fn compile_literal_result_stylesheet(
+ &self,
+ root: roxmltree::Node<'_, '_>,
+ base_uri: Option<&str>,
+ precedence: usize,
+ state: &mut CompileState,
+ depth: usize,
+ ) -> Result<()> {
+ let version = root.attribute((XSLT_NS, "version")).ok_or_else(|| {
+ Error::Static("literal result stylesheet requires xsl:version".into())
+ })?;
+ let forward = stylesheet_version_is_forward_compatible(version)?;
+ let order = state.next_order();
+ state.templates.push(Template {
+ name: None,
+ pattern: Some(Pattern::new("/", root, state.workspace())?),
+ mode: None,
+ priority: 0.5,
+ precedence,
+ order,
+ params: Arc::from([]),
+ body: vec![compile_literal_element(
+ root,
+ CompileContext::new(
+ forward,
+ depth,
+ state.budget.recursion_depth,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.workspace(),
+ )?,
+ )?]
+ .into(),
+ });
+ Ok(())
+ }
+
+ fn compile_top_level(
+ &self,
+ node: roxmltree::Node<'_, '_>,
+ base_uri: Option<&str>,
+ precedence: usize,
+ forward: bool,
+ state: &mut CompileState,
+ depth: usize,
+ ) -> Result<()> {
+ if is_exslt_function_declaration(node)? {
+ validate_exslt_function_result_structure(node)?;
+ let context = CompileContext::new(
+ forward,
+ depth,
+ state.budget.recursion_depth,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.workspace(),
+ )?
+ .inside_function();
+ let mut children = node.children().peekable();
+ let mut params = Vec::new();
+ while let Some(child) = children.peek().copied() {
+ if child.has_tag_name((XSLT_NS, "param")) {
+ params.push(compile_variable(child, context.clone())?);
+ children.next();
+ } else if is_ignorable_stylesheet_child(child) {
+ children.next();
+ } else {
+ break;
+ }
+ }
+ let body = compile_sequence(children, context)?;
+ let order = state.next_order();
+ let name = required_qname_attr(node, "name")?;
+ // EXSLT func:function requires the expanded name to have a non-null namespace;
+ // otherwise it could replace an XPath core function binding.
+ // https://exslt.github.io/func/elements/function/index.html
+ if name.namespace.is_none() {
+ return Err(Error::Static(
+ "EXSLT func:function name requires a namespace prefix".into(),
+ ));
+ }
+ state.functions.push(ExsltFunction {
+ name,
+ params,
+ body,
+ precedence,
+ order,
+ });
+ return Ok(());
+ }
+ if node.tag_name().namespace() != Some(XSLT_NS) {
+ if node.tag_name().namespace().is_some() || forward {
+ return Ok(());
+ }
+ // XSLT 1.0 section 2.2 permits extension top-level elements only when their
+ // expanded name has a non-null namespace URI. Section 2.5 separately requires
+ // unknown top-level elements to be ignored during forwards-compatible processing.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#stylesheet-element
+ return Err(Error::Static(
+ "non-XSLT top-level elements require a namespace".into(),
+ ));
+ }
+ match node.tag_name().name() {
+ "template" => {
+ let name = optional_qname_attr(node, "name")?;
+ let patterns = node
+ .attribute("match")
+ .map(|value| Pattern::template_branches(value, node, state.workspace()))
+ .transpose()?
+ .unwrap_or_default();
+ if name.is_none() && patterns.is_empty() {
+ return Err(Error::Static("xsl:template requires name or match".into()));
+ }
+ let explicit_priority = node
+ .attribute("priority")
+ .map(|value| {
+ let priority = crate::xpath::xpath_number(value);
+ priority.is_finite().then_some(priority).ok_or_else(|| {
+ Error::Static("template priority must be a finite XPath number".into())
+ })
+ })
+ .transpose()?
+ .filter(|_| !patterns.is_empty());
+ // XSLT 1.0 sections 5.5 and 6 define priority for template-rule conflict
+ // resolution, but explicitly make it irrelevant to named-template invocation.
+ // Validate the lexical value above, then discard it for a named-only template.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#named-templates
+ let mode = optional_qname_attr(node, "mode")?;
+ // XSLT 1.0 section 5.7 forbids mode when the template has no match rule.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#modes
+ if patterns.is_empty() && mode.is_some() {
+ return Err(Error::Static(
+ "xsl:template mode requires a match attribute".into(),
+ ));
+ }
+ let context = CompileContext::new(
+ forward,
+ depth,
+ state.budget.recursion_depth,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.workspace(),
+ )?;
+ let mut children = node.children().peekable();
+ let mut params = Vec::new();
+ while let Some(child) = children.peek().copied() {
+ if child.has_tag_name((XSLT_NS, "param")) {
+ params.push(compile_variable(child, context.clone())?);
+ children.next();
+ } else if is_ignorable_stylesheet_child(child) {
+ children.next();
+ } else {
+ break;
+ }
+ }
+ let body: Arc<[Instruction]> = compile_sequence(children, context)?;
+ let params: Arc<[Variable]> = params.into();
+ let order = state.next_order();
+ if patterns.is_empty() {
+ state.templates.push(Template {
+ name,
+ pattern: None,
+ mode,
+ priority: 0.0,
+ precedence,
+ order,
+ params,
+ body,
+ });
+ } else {
+ let pattern_buffer_bytes = patterns.capacity() * std::mem::size_of::();
+ for (index, pattern) in patterns.into_iter().enumerate() {
+ state.templates.push(Template {
+ name: (index == 0).then(|| name.clone()).flatten(),
+ priority: match explicit_priority {
+ Some(priority) => priority,
+ None => pattern.default_priority(state.workspace())?,
+ },
+ pattern: Some(pattern),
+ mode: mode.clone(),
+ precedence,
+ order,
+ params: Arc::clone(¶ms),
+ body: Arc::clone(&body),
+ });
+ }
+ state.workspace().release(pattern_buffer_bytes);
+ }
+ }
+ "variable" | "param" => {
+ let variable = compile_variable(
+ node,
+ CompileContext::new(
+ forward,
+ depth,
+ state.budget.recursion_depth,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.workspace(),
+ )?,
+ )?;
+ let order = state.next_order();
+ state.globals.push(GlobalVariable {
+ variable,
+ precedence,
+ order,
+ is_parameter: node.tag_name().name() == "param",
+ });
+ }
+ "output" => merge_output(
+ &mut state.output,
+ &mut state.output_precedence,
+ node,
+ precedence,
+ forward,
+ )?,
+ "strip-space" | "preserve-space" => {
+ // XSLT 1.0 section 3.4 gives both declarations an EMPTY content model.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#strip
+ require_empty_instruction(node)?;
+ let preserve = node.tag_name().name() == "preserve-space";
+ let elements = required_attr(node, "elements")?;
+ let mut name_tests = elements.split_ascii_whitespace().peekable();
+ if name_tests.peek().is_none() {
+ return Err(Error::Static(format!(
+ "xsl:{} elements must contain at least one name test",
+ node.tag_name().name()
+ )));
+ }
+ for token in name_tests {
+ let order = state.next_order();
+ state.whitespace.push((
+ NameTest::parse(token, node)?,
+ preserve,
+ precedence,
+ order,
+ ));
+ }
+ }
+ "key" => {
+ // XSLT 1.0 section 12.2 defines xsl:key with an EMPTY content model.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#key
+ require_empty_instruction(node)?;
+ let match_pattern = required_attr(node, "match")?;
+ let use_expression = required_attr(node, "use")?;
+ validate_key_dependency_expression(
+ "match",
+ match_pattern,
+ state.workspace().pending_source(match_pattern, node),
+ )?;
+ validate_key_dependency_expression(
+ "use",
+ use_expression,
+ state.workspace().pending_source(use_expression, node),
+ )?;
+ state.keys.push(KeyDeclaration {
+ name: required_qname_attr(node, "name")?,
+ match_pattern: Pattern::new(match_pattern, node, state.workspace())?,
+ use_expression: Expression::new(
+ use_expression,
+ node,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.budget.recursion_depth,
+ state.workspace(),
+ )?,
+ });
+ }
+ "decimal-format" => {
+ // XSLT 1.0 section 12.3 defines xsl:decimal-format as EMPTY.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#format-number
+ require_empty_instruction(node)?;
+ let format = DecimalFormat::parse(node, precedence)?;
+ if let Some(existing) = state.decimal_formats.iter_mut().find(|existing| {
+ existing.name == format.name && existing.precedence == format.precedence
+ }) {
+ existing.merge(format)?;
+ } else {
+ state.decimal_formats.push(format);
+ }
+ }
+ "namespace-alias" => {
+ // XSLT 1.0 section 7.1.1 and its element syntax define this declaration as
+ // EMPTY. Validate before parsing and merging its namespace mapping.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#namespace-alias
+ require_empty_instruction(node)?;
+ merge_namespace_alias(
+ &mut state.namespace_aliases,
+ &mut state.namespace_alias_index,
+ parse_namespace_alias(node, precedence)?,
+ )?;
+ }
+ "attribute-set" => {
+ let order = state.next_order();
+ state.attribute_sets.push(AttributeSet::parse(
+ node,
+ CompileContext::new(
+ forward,
+ depth,
+ state.budget.recursion_depth,
+ base_uri,
+ state.current_stylesheet_document(),
+ state.workspace(),
+ )?,
+ precedence,
+ order,
+ )?)
+ }
+ _unknown if forward => {}
+ unknown => return Err(Error::Static(format!("unknown top-level xsl:{unknown}"))),
+ }
+ Ok(())
+ }
+}
+
+fn clone_compile_string(value: Option<&str>, state: &mut CompileState) -> Result> {
+ let Some(value) = value else {
+ return Ok(None);
+ };
+ state.charge_owned(value.len())?;
+ let mut owned = String::new();
+ if owned.try_reserve_exact(value.len()).is_err() {
+ state.release_owned(value.len());
+ return Err(Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: state.budget.owned_bytes,
+ actual: usize::MAX,
+ });
+ }
+ let capacity = owned.capacity();
+ if capacity > value.len() {
+ if let Err(error) = state.charge_owned(capacity - value.len()) {
+ state.release_owned(value.len());
+ return Err(error);
+ }
+ } else {
+ state.release_owned(value.len() - capacity);
+ }
+ owned.push_str(value);
+ Ok(Some(owned))
+}
+
+fn classify_decoded_workspace(xml: Cow<'_, str>) -> (Cow<'_, str>, usize) {
+ let workspace = match &xml {
+ Cow::Owned(xml) => xml.capacity(),
+ Cow::Borrowed(_) => 0,
+ };
+ (xml, workspace)
+}
+
+fn validate_standard_stylesheet_content(root: roxmltree::Node<'_, '_>) -> Result<()> {
+ // XSLT 1.0 section 2.2 permits only template top-level declarations after stylesheet
+ // whitespace stripping; other character data is a static error.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#stylesheet-element
+ if root
+ .children()
+ .any(|child| child.is_text() && !is_ignorable_stylesheet_text(child))
+ {
+ return Err(Error::Static(
+ "non-whitespace character data is not allowed at stylesheet top level".into(),
+ ));
+ }
+ Ok(())
+}
+
+/// Immutable compiled XSLT stylesheet.
+#[derive(Debug, Clone)]
+pub struct Stylesheet {
+ pub(crate) principal_document: Document,
+ pub(crate) principal_base_uri: Option,
+ pub(crate) module_documents: Arc<[ModuleDocument]>,
+ pub(crate) templates: Arc<[Template]>,
+ pub(crate) named_template_index: Arc>,
+ pub(crate) globals: Arc<[GlobalVariable]>,
+ pub(crate) output: OutputDefinition,
+ pub(crate) whitespace: Arc<[(NameTest, bool, usize, usize)]>,
+ pub(crate) keys: Arc<[KeyDeclaration]>,
+ pub(crate) key_name_indices: Arc<[usize]>,
+ pub(crate) decimal_formats: Arc<[DecimalFormat]>,
+ pub(crate) namespace_aliases: Arc<[NamespaceAlias]>,
+ pub(crate) namespace_alias_index: Arc, usize>>,
+ pub(crate) default_namespace_alias: Option,
+ pub(crate) attribute_sets: Arc<[AttributeSet]>,
+ pub(crate) functions: Arc<[ExsltFunction]>,
+ pub(crate) function_names: Arc>,
+ pub(crate) resource_identities: Arc<[ResourceIdentity]>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) struct StylesheetDocumentId(pub(crate) usize);
+
+impl StylesheetDocumentId {
+ pub(crate) const PRINCIPAL: Self = Self(0);
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ModuleDocument {
+ pub(crate) id: StylesheetDocumentId,
+ pub(crate) document: Document,
+}
+
+impl Stylesheet {
+ #[must_use]
+ pub fn output_definition(&self) -> &OutputDefinition {
+ &self.output
+ }
+ #[must_use]
+ pub fn resource_identities(&self) -> &[ResourceIdentity] {
+ &self.resource_identities
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct Template {
+ pub name: Option,
+ pub pattern: Option,
+ pub mode: Option,
+ pub priority: f64,
+ pub precedence: usize,
+ pub order: usize,
+ pub params: Arc<[Variable]>,
+ pub body: InstructionSequence,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct GlobalVariable {
+ pub variable: Variable,
+ pub precedence: usize,
+ pub order: usize,
+ pub is_parameter: bool,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct ExsltFunction {
+ pub name: ExpandedName,
+ pub params: Vec,
+ pub body: InstructionSequence,
+ pub precedence: usize,
+ pub order: usize,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct Variable {
+ pub name: ExpandedName,
+ pub select: Option,
+ pub content: InstructionSequence,
+ pub base_uri: Option,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct Expression {
+ pub source: String,
+ pub namespaces: Arc>,
+ /// Expanded references let execution initialize only globals on the reached XPath path.
+ pub variable_references: Arc<[ExpandedName]>,
+ /// Static base of the stylesheet module that owns this expression.
+ pub static_base_uri: Option>,
+ /// Stable identity of the stylesheet document containing this expression.
+ pub(crate) stylesheet_document: StylesheetDocumentId,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct Pattern {
+ pub source: String,
+ pub namespaces: Vec<(String, String)>,
+ pub matches_attributes: bool,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct Sort {
+ pub select: Expression,
+ pub data_type: AttributeValueTemplate,
+ pub order: AttributeValueTemplate,
+ pub case_order: Option,
+ pub lang: Option,
+ pub forward_compatible: bool,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct WithParam {
+ pub variable: Variable,
+}
+pub(crate) type InstructionSequence = Arc<[Instruction]>;
+
+#[derive(Debug, Clone)]
+pub(crate) enum Instruction {
+ Text(String, bool),
+ LiteralElement {
+ // XSLT 1.0 section 3.2 assigns a constructed node the base URI of its creating
+ // stylesheet instruction: https://www.w3.org/TR/1999/REC-xslt-19991116#base-uri
+ base_uri: Option,
+ name: ExpandedName,
+ prefix: Option,
+ attributes: Vec,
+ namespaces: Vec,
+ children: InstructionSequence,
+ attribute_sets: Vec,
+ },
+ ApplyTemplates {
+ select: Expression,
+ mode: Option,
+ sorts: Vec,
+ parameters: Vec,
+ },
+ ApplyImports,
+ CallTemplate {
+ name: ExpandedName,
+ parameters: Vec,
+ },
+ ForEach {
+ select: Expression,
+ sorts: Vec,
+ body: InstructionSequence,
+ },
+ If {
+ test: Expression,
+ body: InstructionSequence,
+ },
+ Choose {
+ branches: Vec<(Expression, InstructionSequence)>,
+ otherwise: InstructionSequence,
+ },
+ ValueOf {
+ select: Expression,
+ disable_output_escaping: bool,
+ },
+ CopyOf {
+ select: Expression,
+ base_uri: Option,
+ },
+ Copy {
+ base_uri: Option,
+ body: InstructionSequence,
+ attribute_sets: Vec,
+ },
+ Element {
+ // Computed and literal result elements share the same creating-instruction rule.
+ base_uri: Option,
+ name: AttributeValueTemplate,
+ namespace: Option,
+ namespaces: Vec<(String, String)>,
+ body: InstructionSequence,
+ attribute_sets: Vec,
+ },
+ Attribute {
+ name: AttributeValueTemplate,
+ namespace: Option,
+ namespaces: Vec<(String, String)>,
+ body: InstructionSequence,
+ },
+ Comment(InstructionSequence),
+ Processing {
+ name: AttributeValueTemplate,
+ body: InstructionSequence,
+ },
+ Number(NumberInstruction),
+ Variable(Variable),
+ Message {
+ terminate: bool,
+ body: InstructionSequence,
+ },
+ SecondaryOutput {
+ uri: AttributeValueTemplate,
+ properties: Vec<(String, AttributeValueTemplate)>,
+ body: InstructionSequence,
+ },
+ ExtensionFallback {
+ name: String,
+ present: bool,
+ body: InstructionSequence,
+ },
+ CompatibilityComment(String),
+ FunctionResult {
+ select: Option,
+ content: InstructionSequence,
+ base_uri: Option,
+ },
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct LiteralAttribute {
+ pub name: ExpandedName,
+ pub prefix: Option,
+ pub value: AttributeValueTemplate,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct AttributeValueTemplate(pub Vec);
+#[derive(Debug, Clone)]
+pub(crate) enum AvtPart {
+ Literal(String),
+ Expression(Expression),
+}
+#[derive(Debug, Clone)]
+pub(crate) struct NumberInstruction {
+ pub value: Option,
+ pub count: Option,
+ pub from: Option,
+ pub level: String,
+ pub format: AttributeValueTemplate,
+ pub lang: Option,
+ pub letter_value: Option,
+ pub grouping_separator: Option,
+ pub grouping_size: Option,
+ pub forward_compatible: bool,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct KeyDeclaration {
+ pub name: ExpandedName,
+ pub match_pattern: Pattern,
+ pub use_expression: Expression,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct NamespaceAlias {
+ pub stylesheet_namespace: Option>,
+ pub output_prefix: Option,
+ pub result_namespace: Option,
+ precedence: usize,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct AttributeSet {
+ pub name: ExpandedName,
+ pub uses: Vec,
+ pub attributes: InstructionSequence,
+ pub precedence: usize,
+ pub order: usize,
+}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct DecimalFormat {
+ pub name: Option,
+ pub precedence: usize,
+ pub decimal_separator: char,
+ pub grouping_separator: char,
+ pub infinity: String,
+ pub minus_sign: char,
+ pub nan: String,
+ pub percent: char,
+ pub per_mille: char,
+ pub zero_digit: char,
+ pub digit: char,
+ pub pattern_separator: char,
+ pub(crate) specified: u16,
+}
+#[derive(Debug, Clone)]
+pub(crate) struct NameTest {
+ pub namespace: NamespaceTest,
+ pub local: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum NamespaceTest {
+ Any,
+ None,
+ Exact(String),
+}
+
+impl Expression {
+ fn new(
+ source: &str,
+ node: roxmltree::Node<'_, '_>,
+ static_base_uri: Option<&str>,
+ stylesheet_document: StylesheetDocumentId,
+ max_depth: usize,
+ workspace: CompileWorkspace<'_>,
+ ) -> Result {
+ workspace.retain(namespace_copy_bytes(node, true, workspace)?)?;
+ Self::new_with_namespaces(
+ source,
+ node,
+ Arc::new(namespaces(node)),
+ static_base_uri,
+ stylesheet_document,
+ max_depth,
+ workspace,
+ )
+ }
+
+ fn new_with_namespaces(
+ source: &str,
+ node: roxmltree::Node<'_, '_>,
+ namespaces: Arc>,
+ static_base_uri: Option<&str>,
+ stylesheet_document: StylesheetDocumentId,
+ max_depth: usize,
+ workspace: CompileWorkspace<'_>,
+ ) -> Result {
+ let static_base_uri = effective_base_uri(node, static_base_uri)?.map(Arc::from);
+ Self::new_with_namespaces_and_base(
+ source,
+ namespaces,
+ static_base_uri,
+ stylesheet_document,
+ max_depth,
+ workspace,
+ workspace.pending_source(source, node),
+ )
+ }
+
+ fn new_with_namespaces_and_base(
+ source: &str,
+ namespaces: Arc>,
+ static_base_uri: Option>,
+ stylesheet_document: StylesheetDocumentId,
+ max_depth: usize,
+ workspace: CompileWorkspace<'_>,
+ validation_workspace: CompileWorkspace<'_>,
+ ) -> Result {
+ validate_xpath_prefixes(source, &namespaces, validation_workspace)?;
+ {
+ let (normalized, workspace) = validation_workspace.normalize(source)?;
+ // This AST is only syntax-checked, never evaluated. Logical-document root rewriting
+ // belongs to execution; adding those synthetic steps here wastes memory and depth.
+ let parsed = workspace.parse_xpath(&normalized, source, false)?;
+ ensure(BudgetKind::RecursionDepth, max_depth, parsed.ast_depth())?;
+ }
+ if validation_workspace.occupied == workspace.occupied {
+ workspace.retain(source.len())?;
+ }
+ let variable_references = referenced_variables_bounded(source, &namespaces, workspace)?;
+ Ok(Self {
+ source: source.to_owned(),
+ namespaces,
+ static_base_uri,
+ stylesheet_document,
+ variable_references,
+ })
+ }
+
+ pub(crate) fn derived(&self, source: impl Into) -> Self {
+ Self::from_parts(
+ source.into(),
+ self.namespaces.clone(),
+ self.static_base_uri.clone(),
+ self.stylesheet_document,
+ )
+ }
+
+ pub(crate) fn generated(source: impl Into, namespaces: Vec<(String, String)>) -> Self {
+ Self::from_parts(
+ source.into(),
+ Arc::new(namespaces),
+ None,
+ StylesheetDocumentId::PRINCIPAL,
+ )
+ }
+
+ fn from_parts(
+ source: String,
+ namespaces: Arc>,
+ static_base_uri: Option>,
+ stylesheet_document: StylesheetDocumentId,
+ ) -> Self {
+ let variable_references = referenced_variables(&source, &namespaces).into();
+ Self {
+ source,
+ namespaces,
+ variable_references,
+ static_base_uri,
+ stylesheet_document,
+ }
+ }
+}
+
+fn referenced_variables(source: &str, namespaces: &[(String, String)]) -> Vec {
+ let mut count = 0usize;
+ visit_referenced_variables(source, namespaces, |_, _| count += 1);
+ let mut output = Vec::with_capacity(count);
+ visit_referenced_variables(source, namespaces, |namespace, local| {
+ output.push(ExpandedName::new(namespace, local));
+ });
+ output.sort_unstable_by(|left, right| {
+ left.namespace
+ .cmp(&right.namespace)
+ .then_with(|| left.local.cmp(&right.local))
+ });
+ output.dedup();
+ output
+}
+
+fn visit_referenced_variables<'a>(
+ source: &'a str,
+ namespaces: &'a [(String, String)],
+ mut visit: impl FnMut(Option<&'a str>, &'a str),
+) {
+ let mut quote = None;
+ let mut characters = source.char_indices().peekable();
+ while let Some((_, character)) = characters.next() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ if matches!(character, '\'' | '"') {
+ quote = Some(character);
+ continue;
+ }
+ if character != '$' {
+ continue;
+ }
+ let Some(&(byte_start, _)) = characters.peek() else {
+ break;
+ };
+ let mut byte_end = byte_start;
+ while let Some(&(offset, character)) = characters.peek() {
+ if !crate::lexical::is_ncname_char(character) && character != ':' {
+ break;
+ }
+ byte_end = offset + character.len_utf8();
+ characters.next();
+ }
+ let lexical = &source[byte_start..byte_end];
+ if is_lexical_qname(lexical) {
+ let (prefix, local) = lexical
+ .split_once(':')
+ .map_or((None, lexical), |(prefix, local)| (Some(prefix), local));
+ let namespace = prefix.and_then(|prefix| {
+ namespaces
+ .iter()
+ .find(|(candidate, _)| candidate == prefix)
+ .map(|(_, namespace)| namespace.as_str())
+ });
+ visit(namespace, local);
+ }
+ }
+}
+
+fn referenced_variables_bounded(
+ source: &str,
+ namespaces: &[(String, String)],
+ workspace: CompileWorkspace<'_>,
+) -> Result> {
+ let mut count = 0usize;
+ let mut payload = Some(0usize);
+ visit_referenced_variables(source, namespaces, |namespace, local| {
+ count += 1; // Each reference consumes at least one distinct source byte.
+ payload = payload
+ .and_then(|bytes| bytes.checked_add(local.len()))
+ .and_then(|bytes| bytes.checked_add(namespace.map_or(0, str::len)));
+ });
+ let vector_bytes = count
+ .checked_mul(std::mem::size_of::())
+ .filter(|bytes| *bytes <= isize::MAX as usize)
+ .ok_or_else(|| workspace.overflow())?;
+ let payload = payload.ok_or_else(|| workspace.overflow())?;
+ let bytes = vector_bytes
+ .checked_add(payload)
+ .ok_or_else(|| workspace.overflow())?;
+ workspace.retain(bytes)?;
+ let references = referenced_variables(source, namespaces);
+ let retained_payload = references.iter().fold(0usize, |bytes, name| {
+ bytes + name.local.capacity() + name.namespace.as_ref().map_or(0, String::capacity)
+ });
+ workspace.release(payload - retained_payload);
+ let arc_bytes = references
+ .len()
+ .checked_mul(std::mem::size_of::())
+ .and_then(|bytes| bytes.checked_add(2 * std::mem::size_of::()))
+ .filter(|bytes| *bytes <= isize::MAX as usize)
+ .ok_or_else(|| workspace.overflow())?;
+ // Vec's buffer and the new Arc allocation overlap during conversion; strings move intact.
+ workspace.retain(arc_bytes)?;
+ let references = references.into();
+ workspace.release(vector_bytes);
+ Ok(references)
+}
+
+fn is_lexical_qname(value: &str) -> bool {
+ let mut parts = value.split(':');
+ let Some(first) = parts.next() else {
+ return false;
+ };
+ is_ncname(first) && parts.next().is_none_or(is_ncname) && parts.next().is_none()
+}
+impl Pattern {
+ fn new(
+ source: &str,
+ node: roxmltree::Node<'_, '_>,
+ workspace: CompileWorkspace<'_>,
+ ) -> Result {
+ let validation_workspace = workspace.pending_source(source, node);
+ if trim_xml_whitespace(source).is_empty() {
+ return Err(validation_workspace.static_error(format_args!("empty template pattern")));
+ }
+ if contains_variable_reference(source) {
+ return Err(validation_workspace.static_error(format_args!(
+ "XSLT 1.0 match pattern `{source}` must not contain a variable reference"
+ )));
+ }
+ workspace.retain(namespace_copy_bytes(node, false, workspace)?)?;
+ let namespaces = namespaces(node);
+ for branch in split_pattern_branches(source) {
+ let branch = trim_xml_whitespace(branch);
+ let (normalized, branch_workspace) = validation_workspace.normalize(branch)?;
+ validate_xslt_pattern_branch(&normalized, branch_workspace)?;
+ validate_xpath_prefixes(branch, &namespaces, branch_workspace)?;
+ branch_workspace.parse_xpath(&normalized, branch, true)?;
+ }
+ let matches_attributes = {
+ let (normalized, workspace) = validation_workspace.normalize(source)?;
+ sxd_xpath_no_unsafe::expression_uses_attribute_axis_bounded(
+ &normalized,
+ workspace.remaining(),
+ )
+ .map_err(|error| workspace.parser_error(error, source, true))?
+ };
+ if validation_workspace.occupied == workspace.occupied {
+ workspace.retain(source.len())?;
+ }
+ Ok(Self {
+ source: source.to_owned(),
+ namespaces,
+ matches_attributes,
+ })
+ }
+
+ fn template_branches(
+ source: &str,
+ node: roxmltree::Node<'_, '_>,
+ workspace: CompileWorkspace<'_>,
+ ) -> Result> {
+ let count = split_pattern_branches(source).count();
+ let bytes = count
+ .checked_mul(std::mem::size_of::())
+ .filter(|bytes| *bytes <= isize::MAX as usize)
+ .ok_or_else(|| workspace.overflow())?;
+ workspace.retain(bytes)?;
+ let mut patterns = Vec::with_capacity(count);
+ for branch in split_pattern_branches(source) {
+ patterns.push(Self::new(trim_xml_whitespace(branch), node, workspace)?);
+ }
+ Ok(patterns)
+ }
+ fn default_priority(&self, workspace: CompileWorkspace<'_>) -> Result {
+ let (normalized, _) = workspace.normalize(trim_xml_whitespace(&self.source))?;
+ let value = normalized
+ .strip_prefix("child::")
+ .or_else(|| normalized.strip_prefix("attribute::"))
+ .unwrap_or(&normalized);
+ let value = value.strip_prefix('@').unwrap_or(value);
+ let single_step = !value.contains(['/', '[', '|', '(', ')']);
+ let node_test = pattern_node_test(value);
+ Ok(
+ if value == "*" || node_test == Some(PatternNodeTest::Generic) {
+ -0.5
+ // XSLT 1.0 section 5.5 assigns -0.25 only to a single NCName:* StepPattern;
+ // a LocationPath containing that step has the complex-pattern priority 0.5.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#conflict
+ } else if single_step && value.ends_with(":*") {
+ -0.25
+ } else if single_step
+ || node_test == Some(PatternNodeTest::ProcessingInstructionWithTarget)
+ {
+ 0.0
+ } else {
+ 0.5
+ },
+ )
+ }
+}
+
+fn contains_variable_reference(source: &str) -> bool {
+ let mut quote = None;
+ for character in source.chars() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ } else if matches!(character, '\'' | '"') {
+ quote = Some(character);
+ } else if character == '$' {
+ return true;
+ }
+ }
+ false
+}
+
+fn validate_xpath_prefixes(
+ source: &str,
+ namespaces: &[(String, String)],
+ workspace: CompileWorkspace<'_>,
+) -> Result<()> {
+ let mut characters = source.char_indices().peekable();
+ let mut quote = None;
+ while let Some((start, character)) = characters.next() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ if matches!(character, '\'' | '"') {
+ quote = Some(character);
+ continue;
+ }
+ if !is_ncname_start(character) {
+ continue;
+ }
+ while characters
+ .peek()
+ .is_some_and(|(_, next)| is_ncname_char(*next))
+ {
+ characters.next();
+ }
+ let Some(&(end, ':')) = characters.peek() else {
+ continue;
+ };
+ let mut following = characters.clone();
+ following.next();
+ if !following
+ .next()
+ .is_some_and(|(_, next)| next == '*' || is_ncname_start(next))
+ {
+ continue;
+ }
+ let prefix = &source[start..end];
+ if prefix != "xml" && !namespaces.iter().any(|(declared, _)| declared == prefix) {
+ return Err(workspace.static_error(format_args!(
+ "XPath expression `{source}` uses unbound namespace prefix `{prefix}`"
+ )));
+ }
+ characters.next();
+ }
+ Ok(())
+}
+
+pub(crate) fn normalize_xpath_for_sxd(source: &str) -> Cow<'_, str> {
+ // XPath 1.0 §3.7 defines whitespace as only XML S characters:
+ // https://www.w3.org/TR/1999/REC-xpath-19991116/#exprlex
+ if !source.chars().any(crate::lexical::is_xml_whitespace) && !source.contains('*') {
+ return Cow::Borrowed(source);
+ }
+ let mut length = 0usize;
+ let changed = visit_normalized_xpath(source, |part| length += part.len());
+ if !changed {
+ return Cow::Borrowed(source);
+ }
+ let mut output = String::with_capacity(length);
+ visit_normalized_xpath(source, |part| output.push_str(part));
+ debug_assert_eq!(output.len(), length);
+ Cow::Owned(output)
+}
+
+// The measuring and writing passes share token handling; neither materializes a character array.
+fn visit_normalized_xpath(source: &str, mut emit: impl FnMut(&str)) -> bool {
+ let mut characters = source.char_indices().peekable();
+ let mut quote = None;
+ let mut previous = None;
+ let mut previous_non_whitespace = None;
+ let mut changed = false;
+ while let Some((index, character)) = characters.next() {
+ let literal = &source[index..index + character.len_utf8()];
+ if let Some(active) = quote {
+ emit(literal);
+ previous = Some(character);
+ if !is_xml_whitespace(character) {
+ previous_non_whitespace = Some(character);
+ }
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ if matches!(character, '\'' | '"') {
+ quote = Some(character);
+ emit(literal);
+ previous = Some(character);
+ previous_non_whitespace = Some(character);
+ continue;
+ }
+ if is_xml_whitespace(character)
+ && previous.is_some_and(|character| character == ':' || is_ncname_char(character))
+ {
+ let mut following = characters.clone();
+ while following
+ .peek()
+ .is_some_and(|(_, next)| is_xml_whitespace(*next))
+ {
+ following.next();
+ }
+ let suffix = following
+ .peek()
+ .map_or("", |(offset, _)| &source[*offset..]);
+ if suffix.starts_with('(') || suffix.starts_with("::") {
+ characters = following;
+ changed = true;
+ continue;
+ }
+ }
+ if character == ':' && characters.peek().is_some_and(|(_, next)| *next == ':') {
+ emit("::");
+ previous = Some(':');
+ previous_non_whitespace = Some(':');
+ characters.next();
+ while characters
+ .peek()
+ .is_some_and(|(_, next)| is_xml_whitespace(*next))
+ {
+ characters.next();
+ changed = true;
+ }
+ continue;
+ }
+ if character == '*' && matches!(previous_non_whitespace, Some('(' | ',')) {
+ let next = characters
+ .clone()
+ .find(|(_, next)| !is_xml_whitespace(*next));
+ if matches!(next, Some((_, ')' | ','))) {
+ emit("child::*");
+ previous = Some('*');
+ previous_non_whitespace = Some('*');
+ changed = true;
+ continue;
+ }
+ }
+ emit(literal);
+ previous = Some(character);
+ if !is_xml_whitespace(character) {
+ previous_non_whitespace = Some(character);
+ }
+ }
+ changed
+}
+
+fn split_pattern_branches(source: &str) -> impl Iterator- {
+ split_top_level(source, '|')
+}
+
+// The caller has normalized and reserved the input; the pattern grammar scans borrow it.
+fn validate_xslt_pattern_branch(branch: &str, workspace: CompileWorkspace<'_>) -> Result<()> {
+ if branch == "/" {
+ return Ok(());
+ }
+ let relative = if let Some(relative) = strip_id_key_pattern(branch, workspace)? {
+ if relative.is_empty() {
+ return Ok(());
+ }
+ relative
+ .strip_prefix("//")
+ .or_else(|| relative.strip_prefix('/'))
+ .ok_or_else(|| invalid_match_pattern(branch, workspace))?
+ } else {
+ branch
+ .strip_prefix("//")
+ .or_else(|| branch.strip_prefix('/'))
+ .unwrap_or(branch)
+ };
+ validate_pattern_steps(relative).ok_or_else(|| invalid_match_pattern(branch, workspace))?;
+ Ok(())
+}
+
+fn strip_id_key_pattern<'a>(
+ branch: &'a str,
+ workspace: CompileWorkspace<'_>,
+) -> Result
> {
+ let (name, expected_arguments) = if branch.starts_with("id(") {
+ ("id", 1)
+ } else if branch.starts_with("key(") {
+ ("key", 2)
+ } else {
+ return Ok(None);
+ };
+ let open = name.len();
+ let Some(close) = matching_delimiter(branch, open, '(', ')') else {
+ return Err(invalid_match_pattern(branch, workspace));
+ };
+ let mut count = 0;
+ for argument in split_top_level(&branch[open + 1..close], ',') {
+ count += 1;
+ if count > expected_arguments || !is_xpath_literal(argument) {
+ return Err(invalid_match_pattern(branch, workspace));
+ }
+ }
+ if count != expected_arguments {
+ return Err(invalid_match_pattern(branch, workspace));
+ }
+ Ok(Some(branch[close + 1..].trim_matches(is_xml_whitespace)))
+}
+
+fn validate_pattern_steps(source: &str) -> Option<()> {
+ if trim_xml_whitespace(source).is_empty() {
+ return None;
+ }
+ let mut start = 0usize;
+ let mut brackets = 0usize;
+ let mut parentheses = 0usize;
+ let mut quote = None;
+ let mut characters = source.char_indices().peekable();
+ while let Some((index, character)) = characters.next() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ match character {
+ '\'' | '"' => quote = Some(character),
+ '[' => brackets = brackets.checked_add(1)?,
+ ']' => brackets = brackets.checked_sub(1)?,
+ '(' => parentheses = parentheses.checked_add(1)?,
+ ')' => parentheses = parentheses.checked_sub(1)?,
+ '/' if brackets == 0 && parentheses == 0 => {
+ let step = trim_xml_whitespace(&source[start..index]);
+ if step.is_empty() || !valid_pattern_step(step) {
+ return None;
+ }
+ if characters.peek().is_some_and(|(_, next)| *next == '/') {
+ let (second, _) = characters.next().expect("peeked path separator exists");
+ start = second + 1;
+ } else {
+ start = index + 1;
+ }
+ }
+ _ => {}
+ }
+ }
+ if quote.is_some() || brackets != 0 || parentheses != 0 {
+ return None;
+ }
+ let final_step = trim_xml_whitespace(&source[start..]);
+ if final_step.is_empty() || !valid_pattern_step(final_step) {
+ return None;
+ }
+ Some(())
+}
+
+fn valid_pattern_step(step: &str) -> bool {
+ let predicate_start = first_top_level_character(step, '[').unwrap_or(step.len());
+ let node_test = trim_xml_whitespace(&step[..predicate_start]);
+ if !valid_pattern_node_test(node_test) {
+ return false;
+ }
+ let mut remainder = trim_xml_whitespace(&step[predicate_start..]);
+ while !remainder.is_empty() {
+ if !remainder.starts_with('[') {
+ return false;
+ }
+ let Some(close) = matching_delimiter(remainder, 0, '[', ']') else {
+ return false;
+ };
+ if trim_xml_whitespace(&remainder[1..close]).is_empty() {
+ return false;
+ }
+ remainder = trim_xml_whitespace(&remainder[close + 1..]);
+ }
+ true
+}
+
+fn valid_pattern_node_test(node_test: &str) -> bool {
+ let (node_test, explicit_axis) = match node_test.split_once("::") {
+ Some((axis, test))
+ if matches!(axis.trim_matches(is_xml_whitespace), "child" | "attribute") =>
+ {
+ (test.trim_matches(is_xml_whitespace), true)
+ }
+ Some(_) => return false,
+ None => (node_test, false),
+ };
+ if explicit_axis && node_test.starts_with('@') {
+ return false;
+ }
+ let node_test = strip_xpath_attribute_axis(node_test).unwrap_or(node_test);
+ if node_test == "*" || is_lexical_qname(node_test) {
+ return true;
+ }
+ if node_test.strip_suffix(":*").is_some_and(is_ncname) {
+ return true;
+ }
+ pattern_node_test(node_test).is_some()
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum PatternNodeTest {
+ Generic,
+ ProcessingInstructionWithTarget,
+}
+
+fn pattern_node_test(value: &str) -> Option {
+ // XPath 1.0 section 3.7 permits ExprWhitespace between grammar tokens, including before `)`.
+ // https://www.w3.org/TR/1999/REC-xpath-19991116/#exprlex
+ let open = value.find('(')?;
+ let close = value.strip_suffix(')')?;
+ let name = trim_xml_whitespace(&value[..open]);
+ let argument = trim_xml_whitespace(&close[open + 1..]);
+ match (name, argument) {
+ ("node" | "text" | "comment" | "processing-instruction", "") => {
+ Some(PatternNodeTest::Generic)
+ }
+ ("processing-instruction", argument) if is_xpath_literal(argument) => {
+ Some(PatternNodeTest::ProcessingInstructionWithTarget)
+ }
+ _ => None,
+ }
+}
+
+fn first_top_level_character(source: &str, needle: char) -> Option {
+ let mut parentheses = 0usize;
+ let mut quote = None;
+ for (index, character) in source.char_indices() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ match character {
+ '\'' | '"' => quote = Some(character),
+ '(' => parentheses += 1,
+ ')' => parentheses = parentheses.saturating_sub(1),
+ _ if character == needle && parentheses == 0 => return Some(index),
+ _ => {}
+ }
+ }
+ None
+}
+
+fn matching_delimiter(source: &str, open: usize, left: char, right: char) -> Option {
+ let mut depth = 0usize;
+ let mut quote = None;
+ for (index, character) in source.char_indices().filter(|(index, _)| *index >= open) {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ match character {
+ '\'' | '"' => quote = Some(character),
+ value if value == left => depth += 1,
+ value if value == right => {
+ depth = depth.checked_sub(1)?;
+ if depth == 0 {
+ return Some(index);
+ }
+ }
+ _ => {}
+ }
+ }
+ None
+}
+
+fn split_top_level(source: &str, separator: char) -> impl Iterator- {
+ let mut start = 0usize;
+ let mut depth = 0usize;
+ let mut quote = None;
+ let mut characters = source.char_indices();
+ let mut finished = false;
+ std::iter::from_fn(move || {
+ if finished {
+ return None;
+ }
+ for (index, character) in characters.by_ref() {
+ if let Some(active) = quote {
+ if character == active {
+ quote = None;
+ }
+ continue;
+ }
+ match character {
+ '\'' | '"' => quote = Some(character),
+ '(' | '[' => depth += 1,
+ ')' | ']' => depth = depth.saturating_sub(1),
+ value if value == separator && depth == 0 => {
+ let part = source[start..index].trim_matches(is_xml_whitespace);
+ start = index + character.len_utf8();
+ return Some(part);
+ }
+ _ => {}
+ }
+ }
+ finished = true;
+ Some(source[start..].trim_matches(is_xml_whitespace))
+ })
+}
+
+fn is_xpath_literal(value: &str) -> bool {
+ xpath_string_literal(value).is_some()
+}
+
+fn invalid_match_pattern(pattern: &str, workspace: CompileWorkspace<'_>) -> Error {
+ workspace.static_error(format_args!(
+ "invalid XSLT 1.0 match pattern `{pattern}`: expected a Pattern location path"
+ ))
+}
+
+impl NameTest {
+ pub(crate) fn matches(&self, name: &ExpandedName) -> bool {
+ self.local.as_ref().is_none_or(|local| local == &name.local)
+ && match &self.namespace {
+ NamespaceTest::Any => true,
+ NamespaceTest::None => name.namespace.is_none(),
+ NamespaceTest::Exact(namespace) => {
+ name.namespace.as_deref() == Some(namespace.as_str())
+ }
+ }
+ }
+
+ pub(crate) const fn priority(&self) -> i8 {
+ match (&self.namespace, &self.local) {
+ (NamespaceTest::Any, None) => -2,
+ (NamespaceTest::Exact(_), None) => -1,
+ (_, Some(_)) => 0,
+ (NamespaceTest::None, None) => -2,
+ }
+ }
+}
+
+fn alias_namespace(node: roxmltree::Node<'_, '_>, prefix: &str) -> Result
> {
+ if prefix == "#default" {
+ return Ok(node.lookup_namespace_uri(None).map(str::to_owned));
+ }
+ node.lookup_namespace_uri(Some(prefix))
+ .map(|uri| Some(uri.to_owned()))
+ .ok_or_else(|| Error::Static(format!("namespace-alias prefix {prefix} is not bound")))
+}
+
+fn parse_namespace_alias(
+ node: roxmltree::Node<'_, '_>,
+ precedence: usize,
+) -> Result {
+ let stylesheet_prefix = required_attr(node, "stylesheet-prefix")?;
+ let result_prefix = required_attr(node, "result-prefix")?;
+ let stylesheet_namespace = alias_namespace(node, stylesheet_prefix)?;
+ let result_namespace = alias_namespace(node, result_prefix)?;
+ Ok(NamespaceAlias {
+ stylesheet_namespace: stylesheet_namespace.map(Arc::from),
+ output_prefix: (result_prefix != "#default").then(|| result_prefix.to_owned()),
+ result_namespace,
+ precedence,
+ })
+}
+
+fn merge_namespace_alias(
+ aliases: &mut Vec,
+ index: &mut HashMap>, usize>,
+ incoming: NamespaceAlias,
+) -> Result<()> {
+ let Some(existing_index) = index.get(&incoming.stylesheet_namespace).copied() else {
+ index.insert(incoming.stylesheet_namespace.clone(), aliases.len());
+ aliases.push(incoming);
+ return Ok(());
+ };
+ let existing = &aliases[existing_index];
+ if existing.precedence > incoming.precedence {
+ return Ok(());
+ }
+ if existing.precedence == incoming.precedence {
+ // XSLT 1.0 section 7.1.1 permits recovery from a highest-precedence conflict by choosing
+ // the declaration occurring last, which is the libxslt-compatible behavior.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#literal-result-element
+ aliases[existing_index] = incoming;
+ return Ok(());
+ }
+ aliases[existing_index] = incoming;
+ Ok(())
+}
+
+#[derive(Default)]
+struct OutputPropertyPrecedence {
+ method: Option,
+ version: Option,
+ encoding: Option,
+ omit_xml_declaration: Option,
+ standalone: Option,
+ doctype_public: Option,
+ doctype_system: Option,
+ indent: Option,
+ media_type: Option,
+}
+
+struct ActiveResource {
+ resource: Arc,
+ fragment: Option>,
+}
+
+struct CompileState {
+ budget: CompileBudget,
+ templates: Vec,
+ globals: Vec,
+ output: OutputDefinition,
+ output_precedence: OutputPropertyPrecedence,
+ whitespace: Vec<(NameTest, bool, usize, usize)>,
+ keys: Vec,
+ decimal_formats: Vec,
+ namespace_aliases: Vec,
+ namespace_alias_index: HashMap>, usize>,
+ attribute_sets: Vec,
+ functions: Vec,
+ resources: Vec,
+ active_resources: Vec,
+ resolved_requests: HashMap>,
+ resolved_identities: HashMap,
+ module_sources: HashMap>,
+ module_documents: HashMap,
+ imported_modules: usize,
+ stylesheet_bytes: usize,
+ owned_bytes: usize,
+ xpath_owned_bytes: Cell,
+ precedence: usize,
+ order: usize,
+}
+impl CompileState {
+ fn workspace(&self) -> CompileWorkspace<'_> {
+ CompileWorkspace {
+ limit: self.budget.owned_bytes,
+ occupied: self.owned_bytes,
+ retained: &self.xpath_owned_bytes,
+ }
+ }
+ fn new(mut budget: CompileBudget, stylesheet_bytes: usize) -> Self {
+ budget.recursion_depth = budget.recursion_depth.min(COMPILE_RECURSION_DEPTH_CEILING);
+ Self {
+ budget,
+ templates: vec![],
+ globals: vec![],
+ output: OutputDefinition::default(),
+ output_precedence: OutputPropertyPrecedence::default(),
+ whitespace: vec![],
+ keys: vec![],
+ decimal_formats: vec![],
+ namespace_aliases: vec![],
+ namespace_alias_index: HashMap::new(),
+ attribute_sets: vec![],
+ functions: vec![],
+ resources: vec![],
+ active_resources: vec![],
+ resolved_requests: HashMap::new(),
+ resolved_identities: HashMap::new(),
+ module_sources: HashMap::new(),
+ module_documents: HashMap::new(),
+ imported_modules: 0,
+ stylesheet_bytes,
+ owned_bytes: 0,
+ xpath_owned_bytes: Cell::new(0),
+ precedence: 0,
+ order: 0,
+ }
+ }
+ fn next_precedence(&mut self) -> usize {
+ self.precedence += 1;
+ self.precedence
+ }
+ fn next_order(&mut self) -> usize {
+ self.order += 1;
+ self.order
+ }
+
+ fn current_stylesheet_document(&self) -> StylesheetDocumentId {
+ self.active_resources
+ .last()
+ .map_or(StylesheetDocumentId::PRINCIPAL, |active| {
+ self.resolved_identities[&active.resource.identity].document_id
+ })
+ }
+ fn charge_owned(&mut self, amount: usize) -> Result<()> {
+ let workspace = self.workspace().reserve(amount)?;
+ self.owned_bytes = workspace.occupied;
+ Ok(())
+ }
+ fn remaining_owned_bytes(&self) -> usize {
+ self.workspace().remaining()
+ }
+ fn release_owned(&mut self, amount: usize) {
+ self.owned_bytes = self
+ .owned_bytes
+ .checked_sub(amount)
+ .expect("released compiler workspace was previously charged");
+ }
+ fn push_active_resource(&mut self, resource: ActiveResource) -> Result<()> {
+ if self.active_resources.len() == self.active_resources.capacity() {
+ let old_capacity = self.active_resources.capacity();
+ let target_capacity = old_capacity.saturating_add(old_capacity.max(4));
+ let target_bytes =
+ target_capacity.saturating_mul(std::mem::size_of::());
+ self.charge_owned(target_bytes)?;
+
+ let mut replacement = Vec::new();
+ if let Err(error) = replacement.try_reserve_exact(target_capacity) {
+ self.release_owned(target_bytes);
+ return Err(Error::Static(format!(
+ "failed to reserve active stylesheet module storage: {error}"
+ )));
+ }
+ let actual_bytes = replacement
+ .capacity()
+ .saturating_mul(std::mem::size_of::());
+ if actual_bytes > target_bytes {
+ if let Err(error) = self.charge_owned(actual_bytes - target_bytes) {
+ self.release_owned(target_bytes);
+ return Err(error);
+ }
+ } else {
+ self.release_owned(target_bytes - actual_bytes);
+ }
+
+ replacement.append(&mut self.active_resources);
+ std::mem::swap(&mut self.active_resources, &mut replacement);
+ self.release_owned(
+ replacement
+ .capacity()
+ .saturating_mul(std::mem::size_of::()),
+ );
+ }
+ self.active_resources.push(resource);
+ Ok(())
+ }
+ fn charge_stylesheet(&mut self, amount: usize) -> Result<()> {
+ self.stylesheet_bytes = self
+ .stylesheet_bytes
+ .checked_add(amount)
+ .ok_or(Error::Budget {
+ kind: BudgetKind::StylesheetBytes,
+ limit: self.budget.stylesheet_bytes,
+ actual: usize::MAX,
+ })?;
+ ensure(
+ BudgetKind::StylesheetBytes,
+ self.budget.stylesheet_bytes,
+ self.stylesheet_bytes,
+ )
+ }
+ fn check_stylesheet(&self, amount: usize) -> Result<()> {
+ ensure(
+ BudgetKind::StylesheetBytes,
+ self.budget.stylesheet_bytes,
+ self.stylesheet_bytes.saturating_add(amount),
+ )
+ }
+ fn finish(mut self) -> Result {
+ self.templates
+ .sort_by_key(|template| (template.precedence, template.order));
+ let named_count = self
+ .templates
+ .iter()
+ .filter(|template| template.name.is_some())
+ .count();
+ let named_workspace =
+ named_count.saturating_mul(hash_entry_storage::<(&ExpandedName, usize), ()>());
+ self.charge_owned(named_workspace)?;
+ let mut named = HashSet::new();
+ let named_result = (|| {
+ for template in self
+ .templates
+ .iter()
+ .filter(|template| template.name.is_some())
+ {
+ if !named.insert((template.name.as_ref(), template.precedence)) {
+ return Err(Error::Static(
+ "duplicate named template at equal import precedence".into(),
+ ));
+ }
+ }
+ Ok(())
+ })();
+ drop(named);
+ self.release_owned(named_workspace);
+ named_result?;
+ let named_index_bytes = self.templates.iter().fold(0usize, |total, template| {
+ total.saturating_add(template.name.as_ref().map_or(0, |name| {
+ hash_entry_storage::()
+ .saturating_add(name.local.len())
+ .saturating_add(name.namespace.as_ref().map_or(0, String::len))
+ }))
+ });
+ self.charge_owned(named_index_bytes)?;
+ let mut named_template_index: HashMap =
+ HashMap::with_capacity(named_count);
+ for (index, template) in self.templates.iter().enumerate() {
+ let Some(name) = &template.name else {
+ continue;
+ };
+ match named_template_index.entry(name.clone()) {
+ std::collections::hash_map::Entry::Occupied(mut entry) => {
+ let selected = &self.templates[*entry.get()];
+ if (template.precedence, template.order) > (selected.precedence, selected.order)
+ {
+ entry.insert(index);
+ }
+ }
+ std::collections::hash_map::Entry::Vacant(entry) => {
+ entry.insert(index);
+ }
+ }
+ }
+ let actual_named_index_bytes = named_template_index.iter().fold(
+ named_template_index
+ .len()
+ .saturating_mul(hash_entry_storage::()),
+ |total, (name, _)| {
+ total
+ .saturating_add(name.local.len())
+ .saturating_add(name.namespace.as_ref().map_or(0, String::len))
+ },
+ );
+ self.release_owned(named_index_bytes.saturating_sub(actual_named_index_bytes));
+ let key_name_workspace = self
+ .keys
+ .len()
+ .saturating_mul(hash_entry_storage::<&ExpandedName, ()>());
+ let key_name_index_upper = self.keys.len().saturating_mul(std::mem::size_of::());
+ self.charge_owned(key_name_workspace.saturating_add(key_name_index_upper))?;
+ let mut key_names = HashSet::with_capacity(self.keys.len());
+ let key_name_indices = self
+ .keys
+ .iter()
+ .enumerate()
+ .filter_map(|(index, declaration)| key_names.insert(&declaration.name).then_some(index))
+ .collect::>();
+ drop(key_names);
+ self.release_owned(key_name_workspace);
+ let key_name_index_bytes = key_name_indices
+ .capacity()
+ .saturating_mul(std::mem::size_of::());
+ self.release_owned(key_name_index_upper.saturating_sub(key_name_index_bytes));
+ let global_validation_workspace = self
+ .globals
+ .len()
+ .saturating_mul(hash_entry_storage::<(&ExpandedName, usize), ()>());
+ self.charge_owned(global_validation_workspace)?;
+ let mut globals = HashSet::with_capacity(self.globals.len());
+ let global_validation = (|| {
+ for global in &self.globals {
+ if !globals.insert((&global.variable.name, global.precedence)) {
+ return Err(Error::Static(format!(
+ "duplicate global variable {} at equal import precedence",
+ global.variable.name.local
+ )));
+ }
+ }
+ Ok(())
+ })();
+ drop(globals);
+ self.release_owned(global_validation_workspace);
+ global_validation?;
+ let function_validation_workspace = self
+ .functions
+ .len()
+ .saturating_mul(hash_entry_storage::<(&ExpandedName, usize), ()>());
+ self.charge_owned(function_validation_workspace)?;
+ let mut functions = HashSet::with_capacity(self.functions.len());
+ let function_validation = (|| {
+ for function in &self.functions {
+ if !functions.insert((&function.name, function.precedence)) {
+ return Err(Error::Static(format!(
+ "duplicate EXSLT function {} at equal import precedence",
+ function.name.local
+ )));
+ }
+ }
+ Ok(())
+ })();
+ drop(functions);
+ self.release_owned(function_validation_workspace);
+ function_validation?;
+ let function_index_bytes = self.functions.iter().fold(
+ self.functions
+ .len()
+ .saturating_mul(hash_entry_storage::()),
+ |total, function| {
+ total
+ .saturating_add(function.name.local.len())
+ .saturating_add(function.name.namespace.as_ref().map_or(0, String::len))
+ },
+ );
+ self.charge_owned(function_index_bytes)?;
+ let function_names = self
+ .functions
+ .iter()
+ .map(|function| function.name.clone())
+ .collect::>();
+ let named_alias_count = self
+ .namespace_aliases
+ .iter()
+ .filter(|alias| alias.stylesheet_namespace.is_some())
+ .count();
+ self.charge_owned(
+ named_alias_count.saturating_mul(hash_entry_storage::, usize>()),
+ )?;
+ let mut namespace_alias_index = HashMap::with_capacity(named_alias_count);
+ let mut default_namespace_alias = None;
+ for (index, alias) in self.namespace_aliases.iter().enumerate() {
+ if let Some(namespace) = &alias.stylesheet_namespace {
+ namespace_alias_index.insert(Arc::clone(namespace), index);
+ } else {
+ default_namespace_alias = Some(index);
+ }
+ }
+ validate_attribute_set_references(
+ &self.attribute_sets,
+ &self.templates,
+ &self.globals,
+ &self.functions,
+ )?;
+ Ok(Stylesheet {
+ principal_document: Document::empty(None),
+ principal_base_uri: None,
+ module_documents: self
+ .module_documents
+ .into_values()
+ .collect::>()
+ .into(),
+ templates: self.templates.into(),
+ named_template_index: Arc::new(named_template_index),
+ globals: self.globals.into(),
+ output: self.output,
+ whitespace: self.whitespace.into(),
+ keys: self.keys.into(),
+ key_name_indices: key_name_indices.into(),
+ decimal_formats: self.decimal_formats.into(),
+ namespace_aliases: self.namespace_aliases.into(),
+ namespace_alias_index: Arc::new(namespace_alias_index),
+ default_namespace_alias,
+ attribute_sets: self.attribute_sets.into(),
+ functions: self.functions.into(),
+ function_names: Arc::new(function_names),
+ resource_identities: self.resources.into(),
+ })
+ }
+}
+
+fn validate_attribute_set_references(
+ sets: &[AttributeSet],
+ templates: &[Template],
+ globals: &[GlobalVariable],
+ functions: &[ExsltFunction],
+) -> Result<()> {
+ let declarations = sets.iter().map(|set| &set.name).collect::>();
+ let validate_name = |name: &ExpandedName| {
+ if declarations.contains(name) {
+ Ok(())
+ } else {
+ Err(Error::Static(format!(
+ "undefined attribute-set {}",
+ name.local
+ )))
+ }
+ };
+
+ for set in sets {
+ for name in &set.uses {
+ validate_name(name)?;
+ }
+ validate_attribute_sets_in_sequence(&set.attributes, &validate_name)?;
+ }
+ validate_attribute_set_cycles(sets)?;
+ for template in templates {
+ for parameter in template.params.iter() {
+ validate_attribute_sets_in_sequence(¶meter.content, &validate_name)?;
+ }
+ validate_attribute_sets_in_sequence(&template.body, &validate_name)?;
+ }
+ for global in globals {
+ validate_attribute_sets_in_sequence(&global.variable.content, &validate_name)?;
+ }
+ for function in functions {
+ for parameter in &function.params {
+ validate_attribute_sets_in_sequence(¶meter.content, &validate_name)?;
+ }
+ validate_attribute_sets_in_sequence(&function.body, &validate_name)?;
+ }
+ Ok(())
+}
+
+fn validate_attribute_set_cycles(sets: &[AttributeSet]) -> Result<()> {
+ let mut indices = HashMap::<&ExpandedName, usize>::new();
+ for set in sets {
+ let next = indices.len();
+ indices.entry(&set.name).or_insert(next);
+ }
+ let mut edges = vec![Vec::new(); indices.len()];
+ let mut incoming = vec![0usize; indices.len()];
+ for set in sets {
+ let source = indices[&set.name];
+ for used in &set.uses {
+ let target = indices[used];
+ edges[source].push(target);
+ incoming[target] = incoming[target].saturating_add(1);
+ }
+ }
+ let mut ready = incoming
+ .iter()
+ .enumerate()
+ .filter_map(|(index, count)| (*count == 0).then_some(index))
+ .collect::>();
+ let mut visited = 0usize;
+ while let Some(source) = ready.pop_front() {
+ visited += 1;
+ for &target in &edges[source] {
+ incoming[target] -= 1;
+ if incoming[target] == 0 {
+ ready.push_back(target);
+ }
+ }
+ }
+ if visited != indices.len() {
+ // XSLT 1.0 section 7.1.4 makes direct and indirect self-use erroneous even when the
+ // attribute set is never reached at runtime.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#attribute-sets
+ return Err(Error::Static("attribute-set cycle".into()));
+ }
+ Ok(())
+}
+
+fn validate_attribute_sets_in_sequence(
+ instructions: &[Instruction],
+ validate_name: &impl Fn(&ExpandedName) -> Result<()>,
+) -> Result<()> {
+ for instruction in instructions {
+ match instruction {
+ Instruction::LiteralElement {
+ children,
+ attribute_sets,
+ ..
+ }
+ | Instruction::Copy {
+ body: children,
+ attribute_sets,
+ ..
+ }
+ | Instruction::Element {
+ body: children,
+ attribute_sets,
+ ..
+ } => {
+ for name in attribute_sets {
+ validate_name(name)?;
+ }
+ validate_attribute_sets_in_sequence(children, validate_name)?;
+ }
+ Instruction::ApplyTemplates { parameters, .. }
+ | Instruction::CallTemplate { parameters, .. } => {
+ for parameter in parameters {
+ validate_attribute_sets_in_sequence(
+ ¶meter.variable.content,
+ validate_name,
+ )?;
+ }
+ }
+ Instruction::ForEach { body, .. }
+ | Instruction::If { body, .. }
+ | Instruction::Attribute { body, .. }
+ | Instruction::Processing { body, .. }
+ | Instruction::Message { body, .. }
+ | Instruction::SecondaryOutput { body, .. }
+ | Instruction::ExtensionFallback { body, .. } => {
+ validate_attribute_sets_in_sequence(body, validate_name)?;
+ }
+ Instruction::Choose {
+ branches,
+ otherwise,
+ } => {
+ for (_, branch) in branches {
+ validate_attribute_sets_in_sequence(branch, validate_name)?;
+ }
+ validate_attribute_sets_in_sequence(otherwise, validate_name)?;
+ }
+ Instruction::Comment(body) => {
+ validate_attribute_sets_in_sequence(body, validate_name)?;
+ }
+ Instruction::Variable(variable) => {
+ validate_attribute_sets_in_sequence(&variable.content, validate_name)?;
+ }
+ Instruction::FunctionResult { content, .. } => {
+ validate_attribute_sets_in_sequence(content, validate_name)?;
+ }
+ Instruction::Text(..)
+ | Instruction::ApplyImports
+ | Instruction::ValueOf { .. }
+ | Instruction::CopyOf { .. }
+ | Instruction::Number(_)
+ | Instruction::CompatibilityComment(_) => {}
+ }
+ }
+ Ok(())
+}
+
+fn estimate_compiled_owned_bytes(
+ document: &roxmltree::Document<'_>,
+ module_base_uri: Option<&str>,
+) -> usize {
+ document.descendants().fold(0usize, |total, node| {
+ // Compilation retains both a normalized semantic node and, conservatively, one IR
+ // instruction for each frontend node. Child IDs, attributes, and namespaces live in
+ // separately allocated containers and therefore need explicit structural accounting.
+ let structural_bytes = std::mem::size_of::()
+ .saturating_add(std::mem::size_of::())
+ .saturating_add(std::mem::size_of::());
+ let node_bytes = if node.is_element() {
+ let tag = node.tag_name();
+ let name_bytes = tag
+ .namespace()
+ .map_or(0, str::len)
+ .saturating_add(tag.name().len());
+ let attribute_bytes = node.attributes().fold(0usize, |sum, attribute| {
+ sum.saturating_add(std::mem::size_of::())
+ .saturating_add(attribute.namespace().map_or(0, str::len))
+ .saturating_add(attribute.name().len())
+ .saturating_add(attribute.value().len())
+ });
+ let namespace_bytes = node.namespaces().fold(0usize, |sum, namespace| {
+ sum.saturating_add(std::mem::size_of::())
+ .saturating_add(namespace.name().map_or(0, str::len))
+ .saturating_add(namespace.uri().len())
+ });
+ let expression_count = node.attributes().fold(0usize, |count, attribute| {
+ count.saturating_add(
+ 1usize.saturating_add(
+ attribute
+ .value()
+ .bytes()
+ .filter(|byte| *byte == b'{')
+ .count(),
+ ),
+ )
+ });
+ // XPath namespace snapshots are charged at their actual construction sites, not
+ // once per element: patterns copy them and expressions may share them through Arc.
+ // One shared Arc is retained by all expressions compiled from this node. URL
+ // serialization may percent-encode every byte, so three times the lexical inputs is
+ // a conservative pre-allocation bound that requires no unmetered URI construction.
+ let expression_base_uri_bytes = if expression_count != 0 {
+ node.ancestors()
+ .filter(roxmltree::Node::is_element)
+ .filter_map(|ancestor| {
+ ancestor.attribute(("http://www.w3.org/XML/1998/namespace", "base"))
+ })
+ .map(str::len)
+ .fold(module_base_uri.map_or(0, str::len), usize::saturating_add)
+ .saturating_mul(3)
+ } else {
+ 0
+ };
+ structural_bytes
+ .saturating_add(name_bytes)
+ .saturating_add(attribute_bytes)
+ .saturating_add(namespace_bytes)
+ .saturating_add(expression_base_uri_bytes)
+ } else {
+ structural_bytes.saturating_add(node.text().map_or(0, str::len))
+ };
+ total.saturating_add(node_bytes)
+ })
+}
+
+fn with_frontend_document(
+ xml: &str,
+ state: &mut CompileState,
+ consume: impl FnOnce(&roxmltree::Document<'_>, &mut CompileState) -> Result,
+) -> Result {
+ let lexical_reserved = parser_workspace_bytes(xml);
+ state.charge_owned(lexical_reserved)?;
+ let prepared = match prepare_xml_frontend_bounded(
+ xml,
+ state.remaining_owned_bytes(),
+ state.budget.recursion_depth,
+ ) {
+ Ok(prepared) => prepared,
+ Err(error) => {
+ state.release_owned(lexical_reserved);
+ return Err(error);
+ }
+ };
+ let expanded_owned_bytes = match &prepared {
+ Cow::Borrowed(_) => 0,
+ Cow::Owned(expanded) => expanded.capacity(),
+ };
+ if let Err(error) = state.charge_owned(expanded_owned_bytes) {
+ state.release_owned(lexical_reserved);
+ return Err(error);
+ }
+ let expanded_workspace = parser_workspace_bytes(prepared.as_ref());
+ let additional_workspace = expanded_workspace.saturating_sub(lexical_reserved);
+ if let Err(error) = state.charge_owned(additional_workspace) {
+ state.release_owned(expanded_owned_bytes);
+ state.release_owned(lexical_reserved);
+ return Err(error);
+ }
+ let result = roxmltree::Document::parse(prepared.as_ref())
+ .map_err(|error| Error::Xml(error.to_string()))
+ .and_then(|document| consume(&document, state));
+ state.release_owned(additional_workspace);
+ state.release_owned(expanded_owned_bytes);
+ state.release_owned(lexical_reserved);
+ result
+}
+
+fn with_compiler_document(
+ xml: &str,
+ base_uri: Option<&str>,
+ state: &mut CompileState,
+ compile: impl FnOnce(&roxmltree::Document<'_>, &mut CompileState) -> Result,
+) -> Result {
+ with_frontend_document(xml, state, |document, state| {
+ // Each include occurrence retains distinct IR even when its source is cached.
+ state.charge_owned(estimate_compiled_owned_bytes(document, base_uri))?;
+ compile(document, state)
+ })
+}
+
+// Module IR is precharged; XPath-owned namespace/reference copies are charged when constructed.
+// All snapshots share that retained counter, while lexical parser reservations are scoped and
+// reusable. No XPath object retains a borrow of this compile-only accounting state.
+#[derive(Debug, Clone, Copy)]
+struct CompileWorkspace<'a> {
+ limit: usize,
+ occupied: usize,
+ retained: &'a Cell,
+}
+
+impl CompileWorkspace<'_> {
+ fn overflow(self) -> Error {
+ Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: self.limit,
+ actual: usize::MAX,
+ }
+ }
+
+ fn reserve(self, bytes: usize) -> Result {
+ let occupied = self
+ .occupied
+ .checked_add(bytes)
+ .ok_or_else(|| self.overflow())?;
+ let actual = occupied
+ .checked_add(self.retained.get())
+ .ok_or_else(|| self.overflow())?;
+ ensure(BudgetKind::OwnedBytes, self.limit, actual)?;
+ Ok(Self { occupied, ..self })
+ }
+
+ fn retain(self, bytes: usize) -> Result<()> {
+ self.reserve(bytes)?;
+ self.retained.set(self.retained.get() + bytes);
+ Ok(())
+ }
+
+ fn release(self, bytes: usize) {
+ self.retained.set(
+ self.retained
+ .get()
+ .checked_sub(bytes)
+ .expect("released XPath storage was reserved"),
+ );
+ }
+
+ fn remaining(self) -> usize {
+ self.limit - self.occupied - self.retained.get()
+ }
+
+ fn pending_source(self, source: &str, node: roxmltree::Node<'_, '_>) -> Self {
+ // Module IR precharges attribute values. Only a borrowed slice of this node's attribute
+ // may reuse its own future source allocation; generated defaults receive no credit.
+ let start = source.as_ptr() as usize;
+ let belongs_to_attribute = node.attributes().any(|attribute| {
+ let value = attribute.value();
+ let base = value.as_ptr() as usize;
+ start >= base
+ && start - base <= value.len()
+ && source.len() <= value.len() - (start - base)
+ });
+ if belongs_to_attribute {
+ Self {
+ occupied: self
+ .occupied
+ .checked_sub(source.len())
+ .expect("attribute source was precharged by module compilation"),
+ ..self
+ }
+ } else {
+ self
+ }
+ }
+
+ fn static_error(self, arguments: std::fmt::Arguments<'_>) -> Error {
+ use std::fmt::Write;
+ struct Length(usize);
+ impl std::fmt::Write for Length {
+ fn write_str(&mut self, value: &str) -> std::fmt::Result {
+ self.0 = self.0.checked_add(value.len()).ok_or(std::fmt::Error)?;
+ Ok(())
+ }
+ }
+ let mut length = Length(0);
+ if length.write_fmt(arguments).is_err() || length.0 > isize::MAX as usize {
+ return self.overflow();
+ }
+ if let Err(error) = self.reserve(length.0) {
+ return error;
+ }
+ let mut message = String::with_capacity(length.0);
+ message
+ .write_fmt(arguments)
+ .expect("formatting to String cannot fail");
+ debug_assert_eq!(message.len(), length.0);
+ Error::Static(message)
+ }
+
+ fn normalize(self, source: &str) -> Result<(Cow<'_, str>, Self)> {
+ let mut length = Some(0usize);
+ let changed = visit_normalized_xpath(source, |part| {
+ length = length.and_then(|length| length.checked_add(part.len()));
+ });
+ if !changed {
+ return Ok((Cow::Borrowed(source), self));
+ }
+ let length = length
+ .filter(|length| *length <= isize::MAX as usize)
+ .ok_or_else(|| self.overflow())?;
+ let workspace = self.reserve(length)?;
+ let mut normalized = String::with_capacity(length);
+ visit_normalized_xpath(source, |part| normalized.push_str(part));
+ Ok((Cow::Owned(normalized), workspace))
+ }
+
+ fn parse_xpath(
+ self,
+ normalized: &str,
+ source: &str,
+ pattern: bool,
+ ) -> Result {
+ sxd_xpath_no_unsafe::Factory::new()
+ .build_bounded(normalized, self.remaining())
+ .map_err(|error| self.parser_error(error, source, pattern))
+ }
+
+ fn parser_error(
+ self,
+ error: sxd_xpath_no_unsafe::ParserError,
+ source: &str,
+ pattern: bool,
+ ) -> Error {
+ if let Some((_, actual)) = error.allocation_limit() {
+ return Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: self.limit,
+ actual: self
+ .occupied
+ .saturating_add(self.retained.get())
+ .saturating_add(actual),
+ };
+ }
+ let kind = if pattern {
+ "match pattern"
+ } else {
+ "XPath expression"
+ };
+ match self.reserve(error.owned_bytes()) {
+ Ok(workspace) => {
+ workspace.static_error(format_args!("invalid {kind} `{source}`: {error}"))
+ }
+ Err(error) => error,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct ResolveRequest {
+ href: String,
+ base_uri: Option,
+ purpose: ResolvePurpose,
+}
+
+struct ResolvedModule<'input> {
+ resource: Arc,
+ fragment: Option<&'input str>,
+}
+
+struct ResolvedIdentity {
+ resource: Arc,
+ document_id: StylesheetDocumentId,
+}
+
+fn hash_entry_storage() -> usize {
+ // Hash tables retain control bytes and spare buckets. Two entry widths conservatively model
+ // the standard maximum load without depending on the allocator implementation.
+ std::mem::size_of::<(K, V)>().saturating_mul(2)
+}
+
+fn resolve_request_retained_bytes(href: &str, base_uri: Option<&str>) -> usize {
+ hash_entry_storage::>()
+ .saturating_add(href.len())
+ .saturating_add(base_uri.map_or(0, str::len))
+}
+
+fn resolved_resource_owned_bytes(resource: &ResolvedResource) -> usize {
+ std::mem::size_of::()
+ .saturating_add(std::mem::size_of::().saturating_mul(2))
+ .saturating_add(resource.canonical_uri.capacity())
+ .saturating_add(resource.identity.0.capacity())
+ .saturating_add(resource.bytes.capacity())
+ .saturating_add(resource.media_type.as_ref().map_or(0, String::capacity))
+ .saturating_add(resource.encoding.as_ref().map_or(0, String::capacity))
+}
+
+fn new_resolved_identity_retained_bytes(resource: &ResolvedResource) -> usize {
+ resolved_resource_owned_bytes(resource)
+ .saturating_add(
+ hash_entry_storage::()
+ .saturating_add(resource.identity.0.len()),
+ )
+ .saturating_add(
+ std::mem::size_of::()
+ .saturating_mul(2)
+ .saturating_add(resource.identity.0.len()),
+ )
+ .saturating_add(std::mem::size_of::>().saturating_mul(2))
+}
+
+fn module_document_cache_entry_bytes() -> usize {
+ hash_entry_storage::()
+}
+
+fn module_source_cache_entry_bytes(identity: &ResourceIdentity) -> usize {
+ hash_entry_storage::>()
+ .saturating_add(identity.0.len())
+ .saturating_add(std::mem::size_of::())
+ .saturating_add(std::mem::size_of::().saturating_mul(2))
+}
+
+#[derive(Debug, Clone)]
+struct CompileContext<'a> {
+ forward: bool,
+ depth: usize,
+ max_depth: usize,
+ workspace: CompileWorkspace<'a>,
+ inside_function: bool,
+ static_base_uri: Option>,
+ stylesheet_document: StylesheetDocumentId,
+ namespace_snapshot: NamespaceSnapshot,
+ base_uri_snapshot: BaseUriSnapshot,
+ local_bindings: LocalBindingIndex,
+}
+
+type NamespaceSnapshot = Rc>)>>>;
+type BaseUriSnapshot = Rc>)>>>;
+type LocalBindingIndex = Rc>>>;
+
+impl<'a> CompileContext<'a> {
+ fn new(
+ forward: bool,
+ depth: usize,
+ max_depth: usize,
+ static_base_uri: Option<&str>,
+ stylesheet_document: StylesheetDocumentId,
+ workspace: CompileWorkspace<'a>,
+ ) -> Result {
+ ensure(BudgetKind::RecursionDepth, max_depth, depth)?;
+ Ok(Self {
+ forward,
+ depth,
+ max_depth,
+ workspace,
+ inside_function: false,
+ static_base_uri: static_base_uri.map(Arc::from),
+ stylesheet_document,
+ namespace_snapshot: Rc::new(RefCell::new(None)),
+ base_uri_snapshot: Rc::new(RefCell::new(None)),
+ local_bindings: Rc::new(RefCell::new(HashMap::new())),
+ })
+ }
+
+ fn descend(&self) -> Result {
+ let depth = self.depth.saturating_add(1);
+ ensure(BudgetKind::RecursionDepth, self.max_depth, depth)?;
+ let mut descended = self.clone();
+ descended.depth = depth;
+ Ok(descended)
+ }
+
+ fn inside_function(mut self) -> Self {
+ self.inside_function = true;
+ self
+ }
+
+ fn expression(&self, source: &str, node: roxmltree::Node<'_, '_>) -> Result {
+ let mut snapshot = self.namespace_snapshot.borrow_mut();
+ let cached = snapshot
+ .as_ref()
+ .filter(|(id, _)| *id == node.id())
+ .and_then(|(_, namespaces)| namespaces.upgrade());
+ let namespaces = if let Some(namespaces) = cached {
+ namespaces
+ } else {
+ self.workspace
+ .retain(namespace_copy_bytes(node, true, self.workspace)?)?;
+ let namespaces = Arc::new(namespaces(node));
+ *snapshot = Some((node.id(), Arc::downgrade(&namespaces)));
+ namespaces
+ };
+ let mut base_snapshot = self.base_uri_snapshot.borrow_mut();
+ let static_base_uri = if let Some((id, base_uri)) = base_snapshot.as_ref()
+ && *id == node.id()
+ {
+ base_uri.clone()
+ } else {
+ let base_uri =
+ effective_base_uri(node, self.static_base_uri.as_deref())?.map(Arc::from);
+ *base_snapshot = Some((node.id(), base_uri.clone()));
+ base_uri
+ };
+ Expression::new_with_namespaces_and_base(
+ source,
+ namespaces,
+ static_base_uri,
+ self.stylesheet_document,
+ self.max_depth,
+ self.workspace,
+ self.workspace.pending_source(source, node),
+ )
+ }
+
+ fn has_visible_local_binding(
+ &self,
+ node: roxmltree::Node<'_, '_>,
+ name: &ExpandedName,
+ ) -> bool {
+ let bindings = self.local_bindings.borrow();
+ let mut cursor = node;
+ while let Some(parent) = cursor.parent_element() {
+ if bindings
+ .get(&parent.id())
+ .is_some_and(|names| names.contains(name))
+ {
+ return true;
+ }
+ cursor = parent;
+ }
+ false
+ }
+
+ fn register_local_binding(&self, node: roxmltree::Node<'_, '_>, name: ExpandedName) {
+ let Some(parent) = node.parent_element() else {
+ return;
+ };
+ self.local_bindings
+ .borrow_mut()
+ .entry(parent.id())
+ .or_default()
+ .insert(name);
+ }
+
+ fn with_literal_version(mut self, node: roxmltree::Node<'_, '_>) -> Result {
+ if let Some(version) = node.attribute((XSLT_NS, "version")) {
+ self.forward = stylesheet_version_is_forward_compatible(version)?;
+ }
+ Ok(self)
+ }
+}
+
+fn resource_source(resource: &ResolvedResource, state: &mut CompileState) -> Result> {
+ if let Some(source) = state.module_sources.get(&resource.identity) {
+ return Ok(Arc::clone(source));
+ }
+ let cache_entry_bytes = module_source_cache_entry_bytes(&resource.identity);
+ state.charge_owned(cache_entry_bytes)?;
+ let remaining_stylesheet = state
+ .budget
+ .stylesheet_bytes
+ .saturating_sub(state.stylesheet_bytes);
+ let remaining_owned = state.remaining_owned_bytes();
+ let maximum = remaining_stylesheet.min(remaining_owned);
+ let decoded =
+ match decode_resource(&resource.bytes, resource.encoding.as_deref(), true, maximum) {
+ Ok(decoded) => decoded,
+ Err(error) => {
+ state.release_owned(cache_entry_bytes);
+ return Err(match error {
+ xml_sec_xml_input::Error::DecodedLimit { actual, .. }
+ if remaining_stylesheet <= remaining_owned =>
+ {
+ Error::Budget {
+ kind: BudgetKind::StylesheetBytes,
+ limit: state.budget.stylesheet_bytes,
+ actual: state.stylesheet_bytes.saturating_add(actual),
+ }
+ }
+ xml_sec_xml_input::Error::DecodedLimit { actual, .. } => Error::Budget {
+ kind: BudgetKind::OwnedBytes,
+ limit: state.budget.owned_bytes,
+ actual: state
+ .owned_bytes
+ .saturating_add(state.xpath_owned_bytes.get())
+ .saturating_add(actual),
+ },
+ error => Error::Xml(error.to_string()),
+ });
+ }
+ };
+ state.charge_stylesheet(decoded.len())?;
+ state.charge_owned(decoded.capacity())?;
+ let source = Arc::new(decoded);
+ state
+ .module_sources
+ .insert(resource.identity.clone(), Arc::clone(&source));
+ Ok(source)
+}
+
+fn parse_semantic_document_metered(
+ xml: &str,
+ base_uri: Option<&str>,
+ state: &mut CompileState,
+) -> Result {
+ let projected_bytes = with_frontend_document(xml, state, |parsed, _state| {
+ Ok(xml
+ .len()
+ .saturating_add(estimate_compiled_owned_bytes(parsed, base_uri))
+ .saturating_add(
+ base_uri
+ .map_or(0, str::len)
+ .saturating_mul(parsed.descendants().count()),
+ ))
+ })?;
+ state.charge_owned(projected_bytes)?;
+ let parser_workspace = parser_workspace_bytes(xml);
+ state.charge_owned(parser_workspace)?;
+ let document = Document::parse(xml, base_uri);
+ state.release_owned(parser_workspace);
+ document
+}
+
+enum StylesheetModuleKind {
+ Standard { forward: bool },
+ Simplified,
+}
+
+fn stylesheet_module_kind(root: roxmltree::Node<'_, '_>) -> Result {
+ if root.tag_name().namespace() == Some(XSLT_NS) {
+ if matches!(root.tag_name().name(), "stylesheet" | "transform") {
+ return Ok(StylesheetModuleKind::Standard {
+ forward: module_forward_compatible(root)?,
+ });
+ }
+ // XSLT 1.0 section 2.3 permits only a literal result element as a simplified
+ // stylesheet; XSLT-namespace elements are instructions, not literal results.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#result-element-stylesheet
+ return Err(Error::Static(format!(
+ "xsl:{} cannot be the document element of a stylesheet",
+ root.tag_name().name()
+ )));
+ }
+ root.attribute((XSLT_NS, "version"))
+ .ok_or_else(|| Error::Static("literal result stylesheet requires xsl:version".into()))
+ .and_then(parse_stylesheet_version)?;
+ Ok(StylesheetModuleKind::Simplified)
+}
+
+fn stylesheet_module_root<'nodes, 'input>(
+ document: &'nodes roxmltree::Document<'input>,
+ fragment: Option<&str>,
+) -> Result> {
+ let Some(raw_fragment) = fragment else {
+ return Ok(document.root_element());
+ };
+ // XSLT 1.0 sections 2.6 and 2.7 allow a URI reference to identify an embedded
+ // xsl:stylesheet by its ID. Module selection is per reference, not per fetched resource.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#embedded
+ let fragment = ValidatedXPointerFragment::new(raw_fragment)?;
+ let mut selected = None;
+ for node in document.descendants().filter(roxmltree::Node::is_element) {
+ let unqualified_match = node
+ .attribute("id")
+ .is_some_and(|candidate| fragment.equals(candidate));
+ let xml_id_match = node
+ .attribute((XML_NS, "id"))
+ .map(normalized_xml_id)
+ .transpose()?
+ .is_some_and(|value| fragment.equals(&value));
+ if !unqualified_match && !xml_id_match {
+ continue;
+ }
+ if selected.replace(node).is_some() {
+ return Err(Error::Static(format!(
+ "stylesheet module fragment #{raw_fragment} is not unique"
+ )));
+ }
+ }
+ let selected = selected.ok_or_else(|| {
+ Error::Static(format!(
+ "stylesheet module fragment #{raw_fragment} does not identify an element"
+ ))
+ })?;
+ if selected.tag_name().namespace() != Some(XSLT_NS)
+ || !matches!(selected.tag_name().name(), "stylesheet" | "transform")
+ {
+ return Err(Error::Static(format!(
+ "stylesheet module fragment #{raw_fragment} does not identify xsl:stylesheet"
+ )));
+ }
+ Ok(selected)
+}
+
+fn module_forward_compatible(root: roxmltree::Node<'_, '_>) -> Result {
+ match root.attribute("version") {
+ Some(version) => stylesheet_version_is_forward_compatible(version),
+ None => Err(Error::Static("xsl:stylesheet requires version".into())),
+ }
+}
+
+fn stylesheet_version_is_forward_compatible(version: &str) -> Result {
+ // XSLT 1.0 sections 2.2 and 2.5 enable forwards-compatible processing whenever
+ // the version Number is not equal to 1.0, including values below 1.0.
+ // https://www.w3.org/TR/1999/REC-xslt-19991116#forwards
+ Ok(parse_stylesheet_version(version)? != 1.0)
+}
+
+fn parse_stylesheet_version(version: &str) -> Result {
+ // XSLT 1.0 sections 2.2 and 2.3 define version as XPath's Number production;
+ // host float syntax is wider: https://www.w3.org/TR/1999/REC-xpath-19991116/#exprlex
+ crate::xpath::parse_xpath_number_token(version)
+ .filter(|value| value.is_finite())
+ .ok_or_else(|| Error::Static(format!("unsupported XSLT version {version}")))
+}
+
+fn effective_base_uri(
+ node: roxmltree::Node<'_, '_>,
+ module_base: Option<&str>,
+) -> Result