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 [![crates.io](https://img.shields.io/crates/v/xml-sec.svg)](https://crates.io/crates/xml-sec) [![docs.rs](https://docs.rs/xml-sec/badge.svg)](https://docs.rs/xml-sec) @@ -6,170 +6,88 @@ [![MSRV](https://img.shields.io/badge/rustc-1.92%2B-blue.svg)](https://www.rust-lang.org) [![License](https://img.shields.io/crates/l/xml-sec.svg)](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 `') + }) + .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, "")?; + 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