From bf6d1d0696b7ccf8317a02d9ee17dd4a6ecbd5a4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 29 Sep 2026 03:19:41 +0300 Subject: [PATCH 1/4] fix(release): publish only xml-sec --- .../scripts/check-support-crate-versions.sh | 71 - .../test-check-support-crate-versions.sh | 48 - .github/workflows/ci.yml | 14 - .github/workflows/release.yml | 21 - .release-plz.toml | 4 + Cargo.toml | 36 +- README.md | 13 +- crates/xml-sec-xml-input/Cargo.toml | 1 + crates/xml-sec-xml-input/src/lib.rs | 1213 +--------------- crates/xml-sec-xslt/Cargo.toml | 1 + src/document.rs | 1 + src/encoding.rs | 1 + src/lib.rs | 33 + .../src => src/sxd_document}/dom.rs | 0 .../src => src/sxd_document}/dom_no_unsafe.rs | 0 .../src => src/sxd_document}/lazy_hash_map.rs | 0 .../src => src/sxd_document}/lib.rs | 16 +- .../src => src/sxd_document}/parser.rs | 8 +- .../src => src/sxd_document}/raw.rs | 0 .../src => src/sxd_document}/raw_no_unsafe.rs | 0 .../src => src/sxd_document}/str.rs | 0 .../src => src/sxd_document}/str_ext.rs | 0 .../src => src/sxd_document}/string_pool.rs | 0 .../sxd_document}/string_pool_no_unsafe.rs | 0 .../src => src/sxd_document}/thindom.rs | 0 .../sxd_document}/thindom_no_unsafe.rs | 0 .../src => src/sxd_document}/writer.rs | 0 .../sxd_document}/writer_no_unsafe.rs | 22 +- .../src => src/sxd_xpath}/axis.rs | 2 + .../src => src/sxd_xpath}/context.rs | 17 +- .../src => src/sxd_xpath}/expression.rs | 2 + .../src => src/sxd_xpath}/function.rs | 4 + .../src => src/sxd_xpath}/lib.rs | 30 +- .../src => src/sxd_xpath}/macros.rs | 3 + .../src => src/sxd_xpath}/node_test.rs | 2 + .../src => src/sxd_xpath}/nodeset.rs | 4 + .../src => src/sxd_xpath}/parser.rs | 2 + .../src => src/sxd_xpath}/token.rs | 0 .../src => src/sxd_xpath}/tokenizer.rs | 2 + src/xml/dom/preflight.rs | 1 + .../src => src/xml_input}/lexical.rs | 1 + src/xml_input/shared.rs | 1223 +++++++++++++++++ src/xmldsig/builder.rs | 1 + src/xmldsig/mutation.rs | 1 + src/xmldsig/xpath.rs | 2 + src/xmlenc/encrypt.rs | 1 + tools/xmlsec1/src/commands.rs | 1 + vendor/sxd-document-no-unsafe/Cargo.toml | 6 +- vendor/sxd-xpath-no-unsafe/Cargo.toml | 6 +- 49 files changed, 1409 insertions(+), 1405 deletions(-) delete mode 100644 .github/scripts/check-support-crate-versions.sh delete mode 100644 .github/scripts/test-check-support-crate-versions.sh rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/dom.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/dom_no_unsafe.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/lazy_hash_map.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/lib.rs (96%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/parser.rs (99%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/raw.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/raw_no_unsafe.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/str.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/str_ext.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/string_pool.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/string_pool_no_unsafe.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/thindom.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/thindom_no_unsafe.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/writer.rs (100%) rename {vendor/sxd-document-no-unsafe/src => src/sxd_document}/writer_no_unsafe.rs (98%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/axis.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/context.rs (97%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/expression.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/function.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/lib.rs (98%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/macros.rs (91%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/node_test.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/nodeset.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/parser.rs (99%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/token.rs (100%) rename {vendor/sxd-xpath-no-unsafe/src => src/sxd_xpath}/tokenizer.rs (99%) rename {crates/xml-sec-xml-input/src => src/xml_input}/lexical.rs (99%) create mode 100644 src/xml_input/shared.rs diff --git a/.github/scripts/check-support-crate-versions.sh b/.github/scripts/check-support-crate-versions.sh deleted file mode 100644 index 0116e553..00000000 --- a/.github/scripts/check-support-crate-versions.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -base="${1:?base commit or tag is required}" -git rev-parse --verify "${base}^{commit}" >/dev/null - -for manifest in \ - vendor/sxd-document-no-unsafe/Cargo.toml \ - vendor/sxd-xpath-no-unsafe/Cargo.toml \ - crates/xml-sec-xml-input/Cargo.toml \ - crates/xml-sec-xslt/Cargo.toml; do - directory="${manifest%/Cargo.toml}" - if ! git cat-file -e "${base}:${manifest}" 2>/dev/null; then - continue # A crate added by this change has no prior version to compare. - fi - if git diff --quiet "${base}" HEAD -- "${directory}"; then - continue - fi - old_version="$(git show "${base}:${manifest}" | awk -F '"' '/^version = "/ { print $2; exit }')" - new_version="$(awk -F '"' '/^version = "/ { print $2; exit }' "${manifest}")" - if [[ -z "${old_version}" || -z "${new_version}" ]]; then - echo "cannot determine support-crate version in ${manifest}" >&2 - exit 1 - fi - if ! jq -ne --arg old "$old_version" --arg new "$new_version" ' - def parsed: - capture("^(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$")
-      | {core: [.major, .minor, .patch] | map(tonumber), pre: (.pre // "" | if . == "" then [] else split(".") end)};
-    def prerelease_lt($a; $b):
-      if ($a | length) == 0 then false
-      elif ($b | length) == 0 then true
-      else
-        (reduce range(0; [($a | length), ($b | length)] | min) as $i
-          (0; if . != 0 then . else
-            ($a[$i] | test("^(0|[1-9][0-9]*)$")) as $an
-            | ($b[$i] | test("^(0|[1-9][0-9]*)$")) as $bn
-            | if $a[$i] == $b[$i] then 0
-              elif $an and $bn then (($a[$i] | tonumber) < ($b[$i] | tonumber) | if . then -1 else 1 end)
-              elif $an then -1 elif $bn then 1
-              elif $a[$i] < $b[$i] then -1 elif $a[$i] > $b[$i] then 1 else 0 end
-          end)) as $order
-        | if $order == 0 then ($a | length) < ($b | length) else $order < 0 end
-      end;
-    try (($old | parsed) as $a | ($new | parsed) as $b |
-      if $a.core == $b.core then prerelease_lt($a.pre; $b.pre)
-      else $a.core < $b.core end) catch false
-  ' >/dev/null; then
-    echo "${directory} changed without a higher version (${old_version} -> ${new_version}); crates.io versions are immutable" >&2
-    exit 1
-  fi
-done
-
-# A bumped support crate must also become the consumer's minimum registry version.
-# Otherwise an existing Cargo.lock can continue selecting the older published code.
-metadata="$(cargo metadata --no-deps --format-version 1 --offline)"
-while IFS=$'\t' read -r consumer dependency requirement; do
-  version="$(jq -r --arg name "$dependency" '.packages[] | select(.name == $name) | .version' <<< "$metadata")"
-  if [[ -z "$version" || "$requirement" != "^${version}" ]]; then
-    echo "${consumer} requires ${dependency} ${requirement}; expected ^${version} for the workspace support crate" >&2
-    exit 1
-  fi
-done < <(
-  jq -r '
-    .packages[] as $consumer
-    | $consumer.dependencies[]
-    | select(.path != null)
-    | select(.name == "xml-sec-sxd-document" or .name == "xml-sec-sxd-xpath" or .name == "xml-sec-xml-input" or .name == "xml-sec-xslt")
-    | [$consumer.name, .name, .req] | @tsv
-  ' <<< "$metadata"
-)
diff --git a/.github/scripts/test-check-support-crate-versions.sh b/.github/scripts/test-check-support-crate-versions.sh
deleted file mode 100644
index 720a150b..00000000
--- a/.github/scripts/test-check-support-crate-versions.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-support-crate-versions.sh"
-fixture="$(mktemp -d)"
-trap 'rm -rf "$fixture"' EXIT
-git -C "$fixture" init -q
-git -C "$fixture" config user.name "Test"
-git -C "$fixture" config user.email "test@example.invalid"
-mkdir -p "$fixture/crates/xml-sec-xslt/src" "$fixture/consumer/src"
-printf '[workspace]\nmembers = ["crates/xml-sec-xslt", "consumer"]\nresolver = "2"\n' > "$fixture/Cargo.toml"
-printf '[package]\nname = "xml-sec-xslt"\nversion = "0.1.0"\n' > "$fixture/crates/xml-sec-xslt/Cargo.toml"
-printf 'pub fn version() {}\n' > "$fixture/crates/xml-sec-xslt/src/lib.rs"
-printf '[package]\nname = "consumer"\nversion = "0.1.0"\n[dependencies]\nxml-sec-xslt = { version = "0.1.0", path = "../crates/xml-sec-xslt" }\n' > "$fixture/consumer/Cargo.toml"
-printf 'pub fn consumer() {}\n' > "$fixture/consumer/src/lib.rs"
-git -C "$fixture" add .
-git -C "$fixture" commit -qm initial
-base="$(git -C "$fixture" rev-parse HEAD)"
-
-printf 'pub fn changed() {}\n' > "$fixture/crates/xml-sec-xslt/src/lib.rs"
-git -C "$fixture" add .
-git -C "$fixture" commit -qm changed
-if (cd "$fixture" && bash "$script" "$base"); then
-  echo "changed crate without a version bump was accepted" >&2
-  exit 1
-fi
-
-printf '[package]\nname = "xml-sec-xslt"\nversion = "0.0.9"\n' > "$fixture/crates/xml-sec-xslt/Cargo.toml"
-printf '[package]\nname = "consumer"\nversion = "0.1.0"\n[dependencies]\nxml-sec-xslt = { version = "0.0.9", path = "../crates/xml-sec-xslt" }\n' > "$fixture/consumer/Cargo.toml"
-git -C "$fixture" add .
-git -C "$fixture" commit -qm downgraded
-if (cd "$fixture" && bash "$script" "$base"); then
-  echo "support-crate version downgrade was accepted" >&2
-  exit 1
-fi
-
-printf '[package]\nname = "xml-sec-xslt"\nversion = "0.1.1"\n' > "$fixture/crates/xml-sec-xslt/Cargo.toml"
-git -C "$fixture" add .
-git -C "$fixture" commit -qm bumped
-if (cd "$fixture" && bash "$script" "$base"); then
-  echo "consumer depending on the old support version was accepted" >&2
-  exit 1
-fi
-printf '[package]\nname = "consumer"\nversion = "0.1.0"\n[dependencies]\nxml-sec-xslt = { version = "0.1.1", path = "../crates/xml-sec-xslt" }\n' > "$fixture/consumer/Cargo.toml"
-git -C "$fixture" add .
-git -C "$fixture" commit -qm dependency
-(cd "$fixture" && bash "$script" "$base")
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1bae1ceb..3c5c92c1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,20 +16,6 @@ env:
   LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/xmlsec1-1.3.13/lib
 
 jobs:
-  support-crate-versions:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v7
-        with:
-          fetch-depth: 0
-          persist-credentials: false
-      - name: Verify support-crate version gate
-        run: bash .github/scripts/test-check-support-crate-versions.sh
-      - name: Require version bumps for changed support crates
-        env:
-          BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
-        run: bash .github/scripts/check-support-crate-versions.sh "$BASE_SHA"
-
   capability-ledger:
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aa1b7730..e023869b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,29 +17,8 @@ jobs:
         with:
           fetch-depth: 0
       - uses: dtolnay/rust-toolchain@stable
-      - name: Check support-crate versions against the prior release
-        run: |
-          previous="$(git describe --tags --match 'v*' --abbrev=0 HEAD^)"
-          bash .github/scripts/check-support-crate-versions.sh "$previous"
       - 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/.release-plz.toml b/.release-plz.toml
index bb4fd30f..838c7994 100644
--- a/.release-plz.toml
+++ b/.release-plz.toml
@@ -1,5 +1,8 @@
 [workspace]
+release = false
 semver_check = false
+# Unlike the single-crate coordinode workspace, older tags lack current internal members.
+# git_only reconstructs those tags and fails before it can select the release package.
 git_release_enable = false
 git_tag_enable = false
 changelog_update = true
@@ -10,6 +13,7 @@ release_commits = "^(feat|fix|perf|refactor|docs)(\\([^)]+\\))?!?:"
 
 [[package]]
 name = "xml-sec"
+release = true
 git_release_enable = true
 git_tag_enable = true
 git_tag_name = "v{{ version }}"
diff --git a/Cargo.toml b/Cargo.toml
index 39c00fbd..d3ddeb62 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,19 @@ documentation = "https://docs.rs/xml-sec"
 keywords = ["xml", "xmldsig", "xmlenc", "c14n", "saml"]
 categories = ["cryptography", "web-programming", "authentication"]
 readme = "README.md"
+include = [
+    "/Cargo.toml",
+    "/Cargo.lock",
+    "/LICENSE",
+    "/README.md",
+    "/docs/**",
+    "/src/**",
+    "/examples/**",
+    "/tests/**",
+    "/tools/xmlsec1/**",
+    "/compatibility/**",
+    "/scripts/**",
+]
 
 [workspace]
 members = [
@@ -64,7 +77,10 @@ required-features = ["xmlenc"]
 roxmltree = { version = "0.21", features = ["positions"], optional = true }
 xmloxide = { version = "0.5", default-features = false, optional = true }
 self_cell = "1.3"
-xml-sec-xml-input = { version = "0.1.0", path = "crates/xml-sec-xml-input" }
+encoding_rs = { version = "0.8", default-features = false, features = ["alloc"] }
+xmlparser = { version = "0.13.6", default-features = false }
+peresil = { version = "0.3", optional = true }
+snafu = { version = "0.9", optional = true }
 
 # Crypto
 rsa = { package = "sad-rsa", version = "0.10.2", features = ["sha1", "sha2"], optional = true }
@@ -81,8 +97,6 @@ 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 = { 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.1", 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 }
@@ -115,7 +129,8 @@ time = "0.3.55"
 tempfile = "3"
 
 [features]
-default = ["xmldsig", "xmlenc", "c14n", "xml-backend-xmloxide"]
+default = ["std", "xmldsig", "xmlenc", "c14n", "xml-backend-xmloxide"]
+std = ["thiserror/std", "xmlparser/std"]
 xml-backend-xmloxide = ["dep:xmloxide"]
 xml-backend-roxmltree = ["dep:roxmltree"]
 xml-backends-all = ["xml-backend-xmloxide", "xml-backend-roxmltree"]
@@ -123,6 +138,9 @@ xml-backends-all = ["xml-backend-xmloxide", "xml-backend-roxmltree"]
 # this also selects differential parsing as the default runtime mode.
 xml-backend-differential = ["xml-backends-all"]
 xmldsig = [            # XML Digital Signatures (sign + verify)
+    "std",
+    "no-unsafe",
+    "embedded",
     "dep:der",
     "dep:crypto-bigint",
     "dep:dsa",
@@ -132,6 +150,7 @@ xmldsig = [            # XML Digital Signatures (sign + verify)
     "dep:hmac",
     "dep:md-5",
     "dep:pem",
+    "dep:peresil",
     "dep:p256",
     "dep:p384",
     "dep:p521",
@@ -140,9 +159,8 @@ xmldsig = [            # XML Digital Signatures (sign + verify)
     "dep:sha1",
     "dep:sha2",
     "dep:signature",
+    "dep:snafu",
     "dep:subtle",
-    "dep:sxd-document-no-unsafe",
-    "dep:sxd-xpath-no-unsafe",
     "dep:x509-parser",
     "dep:x509-cert",
     "dep:x520-stringprep",
@@ -150,6 +168,7 @@ xmldsig = [            # XML Digital Signatures (sign + verify)
     "dep:zeroize",
 ]
 xmlenc = [             # XML Encryption (encrypt + decrypt)
+    "std",
     "dep:aes",
     "dep:aes-gcm",
     "dep:aes-kw",
@@ -160,3 +179,8 @@ xmlenc = [             # XML Encryption (encrypt + decrypt)
     "dep:sha2",
 ]
 c14n = []              # XML Canonicalization (inclusive + exclusive)
+no-unsafe = []
+embedded = []
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("raw-pointer-backend", "__internal_expose_string_pool"))'] }
diff --git a/README.md b/README.md
index a7c9e5ee..98631023 100644
--- a/README.md
+++ b/README.md
@@ -142,7 +142,7 @@ ambiguous BOM-less UTF-16/UTF-32, and unsupported EBCDIC variants fail explicitl
 
 **API migration for this pre-release change:** `encoding::decode_xml_octets` now requires a
 maximum decoded-byte count as its second argument. `encoding::XmlEncodingError` is now the
-shared, non-exhaustive `xml-sec-xml-input::Error`; update matches to handle the new error
+shared, non-exhaustive `xml_sec::encoding::XmlEncodingError`; update matches to handle the new error
 variants and include a fallback arm. Code constructing `ResourcePolicy` with every field must
 also set `max_xml_namespace_bindings` (or start from `ResourcePolicy::default()` and override
 selected fields). These changes keep decoding and namespace-scope allocation under explicit
@@ -184,14 +184,11 @@ access, XInclude processing, and operation time explicit. The default grants no
 clock access; callers may supply a fixed clock for reproducible EXSLT date functions or explicitly
 request host-clock compatibility.
 
-```sh
-cargo add xml-sec-xslt
-```
-
-The engine remains a separate architectural boundary. The main crate continues to reject XMLDSig
+The engine currently remains an in-repository workspace crate rather than a separately published
+package; `xml-sec` is the only crate published to crates.io. 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.
+The shared strict byte-decoding and lexical source is compiled into `xml-sec` and reused by the
+internal XSLT workspace crate.
 
 ## Specifications
 
diff --git a/crates/xml-sec-xml-input/Cargo.toml b/crates/xml-sec-xml-input/Cargo.toml
index f9ae2417..d67c250e 100644
--- a/crates/xml-sec-xml-input/Cargo.toml
+++ b/crates/xml-sec-xml-input/Cargo.toml
@@ -1,6 +1,7 @@
 [package]
 name = "xml-sec-xml-input"
 version = "0.1.0"
+publish = false
 edition = "2024"
 rust-version = "1.92"
 license = "Apache-2.0"
diff --git a/crates/xml-sec-xml-input/src/lib.rs b/crates/xml-sec-xml-input/src/lib.rs
index ca8a1b8a..038a49ac 100644
--- a/crates/xml-sec-xml-input/src/lib.rs
+++ b/crates/xml-sec-xml-input/src/lib.rs
@@ -1,1218 +1,11 @@
 //! 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;
+#[path = "../../../src/xml_input/shared.rs"]
+mod shared;
 
-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> {
-    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, true)
-}
-
-/// Detect XML-media-type text encoding without changing the included text's declaration.
-///
-/// XInclude `parse="text"` uses XML encoding detection for XML media types, but includes the
-/// decoded characters as text rather than reparsing or rewriting an XML declaration.
-pub fn decode_xml_text_bounded<'a>(
-    bytes: &'a [u8],
-    explicit_encoding: Option<&str>,
-    maximum_decoded_bytes: usize,
-) -> Result, Error> {
-    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, false)
-}
-
-fn decode_xml_bounded_inner<'a>(
-    bytes: &'a [u8],
-    explicit_encoding: Option<&str>,
-    maximum_decoded_bytes: usize,
-    normalize_declaration: bool,
-) -> 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 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 !encodings_compatible(selected, declared, false) {
-                return Err(Error::ConflictingEncoding(label.into()));
-            }
-        }
-    }
-
-    if normalize_declaration
-        && !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)?;
-    let bytes = match selected {
-        // XInclude 1.0 section 4.3 retains an initial U+FEFF for explicit-endian text;
-        // only generic UTF-16 treats it as a BOM. The opposite-order signature is invalid.
-        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
-        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16LE => {
-            if bytes.starts_with(&[0xFE, 0xFF]) {
-                return Err(Error::ConflictingEncoding(selected.name().into()));
-            }
-            bytes
-        }
-        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16BE => {
-            if bytes.starts_with(&[0xFF, 0xFE]) {
-                return Err(Error::ConflictingEncoding(selected.name().into()));
-            }
-            bytes
-        }
-        // XInclude 1.0 section 4.3 uses this signature to identify UTF-8, not as included text.
-        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
-        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8 => {
-            bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)
-        }
-        _ => bytes,
-    };
-    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_accepts_a_matching_byte_order_mark() {
-        // RFC 2781 sections 4.1 and 4.2 require a matching signature to be consumed without
-        // affecting deserialization. https://www.rfc-editor.org/rfc/rfc2781#section-4.1
-        let mut metadata = vec![0xFF, 0xFE];
-        metadata.extend("".encode_utf16().flat_map(u16::to_le_bytes));
-        assert_eq!(decode_xml(&metadata, Some("UTF-16LE")).unwrap(), "");
-        assert_eq!(
-            decode_text(&metadata, "UTF-16LE").unwrap(),
-            "\u{feff}"
-        );
-
-        let mut big_endian_text = vec![0xFE, 0xFF];
-        big_endian_text.extend("body".encode_utf16().flat_map(u16::to_be_bytes));
-        assert_eq!(
-            decode_text(&big_endian_text, "UTF-16BE").unwrap(),
-            "\u{feff}body"
-        );
-
-        let mut declaration = vec![0xFE, 0xFF];
-        declaration.extend(
-            ""
-                .encode_utf16()
-                .flat_map(u16::to_be_bytes),
-        );
-        assert_eq!(
-            decode_xml(&declaration, None).unwrap(),
-            ""
-        );
-    }
-
-    #[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 utf8_text_consumes_its_signature_without_removing_content_fe_ff() {
-        // XInclude 1.0 section 4.3 treats the BOM as an encoding signature, not included text.
-        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
-        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "UTF-8").unwrap(), "body");
-        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "utf8").unwrap(), "body");
-        assert_eq!(
-            decode_text(b"body\xef\xbb\xbftail", "UTF-8").unwrap(),
-            "body\u{feff}tail"
-        );
-        assert_eq!(decode_text(b"body", "UTF-8").unwrap(), "body");
-    }
-
-    #[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"))
-        ));
-    }
-}
+pub use shared::*;
diff --git a/crates/xml-sec-xslt/Cargo.toml b/crates/xml-sec-xslt/Cargo.toml
index 76f8df5e..7d9fcb90 100644
--- a/crates/xml-sec-xslt/Cargo.toml
+++ b/crates/xml-sec-xslt/Cargo.toml
@@ -1,6 +1,7 @@
 [package]
 name = "xml-sec-xslt"
 version = "0.1.1"
+publish = false
 edition = "2024"
 rust-version = "1.92"
 license = "Apache-2.0"
diff --git a/src/document.rs b/src/document.rs
index 13ea407f..e560b0e6 100644
--- a/src/document.rs
+++ b/src/document.rs
@@ -11,6 +11,7 @@ use std::collections::{HashMap, HashSet, hash_map::Entry};
 use std::sync::atomic::{AtomicU64, Ordering};
 
 use crate::xml::dom::{Document, Node, NodeId, ParseError, ParsingOptions, XmlBackend};
+use crate::xml_input as xml_sec_xml_input;
 use self_cell::self_cell;
 
 use crate::IdAttributeRegistration;
diff --git a/src/encoding.rs b/src/encoding.rs
index e3072cc5..6607ce8d 100644
--- a/src/encoding.rs
+++ b/src/encoding.rs
@@ -1,5 +1,6 @@
 //! XML byte-encoding detection shared by process and transform boundaries.
 
+use crate::xml_input as xml_sec_xml_input;
 use std::borrow::Cow;
 
 /// Shared decoder errors, including unsupported encodings and decoded-size limits.
diff --git a/src/lib.rs b/src/lib.rs
index e8c0da9f..80ae9104 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,6 +30,11 @@
 #![deny(clippy::unwrap_used)]
 #![warn(missing_docs)]
 
+extern crate alloc;
+#[cfg(feature = "xmldsig")]
+#[macro_use]
+extern crate peresil;
+
 #[cfg(not(any(feature = "xml-backend-xmloxide", feature = "xml-backend-roxmltree")))]
 compile_error!(
     "compile at least one XML backend: `xml-backend-xmloxide` or `xml-backend-roxmltree`"
@@ -40,6 +45,34 @@ pub mod document;
 pub mod encoding;
 pub mod error;
 mod hard_limits;
+#[cfg(feature = "xmldsig")]
+// The same sources also build as standalone crates for the XSLT workspace member.
+// Only their XPath-facing surface is used by this package.
+#[doc(hidden)]
+#[allow(dead_code, unused_imports, missing_docs)]
+#[cfg_attr(test, allow(clippy::unwrap_used))]
+#[path = "sxd_document/lib.rs"]
+mod sxd_document;
+#[cfg(feature = "xmldsig")]
+#[doc(hidden)]
+#[allow(dead_code, unused_imports, missing_docs)]
+#[cfg_attr(test, allow(clippy::unwrap_used))]
+#[path = "sxd_xpath/lib.rs"]
+mod sxd_xpath;
+/// Shared XML byte-decoding and lexical processing primitives.
+#[path = "xml_input/shared.rs"]
+pub mod xml_input;
+#[cfg(all(test, feature = "xmldsig"))]
+pub(crate) use sxd_document::{Package, QName, dom};
+#[cfg(feature = "xmldsig")]
+pub(crate) use sxd_document::{StorageRequirements, XML_NS_PREFIX, XML_NS_URI, str, string_pool};
+#[cfg(all(test, feature = "xmldsig"))]
+pub(crate) use sxd_xpath::{Context, Factory};
+#[cfg(feature = "xmldsig")]
+pub(crate) use sxd_xpath::{
+    LiteralValue, OwnedPrefixedName, OwnedQName, ParseBudget, Value, axis, context, expression,
+    function, node_test, node_to_num_with_context, nodeset, parser, str_to_num, token, tokenizer,
+};
 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
 mod operation;
 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
diff --git a/vendor/sxd-document-no-unsafe/src/dom.rs b/src/sxd_document/dom.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/dom.rs
rename to src/sxd_document/dom.rs
diff --git a/vendor/sxd-document-no-unsafe/src/dom_no_unsafe.rs b/src/sxd_document/dom_no_unsafe.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/dom_no_unsafe.rs
rename to src/sxd_document/dom_no_unsafe.rs
diff --git a/vendor/sxd-document-no-unsafe/src/lazy_hash_map.rs b/src/sxd_document/lazy_hash_map.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/lazy_hash_map.rs
rename to src/sxd_document/lazy_hash_map.rs
diff --git a/vendor/sxd-document-no-unsafe/src/lib.rs b/src/sxd_document/lib.rs
similarity index 96%
rename from vendor/sxd-document-no-unsafe/src/lib.rs
rename to src/sxd_document/lib.rs
index 5769df41..28402b8b 100644
--- a/vendor/sxd-document-no-unsafe/src/lib.rs
+++ b/src/sxd_document/lib.rs
@@ -1,5 +1,7 @@
 //!
 //! ```
+//! # #[cfg(not(feature = "embedded"))]
+//! # {
 //! use sxd_document_no_unsafe::Package;
 //! let package = Package::new();
 //! let doc = package.as_document();
@@ -12,6 +14,7 @@
 //! hello.append_child(comment);
 //! hello.append_child(text);
 //! doc.root().append_child(hello);
+//! # }
 //! ```
 //!
 //! ### Memory and ownership
@@ -56,6 +59,7 @@ compile_error!("select either `no-unsafe` or `raw-pointer-backend`");
 // Cargo's all-feature verification enables both selectors. Safe precedence keeps that profile
 // free of raw pointers; selecting the legacy backend requires disabling default features.
 
+#[cfg(not(feature = "embedded"))]
 #[macro_use]
 extern crate peresil;
 
@@ -141,7 +145,7 @@ pub fn estimated_storage_bytes(requirements: StorageRequirements) -> usize {
 }
 
 mod lazy_hash_map;
-mod str;
+pub(crate) mod str;
 mod str_ext;
 
 #[cfg(not(feature = "no-unsafe"))]
@@ -149,7 +153,7 @@ pub mod dom;
 #[cfg(not(feature = "no-unsafe"))]
 mod raw;
 #[cfg(not(feature = "no-unsafe"))]
-mod string_pool;
+pub(crate) mod string_pool;
 #[cfg(not(feature = "no-unsafe"))]
 #[doc(hidden)]
 pub mod thindom;
@@ -161,7 +165,7 @@ pub mod writer;
 mod raw;
 #[cfg(feature = "no-unsafe")]
 #[path = "string_pool_no_unsafe.rs"]
-mod string_pool;
+pub(crate) mod string_pool;
 #[cfg(feature = "no-unsafe")]
 pub use string_pool::InternedString;
 #[cfg(feature = "no-unsafe")]
@@ -190,6 +194,8 @@ pub mod __internal {
 }
 
 pub use crate::str::XmlChar;
+#[cfg(feature = "embedded")]
+pub use crate::{as_opt_str, as_qname, as_str, to_ns_str};
 
 #[cfg(not(feature = "no-unsafe"))]
 #[macro_export]
@@ -267,8 +273,8 @@ pub type NsStr<'d> = &'d str;
 /// signature; owned storage does not borrow from `'d` in this mode.
 pub type NsStr<'d> = String;
 
-static XML_NS_PREFIX: &str = "xml";
-static XML_NS_URI: &str = "http://www.w3.org/XML/1998/namespace";
+pub(crate) static XML_NS_PREFIX: &str = "xml";
+pub(crate) static XML_NS_URI: &str = "http://www.w3.org/XML/1998/namespace";
 
 /// A prefixed name. This represents what is found in the string form
 /// of an XML document, and does not apply any namespace mapping.
diff --git a/vendor/sxd-document-no-unsafe/src/parser.rs b/src/sxd_document/parser.rs
similarity index 99%
rename from vendor/sxd-document-no-unsafe/src/parser.rs
rename to src/sxd_document/parser.rs
index 55bee8eb..d91fb147 100644
--- a/vendor/sxd-document-no-unsafe/src/parser.rs
+++ b/src/sxd_document/parser.rs
@@ -3,6 +3,8 @@
 //! ### Example
 //!
 //! ```
+//! # #[cfg(not(feature = "embedded"))]
+//! # {
 //! use sxd_document_no_unsafe::parser;
 //! let xml = r#"
 //! 
@@ -12,6 +14,7 @@
 //!   Math > others
 //! "#;
 //! let doc = parser::parse(xml).expect("Failed to parse");
+//! # }
 //! ```
 
 use std::{
@@ -1364,7 +1367,10 @@ impl<'a> DeferredAttributes<'a> {
                 Ok(Some(value))
             }
             _ => {
-                let last_namespace = self.default_namespaces.last().unwrap();
+                let last_namespace = self
+                    .default_namespaces
+                    .last()
+                    .expect("this branch contains multiple default namespaces");
                 Err(last_namespace
                     .name
                     .map(|_| SpecificError::RedefinedDefaultNamespace))
diff --git a/vendor/sxd-document-no-unsafe/src/raw.rs b/src/sxd_document/raw.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/raw.rs
rename to src/sxd_document/raw.rs
diff --git a/vendor/sxd-document-no-unsafe/src/raw_no_unsafe.rs b/src/sxd_document/raw_no_unsafe.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/raw_no_unsafe.rs
rename to src/sxd_document/raw_no_unsafe.rs
diff --git a/vendor/sxd-document-no-unsafe/src/str.rs b/src/sxd_document/str.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/str.rs
rename to src/sxd_document/str.rs
diff --git a/vendor/sxd-document-no-unsafe/src/str_ext.rs b/src/sxd_document/str_ext.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/str_ext.rs
rename to src/sxd_document/str_ext.rs
diff --git a/vendor/sxd-document-no-unsafe/src/string_pool.rs b/src/sxd_document/string_pool.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/string_pool.rs
rename to src/sxd_document/string_pool.rs
diff --git a/vendor/sxd-document-no-unsafe/src/string_pool_no_unsafe.rs b/src/sxd_document/string_pool_no_unsafe.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/string_pool_no_unsafe.rs
rename to src/sxd_document/string_pool_no_unsafe.rs
diff --git a/vendor/sxd-document-no-unsafe/src/thindom.rs b/src/sxd_document/thindom.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/thindom.rs
rename to src/sxd_document/thindom.rs
diff --git a/vendor/sxd-document-no-unsafe/src/thindom_no_unsafe.rs b/src/sxd_document/thindom_no_unsafe.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/thindom_no_unsafe.rs
rename to src/sxd_document/thindom_no_unsafe.rs
diff --git a/vendor/sxd-document-no-unsafe/src/writer.rs b/src/sxd_document/writer.rs
similarity index 100%
rename from vendor/sxd-document-no-unsafe/src/writer.rs
rename to src/sxd_document/writer.rs
diff --git a/vendor/sxd-document-no-unsafe/src/writer_no_unsafe.rs b/src/sxd_document/writer_no_unsafe.rs
similarity index 98%
rename from vendor/sxd-document-no-unsafe/src/writer_no_unsafe.rs
rename to src/sxd_document/writer_no_unsafe.rs
index 8364bfe9..ec49b1e3 100644
--- a/vendor/sxd-document-no-unsafe/src/writer_no_unsafe.rs
+++ b/src/sxd_document/writer_no_unsafe.rs
@@ -123,16 +123,26 @@ impl PrefixMapping {
     }
 
     fn default_namespace_uri_in_current_scope(&self) -> Option<&str> {
-        self.scopes.last().unwrap().default_namespace_uri.as_deref()
+        self.scopes
+            .last()
+            .expect("prefix mapping always has a current scope")
+            .default_namespace_uri
+            .as_deref()
     }
 
     fn prefixes_in_current_scope(&self) -> std::slice::Iter<'_, (String, String)> {
-        self.scopes.last().unwrap().defined_prefixes.iter()
+        self.scopes
+            .last()
+            .expect("prefix mapping always has a current scope")
+            .defined_prefixes
+            .iter()
     }
 
     fn populate_scope(&mut self, element: &dom::Element<'_>, attributes: &[dom::Attribute<'_>]) {
-        self.scopes.last_mut().unwrap().default_namespace_uri =
-            element.default_namespace_uri().map(|s| s.to_string());
+        self.scopes
+            .last_mut()
+            .expect("prefix mapping always has a current scope")
+            .default_namespace_uri = element.default_namespace_uri().map(|s| s.to_string());
 
         if let Some(prefix) = element.preferred_prefix() {
             let name = element.name();
@@ -511,8 +521,8 @@ impl Writer {
         let mut todo = vec![Element(element)];
         let mut mapping = PrefixMapping::new();
 
-        while !todo.is_empty() {
-            self.format_one(todo.pop().unwrap(), &mut todo, &mut mapping, writer)?;
+        while let Some(next) = todo.pop() {
+            self.format_one(next, &mut todo, &mut mapping, writer)?;
         }
 
         Ok(())
diff --git a/vendor/sxd-xpath-no-unsafe/src/axis.rs b/src/sxd_xpath/axis.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/axis.rs
rename to src/sxd_xpath/axis.rs
index 4a2d5dd6..9c8f370c 100644
--- a/vendor/sxd-xpath-no-unsafe/src/axis.rs
+++ b/src/sxd_xpath/axis.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use std::fmt;
 
 use crate::context;
diff --git a/vendor/sxd-xpath-no-unsafe/src/context.rs b/src/sxd_xpath/context.rs
similarity index 97%
rename from vendor/sxd-xpath-no-unsafe/src/context.rs
rename to src/sxd_xpath/context.rs
index 30abb889..667b4337 100644
--- a/vendor/sxd-xpath-no-unsafe/src/context.rs
+++ b/src/sxd_xpath/context.rs
@@ -1,7 +1,9 @@
-//! Support for the various types of contexts before and during XPath
+//! Support for the various types of contexts before and during XPath
 //! evaluation.
 
-use sxd_document_no_unsafe::QName;
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
+use sxd_document_no_unsafe::QName;
 
 use std::cell::Cell;
 use std::collections::{HashMap, hash_map::RandomState};
@@ -75,8 +77,10 @@ impl QNameMap {
 ///
 /// A complete example showing all optional settings.
 ///
-/// ```
-/// use std::collections::HashMap;
+/// ```
+/// # #[cfg(not(feature = "embedded"))]
+/// # {
+/// use std::collections::HashMap;
 /// use sxd_document_no_unsafe::parser;
 /// use sxd_xpath_no_unsafe::{Factory, Context, Value};
 /// use sxd_xpath_no_unsafe::{context, function};
@@ -118,7 +122,8 @@ impl QNameMap {
 ///
 /// let number = value.number(&evaluation).expect("numeric conversion failed");
 /// assert_eq!(0.952, (number * 1000.0).trunc() / 1000.0);
-/// ```
+/// # }
+/// ```
 ///
 /// Note that we are using a custom function (`sigmoid`), a variable
 /// (`$t`), and a namespace (`neural:`). The current node is passed to
@@ -491,6 +496,8 @@ impl<'c, 'd> Iterator for EvaluationNodesetIter<'c, 'd> {
 #[cfg(test)]
 mod tests {
     use super::Context;
+    #[cfg(feature = "embedded")]
+    use super::sxd_document_no_unsafe;
 
     #[test]
     fn qname_lookup_does_not_allocate() {
diff --git a/vendor/sxd-xpath-no-unsafe/src/expression.rs b/src/sxd_xpath/expression.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/expression.rs
rename to src/sxd_xpath/expression.rs
index 4ef92aee..6eea03ad 100644
--- a/vendor/sxd-xpath-no-unsafe/src/expression.rs
+++ b/src/sxd_xpath/expression.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use snafu::{OptionExt, ResultExt, Snafu};
 use std::fmt;
 use sxd_document_no_unsafe::QName;
diff --git a/vendor/sxd-xpath-no-unsafe/src/function.rs b/src/sxd_xpath/function.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/function.rs
rename to src/sxd_xpath/function.rs
index 8e246bce..b22e5140 100644
--- a/vendor/sxd-xpath-no-unsafe/src/function.rs
+++ b/src/sxd_xpath/function.rs
@@ -1,5 +1,7 @@
 //! Support for registering and creating XPath functions.
 
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use snafu::Snafu;
 use std::borrow::ToOwned;
 use std::ops::Index;
@@ -803,6 +805,8 @@ mod test {
     use std::borrow::ToOwned;
     use std::{f64, fmt};
 
+    #[cfg(feature = "embedded")]
+    use super::sxd_document_no_unsafe;
     use sxd_document_no_unsafe::Package;
 
     use crate::context;
diff --git a/vendor/sxd-xpath-no-unsafe/src/lib.rs b/src/sxd_xpath/lib.rs
similarity index 98%
rename from vendor/sxd-xpath-no-unsafe/src/lib.rs
rename to src/sxd_xpath/lib.rs
index d2d21add..04edd673 100644
--- a/vendor/sxd-xpath-no-unsafe/src/lib.rs
+++ b/src/sxd_xpath/lib.rs
@@ -16,6 +16,8 @@
 //! to use [`evaluate_xpath`][evaluate_xpath].
 //!
 //! ```
+//! # #[cfg(not(feature = "embedded"))]
+//! # {
 //! use sxd_document_no_unsafe::parser;
 //! use sxd_xpath_no_unsafe::{evaluate_xpath, Value};
 //!
@@ -25,6 +27,7 @@
 //! let value = evaluate_xpath(&document, "/root").expect("XPath evaluation failed");
 //!
 //! assert_eq!("hello", value.string());
+//! # }
 //! ```
 //!
 //! Evaluating an XPath returns a [`Value`][], representing the
@@ -40,6 +43,8 @@
 //! accomplished:
 //!
 //! ```
+//! # #[cfg(not(feature = "embedded"))]
+//! # {
 //! use sxd_document_no_unsafe::parser;
 //! use sxd_xpath_no_unsafe::{Factory, Context, Value};
 //!
@@ -56,6 +61,7 @@
 //!     .expect("XPath evaluation failed");
 //!
 //! assert_eq!("hello", value.string());
+//! # }
 //! ```
 //!
 //! See [`Context`][] for details on how to customize the
@@ -109,6 +115,8 @@ compile_error!("select either `no-unsafe` or `raw-pointer-backend`");
 // Cargo's all-feature verification enables both selectors. Safe precedence keeps that profile
 // free of raw pointers; selecting the legacy backend requires disabling default features.
 
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use snafu::{ResultExt, Snafu};
 use std::borrow::ToOwned;
 use std::string;
@@ -123,15 +131,15 @@ pub use crate::context::Context;
 
 #[macro_use]
 pub mod macros;
-mod axis;
+pub(crate) mod axis;
 pub mod context;
-mod expression;
+pub(crate) mod expression;
 pub mod function;
-mod node_test;
+pub(crate) mod node_test;
 pub mod nodeset;
-mod parser;
-mod token;
-mod tokenizer;
+pub(crate) mod parser;
+pub(crate) mod token;
+pub(crate) mod tokenizer;
 
 // These belong in the the document
 
@@ -219,7 +227,7 @@ impl OwnedQName {
     }
 }
 
-type LiteralValue = Value<'static>;
+pub(crate) type LiteralValue = Value<'static>;
 
 struct FormattedLength(usize);
 
@@ -260,7 +268,7 @@ pub enum Value<'d> {
     Nodeset(nodeset::Nodeset<'d>),
 }
 
-fn str_to_num(s: &str) -> f64 {
+pub(crate) fn str_to_num(s: &str) -> f64 {
     let lexical = s.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n'));
     let unsigned = lexical.strip_prefix('-').unwrap_or(lexical);
     let mut parts = unsigned.split('.');
@@ -476,6 +484,8 @@ impl XPath {
     /// The most common case is to pass in a reference to a [`Context`][]:
     ///
     /// ```rust,no_run
+    /// # #[cfg(not(feature = "embedded"))]
+    /// # mod example {
     /// use sxd_document_no_unsafe::dom::Document;
     /// use sxd_xpath_no_unsafe::{XPath, Context};
     ///
@@ -486,6 +496,7 @@ impl XPath {
     /// }
     ///
     /// # fn main() {}
+    /// # }
     /// ```
     ///
     /// [`Context`]: context/struct.Context.html
@@ -698,6 +709,8 @@ pub enum Error {
 /// # Examples
 ///
 /// ```
+/// # #[cfg(not(feature = "embedded"))]
+/// # {
 /// use sxd_document_no_unsafe::parser;
 /// use sxd_xpath_no_unsafe::{evaluate_xpath, Value};
 ///
@@ -705,6 +718,7 @@ pub enum Error {
 /// let document = package.as_document();
 ///
 /// assert_eq!(Ok(Value::Number(3.0)), evaluate_xpath(&document, "/*/a + /*/b"));
+/// # }
 /// ```
 pub fn evaluate_xpath<'d>(document: &'d Document<'d>, xpath: &str) -> Result, Error> {
     let factory = Factory::new();
diff --git a/vendor/sxd-xpath-no-unsafe/src/macros.rs b/src/sxd_xpath/macros.rs
similarity index 91%
rename from vendor/sxd-xpath-no-unsafe/src/macros.rs
rename to src/sxd_xpath/macros.rs
index c959ac87..844393b4 100644
--- a/vendor/sxd-xpath-no-unsafe/src/macros.rs
+++ b/src/sxd_xpath/macros.rs
@@ -1,12 +1,15 @@
 /// Convenience constructor for a nodeset.
 ///
 /// ```
+/// # #[cfg(not(feature = "embedded"))]
+/// # {
 /// use sxd_document_no_unsafe::Package;
 ///
 /// let package = Package::new();
 /// let root = package.as_document().root();
 /// let nodes = sxd_xpath_no_unsafe::nodeset![root,];
 /// assert_eq!(nodes.size(), 1);
+/// # }
 /// ```
 #[macro_export]
 macro_rules! nodeset(
diff --git a/vendor/sxd-xpath-no-unsafe/src/node_test.rs b/src/sxd_xpath/node_test.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/node_test.rs
rename to src/sxd_xpath/node_test.rs
index f92d52c2..ee3ef161 100644
--- a/vendor/sxd-xpath-no-unsafe/src/node_test.rs
+++ b/src/sxd_xpath/node_test.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use std::fmt;
 
 use sxd_document_no_unsafe::QName;
diff --git a/vendor/sxd-xpath-no-unsafe/src/nodeset.rs b/src/sxd_xpath/nodeset.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/nodeset.rs
rename to src/sxd_xpath/nodeset.rs
index 27c141c0..86b14363 100644
--- a/vendor/sxd-xpath-no-unsafe/src/nodeset.rs
+++ b/src/sxd_xpath/nodeset.rs
@@ -1,5 +1,7 @@
 //! Support for collections of nodes.
 
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use std::borrow::ToOwned;
 use std::collections::HashSet;
 use std::collections::hash_set;
@@ -1259,6 +1261,8 @@ impl<'d> FromIterator> for OrderedNodes<'d> {
 mod test {
     use std::borrow::ToOwned;
 
+    #[cfg(feature = "embedded")]
+    use super::sxd_document_no_unsafe;
     use sxd_document_no_unsafe::Package;
 
     use super::Node::*;
diff --git a/vendor/sxd-xpath-no-unsafe/src/parser.rs b/src/sxd_xpath/parser.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/parser.rs
rename to src/sxd_xpath/parser.rs
index ec01e531..6172389e 100644
--- a/vendor/sxd-xpath-no-unsafe/src/parser.rs
+++ b/src/sxd_xpath/parser.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use snafu::{OptionExt, ResultExt, Snafu, ensure};
 use std::cell::Cell;
 use std::iter::Peekable;
diff --git a/vendor/sxd-xpath-no-unsafe/src/token.rs b/src/sxd_xpath/token.rs
similarity index 100%
rename from vendor/sxd-xpath-no-unsafe/src/token.rs
rename to src/sxd_xpath/token.rs
diff --git a/vendor/sxd-xpath-no-unsafe/src/tokenizer.rs b/src/sxd_xpath/tokenizer.rs
similarity index 99%
rename from vendor/sxd-xpath-no-unsafe/src/tokenizer.rs
rename to src/sxd_xpath/tokenizer.rs
index d52f23a1..d1bba307 100644
--- a/vendor/sxd-xpath-no-unsafe/src/tokenizer.rs
+++ b/src/sxd_xpath/tokenizer.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "embedded")]
+use crate::sxd_document as sxd_document_no_unsafe;
 use peresil::{self, Identifier, Recoverable, StringPoint, try_parse};
 use snafu::Snafu;
 use std::borrow::ToOwned;
diff --git a/src/xml/dom/preflight.rs b/src/xml/dom/preflight.rs
index 07d11356..5872436c 100644
--- a/src/xml/dom/preflight.rs
+++ b/src/xml/dom/preflight.rs
@@ -1,5 +1,6 @@
 //! Shared lexical preflight and source-position sidecar for every DOM backend.
 
+use crate::xml_input as xml_sec_xml_input;
 use std::ops::Range;
 
 use xml_sec_xml_input::lexical::{Event, Scanner};
diff --git a/crates/xml-sec-xml-input/src/lexical.rs b/src/xml_input/lexical.rs
similarity index 99%
rename from crates/xml-sec-xml-input/src/lexical.rs
rename to src/xml_input/lexical.rs
index 2f061439..b847b84b 100644
--- a/crates/xml-sec-xml-input/src/lexical.rs
+++ b/src/xml_input/lexical.rs
@@ -958,6 +958,7 @@ fn validate_writer_characters(value: &str) -> std::io::Result<()> {
 }
 
 #[cfg(all(test, feature = "std"))]
+#[allow(clippy::unwrap_used)]
 mod tests {
     #[test]
     fn xml_version_requires_digits_after_the_period() {
diff --git a/src/xml_input/shared.rs b/src/xml_input/shared.rs
new file mode 100644
index 00000000..ea818140
--- /dev/null
+++ b/src/xml_input/shared.rs
@@ -0,0 +1,1223 @@
+//! 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.
+
+use alloc::{borrow::Cow, string::String};
+use core::ops::Range;
+
+#[path = "lexical.rs"]
+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 {
+        /// Configured maximum decoded size.
+        maximum: usize,
+        /// Minimum decoded size required by the input.
+        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]
+    /// Return the registered encoding name.
+    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]
+    /// Decode one byte, or return `None` for an undefined mapping.
+    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]
+    /// Encode one character, or return `None` if it is not representable.
+    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> {
+    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, true)
+}
+
+/// Detect XML-media-type text encoding without changing the included text's declaration.
+///
+/// XInclude `parse="text"` uses XML encoding detection for XML media types, but includes the
+/// decoded characters as text rather than reparsing or rewriting an XML declaration.
+pub fn decode_xml_text_bounded<'a>(
+    bytes: &'a [u8],
+    explicit_encoding: Option<&str>,
+    maximum_decoded_bytes: usize,
+) -> Result, Error> {
+    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, false)
+}
+
+fn decode_xml_bounded_inner<'a>(
+    bytes: &'a [u8],
+    explicit_encoding: Option<&str>,
+    maximum_decoded_bytes: usize,
+    normalize_declaration: bool,
+) -> 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 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 !encodings_compatible(selected, declared, false) {
+                return Err(Error::ConflictingEncoding(label.into()));
+            }
+        }
+    }
+
+    if normalize_declaration
+        && !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)?;
+    let bytes = match selected {
+        // XInclude 1.0 section 4.3 retains an initial U+FEFF for explicit-endian text;
+        // only generic UTF-16 treats it as a BOM. The opposite-order signature is invalid.
+        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
+        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16LE => {
+            if bytes.starts_with(&[0xFE, 0xFF]) {
+                return Err(Error::ConflictingEncoding(selected.name().into()));
+            }
+            bytes
+        }
+        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16BE => {
+            if bytes.starts_with(&[0xFF, 0xFE]) {
+                return Err(Error::ConflictingEncoding(selected.name().into()));
+            }
+            bytes
+        }
+        // XInclude 1.0 section 4.3 uses this signature to identify UTF-8, not as included text.
+        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
+        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8 => {
+            bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)
+        }
+        _ => bytes,
+    };
+    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"))]
+#[allow(clippy::unwrap_used)]
+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_accepts_a_matching_byte_order_mark() {
+        // RFC 2781 sections 4.1 and 4.2 require a matching signature to be consumed without
+        // affecting deserialization. https://www.rfc-editor.org/rfc/rfc2781#section-4.1
+        let mut metadata = vec![0xFF, 0xFE];
+        metadata.extend("".encode_utf16().flat_map(u16::to_le_bytes));
+        assert_eq!(decode_xml(&metadata, Some("UTF-16LE")).unwrap(), "");
+        assert_eq!(
+            decode_text(&metadata, "UTF-16LE").unwrap(),
+            "\u{feff}"
+        );
+
+        let mut big_endian_text = vec![0xFE, 0xFF];
+        big_endian_text.extend("body".encode_utf16().flat_map(u16::to_be_bytes));
+        assert_eq!(
+            decode_text(&big_endian_text, "UTF-16BE").unwrap(),
+            "\u{feff}body"
+        );
+
+        let mut declaration = vec![0xFE, 0xFF];
+        declaration.extend(
+            ""
+                .encode_utf16()
+                .flat_map(u16::to_be_bytes),
+        );
+        assert_eq!(
+            decode_xml(&declaration, None).unwrap(),
+            ""
+        );
+    }
+
+    #[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 utf8_text_consumes_its_signature_without_removing_content_fe_ff() {
+        // XInclude 1.0 section 4.3 treats the BOM as an encoding signature, not included text.
+        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
+        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "UTF-8").unwrap(), "body");
+        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "utf8").unwrap(), "body");
+        assert_eq!(
+            decode_text(b"body\xef\xbb\xbftail", "UTF-8").unwrap(),
+            "body\u{feff}tail"
+        );
+        assert_eq!(decode_text(b"body", "UTF-8").unwrap(), "body");
+    }
+
+    #[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/src/xmldsig/builder.rs b/src/xmldsig/builder.rs
index 499bd64a..3fffe265 100644
--- a/src/xmldsig/builder.rs
+++ b/src/xmldsig/builder.rs
@@ -1,5 +1,6 @@
 //! Builders for deterministic XMLDSig signature templates.
 
+use crate::xml_input as xml_sec_xml_input;
 use std::{collections::HashSet, io::Write};
 
 use base64::Engine;
diff --git a/src/xmldsig/mutation.rs b/src/xmldsig/mutation.rs
index 55a20be5..cebe992a 100644
--- a/src/xmldsig/mutation.rs
+++ b/src/xmldsig/mutation.rs
@@ -3,6 +3,7 @@
 //! The selected semantic DOM is immutable. These helpers validate structure
 //! through the backend-neutral DOM contract, then splice validated source ranges.
 
+use crate::xml_input as xml_sec_xml_input;
 use std::ops::Range;
 
 use xml_sec_xml_input::lexical::{escape_attribute, escape_text};
diff --git a/src/xmldsig/xpath.rs b/src/xmldsig/xpath.rs
index 5d4ccd53..ab3a020a 100644
--- a/src/xmldsig/xpath.rs
+++ b/src/xmldsig/xpath.rs
@@ -9,6 +9,8 @@ use std::cell::Cell;
 use std::collections::{HashMap, HashSet, hash_map::Entry};
 use std::rc::Rc;
 
+use crate::sxd_document as sxd_document_no_unsafe;
+use crate::sxd_xpath as sxd_xpath_no_unsafe;
 use crate::xml::dom::{Document, NodeId};
 use sxd_document_no_unsafe::{Package, QName, dom};
 use sxd_xpath_no_unsafe::{Context, Factory, Value, function, nodeset};
diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs
index 21a156d8..1f3304af 100644
--- a/src/xmlenc/encrypt.rs
+++ b/src/xmlenc/encrypt.rs
@@ -1,5 +1,6 @@
 //! XMLEnc content encryption, key wrapping, and XML generation.
 
+use crate::xml_input as xml_sec_xml_input;
 use std::{fmt, sync::Arc};
 
 use crate::xml::dom::{Document, Node};
diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs
index ac2a3b9b..2d1b1d1c 100644
--- a/tools/xmlsec1/src/commands.rs
+++ b/tools/xmlsec1/src/commands.rs
@@ -13,6 +13,7 @@ use rsa::{
     traits::PublicKeyParts as _,
 };
 use x509_parser::prelude::FromDer as _;
+use xml_sec::xml_input as xml_sec_xml_input;
 use xml_sec::{
     IdAttributeRegistration, XmlBackend,
     policy::{
diff --git a/vendor/sxd-document-no-unsafe/Cargo.toml b/vendor/sxd-document-no-unsafe/Cargo.toml
index 37e0f02c..2fba2ae5 100644
--- a/vendor/sxd-document-no-unsafe/Cargo.toml
+++ b/vendor/sxd-document-no-unsafe/Cargo.toml
@@ -1,6 +1,7 @@
 [package]
 name = "xml-sec-sxd-document"
 version = "0.1.0"
+publish = false
 authors = ["Jake Goulding ", "Neil Soiffer "]
 edition = "2024"
 
@@ -23,7 +24,10 @@ raw-pointer-backend = []
 
 [lib]
 name = "sxd_document_no_unsafe"
-path = "src/lib.rs"
+path = "../../src/sxd_document/lib.rs"
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("embedded"))'] }
 
 [dependencies]
 peresil = "0.3.0"
diff --git a/vendor/sxd-xpath-no-unsafe/Cargo.toml b/vendor/sxd-xpath-no-unsafe/Cargo.toml
index d6c13a65..5a03132c 100644
--- a/vendor/sxd-xpath-no-unsafe/Cargo.toml
+++ b/vendor/sxd-xpath-no-unsafe/Cargo.toml
@@ -13,6 +13,7 @@
 edition = "2024"
 name = "xml-sec-sxd-xpath"
 version = "0.1.1"
+publish = false
 authors = [
     "Jake Goulding ",
     "Neil Soiffer ",
@@ -42,7 +43,10 @@ unstable = []
 
 [lib]
 name = "sxd_xpath_no_unsafe"
-path = "src/lib.rs"
+path = "../../src/sxd_xpath/lib.rs"
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("embedded"))'] }
 
 [[test]]
 name = "integration"

From f030372510a18551825d4ce5f797ff3707b0f3b0 Mon Sep 17 00:00:00 2001
From: Dmitry Prudnikov 
Date: Tue, 29 Sep 2026 03:41:21 +0300
Subject: [PATCH 2/4] fix(release): ship notices and bound public API

---
 Cargo.toml          |  3 ++-
 LICENSE-THIRD-PARTY | 52 +++++++++++++++++++++++++++++++++++++++++++++
 src/encoding.rs     |  2 +-
 src/lib.rs          | 17 +++++++++++++--
 4 files changed, 70 insertions(+), 4 deletions(-)
 create mode 100644 LICENSE-THIRD-PARTY

diff --git a/Cargo.toml b/Cargo.toml
index d3ddeb62..ec7e2dd8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ name = "xml-sec"
 version = "0.1.16"
 edition = "2024"
 rust-version = "1.92"
-license = "Apache-2.0"
+license = "Apache-2.0 AND MIT"
 description = "Pure Rust XML Security: XMLDSig, XMLEnc, C14N. Drop-in replacement for libxmlsec1."
 repository = "https://github.com/structured-world/xml-sec"
 homepage = "https://github.com/structured-world/xml-sec"
@@ -15,6 +15,7 @@ include = [
     "/Cargo.toml",
     "/Cargo.lock",
     "/LICENSE",
+    "/LICENSE-THIRD-PARTY",
     "/README.md",
     "/docs/**",
     "/src/**",
diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY
new file mode 100644
index 00000000..aa1d6a41
--- /dev/null
+++ b/LICENSE-THIRD-PARTY
@@ -0,0 +1,52 @@
+The xml-sec crate includes code derived from the following projects. Their
+copyright and permission notices are preserved below.
+
+sxd-document (src/sxd_document/)
+--------------------------------
+
+The MIT License (MIT)
+
+Copyright (c) 2014-2015 Jake Goulding
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+sxd-xpath (src/sxd_xpath/)
+-------------------------
+
+The MIT License (MIT)
+
+Copyright (c) 2014-2017 Jake Goulding
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/encoding.rs b/src/encoding.rs
index 6607ce8d..9df1c362 100644
--- a/src/encoding.rs
+++ b/src/encoding.rs
@@ -6,7 +6,7 @@ use std::borrow::Cow;
 /// Shared decoder errors, including unsupported encodings and decoded-size limits.
 /// This replaces the former XML-only error variants rather than collapsing new
 /// failure classes into misleading legacy variants.
-pub use xml_sec_xml_input::Error as XmlEncodingError;
+pub use crate::xml_input_shared::Error as XmlEncodingError;
 
 /// Decode XML 1.0 octets into the backend-neutral Unicode parser contract.
 ///
diff --git a/src/lib.rs b/src/lib.rs
index 80ae9104..a2747130 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -59,9 +59,22 @@ mod sxd_document;
 #[cfg_attr(test, allow(clippy::unwrap_used))]
 #[path = "sxd_xpath/lib.rs"]
 mod sxd_xpath;
-/// Shared XML byte-decoding and lexical processing primitives.
 #[path = "xml_input/shared.rs"]
-pub mod xml_input;
+// The internal xml-input crate also compiles this source for XSLT's wider API.
+#[allow(dead_code)]
+mod xml_input_shared;
+/// XML lexical helpers used by the packaged CLI.
+///
+/// Decoding untrusted XML requires a caller-supplied limit through
+/// [`encoding::decode_xml_octets`]; the unbounded internal helpers are not public.
+///
+/// ```compile_fail
+/// use xml_sec::xml_input::decode_xml;
+/// ```
+pub mod xml_input {
+    pub use crate::xml_input_shared::lexical;
+    pub(crate) use crate::xml_input_shared::{Error, decode_xml_bounded};
+}
 #[cfg(all(test, feature = "xmldsig"))]
 pub(crate) use sxd_document::{Package, QName, dom};
 #[cfg(feature = "xmldsig")]

From b0784234c62f711601932123450f4e0d41f700b5 Mon Sep 17 00:00:00 2001
From: Dmitry Prudnikov 
Date: Tue, 29 Sep 2026 03:45:12 +0300
Subject: [PATCH 3/4] fix(release): bundle README image

---
 Cargo.toml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/Cargo.toml b/Cargo.toml
index ec7e2dd8..df7774a7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@ include = [
     "/LICENSE",
     "/LICENSE-THIRD-PARTY",
     "/README.md",
+    "/assets/usdt-qr.svg",
     "/docs/**",
     "/src/**",
     "/examples/**",

From 318186663dac1148b318fb399a369f8d54245159 Mon Sep 17 00:00:00 2001
From: Dmitry Prudnikov 
Date: Tue, 29 Sep 2026 04:13:52 +0300
Subject: [PATCH 4/4] fix(xml): expose bounded trusted decoding

---
 README.md                    |  4 +++-
 src/lib.rs                   |  9 ++++-----
 tests/encoding_public_api.rs | 30 ++++++++++++++++++++++++++++++
 3 files changed, 37 insertions(+), 6 deletions(-)
 create mode 100644 tests/encoding_public_api.rs

diff --git a/README.md b/README.md
index 98631023..ed94d7d9 100644
--- a/README.md
+++ b/README.md
@@ -146,7 +146,9 @@ shared, non-exhaustive `xml_sec::encoding::XmlEncodingError`; update matches to
 variants and include a fallback arm. Code constructing `ResourcePolicy` with every field must
 also set `max_xml_namespace_bindings` (or start from `ResourcePolicy::default()` and override
 selected fields). These changes keep decoding and namespace-scope allocation under explicit
-resource limits.
+resource limits. For trusted resolver-provided encoding metadata, use
+`xml_input::decode_xml_bounded(bytes, Some(encoding), maximum_decoded_bytes)`; the unbounded
+decoder remains internal.
 
 ## Native xmlsec1 CLI
 
diff --git a/src/lib.rs b/src/lib.rs
index a2747130..7f7e65e3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -63,17 +63,16 @@ mod sxd_xpath;
 // The internal xml-input crate also compiles this source for XSLT's wider API.
 #[allow(dead_code)]
 mod xml_input_shared;
-/// XML lexical helpers used by the packaged CLI.
+/// Bounded XML input decoding and lexical helpers.
 ///
-/// Decoding untrusted XML requires a caller-supplied limit through
-/// [`encoding::decode_xml_octets`]; the unbounded internal helpers are not public.
+/// [`decode_xml_bounded`] accepts trusted encoding metadata and a caller-supplied
+/// decoded-byte limit. The unbounded internal helpers are not public.
 ///
 /// ```compile_fail
 /// use xml_sec::xml_input::decode_xml;
 /// ```
 pub mod xml_input {
-    pub use crate::xml_input_shared::lexical;
-    pub(crate) use crate::xml_input_shared::{Error, decode_xml_bounded};
+    pub use crate::xml_input_shared::{Error, decode_xml_bounded, lexical};
 }
 #[cfg(all(test, feature = "xmldsig"))]
 pub(crate) use sxd_document::{Package, QName, dom};
diff --git a/tests/encoding_public_api.rs b/tests/encoding_public_api.rs
new file mode 100644
index 00000000..e1ab681d
--- /dev/null
+++ b/tests/encoding_public_api.rs
@@ -0,0 +1,30 @@
+use xml_sec::{
+    encoding::decode_xml_octets,
+    xml_input::{Error, decode_xml_bounded},
+};
+
+#[test]
+fn trusted_encoding_metadata_decodes_declarationless_xml_with_a_limit() {
+    let latin1 = b"caf\xe9";
+    assert!(matches!(
+        decode_xml_octets(latin1, 64),
+        Err(Error::InvalidBytes("UTF-8"))
+    ));
+
+    let decoded = decode_xml_bounded(latin1, Some("ISO-8859-1"), 64)
+        .expect("trusted metadata selects Latin-1");
+    assert_eq!(decoded, "café");
+    assert!(matches!(
+        decode_xml_bounded(latin1, Some("ISO-8859-1"), 8),
+        Err(Error::DecodedLimit { maximum: 8, .. })
+    ));
+}
+
+#[test]
+fn trusted_metadata_cannot_override_the_xml_declaration() {
+    let xml = b"";
+    assert!(matches!(
+        decode_xml_bounded(xml, Some("ISO-8859-1"), 128),
+        Err(Error::ConflictingEncoding(_))
+    ));
+}