diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dfee133e..e201558c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,7 +42,7 @@ jobs:
test "${{ inputs.release_tag }}" = "v${version}"
fi
- run: cargo fmt --all -- --check
- - run: cargo test --workspace --all-features --locked
+ - run: cargo test --workspace --all-features --locked -- --test-threads=1
- run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
binaries:
@@ -84,12 +84,16 @@ jobs:
version="${{ needs.validate.outputs.version }}"
archive="a3s-use-${version}-${{ matrix.name }}.tar.gz"
stage="${RUNNER_TEMP}/a3s-use-${version}-${{ matrix.name }}"
- install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/dashboard"
+ install -d "${stage}/skills" "${stage}/skill-data" "${stage}/office-skills" "${stage}/ocr-skills" "${stage}/dashboard"
install -m 0755 "target/${{ matrix.target }}/release/a3s-use" "${stage}/a3s-use"
install -m 0755 "target/${{ matrix.target }}/release/a3s-use-browser-driver" "${stage}/a3s-use-browser-driver"
+ A3S_USE_OCR_HOME="${stage}/ocr-models" \
+ "${stage}/a3s-use" component install ocr --json > "${RUNNER_TEMP}/ocr-model-install.json"
+ rm -f "${stage}/ocr-models/.install.lock"
cp -R crates/browser-driver/skills/. "${stage}/skills/"
cp -R crates/browser-driver/skill-data/. "${stage}/skill-data/"
cp -R crates/office/skills/. "${stage}/office-skills/"
+ cp -R crates/ocr/skills/. "${stage}/ocr-skills/"
cp -R crates/browser-driver/dashboard/out/. "${stage}/dashboard/"
install -m 0644 LICENSE README.md THIRD_PARTY_NOTICES.md "${stage}/"
install -m 0644 crates/browser-driver/LICENSE-APACHE-2.0 "${stage}/LICENSE-APACHE-2.0"
@@ -107,9 +111,15 @@ jobs:
New-Item -ItemType Directory -Force -Path $stage | Out-Null
Copy-Item "target/${{ matrix.target }}/release/a3s-use.exe" "$stage/a3s-use.exe"
Copy-Item "target/${{ matrix.target }}/release/a3s-use-browser-driver.exe" "$stage/a3s-use-browser-driver.exe"
+ $env:A3S_USE_OCR_HOME = "$stage/ocr-models"
+ & "$stage/a3s-use.exe" component install ocr --json | Out-File "$env:RUNNER_TEMP/ocr-model-install.json"
+ if ($LASTEXITCODE -ne 0) { throw "Failed to install the pinned PP-OCRv6 release assets" }
+ Remove-Item Env:A3S_USE_OCR_HOME
+ Remove-Item "$stage/ocr-models/.install.lock" -ErrorAction SilentlyContinue
Copy-Item -Recurse "crates/browser-driver/skills" "$stage/skills"
Copy-Item -Recurse "crates/browser-driver/skill-data" "$stage/skill-data"
Copy-Item -Recurse "crates/office/skills" "$stage/office-skills"
+ Copy-Item -Recurse "crates/ocr/skills" "$stage/ocr-skills"
Copy-Item -Recurse "crates/browser-driver/dashboard/out" "$stage/dashboard"
Copy-Item LICENSE,README.md,THIRD_PARTY_NOTICES.md $stage
Copy-Item crates/browser-driver/LICENSE-APACHE-2.0 "$stage/LICENSE-APACHE-2.0"
@@ -128,6 +138,11 @@ jobs:
test -x "${install_root}/a3s-use-browser-driver"
test -f "${install_root}/skill-data/core/SKILL.md"
test -f "${install_root}/office-skills/a3s-use-office/SKILL.md"
+ test -f "${install_root}/ocr-skills/a3s-use-ocr/SKILL.md"
+ test -f "${install_root}/ocr-models/PP-OCRv6_small/det/inference.onnx"
+ test -f "${install_root}/ocr-models/PP-OCRv6_small/det/inference.yml"
+ test -f "${install_root}/ocr-models/PP-OCRv6_small/rec/inference.onnx"
+ test -f "${install_root}/ocr-models/PP-OCRv6_small/rec/inference.yml"
test -f "${install_root}/dashboard/index.html"
test -f "${install_root}/LICENSE-APACHE-2.0"
test -f "${install_root}/UPSTREAM.md"
@@ -143,6 +158,49 @@ jobs:
"agentcore", "core", "dogfood", "electron", "slack", "vercel-sandbox"
}
PY
+ "${install_root}/a3s-use" ocr doctor --json > "${RUNNER_TEMP}/ocr-doctor.json"
+ python3 - "${RUNNER_TEMP}/ocr-doctor.json" <<'PY'
+ import json, pathlib, sys
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text())
+ assert value["ok"] is True
+ assert value["data"]["readiness"] == "ready"
+ assert value["data"]["provider"] == "pp-ocr-v6"
+ assert value["data"]["engine"] == "onnx-runtime"
+ assert value["data"]["model"] == "PP-OCRv6_small"
+ assert value["data"]["sendsSourceOffDevice"] is False
+ PY
+ python3 - "${RUNNER_TEMP}/ocr-white.bmp" <<'PY'
+ import base64, pathlib, sys
+ pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(
+ "Qk06AAAAAAAAADYAAAAoAAAAAQAAAAEAAAABABgAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAA////AA=="
+ ))
+ PY
+ "${install_root}/a3s-use" ocr extract "${RUNNER_TEMP}/ocr-white.bmp" --json > "${RUNNER_TEMP}/ocr-extract.json"
+ python3 - "${RUNNER_TEMP}/ocr-extract.json" <<'PY'
+ import json, pathlib, sys
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text())
+ assert value["ok"] is True
+ assert value["data"]["provider"] == "pp-ocr-v6"
+ assert value["data"]["engine"] == "onnx-runtime"
+ assert value["data"]["model"] == "PP-OCRv6_small"
+ assert value["data"]["source"]["mediaType"] == "image/bmp"
+ PY
+ "${install_root}/a3s-use" capability snapshot --json > "${RUNNER_TEMP}/capabilities.json"
+ python3 - "${RUNNER_TEMP}/capabilities.json" "${install_root}" <<'PY'
+ import json, pathlib, sys
+ value = json.loads(pathlib.Path(sys.argv[1]).read_text())
+ root = pathlib.Path(sys.argv[2]).resolve()
+ ocr = next(
+ capability
+ for capability in value["data"]["registry"]["capabilities"]
+ if capability["id"] == "use/ocr"
+ )
+ assert ocr["mcp"]["target"] == "ocr-native"
+ assert len(ocr["skills"]) == 1
+ assert pathlib.Path(ocr["skills"][0]["path"]).resolve() == (
+ root / "ocr-skills" / "a3s-use-ocr" / "SKILL.md"
+ )
+ PY
A3S_OFFICECLI_EXECUTABLE="${install_root}/must-not-be-invoked" \
"${install_root}/a3s-use" office skills list --json > "${RUNNER_TEMP}/office-skills.json"
python3 - "${RUNNER_TEMP}/office-skills.json" <<'PY'
@@ -179,6 +237,11 @@ jobs:
"$root/a3s-use-browser-driver.exe",
"$root/skill-data/core/SKILL.md",
"$root/office-skills/a3s-use-office/SKILL.md",
+ "$root/ocr-skills/a3s-use-ocr/SKILL.md",
+ "$root/ocr-models/PP-OCRv6_small/det/inference.onnx",
+ "$root/ocr-models/PP-OCRv6_small/det/inference.yml",
+ "$root/ocr-models/PP-OCRv6_small/rec/inference.onnx",
+ "$root/ocr-models/PP-OCRv6_small/rec/inference.yml",
"$root/dashboard/index.html",
"$root/LICENSE-APACHE-2.0",
"$root/UPSTREAM.md",
@@ -194,6 +257,36 @@ jobs:
if (-not $officeSkills.ok -or $officeSkills.data.Count -ne 1 -or $officeSkills.data[0].name -ne "a3s-use-office") {
throw "Packaged Office Skill smoke failed"
}
+ $ocr = (& "$root/a3s-use.exe" ocr doctor --json | ConvertFrom-Json)
+ if (
+ -not $ocr.ok -or
+ $ocr.data.readiness -ne "ready" -or
+ $ocr.data.provider -ne "pp-ocr-v6" -or
+ $ocr.data.engine -ne "onnx-runtime" -or
+ $ocr.data.model -ne "PP-OCRv6_small" -or
+ $ocr.data.sendsSourceOffDevice
+ ) { throw "Packaged PP-OCRv6 doctor smoke failed" }
+ $whiteBmp = [Convert]::FromBase64String("Qk06AAAAAAAAADYAAAAoAAAAAQAAAAEAAAABABgAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAA////AA==")
+ [IO.File]::WriteAllBytes("$env:RUNNER_TEMP/ocr-white.bmp", $whiteBmp)
+ $ocrExtract = (& "$root/a3s-use.exe" ocr extract "$env:RUNNER_TEMP/ocr-white.bmp" --json | ConvertFrom-Json)
+ if (
+ -not $ocrExtract.ok -or
+ $ocrExtract.data.provider -ne "pp-ocr-v6" -or
+ $ocrExtract.data.engine -ne "onnx-runtime" -or
+ $ocrExtract.data.model -ne "PP-OCRv6_small" -or
+ $ocrExtract.data.source.mediaType -ne "image/bmp"
+ ) { throw "Packaged PP-OCRv6 extraction smoke failed" }
+ $capabilities = (& "$root/a3s-use.exe" capability snapshot --json | ConvertFrom-Json)
+ $ocrCapability = $capabilities.data.registry.capabilities |
+ Where-Object id -eq "use/ocr"
+ if ($ocrCapability.mcp.target -ne "ocr-native" -or $ocrCapability.skills.Count -ne 1) {
+ throw "Built-in OCR capability projection smoke failed"
+ }
+ $expectedOcrSkill = (Resolve-Path "$root/ocr-skills/a3s-use-ocr/SKILL.md").Path
+ $actualOcrSkill = (Resolve-Path $ocrCapability.skills[0].path).Path
+ if (-not [StringComparer]::OrdinalIgnoreCase.Equals($actualOcrSkill, $expectedOcrSkill)) {
+ throw "Built-in OCR Skill was not projected from the installed release"
+ }
$requests = @(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"release-smoke","version":"1"}}}',
'{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}',
@@ -223,7 +316,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- - name: Publish Core then Browser
+ - name: Publish Core, Extension, OCR, then Browser
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
VERSION: ${{ needs.validate.outputs.version }}
@@ -262,6 +355,10 @@ jobs:
publish_once a3s-use-core
wait_until_visible a3s-use-core
+ publish_once a3s-use-extension
+ wait_until_visible a3s-use-extension
+ publish_once a3s-use-ocr
+ wait_until_visible a3s-use-ocr
publish_once a3s-use-browser
release:
diff --git a/Cargo.lock b/Cargo.lock
index 14cb1eb3..aa7c726e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7,28 +7,39 @@ name = "a3s-acl"
version = "0.2.1"
source = "git+https://github.com/A3S-Lab/ACL?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22#6e2a6469edc0f4c61b1e588d0ace873aaf15ce22"
+[[package]]
+name = "a3s-acl"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2dc4eb3b0dd1b11efa0ad9bf397c97fd1d16e3eb4f9ca43872df069560d0b69"
+
[[package]]
name = "a3s-use"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"a3s-use-browser",
"a3s-use-core",
"a3s-use-extension",
+ "a3s-use-ocr",
"a3s-use-office",
"anyhow",
"async-trait",
"axum",
"base64",
"clap",
+ "flate2",
"fs2",
"futures-util",
"getrandom 0.3.4",
+ "olpc-cjson",
"reqwest",
+ "ring",
"rmcp",
"schemars",
"serde",
"serde_json",
"sha2 0.10.9",
+ "tar",
"tempfile",
"tokio",
"tokio-util",
@@ -39,7 +50,7 @@ dependencies = [
[[package]]
name = "a3s-use-browser"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"a3s-use-core",
"async-trait",
@@ -60,9 +71,10 @@ dependencies = [
[[package]]
name = "a3s-use-browser-driver"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
- "a3s-acl",
+ "a3s-acl 0.2.1",
+ "a3s-use-core",
"aes-gcm",
"async-trait",
"base64",
@@ -93,31 +105,63 @@ dependencies = [
[[package]]
name = "a3s-use-core"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"serde",
"serde_json",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
name = "a3s-use-extension"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
- "a3s-acl",
+ "a3s-acl 0.2.2",
"a3s-use-core",
+ "flate2",
"fs2",
+ "olpc-cjson",
+ "reqwest",
+ "ring",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
+ "tar",
+ "tempfile",
+ "tokio",
+ "tough",
+ "url",
+ "zip",
+]
+
+[[package]]
+name = "a3s-use-ocr"
+version = "0.1.2"
+dependencies = [
+ "a3s-use-core",
+ "clap",
+ "clipper2",
+ "fs2",
+ "image",
+ "imageproc",
+ "ort",
+ "reqwest",
+ "rmcp",
+ "schemars",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "sha2 0.10.9",
+ "tar",
"tempfile",
"tokio",
+ "url",
]
[[package]]
name = "a3s-use-office"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"a3s-use-core",
"async-trait",
@@ -135,6 +179,40 @@ dependencies = [
"zip",
]
+[[package]]
+name = "a3s-use-science"
+version = "0.1.2"
+dependencies = [
+ "a3s-use-core",
+ "a3s-use-extension",
+ "axum",
+ "clap",
+ "reqwest",
+ "rmcp",
+ "schemars",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -185,15 +263,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "aligned"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
-dependencies = [
- "as-slice",
-]
-
[[package]]
name = "aligned-vec"
version = "0.6.4"
@@ -264,9 +333,18 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.103"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
[[package]]
name = "arbitrary"
@@ -285,7 +363,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -294,15 +372,6 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
-[[package]]
-name = "as-slice"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
-dependencies = [
- "stable_deref_trait",
-]
-
[[package]]
name = "async-attributes"
version = "1.1.2"
@@ -412,6 +481,17 @@ dependencies = [
"rustix 1.1.4",
]
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "async-signal"
version = "0.2.14"
@@ -466,13 +546,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
-version = "0.1.89"
+version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 3.0.0",
]
[[package]]
@@ -502,26 +582,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
-[[package]]
-name = "av-scenechange"
-version = "0.14.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
-dependencies = [
- "aligned",
- "anyhow",
- "arg_enum_proc_macro",
- "arrayvec",
- "log",
- "num-rational",
- "num-traits",
- "pastey",
- "rayon",
- "thiserror 2.0.18",
- "v_frame",
- "y4m",
-]
-
[[package]]
name = "av1-grain"
version = "0.2.5"
@@ -545,6 +605,30 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "aws-lc-rs"
+version = "1.17.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
+dependencies = [
+ "aws-lc-sys",
+ "untrusted 0.7.1",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.43.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
[[package]]
name = "axum"
version = "0.8.9"
@@ -603,6 +687,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -611,18 +701,21 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
-version = "2.13.0"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bitstream-io"
-version = "4.10.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
-dependencies = [
- "no_std_io2",
-]
+checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
[[package]]
name = "block-buffer"
@@ -655,11 +748,21 @@ dependencies = [
"piper",
]
+[[package]]
+name = "bstr"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
[[package]]
name = "built"
-version = "0.8.1"
+version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
+checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
[[package]]
name = "bumpalo"
@@ -696,9 +799,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.67"
+version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -706,6 +809,16 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec 1.15.2",
+ "target-lexicon",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -714,9 +827,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
@@ -823,9 +936,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.6.1"
+version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
@@ -833,9 +946,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.6.0"
+version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstream",
"anstyle",
@@ -852,7 +965,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -861,6 +974,36 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "clipper2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e9c96871d5c50dd16c0f9df018f701fc62bd4f1b73cea8efe1985df6ab3d7e6"
+dependencies = [
+ "clipper2c-sys",
+ "libc",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "clipper2c-sys"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6801d5a9a1d30e747025ec2afdc841d51665028481d61b5c719d173e88211ea3"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -888,6 +1031,16 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -1002,7 +1155,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1013,7 +1166,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1022,6 +1175,16 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+[[package]]
+name = "der"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
+dependencies = [
+ "pem-rfc7468",
+ "zeroize",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1036,7 +1199,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1090,7 +1253,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1134,7 +1297,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1193,7 +1356,7 @@ dependencies = [
"num-complex",
"pulp",
"rayon-core",
- "smallvec",
+ "smallvec 1.15.2",
"zune-inflate",
]
@@ -1203,12 +1366,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
-[[package]]
-name = "fax"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
-
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1218,6 +1375,16 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1240,6 +1407,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1259,11 +1441,17 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
[[package]]
name = "futures"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
@@ -1276,9 +1464,9 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
@@ -1286,15 +1474,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -1303,9 +1491,9 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
@@ -1322,26 +1510,26 @@ dependencies = [
[[package]]
name = "futures-macro"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
name = "futures-sink"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
@@ -1351,9 +1539,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-channel",
"futures-core",
@@ -1427,14 +1615,27 @@ dependencies = [
[[package]]
name = "gif"
-version = "0.14.2"
+version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
+[[package]]
+name = "globset"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1576,7 +1777,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
- "smallvec",
+ "smallvec 1.15.2",
"tokio",
"want",
]
@@ -1594,7 +1795,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots 1.0.8",
+ "webpki-roots 1.0.9",
]
[[package]]
@@ -1681,7 +1882,7 @@ dependencies = [
"icu_normalizer_data",
"icu_properties",
"icu_provider",
- "smallvec",
+ "smallvec 1.15.2",
"zerovec",
]
@@ -1739,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
- "smallvec",
+ "smallvec 1.15.2",
"utf8_iter",
]
@@ -1755,9 +1956,9 @@ dependencies = [
[[package]]
name = "image"
-version = "0.25.10"
+version = "0.25.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
dependencies = [
"bytemuck",
"byteorder-lite",
@@ -1765,7 +1966,6 @@ dependencies = [
"exr",
"gif",
"image-webp",
- "moxcms",
"num-traits",
"png",
"qoi",
@@ -1787,6 +1987,23 @@ dependencies = [
"quick-error",
]
+[[package]]
+name = "imageproc"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d"
+dependencies = [
+ "ab_glyph",
+ "approx",
+ "getrandom 0.2.17",
+ "image",
+ "itertools",
+ "nalgebra",
+ "num",
+ "rand 0.8.7",
+ "rand_distr",
+]
+
[[package]]
name = "imgref"
version = "1.12.2"
@@ -1820,7 +2037,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -1837,9 +2054,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
-version = "0.14.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
@@ -1860,6 +2077,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "jpeg-decoder"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
+
[[package]]
name = "js-sys"
version = "0.3.103"
@@ -1965,6 +2188,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -2019,30 +2252,58 @@ dependencies = [
]
[[package]]
-name = "moxcms"
-version = "0.8.1"
+name = "nalgebra"
+version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
dependencies = [
+ "approx",
+ "matrixmultiply",
+ "num-complex",
+ "num-rational",
"num-traits",
- "pxfm",
+ "simba",
+ "typenum",
]
[[package]]
-name = "new_debug_unreachable"
-version = "1.0.6"
+name = "native-tls"
+version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
[[package]]
-name = "no_std_io2"
-version = "0.9.4"
+name = "ndarray"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
+checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
- "memchr",
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
]
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
[[package]]
name = "nom"
version = "8.0.0"
@@ -2058,6 +2319,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -2092,7 +2367,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -2104,6 +2379,16 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -2122,6 +2407,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
+]
+
+[[package]]
+name = "olpc-cjson"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083"
+dependencies = [
+ "serde",
+ "serde_json",
+ "unicode-normalization",
]
[[package]]
@@ -2142,12 +2439,89 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags 2.13.1",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ort"
+version = "2.0.0-rc.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
+dependencies = [
+ "ndarray",
+ "ort-sys",
+ "smallvec 2.0.0-alpha.10",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
+dependencies = [
+ "flate2",
+ "pkg-config",
+ "sha2 0.10.9",
+ "tar",
+ "ureq",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -2161,10 +2535,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
-name = "pastey"
-version = "0.1.1"
+name = "pem"
+version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
+dependencies = [
+ "base64ct",
+]
[[package]]
name = "percent-encoding"
@@ -2172,6 +2559,26 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2195,13 +2602,19 @@ dependencies = [
"futures-io",
]
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
[[package]]
name = "png"
-version = "0.18.1"
+version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
- "bitflags",
+ "bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
@@ -2234,6 +2647,21 @@ dependencies = [
"universal-hash",
]
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2260,9 +2688,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.106"
+version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -2283,7 +2711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -2309,12 +2737,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
-[[package]]
-name = "pxfm"
-version = "0.1.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
-
[[package]]
name = "qoi"
version = "0.4.1"
@@ -2353,7 +2775,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
@@ -2375,7 +2797,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
@@ -2397,9 +2819,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.46"
+version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -2492,6 +2914,16 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+[[package]]
+name = "rand_distr"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
+dependencies = [
+ "num-traits",
+ "rand 0.8.7",
+]
+
[[package]]
name = "rand_pcg"
version = "0.10.2"
@@ -2503,15 +2935,13 @@ dependencies = [
[[package]]
name = "rav1e"
-version = "0.8.1"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
+checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
dependencies = [
- "aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
- "av-scenechange",
"av1-grain",
"bitstream-io",
"built",
@@ -2526,21 +2956,23 @@ dependencies = [
"noop_proc_macro",
"num-derive",
"num-traits",
+ "once_cell",
"paste",
"profiling",
- "rand 0.9.5",
- "rand_chacha 0.9.0",
+ "rand 0.8.7",
+ "rand_chacha 0.3.1",
"simd_helpers",
- "thiserror 2.0.18",
+ "system-deps",
+ "thiserror 1.0.69",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
-version = "0.13.0"
+version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
+checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
dependencies = [
"avif-serialize",
"imgref",
@@ -2557,9 +2989,15 @@ version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -2599,29 +3037,29 @@ dependencies = [
[[package]]
name = "ref-cast"
-version = "1.0.25"
+version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
-version = "1.0.25"
+version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 3.0.0",
]
[[package]]
name = "regex"
-version = "1.13.0"
+version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
@@ -2631,9 +3069,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.15"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
@@ -2684,7 +3122,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "webpki-roots 1.0.8",
+ "webpki-roots 1.0.9",
]
[[package]]
@@ -2703,7 +3141,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
- "untrusted",
+ "untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -2729,7 +3167,7 @@ dependencies = [
"serde",
"serde_json",
"sse-stream",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tokio-stream",
"tokio-util",
@@ -2748,7 +3186,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_json",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -2772,7 +3210,7 @@ dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
- "syn 2.0.118",
+ "syn 2.0.119",
"walkdir",
]
@@ -2798,7 +3236,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -2811,7 +3249,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -2824,6 +3262,8 @@ version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
+ "aws-lc-rs",
+ "log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -2848,9 +3288,10 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
- "untrusted",
+ "untrusted 0.9.0",
]
[[package]]
@@ -2865,6 +3306,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "safe_arch"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2874,6 +3324,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "schemars"
version = "1.2.1"
@@ -2897,7 +3356,30 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
- "syn 2.0.118",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
]
[[package]]
@@ -2908,9 +3390,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -2918,22 +3400,22 @@ dependencies = [
[[package]]
name = "serde_core"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 3.0.0",
]
[[package]]
@@ -2944,7 +3426,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -2971,6 +3453,24 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -2983,6 +3483,19 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "sha1"
version = "0.10.7"
@@ -3032,11 +3545,24 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simba"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
+dependencies = [
+ "approx",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "wide",
+]
+
[[package]]
name = "simd-adler32"
-version = "0.3.9"
+version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_helpers"
@@ -3065,6 +3591,35 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+[[package]]
+name = "smallvec"
+version = "2.0.0-alpha.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
+
+[[package]]
+name = "snafu"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
+dependencies = [
+ "futures-core",
+ "pin-project",
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "socket2"
version = "0.6.5"
@@ -3075,6 +3630,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "socks"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
+dependencies = [
+ "byteorder",
+ "libc",
+ "winapi",
+]
+
[[package]]
name = "sse-stream"
version = "0.2.4"
@@ -3119,9 +3685,20 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.118"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
dependencies = [
"proc-macro2",
"quote",
@@ -3145,9 +3722,39 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml",
+ "version-compare",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3172,11 +3779,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.18"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
- "thiserror-impl 2.0.18",
+ "thiserror-impl 2.0.19",
]
[[package]]
@@ -3187,32 +3794,29 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.18"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 3.0.0",
]
[[package]]
name = "tiff"
-version = "0.11.3"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
- "fax",
"flate2",
- "half",
- "quick-error",
+ "jpeg-decoder",
"weezl",
- "zune-jpeg",
]
[[package]]
@@ -3272,9 +3876,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.52.3"
+version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"libc",
@@ -3288,13 +3892,13 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.7.0"
+version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -3347,6 +3951,75 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tough"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8031cff0872dd1c6312370515a6be8098f6ea5512f1bad725016046fc725f272"
+dependencies = [
+ "async-recursion",
+ "async-trait",
+ "aws-lc-rs",
+ "bytes",
+ "chrono",
+ "dyn-clone",
+ "futures",
+ "futures-core",
+ "globset",
+ "hex",
+ "log",
+ "olpc-cjson",
+ "pem",
+ "percent-encoding",
+ "reqwest",
+ "rustls",
+ "serde",
+ "serde_json",
+ "serde_plain",
+ "snafu",
+ "tempfile",
+ "tokio",
+ "tokio-util",
+ "typed-path",
+ "untrusted 0.7.1",
+ "url",
+ "walkdir",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -3369,7 +4042,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"bytes",
"futures-util",
"http",
@@ -3413,7 +4086,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -3431,6 +4104,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
[[package]]
name = "tungstenite"
version = "0.23.0"
@@ -3469,6 +4148,12 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "typed-path"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82205ffd44a9697e34fc145491aa47310f9871540bb7909eaa9365e0a9a46607"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -3487,6 +4172,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -3497,12 +4191,54 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
+dependencies = [
+ "base64",
+ "der",
+ "log",
+ "native-tls",
+ "percent-encoding",
+ "rustls-pki-types",
+ "socks",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-root-certs",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -3528,6 +4264,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3542,9 +4284,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.23.5"
+version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -3568,6 +4310,18 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd4ec1eb1d240636e354a30110a1dfcb37047169a4d9bd6d9d3469df574b5c4"
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3650,7 +4404,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
"wasm-bindgen-shared",
]
@@ -3696,20 +4450,29 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
- "webpki-roots 1.0.8",
+ "webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
-version = "1.0.8"
+version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
@@ -3744,6 +4507,16 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "wide"
+version = "0.7.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
+dependencies = [
+ "bytemuck",
+ "safe_arch",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -3796,7 +4569,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -3807,7 +4580,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -3991,6 +4764,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winreg"
version = "0.52.0"
@@ -4020,10 +4802,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
-name = "y4m"
-version = "0.8.0"
+name = "xattr"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix 1.1.4",
+]
[[package]]
name = "yoke"
@@ -4044,7 +4830,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
"synstructure",
]
@@ -4065,7 +4851,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -4085,7 +4871,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
"synstructure",
]
@@ -4125,7 +4911,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 2.0.119",
]
[[package]]
@@ -4141,7 +4927,7 @@ dependencies = [
"flate2",
"indexmap",
"memchr",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"zopfli",
]
@@ -4165,9 +4951,9 @@ dependencies = [
[[package]]
name = "zune-core"
-version = "0.5.1"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-inflate"
@@ -4180,9 +4966,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
-version = "0.5.15"
+version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index a136a13b..d4710d39 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,11 +5,13 @@ members = [
"crates/browser-driver",
"crates/office",
"crates/extension",
+ "crates/ocr",
+ "crates/science",
]
resolver = "2"
[workspace.package]
-version = "0.1.1"
+version = "0.1.2"
edition = "2021"
license = "MIT"
repository = "https://github.com/A3S-Lab/Use"
@@ -22,9 +24,14 @@ async-trait = "0.1"
axum = "0.8"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
+clipper2 = { version = "=0.5.3", default-features = false }
fs2 = "0.4"
futures-util = "0.3"
+flate2 = "1"
getrandom = "0.3"
+image = { version = "=0.25.5", default-features = false, features = ["bmp", "gif", "jpeg", "png", "tiff", "webp"] }
+imageproc = { version = "=0.25.0", default-features = false }
+ort = { version = "=2.0.0-rc.10", default-features = false, features = ["copy-dylibs", "download-binaries", "ndarray", "std"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
quick-xml = "0.38"
regex = "1"
@@ -32,11 +39,14 @@ rmcp = { version = "=0.8.5", default-features = false, features = ["base64", "cl
schemars = "1.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+serde_yaml = "0.9"
sha2 = "0.10"
+tar = "0.4"
thiserror = "2"
tempfile = "3"
-tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "process", "sync", "time"] }
+tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "process", "sync", "time"] }
tokio-util = "0.7"
+tough = { version = "0.22", default-features = false, features = ["http"] }
url = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -48,7 +58,7 @@ license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version = "1.85"
-description = "Typed Browser, Office, and external application capabilities for A3S"
+description = "Typed Browser, Office, OCR, and external application capabilities for A3S"
[lib]
name = "a3s_use"
@@ -59,7 +69,7 @@ name = "a3s-use"
path = "src/main.rs"
[features]
-default = ["browser", "office", "extensions", "mcp"]
+default = ["browser", "office", "ocr", "extensions", "mcp"]
browser = ["dep:a3s-use-browser"]
office = [
"dep:a3s-use-office",
@@ -67,6 +77,7 @@ office = [
"dep:futures-util",
"dep:getrandom",
]
+ocr = ["dep:a3s-use-ocr"]
extensions = ["dep:a3s-use-extension"]
mcp = [
"dep:axum",
@@ -81,10 +92,11 @@ mcp = [
lightpanda = ["browser", "a3s-use-browser/lightpanda"]
[dependencies]
-a3s-use-core = { version = "0.1.1", path = "crates/core" }
-a3s-use-browser = { version = "0.1.1", path = "crates/browser", optional = true }
-a3s-use-office = { version = "0.1.1", path = "crates/office", optional = true }
-a3s-use-extension = { version = "0.1.1", path = "crates/extension", optional = true }
+a3s-use-core = { version = "0.1.2", path = "crates/core" }
+a3s-use-browser = { version = "0.1.2", path = "crates/browser", optional = true }
+a3s-use-office = { version = "0.1.2", path = "crates/office", optional = true }
+a3s-use-ocr = { version = "0.1.2", path = "crates/ocr", optional = true }
+a3s-use-extension = { version = "0.1.2", path = "crates/extension", optional = true }
anyhow.workspace = true
axum = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
@@ -108,5 +120,9 @@ windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_
[dev-dependencies]
async-trait.workspace = true
reqwest.workspace = true
+flate2.workspace = true
+olpc-cjson = "0.1"
+ring = "0.17"
+tar.workspace = true
tempfile.workspace = true
zip.workspace = true
diff --git a/README.md b/README.md
index 9b8376a1..20fa1e19 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- Use browsers, Office documents, and independently shipped application domains through native CLI, standard MCP, and Skills
+ Use browsers, Office documents, OCR, and independently shipped application domains through native CLI, standard MCP, and Skills
@@ -14,6 +14,7 @@
Quick Start •
Browser •
Office •
+ OCR •
Extensions •
Architecture •
Development
@@ -23,10 +24,10 @@
## Overview
-**A3S Use** is the application-capability layer for A3S. Browser and Office are
-first-party domains in the default distribution. Independently distributed
-packages can add more domains without rebuilding Use by declaring native CLI,
-standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest.
+**A3S Use** is the application-capability layer for A3S. Browser, native Office,
+and OCR are first-party domains in the default distribution. Independently
+distributed packages can add more domains without rebuilding Use by declaring
+native CLI, standard MCP, and/or `SKILL.md` surfaces in an A3S ACL manifest.
The primary user entry point is `a3s use`; `a3s-use` is the standalone binary
used by the umbrella CLI and remains available for direct use, automation, and
@@ -70,6 +71,8 @@ a3s use office native set report.docx '/body/p[1]' --align center --json
a3s use office native set workbook.xlsx /Sheet1/A1:C3 --number-format currency --fill FFF2CC --border-all thin --border-color C9B458 --vertical-align center --wrap-text true --json
a3s use office native set workbook.xlsx /Sheet1/A1:C1 --text 'Quarter' --bold true --merge-cells true --json
a3s use office native sort workbook.xlsx /Sheet1/A1:D100 --key B:desc --key C:asc --header true --case-sensitive false --json
+a3s use office native import workbook.xlsx /Sheet1 source.csv --header --start-cell A1 --json
+a3s use office native recalculate workbook.xlsx --output calculated.xlsx --json
a3s use office native add workbook.xlsx /Sheet1 --type table --name Sales --range F1:H4 --table-column Name --table-column Qty --table-column Price --style medium:4 --json
a3s use office native add report.docx '/body/p[1]' --type hyperlink --url https://example.com --display 'Open site' --tooltip 'A3S site' --json
a3s use office native add report.docx '/body/p[1]' --type comment --author Alice --initials AL --text 'Please review' --json
@@ -95,7 +98,15 @@ a3s use mcp serve browser
a3s use mcp serve office-native
# Keep using the pinned OfficeCLI compatibility MCP server where needed.
+a3s use mcp serve office-compat
+# Legacy alias:
a3s use mcp serve office
+
+# Built-in local PP-OCRv6.
+a3s use ocr doctor --json
+# The first extraction installs the pinned models when needed and allowed.
+a3s use ocr extract ./scan.png --json
+a3s use mcp serve ocr
```
Every domain argument accepted by `a3s use ...` can also be passed directly to
@@ -103,8 +114,8 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
## Features
-- **Built-In Browser and Office**: Keep stable first-party command routes while
- reporting provider readiness separately
+- **Built-In Browser, Office, and OCR**: Keep stable first-party command routes
+ while reporting provider readiness separately
- **Typed Rust Contracts**: Embed Browser rendering and Office operations
without starting a CLI process or an MCP server
- **Agent Browser Compatibility**: Provide the locked 82-command vocabulary,
@@ -115,9 +126,12 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
scoped cross-format literal/regex replacement, typed text formatting,
Spreadsheet number/fill/border/alignment formatting, exact merged-cell
editing, stable multi-key physical row sorting with persisted sort state,
- typed worksheet and table AutoFilters, typed data validation and conditional
- formatting, scoped defined names, native Spreadsheet ListObject table
- lifecycle, inert hyperlinks, and
+ bounded CSV/TSV import with typed inference and canonical header
+ filter/freeze behavior, a bounded formula parser and dependency graph,
+ explicit native formula recalculation with typed cached values and dynamic
+ arrays, typed worksheet and table AutoFilters, typed data validation and
+ conditional formatting, scoped defined names, native Spreadsheet ListObject
+ table lifecycle, inert hyperlinks, and
legacy comments,
native PNG/JPEG/GIF embedding, cross-format template merge, deterministic
bounded all-format annotated views, all-format HTML/SVG semantic previews,
@@ -129,13 +143,19 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
safe Word, Spreadsheet, Presentation, native MCP, and compatibility workflows
- **External Domains**: Install process-isolated packages that expose any useful
combination of CLI, MCP, and Skill surfaces
+- **First-Party OCR Domain**: Run pinned PP-OCRv6 detection and recognition
+ models locally through ONNX Runtime, with source digests and bounded layout
+ evidence
+- **Reference Science Toolkit**: Query PubMed, ChEMBL, ClinicalTrials.gov,
+ bioRxiv, and Ensembl through one typed read-only extension
- **Hot-Plug Discovery**: Publish immutable generation/revision snapshots so a
resident host can add, replace, or remove live capabilities without restarting
- **Content-Bound Skills**: Project an absolute package path and lowercase
SHA-256 for every `SKILL.md`, allowing consumers to verify the exact bytes
before loading them
- **Managed Provider Safety**: Require explicit installation authority, bounded
- downloads, approved HTTPS origins, receipts, staging, and atomic activation
+ first-use policy, bounded downloads, approved HTTPS origins, receipts,
+ staging, and atomic activation
- **Structured Automation**: Return versioned `--json` documents and typed error
codes while retaining native process status and streams for delegated commands
- **Component Ownership**: Remove only A3S-managed provider or package files;
@@ -146,9 +166,11 @@ Every domain argument accepted by `a3s use ...` can also be passed directly to
| Domain | Origin | CLI | MCP | Skill | Runtime owner |
| --- | --- | --- | --- | --- | --- |
-| Browser | Built in | Full Browser vocabulary | A3S Use standard MCP server | Six packaged Browser Skills | A3S Use |
-| Office | Built in | Stable Office vocabulary | Typed native preview plus OfficeCLI compatibility server | Packaged `a3s-use-office` Skill | A3S Use native engine; OfficeCLI compatibility in 0.1.x |
+| Browser | Built in | Full Browser vocabulary with first-launch preparation | A3S Use standard MCP server with confirmed installer | Six packaged Browser Skills | A3S Use |
+| Office | Built in | Native Office plus first-use compatibility fallback | Typed native server with confirmed compatibility installer plus OfficeCLI server | Packaged `a3s-use-office` Skill | A3S Use native engine; OfficeCLI compatibility in 0.1.x |
| Box | Reserved built-in route | Native A3S Box vocabulary | — | — | Umbrella A3S CLI |
+| OCR | Built in | Doctor and first-use typed image extraction | `ocr_doctor`, confirmed `ocr_install`, and `ocr_extract` | One local PP-OCRv6 Skill | A3S Use process with ONNX Runtime |
+| Science | External `a3s/science` package | Source-specific retrieval commands | 13 typed `science_*` tools | One research workflow Skill | Science extension process |
| External domain | Installed extension | Optional native executable | Optional standard MCP server | Optional `SKILL.md` | Extension package plus A3S Use lifecycle |
The Box route is component-backed. The umbrella CLI resolves its authoritative
@@ -157,12 +179,13 @@ does not copy Box, discover a replacement on `PATH`, or write a second receipt.
### Cargo feature matrix
-Default features are `browser`, `office`, `extensions`, and `mcp`.
+Default features are `browser`, `office`, `ocr`, `extensions`, and `mcp`.
| Feature | Included capability |
| --- | --- |
| `browser` | Typed Browser library, stateless rendering, and full Browser driver delegation |
| `office` | Typed Office contracts, native OOXML read engine, and temporary OfficeCLI compatibility |
+| `ocr` | Built-in typed PP-OCRv6 CLI/MCP with local ONNX inference |
| `extensions` | ACL manifests, package receipts, hot-plug registry, and external CLI/MCP/Skill routes |
| `mcp` | Standard MCP servers plus the managed Browser Streamable HTTP lifecycle |
| `lightpanda` | Explicit opt-in Lightpanda provider support in addition to Chrome |
@@ -179,6 +202,8 @@ A compiled command surface is not proof that its provider is installed. Use
| `a3s-use-browser-driver` | Complete interactive Browser CLI, MCP tools, Skills, Dashboard, and compatibility runtime |
| `a3s-use-office` | Native OOXML foundation, typed Office operations, and compatibility lifecycle |
| `a3s-use-extension` | A3S ACL manifest model, package registry, leases, and native surface descriptors |
+| `a3s-use-ocr` | Local PP-OCRv6 engine, CLI, MCP tools, pinned models, and release-packaged Skill assets |
+| `a3s-use-science` | Typed public life-science APIs, CLI, MCP tools, and extension package assets |
| `a3s-use` | Facade library, standalone CLI host, capability projection, and MCP entry points |
## Quick Start
@@ -190,6 +215,7 @@ release selection and the top-level component receipt:
```bash
a3s install use --source release
+# Optional deterministic pre-warm; normal first use prepares these as needed.
a3s install use/browser
a3s install use/office
a3s use doctor --json
@@ -218,7 +244,7 @@ not the facade binary:
```toml
[dependencies]
-a3s-use-browser = "0.1.1"
+a3s-use-browser = "0.1.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
url = "2"
```
@@ -242,9 +268,9 @@ async fn main() -> Result<(), Box> {
}
```
-`BrowserPoolConfig::default()` discovers an existing Chrome-compatible browser
-and never authorizes a download. Select a managed provider or run an explicit
-component install when A3S should own the runtime.
+`BrowserPoolConfig::default()` remains non-installing for embedded callers such
+as Search. Product commands validate their arguments and then prepare the same
+shared managed runtime on the first local Browser launch when policy allows.
## Browser
@@ -279,12 +305,16 @@ port, requires a private bearer token, has bounded idle and maximum lifetimes,
and shares typed Browser session state. It is an MCP deployment, not an A3S
JSON-RPC service.
-Provider selection stays explicit. Discovered providers never download
-software. Managed Chrome and Lightpanda installations use bounded staging and
-atomic activation; Lightpanda assets require the publisher SHA-256. Chrome for
-Testing does not publish an independent checksum in its current version feed,
-so A3S records HTTPS provenance and the locally observed digest without claiming
-publisher verification.
+Provider selection stays typed. Embedded `Discovered*` providers never
+download software. A direct local Browser launch is first-use authority for the
+A3S product CLI; Code workers request the bounded installer through parent
+confirmation. Both paths reuse a system browser or the shared A3S-managed
+cache before downloading. Managed Chrome and Lightpanda installations use
+bounded staging and atomic activation; Lightpanda assets require the publisher
+SHA-256. Chrome for Testing does not publish an independent checksum in its
+current version feed, so A3S records HTTPS provenance and the locally observed
+digest without claiming publisher verification. Help, version, doctor, Skills,
+profiles, and MCP server startup never install a browser.
See [Agent Browser Compatibility Baseline](docs/agent-browser-parity.md) for the
locked schemas, digests, runtime evidence, and promotion criteria.
@@ -361,9 +391,10 @@ protects `_xlnm.*` and `Slicer_*` names owned by other Office features.
Semantic get/query, ordinary typed remove, batch, exact replay, CLI, Rust, and
standard MCP share the same value. Strict/transitional SpreadsheetML and
unknown defined-name attributes are retained; unknown collection or child
-content fails closed when it cannot be preserved. This is defined-name
-lifecycle support, not formula evaluation, external-link authoring, or complete
-Spreadsheet parity.
+content fails closed when it cannot be preserved. This contract owns
+defined-name lifecycle, not external-link authoring or complete Spreadsheet
+parity; supported names participate when an explicit native cell-formula
+recalculation references them.
Native Spreadsheet AutoFilters use a closed typed contract shared by worksheet
filters and ListObject tables. One value owns a normalized rectangular A1
@@ -405,6 +436,63 @@ ignored errors, and supported drawing anchors move with their records; chart
caches are cleared and the worksheet used dimension is recomputed after the
physical change.
+Native Spreadsheet delimited import accepts bounded UTF-8 CSV or TSV from a
+regular file or stdin and writes it into an existing worksheet from an explicit
+A1 start cell. The parser supports a leading BOM, CRLF, quoted delimiters,
+embedded newlines, and doubled quotes; malformed quote state fails atomically.
+One request is limited to 8 MiB and a 100,000-cell rectangular extent. Explicit
+empty fields clear existing target cells, while missing trailing fields in a
+ragged row leave those cells unchanged.
+
+Typed inference stores formulas, finite numbers, booleans, ISO dates/times, and
+text without a Python, Node.js, OfficeCLI, or spreadsheet-application runtime.
+Dates honor the workbook's 1900/1904 system and receive a native date number
+format. Inferred formulas pass the same bounded native syntax parser as direct
+cell writes; malformed expressions fail the complete import. Import does not
+implicitly calculate formulas; run the explicit native recalculation command
+or mutation when fresh cached results are required. Header mode atomically
+installs the worksheet AutoFilter and a canonical frozen pane below the header.
+Frozen pane state is readable at `/Sheet/freeze`, is set through the typed
+batch/Rust/MCP contract, and is removed through the ordinary typed `remove`
+mutation. Strict/transitional SpreadsheetML and unknown view content are
+preserved; unsupported pane content reports `nativeMutable=false` and fails
+closed on mutation.
+
+Native Spreadsheet formula calculation builds a deterministic bounded
+dependency graph across worksheets, ranges, spills, and workbook- or
+worksheet-scoped names. The closed built-in registry implements
+`SUM`, `AVERAGE`, `MIN`, `MAX`, `COUNT`, `COUNTA`, `ABS`, `SQRT`, `POWER`,
+`MOD`, `ROUND`, `IF`, `IFERROR`, `AND`, `OR`, `NOT`, `CONCAT`,
+`CONCATENATE`, `ROW`, `COLUMN`, `SEQUENCE`, `TRANSPOSE`, `PI`, and `NA`.
+Operators and typed blank, number, text, boolean, and Spreadsheet error values
+participate in scalar or rectangular array calculation.
+
+The read-only Rust calculation API leaves package bytes unchanged. The editor,
+versioned batch, replay, CLI `office native recalculate`, and standard MCP
+`recalculate-spreadsheet-formulas` mutation atomically write typed OOXML
+caches, canonical array anchors, spill children, and calculated-workbook
+metadata. Spill children are read-only; edit or remove their formula anchor.
+Exact replay accepts canonical formula storage and natively cached array
+anchors. It fails closed for physical distinctions typed mutations cannot
+reproduce, including explicit `t="normal"` storage and uncached or malformed
+array anchors.
+ListObject structured references resolve table names or display names.
+`Sales[Qty]` and `Sales[[Qty]:[Price]]` select data rows; `#All`, `#Data`,
+`#Headers`, and `#Totals` select structural rows; and `Sales[@Qty]`,
+`Sales[[#This Row],[Qty]]`, or table-local `[@Qty]` select the current data row.
+Table-local forms require the formula cell to be inside the inferred table.
+Missing tables, columns, or requested structural rows, disjoint columns,
+non-canonical forms, cycles, unsupported or qualified functions, and
+external-workbook reads fail with stable typed errors and roll back the whole
+batch. The engine never fetches an external workbook or falls back to a shell
+or script runtime. One formula is limited to 8,192 characters, depth
+128 (including nested named-reference resolution), and 8,192 AST nodes; one
+reference value to 100,000 areas; one graph to 100,000 formulas, 1,000,000
+edges, and 1,000,000 formula-cell reference visits; one materialized array or
+function call to 100,000 cells; and one text result to 1 MiB.
+One calculation pass is also limited to 100,000 cumulative spill children and
+200,000 OOXML cell writes, plus 8 MiB of cumulative text-result bytes.
+
Native Spreadsheet tables use a separate closed ListObject contract. Add and
set own the workbook-wide `name`, optional distinct `displayName`, final
rectangular A1 range, one exact column identity per range column, header/totals
@@ -412,7 +500,17 @@ row state, typed filter criteria, built-in light/medium/dark style identity,
and first/last-column plus row/column-stripe flags; ordinary typed `remove`
owns deletion. When a header is enabled, its names are stamped into the first
row and the table-owned AutoFilter range excludes an enabled totals row. The
-editor rejects
+`set` lifecycle keeps common structured references consistent: changed table
+names/display names and position-mapped column identities are rewritten across
+cell formulas, defined names, conditional formats, data validations, charts,
+and table formulas without touching string literals or external-workbook
+references. Table-local references are rewritten only when their ListObject
+context is provable. Unsafe local-reference geometry changes fail with
+`use.office.spreadsheet_table_formula_rewrite_unsupported`, and `remove`
+fails with `use.office.spreadsheet_table_referenced` while a live structured
+reference still targets the table. All checks and rewrites share the table
+mutation's atomic rollback boundary.
+The editor rejects
Excel-identifier and A1/R1C1 name errors,
case-insensitive table/defined-name collisions, duplicate columns, missing data
rows, table/merge/worksheet-AutoFilter overlap, and unsafe relationship graphs.
@@ -495,10 +593,11 @@ Browser contract.
The explicit `office native` CLI exposes in-process blank creation, reads,
typed add/set/remove/move/copy/swap, scoped literal/regex replacement,
rich-text, exact Spreadsheet merged-cell, stable Spreadsheet physical sorting
-with persisted sort state, worksheet/table AutoFilter,
-data-validation, conditional-format, defined-name, ListObject table, hyperlink,
-and legacy-comment
-operations, constrained raw XML access,
+with persisted sort state, bounded CSV/TSV import with typed inference and
+header filter/freeze behavior, explicit Spreadsheet formula recalculation,
+worksheet/table AutoFilter, data-validation, conditional-format, defined-name,
+ListObject table, hyperlink, and legacy-comment operations, constrained raw
+XML access,
known typed part carriers, exact replay artifacts for a constrained canonical
subset, visible PNG/JPEG/GIF pictures, and atomic mutation batches, plus
dependency-free template merge and semantic rendering today. HTML and SVG are
@@ -515,12 +614,18 @@ agents without starting OfficeCLI. Discover its metadata with
`office skills get a3s-use-office`, append its four format/MCP references with
`--full`, or locate the installed directory with `office skills path`. The
capability snapshot binds the Skill path and lowercase SHA-256 so a resident
-host can verify the bytes before loading them.
+host can verify the bytes before loading them. Resident Code hosts receive the
+native engine as canonical route `use/office` targeting `office-native`; a ready
+OfficeCLI installation is projected separately as `use/office-compat` targeting
+`office-compat`.
Other `0.1.x` commands and the default `mcp serve office` target still use a
compatibility backend pinned to OfficeCLI `1.0.136`. This is a migration
boundary, not a native-promotion claim. The default routes will be promoted
only after mutation, fidelity, rendering, compatibility, and cross-application
-interoperability gates pass.
+interoperability gates pass. The first real compatibility CLI command prepares
+that pinned provider when first-use policy allows. In Code, the native Office
+worker requests `office_install_compat` through parent confirmation only when
+the requested operation is outside the native surface.
```bash
# Inspect without downloading anything.
@@ -592,6 +697,12 @@ a3s use office native set workbook.xlsx /Sheet1/E1 --border-diagonal slant-dash-
a3s use office native set workbook.xlsx /Sheet1/A1:C1 --text 'Quarter' --bold true --merge-cells true --json
a3s use office native set workbook.xlsx /Sheet1/A1:C1 --merge-cells false --json
+# Import a bounded UTF-8 CSV/TSV source. Header mode also installs the
+# worksheet AutoFilter and canonical frozen pane in the same transaction.
+a3s use office native import workbook.xlsx /Sheet1 source.csv --header --start-cell A1 --json
+a3s use office native import workbook.xlsx /Sheet1 --stdin --format tsv --output imported.xlsx --json
+a3s use office native get workbook.xlsx /Sheet1/freeze --json
+
# Add, inspect, replace, clear, and remove one worksheet AutoFilter. Each
# --filter is a strict JSON object with a zero-based column and typed criteria.
a3s use office native add workbook.xlsx /Sheet1 --type auto-filter --range A1:C20 --filter '{"column":0,"criteria":{"type":"values","values":["Open","Closed"],"includeBlanks":true}}' --filter '{"column":2,"criteria":{"type":"greater-than","value":"100"}}' --json
@@ -669,10 +780,12 @@ a3s use office native add deck.pptx '/slide[1]' --type comment --author Alice --
a3s use office native query deck.pptx comment --json
a3s use office native remove workbook.xlsx /Sheet1/B2/comment --json
-# Preserve Spreadsheet value types; formula storage requests application recalculation.
+# Preserve Spreadsheet value types. Formula writes validate and store the
+# expression; explicit recalculation computes and writes cached values.
a3s use office native set workbook.xlsx /Sheet1/A1 --number 42.5 --json
a3s use office native set workbook.xlsx /Sheet1/B1 --boolean true --json
a3s use office native set workbook.xlsx /Sheet1/C1 --formula 'SUM(A1:B1)' --json
+a3s use office native recalculate workbook.xlsx --output calculated.xlsx --json
# Set or remove a bounded rectangular range atomically.
a3s use office native set workbook.xlsx /Sheet1/A2:C4 --number 0 --json
@@ -731,7 +844,8 @@ a3s use office native dump report.docx --output report.replay.json --json
a3s use office native create restored.docx --json
a3s use office native batch restored.docx --input report.replay.json --json
-# Install the current compatibility provider explicitly.
+# Optional compatibility pre-warm. The following compatibility commands also
+# prepare this pinned provider on first use.
a3s install use/office
a3s use office get report.docx /body --json
a3s use office batch report.xlsx --input updates.json --json
@@ -743,7 +857,8 @@ a3s use mcp serve office-native
a3s use mcp serve office
```
-The native MCP process exposes 12 typed tools: `office_validate`,
+The native MCP process exposes 12 document tools plus the confirmed
+`office_install_compat` compatibility installer: `office_validate`,
`office_create`, `office_open`, `office_list`, `office_get`, `office_query`,
`office_view`, `office_raw_xml`, `office_apply_batch`,
`office_merge_template`, `office_save`, and `office_close`. It accepts no shell
@@ -851,8 +966,9 @@ and 10,000 mutations. The version 1 mutation set is `replace-text`, `set-text`,
`set-data-validation`, `add-conditional-format`, `set-conditional-format`,
`add-named-range`, `set-named-range`, `add-spreadsheet-table`,
`set-spreadsheet-table`, `add-spreadsheet-auto-filter`,
-`set-spreadsheet-auto-filter`, `sort-spreadsheet-range`, `merge-cells`,
-`unmerge-cells`,
+`set-spreadsheet-auto-filter`, `sort-spreadsheet-range`,
+`import-spreadsheet-delimited`, `set-spreadsheet-frozen-pane`, `merge-cells`,
+`unmerge-cells`, `recalculate-spreadsheet-formulas`,
`set-hyperlink`, `set-comment`, `set-table-column-width`,
`set-cell-value`, `add-paragraph`,
`add-table`, `add-table-row`, `add-table-column`, `add-table-cell`,
@@ -1012,6 +1128,39 @@ follow the row permutation. The worksheet used dimension is recomputed and
chart caches are cleared. Exact replay emits the same
`sort-spreadsheet-range` mutation.
+A Spreadsheet delimited import embeds bounded content in the typed batch or MCP
+request; filesystem paths remain a CLI-only concern:
+
+```json
+{
+ "operation": "import-spreadsheet-delimited",
+ "sheet": "/Sheet1",
+ "import": {
+ "content": "Name,Amount,Date\nAlpha,42,2026-07-17",
+ "format": "csv",
+ "header": true,
+ "startCell": "A1"
+ }
+}
+```
+
+`format` is `csv` or `tsv`. Header mode replaces the worksheet AutoFilter range
+and its canonical frozen pane in the same transaction, so inspect those nodes
+before importing into a populated sheet. Set a pane independently with
+`set-spreadsheet-frozen-pane` and remove it through `/Sheet/freeze`:
+
+```json
+{
+ "operation": "set-spreadsheet-frozen-pane",
+ "sheet": "/Sheet1",
+ "pane": {
+ "frozenRows": 1,
+ "frozenColumns": 0,
+ "topLeftCell": "A2"
+ }
+}
+```
+
A Spreadsheet table mutation uses one complete ListObject value. CLI `set`
preserves omitted fields; batch, Rust, and standard MCP replacements supply the
complete table:
@@ -1107,8 +1256,9 @@ that current typed mutations can reproduce byte-for-byte at the OOXML part-map
level: plain Word paragraphs and rectangular tables, Spreadsheet worksheets,
typed defined names, typed cells, typed worksheet/table AutoFilters, typed
ListObject tables, stable physical row order with supported typed sort state,
-merged ranges, typed data-validation rules, and canonical typed
-conditional-format rules without cached formula results;
+canonical frozen panes and import date styles, merged ranges, typed
+data-validation rules, canonical typed conditional-format rules, and natively
+recalculable formula caches and canonical cached dynamic-array spills;
plus Presentation slides with plain one-run text shapes and canonical basic
tables.
Headers, notes,
@@ -1391,10 +1541,26 @@ Typed Spreadsheet content values use an explicit nested type, for example:
}
```
-Formula mutation stores OOXML formula text and marks the workbook for a full
-recalculation when opened. Structural edits rewrite supported A1 references
-without evaluating formulas. A complete formula parser, dependency graph, and
-evaluator remain a separate rich-Spreadsheet delivery gate.
+Formula mutation removes one optional leading `=`, parses the bounded body into
+a source-spanned typed AST, stores the original normalized OOXML formula text,
+and marks the workbook for recalculation. It does not implicitly calculate the
+workbook. The parser covers scalar and error literals, Excel operator
+precedence, function calls and omitted arguments, parentheses and array
+constants, names and structured references, A1 cell/row/column references with
+quoted, 3D, or external qualifiers, and range/intersection/union operators.
+Invalid syntax returns `use.office.spreadsheet_formula_invalid` with zero-based
+UTF-8 byte and character offsets before any package mutation. Structural edits
+continue to rewrite supported A1 references without evaluating formulas.
+
+`NativeOfficeDocument::formula_dependency_graph` and
+`calculate_spreadsheet_formulas` provide read-only graph and calculation
+results. `NativeOfficeEditor::recalculate_spreadsheet_formulas`, the
+`recalculate-spreadsheet-formulas` batch/MCP mutation, exact replay, and the
+CLI command above atomically persist supported cached values and spills. Excel
+formula breadth beyond the closed native registry, disjoint or non-canonical
+structured-reference forms, qualified functions, external-workbook
+calculation, and full cross-application conformance remain rich-Spreadsheet
+delivery gates.
The native package, semantic, and editor APIs are available directly to Rust
callers:
@@ -1586,6 +1752,74 @@ compatibility response can return
See [Native Office Engine](docs/native-office.md) for the complete requirements,
compatibility scope, safety invariants, delivery gates, and migration plan.
+## OCR
+
+`a3s-use-ocr` implements the reserved built-in `ocr` route. The default Use
+release packages its `a3s-use-ocr` Skill and exposes `ocr_doctor`,
+`ocr_install`, and `ocr_extract` over standard MCP, so a resident A3S Code
+session receives `mcp__use_ocr__*` without installing a separate extension.
+
+OCR has one backend: the pinned `PP-OCRv6_small` detection and recognition
+models running locally through ONNX Runtime. The first CLI extraction installs
+or repairs the fixed-size, SHA-256-pinned official model archives when
+networking and first-use installation are allowed. `a3s install use/ocr`
+prepares the same bundle explicitly. Supported inputs are bounded local PNG,
+JPEG, WebP, GIF, BMP, and TIFF files. The result binds the canonical source
+path, media type, byte length, and SHA-256 alongside text,
+recognition/detection confidence, polygons, and bounding boxes.
+
+The pipeline decodes and normalizes the image, runs
+`PP-OCRv6_small_det`, applies DB post-processing and reading-order sorting,
+perspective-rectifies and rotates text crops, runs batched
+`PP-OCRv6_small_rec`, and applies CTC decoding. It does not require Python or
+PaddlePaddle, call a remote OCR API, or transfer source bytes off the device.
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract ./scan.png --json
+a3s use mcp serve ocr
+```
+
+A3S Code may first-use install the verified parent Use release. When OCR models
+are missing or damaged, the Code `use` worker requests the bounded
+`ocr_install` MCP tool. The parent TUI must confirm that network mutation before
+the worker continues to extraction. `--offline`, `A3S_OFFLINE=1`, and
+`A3S_NO_AUTO_INSTALL=1` prohibit first-use model installation. Diagnostics stay
+read-only, and `a3s install use/ocr` remains available for explicit preparation.
+
+See the [OCR crate](crates/ocr/README.md) for model resolution, the inference
+workflow, and input boundaries.
+
+## Science Toolkit
+
+The repository includes `a3s-use-science` as a reference external extension,
+not as another built-in route. Its process exposes one typed Rust client as 13
+read-only MCP tools plus source-specific CLI commands for PubMed, ChEMBL,
+ClinicalTrials.gov, bioRxiv, and Ensembl.
+
+Build a local package into a new directory and install it explicitly:
+
+```bash
+./crates/science/scripts/package.sh /tmp/a3s-use-science-package
+a3s install use/a3s/science \
+ --from /tmp/a3s-use-science-package \
+ --allow-unsigned
+
+export A3S_SCIENCE_CONTACT_EMAIL=researcher@example.org
+a3s use science pubmed search "single-cell atlas" --limit 10 --json
+a3s use science ensembl lookup homo_sapiens TP53 --json
+a3s use mcp serve a3s/science
+```
+
+The same package can be archived as `.tar.gz`, `.tgz`, or `.zip` and installed
+through the explicit local-package flow. Local packages require
+`--allow-unsigned`; use them only after review. PubMed requires the contact
+email, while `NCBI_API_KEY` is optional. See the
+[Science crate](crates/science/README.md), its
+[data-source notice](crates/science/DATA_SOURCES.md), and
+[clean-room provenance](crates/science/UPSTREAM.md) for the full command set,
+data egress, limits, and interpretation boundaries.
+
## External Extensions
External Use domains stay behind process boundaries. A package contains an
@@ -1629,22 +1863,87 @@ a3s use extension enable acme/slack --json
a3s uninstall use/acme/slack
```
-The current extension source is an explicit local directory. It must pass
-manifest, route, path, package-size, and executable validation, and unsigned
-content requires `--allow-unsigned`. A signed remote publisher channel is
-roadmap work; Use does not silently install arbitrary Homebrew, npm, Cargo,
-system, or `PATH` packages.
+The current extension source is an explicit local directory or a `.tar.gz`,
+`.tgz`, or `.zip` archive. Archives must contain exactly one package manifest;
+every entry must belong to that manifest's package root. Installation rejects
+links, traversal, duplicate paths, unsupported entries, excessive expansion,
+and non-portable paths before validating the manifest, route, executable, and
+Skill surfaces. Unsigned content requires `--allow-unsigned`. Use does not
+silently install arbitrary Homebrew, npm, Cargo, system, or `PATH` packages.
+
+### Signed extension registries
+
+Remote extensions use TUF metadata and a separately established bootstrap-root
+digest. Enroll a registry with either a root file or its SHA-256, verify it,
+review the immutable component plan, and apply that exact plan:
+
+```bash
+a3s registry add https://packages.example.org/a3s/ \
+ --trust-root ./root.json \
+ --yes
+a3s registry refresh packages
+
+a3s --output json install use/acme/slack --dry-run
+a3s --output json install use/acme/slack \
+ --plan-digest
+
+a3s --output json upgrade use/acme/slack --dry-run
+a3s --output json upgrade use/acme/slack \
+ --plan-digest
+```
+
+When a root file is supplied, the umbrella CLI copies it into registry-owned
+configuration and records its digest. With a digest-only enrollment, Use may
+fetch `/metadata/root.json`, but it caches the file only after the
+bytes match the pinned SHA-256. Subsequent root rotation, timestamp, snapshot,
+and targets metadata are verified by TUF with expiration and rollback
+enforcement. Registry URLs require HTTPS; loopback HTTP is accepted only for
+tests and local development.
+
+A dry-run verifies metadata but does not download the target archive. Its outer
+component digest includes the exact `ResolvedRemotePackage`: registry identity,
+bootstrap root, every TUF metadata version, package version and channel,
+platform target, archive path, length, and SHA-256. Apply resolves again and
+fails before target download if that plan changed. It then passes the resolved
+package's own digest to `a3s-use`, which repeats TUF verification immediately
+before downloading and activating the archive. The installed receipt records
+`registry-tuf` trust and the complete signed provenance. Registry installs
+reject `--allow-unsigned`; local `--from` installs cannot provide registry
+options.
+
+Registry upgrades reuse the registry identity and channel recorded in that
+signed provenance instead of searching every configured source again. A
+missing registry, changed URL or bootstrap root, and semantic-version downgrade
+are rejected before payload download. Plain `a3s upgrade` reports newer signed
+targets, while `a3s upgrade --all` includes them in the selected batch. If the
+verified target is identical to the installed target, `a3s-use` validates and
+reconciles the receipt and registry snapshot without downloading or
+reactivating the package.
+
+Publish metadata below `/metadata/` and payloads below
+`/targets/`. An extension target uses this canonical path:
+
+```text
+extensions//////
+```
+
+Its TUF target `custom.a3s` object must contain `schemaVersion`, `packageId`,
+`version`, `channel` (`stable`, `beta`, or `nightly`), and `target` (an A3S host
+target or `any`). Duplicate identities, mismatched paths, unsupported archives,
+and oversized targets are rejected before payload download.
Built-in and management routes are reserved. Extensions cannot shadow
-`browser`, `office`, `box`, `component`, `capability`, or other host commands.
+`browser`, `office`, `ocr`, `box`, `component`, `capability`, or other host
+commands.
## Live Host Integration
Resident hosts consume `capability snapshot` and `capability watch`. The
-projection presents Browser, Office, Box, and enabled extensions through one
-read-only schema while preserving each binding's `built-in` or `extension`
-origin. The extension generation advances on receipt mutations; a content
-revision also changes when built-in readiness or packaged Skill content changes.
+projection presents Browser, native Office, OCR, Box, and enabled extensions
+through one read-only schema while preserving each binding's `built-in` or
+`extension` origin. The extension generation advances on receipt mutations; a
+content revision also changes when built-in readiness or packaged Skill content
+changes.
```bash
a3s-use capability snapshot --json
@@ -1662,10 +1961,18 @@ tools. Projected Skills provide guidance only and cannot expand permissions or
authorize installation. Code verifies their projected SHA-256 before loading
the exact bytes.
+The built-in Office projection is intentionally host-oriented: `use/office`
+always exposes the in-process native MCP target when MCP support is compiled,
+without consulting OfficeCLI. A discovered OfficeCLI provider is a separate
+optional `use/office-compat` route, so native readiness and compatibility
+installation cannot mask or replace each other.
+
A capability becomes callable only after its MCP connection is ready. A
removed or replaced route leaves the worker catalog before its old connection
-drains. Starting Code never installs Use: component installation remains an
-explicit umbrella CLI action.
+drains. Code TUI resolves the catalogued Use component on first launch and may
+install its verified release before terminal takeover. Offline mode and
+`A3S_NO_AUTO_INSTALL=1` remain strict no-mutation boundaries; setup failure is
+non-fatal and stays visible through `/use`.
## Protocol and Lifecycle Boundaries
@@ -1702,13 +2009,13 @@ crash, and in-flight calls retain the exact package generation they accepted.
a3s use
│
a3s-use host
- ┌─────────────┼──────────────┐
- │ │ │
- Browser Office extension registry
- typed + driver native OOXML CLI / MCP / Skill
- + 0.1 compat
- │ │ │
- └──────── capability snapshot/watch ───────► A3S Code
+ ┌──────────┬──────────┬──────────┬──────────────┐
+ │ │ │ │ │
+ Browser Office OCR extension registry
+ typed + driver OOXML PP-OCRv6 ONNX CLI / MCP / Skill
+ + 0.1 compat
+ │ │ │ │
+ └──────── capability snapshot/watch ───────────► A3S Code
a3s-search ── Arc ──► a3s-use-browser
@@ -1717,10 +2024,12 @@ crash, and in-flight calls retain the exact package generation they accepted.
The dependency arrows are intentional. Search links only the Browser contract,
so rendering does not require `a3s-use`, MCP, or a resident process. Office is
-an in-process typed engine with a temporary 0.1.x compatibility process;
-external domains retain their process boundaries. A3S Code consumes the
-read-only projection and connects standard MCP/Skill surfaces; it does not gain
-component installation authority.
+an in-process typed engine with a temporary 0.1.x compatibility process. OCR
+runs the pinned PP-OCRv6 models locally through ONNX Runtime; model installation
+is an explicit component operation. External domains retain their process
+boundaries. A3S Code consumes the read-only projection and connects standard
+MCP/Skill surfaces; bounded component installation requests still require the
+parent TUI's authority.
Source is split between the facade under `src/` and focused workspace crates
under `crates/`. See [Architecture](docs/architecture.md) for package leases,
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 596fd092..8040153b 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -11,3 +11,57 @@ license text is distributed as `LICENSE-APACHE-2.0`, and detailed provenance is
distributed as `UPSTREAM.md`.
Upstream repository:
+
+## PaddlePaddle/PaddleOCR PP-OCRv6 Models
+
+A3S Use release archives redistribute the official
+`PP-OCRv6_small_det` and `PP-OCRv6_small_rec` ONNX inference model bundles
+published by PaddlePaddle/PaddleOCR. The installer pins the upstream archive
+URLs, byte sizes, and SHA-256 digests and does not modify the model weights.
+
+PaddleOCR is licensed under the Apache License, Version 2.0.
+
+Upstream repository:
+
+Model collection:
+
+## Microsoft ONNX Runtime
+
+`a3s-use-ocr` executes the models with Microsoft ONNX Runtime 1.22.0, obtained
+through the pinned `ort`/`ort-sys` Rust dependencies. ONNX Runtime is licensed
+under the MIT License.
+
+Copyright (c) Microsoft Corporation.
+
+Upstream repository:
+
+## pykeio/ort
+
+The `ort` and `ort-sys` Rust crates, version `2.0.0-rc.10`, provide the native
+ONNX Runtime bindings and build integration. They are available under the MIT
+License or the Apache License, Version 2.0.
+
+Upstream repository:
+
+## image-rs/imageproc
+
+`a3s-use-ocr` uses `imageproc` version `0.25.0` for geometric image
+transformations. `imageproc` is licensed under the MIT License.
+
+Copyright (c) 2015 PistonDevelopers.
+
+Upstream repository:
+
+## clipper2
+
+`a3s-use-ocr` uses the `clipper2` Rust crate version `0.5.3` and
+`clipper2c-sys` version `0.1.6` for bounded polygon offsetting during DB
+post-processing. The Rust crates are available under the MIT License or the
+Apache License, Version 2.0. Their bundled Clipper2 C/C++ implementation is
+licensed under the Boost Software License, Version 1.0.
+
+Upstream repositories:
+
+-
+-
+-
diff --git a/crates/browser-driver/Cargo.toml b/crates/browser-driver/Cargo.toml
index c58f3d19..3e837773 100644
--- a/crates/browser-driver/Cargo.toml
+++ b/crates/browser-driver/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "a3s-use-browser-driver"
-version = "0.1.1"
+version = "0.1.2"
edition = "2021"
description = "Full browser automation driver embedded in A3S Use"
license = "Apache-2.0"
@@ -13,6 +13,7 @@ name = "a3s-use-browser-driver"
path = "src/main.rs"
[dependencies]
+a3s-use-core = { version = "0.1.2", path = "../core" }
a3s-acl = { git = "https://github.com/A3S-Lab/ACL", rev = "6e2a6469edc0f4c61b1e588d0ace873aaf15ce22" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
diff --git a/crates/browser-driver/skills/a3s-use-browser/SKILL.md b/crates/browser-driver/skills/a3s-use-browser/SKILL.md
index 072d4ef5..1e0b537e 100644
--- a/crates/browser-driver/skills/a3s-use-browser/SKILL.md
+++ b/crates/browser-driver/skills/a3s-use-browser/SKILL.md
@@ -10,15 +10,24 @@ Use the host surface that is already available:
- In an A3S Code `use` worker, call the available
`mcp__use_browser__*` tools directly. The host owns installation and MCP
- lifecycle; do not run component installation or shell commands there.
+ lifecycle; do not run component installation or shell commands there. Call
+ `mcp__use_browser__agent_browser_doctor` first. If its managed browser is
+ missing, request `mcp__use_browser__agent_browser_install`; the parent TUI
+ must obtain HITL approval before that mutation can run.
- In a CLI-only agent host, use the `a3s use browser ...` commands below.
-Install the built-in capability and its managed runtime when needed:
+The first direct local launch installs the shared A3S-managed Chrome runtime
+when no system or managed browser is available and first-use policy permits it.
+Prepare it explicitly for deterministic startup or offline work:
```bash
a3s install use use/browser
```
+Doctor, help, version, Skills, profiles, and MCP server startup remain
+non-installing. `A3S_OFFLINE=1` and `A3S_NO_AUTO_INSTALL=1` prohibit the
+first-use download.
+
Load the version-matched core workflow before browser automation:
```bash
diff --git a/crates/browser-driver/src/lifecycle.rs b/crates/browser-driver/src/lifecycle.rs
index fbc25d57..51ad3955 100644
--- a/crates/browser-driver/src/lifecycle.rs
+++ b/crates/browser-driver/src/lifecycle.rs
@@ -3,8 +3,16 @@
use std::path::PathBuf;
use std::process::{Command, Stdio};
+use a3s_use_core::FirstUseInstallPolicy;
+
const USE_EXECUTABLE_ENV: &str = "A3S_USE_EXECUTABLE";
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AutoInstallAction {
+ Ready,
+ Install,
+}
+
pub fn install(with_dependencies: bool, json: bool) -> i32 {
if with_dependencies {
if let Err(error) = crate::install::install_system_dependencies() {
@@ -24,18 +32,89 @@ pub fn upgrade(json: bool) -> i32 {
run_component_install(true, json)
}
+pub fn ensure_first_use_browser() -> Result<(), String> {
+ let available = crate::native::cdp::chrome::find_chrome().is_some();
+ let explicit_invalid = explicit_browser_provider_invalid();
+ let policy = FirstUseInstallPolicy::from_env()
+ .map_err(|error| format!("{}: {}", error.code, error.message))?;
+ match automatic_install_action(available, explicit_invalid, policy)? {
+ AutoInstallAction::Ready => Ok(()),
+ AutoInstallAction::Install => {
+ run_component_install_captured()?;
+ crate::native::cdp::chrome::find_chrome()
+ .is_some()
+ .then_some(())
+ .ok_or_else(|| {
+ "use.browser.install_failed: Browser installation completed without a usable Chrome executable."
+ .to_string()
+ })
+ }
+ }
+}
+
+fn automatic_install_action(
+ available: bool,
+ explicit_invalid: bool,
+ policy: FirstUseInstallPolicy,
+) -> Result {
+ if explicit_invalid {
+ return Err(
+ "use.browser.explicit_provider_invalid: The explicit Browser executable is not usable. Fix or unset it before retrying."
+ .to_string(),
+ );
+ }
+ if available {
+ return Ok(AutoInstallAction::Ready);
+ }
+ if let Some(block) = policy.blocked_by() {
+ return Err(format!(
+ "use.browser.auto_install_disabled: No compatible browser is ready and first-use installation is disabled by {}. Run 'a3s install use/browser' explicitly while online.",
+ block.reason()
+ ));
+ }
+ Ok(AutoInstallAction::Install)
+}
+
+fn explicit_browser_provider_invalid() -> bool {
+ [
+ "A3S_USE_BROWSER_EXECUTABLE_PATH",
+ "AGENT_BROWSER_EXECUTABLE_PATH",
+ "A3S_BROWSER_EXECUTABLE",
+ "CHROME",
+ ]
+ .iter()
+ .filter_map(std::env::var_os)
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ .any(|path| !is_usable_executable(&path))
+}
+
+fn is_usable_executable(path: &std::path::Path) -> bool {
+ let Ok(metadata) = std::fs::metadata(path) else {
+ return false;
+ };
+ if !metadata.is_file() {
+ return false;
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o111 != 0
+ }
+ #[cfg(not(unix))]
+ {
+ true
+ }
+}
+
fn run_component_install(force: bool, json: bool) -> i32 {
- let executable = match resolve_use_executable() {
- Some(executable) => executable,
- None => {
- eprintln!(
- "Cannot find a3s-use for the Browser component lifecycle. Install or repair the A3S Use package, or set {USE_EXECUTABLE_ENV}."
- );
+ let mut command = match component_install_command(force, json) {
+ Ok(command) => command,
+ Err(error) => {
+ eprintln!("{error}");
return 1;
}
};
- let mut command = Command::new(&executable);
- command.args(component_install_args(force, json));
command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
@@ -45,13 +124,51 @@ fn run_component_install(force: bool, json: bool) -> i32 {
Err(error) => {
eprintln!(
"Failed to launch A3S Use component lifecycle '{}': {error}",
- executable.display()
+ command.get_program().to_string_lossy()
);
1
}
}
}
+fn run_component_install_captured() -> Result<(), String> {
+ let mut command = component_install_command(false, true)?;
+ let output = command
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .map_err(|error| {
+ format!(
+ "use.browser.install_failed: Failed to launch A3S Use component lifecycle '{}': {error}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if output.status.success() {
+ return Ok(());
+ }
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let detail = [stderr.trim(), stdout.trim()]
+ .into_iter()
+ .find(|value| !value.is_empty())
+ .unwrap_or("the component installer returned no diagnostic");
+ Err(format!(
+ "use.browser.install_failed: Browser first-use installation failed: {detail}"
+ ))
+}
+
+fn component_install_command(force: bool, json: bool) -> Result {
+ let executable = resolve_use_executable().ok_or_else(|| {
+ format!(
+ "Cannot find a3s-use for the Browser component lifecycle. Install or repair the A3S Use package, or set {USE_EXECUTABLE_ENV}."
+ )
+ })?;
+ let mut command = Command::new(executable);
+ command.args(component_install_args(force, json));
+ Ok(command)
+}
+
fn component_install_args(force: bool, json: bool) -> Vec<&'static str> {
let mut arguments = vec!["component", "install", "browser"];
if force {
@@ -124,6 +241,30 @@ mod tests {
);
}
+ #[test]
+ fn first_use_installs_only_when_the_runtime_is_missing_and_policy_allows_it() {
+ assert_eq!(
+ automatic_install_action(true, false, FirstUseInstallPolicy::new(true, true)).unwrap(),
+ AutoInstallAction::Ready
+ );
+ assert_eq!(
+ automatic_install_action(false, false, FirstUseInstallPolicy::new(false, false))
+ .unwrap(),
+ AutoInstallAction::Install
+ );
+ for policy in [
+ FirstUseInstallPolicy::new(true, false),
+ FirstUseInstallPolicy::new(false, true),
+ ] {
+ let error = automatic_install_action(false, false, policy).unwrap_err();
+ assert!(error.starts_with("use.browser.auto_install_disabled:"));
+ }
+ let explicit =
+ automatic_install_action(false, true, FirstUseInstallPolicy::new(false, false))
+ .unwrap_err();
+ assert!(explicit.starts_with("use.browser.explicit_provider_invalid:"));
+ }
+
#[test]
fn explicit_lifecycle_executable_must_be_a_file() {
let temp = tempfile::tempdir().unwrap();
diff --git a/crates/browser-driver/src/main.rs b/crates/browser-driver/src/main.rs
index db3e20d7..a0c02392 100644
--- a/crates/browser-driver/src/main.rs
+++ b/crates/browser-driver/src/main.rs
@@ -156,6 +156,33 @@ fn incompatible_launch_mode_error(flags: &Flags) -> Option<&'static str> {
None
}
+fn uses_external_browser(flags: &Flags) -> bool {
+ flags.executable_path.is_some()
+ || flags.provider.is_some()
+ || flags.cdp.is_some()
+ || flags.auto_connect
+}
+
+fn command_starts_local_browser(command: &serde_json::Value, flags: &Flags) -> bool {
+ if uses_external_browser(flags)
+ || command.get("cdpUrl").is_some()
+ || command.get("cdpPort").is_some()
+ {
+ return false;
+ }
+ matches!(
+ command.get("action").and_then(|value| value.as_str()),
+ Some("launch" | "navigate" | "batch" | "diff_url")
+ )
+}
+
+fn prepare_first_use_browser(flags: &Flags) -> Result<(), String> {
+ if uses_external_browser(flags) {
+ return Ok(());
+ }
+ lifecycle::ensure_first_use_browser()
+}
+
fn should_send_local_launch_config(flags: &Flags) -> bool {
(flags.headed
|| flags.cli_headed
@@ -1111,6 +1138,14 @@ fn main() {
} else {
None
};
+ if let Err(error) = prepare_first_use_browser(&flags) {
+ if flags.json {
+ print_json_error(error);
+ } else {
+ eprintln!("{} {}", color::error_indicator(), error);
+ }
+ exit(1);
+ }
chat::run_chat(&flags, message);
return;
}
@@ -1237,6 +1272,17 @@ fn main() {
exit(1);
}
+ if command_starts_local_browser(&cmd, &flags) {
+ if let Err(error) = prepare_first_use_browser(&flags) {
+ if flags.json {
+ print_json_error(error);
+ } else {
+ eprintln!("{} {}", color::error_indicator(), error);
+ }
+ exit(1);
+ }
+ }
+
// Parse proxy URL to separate server from credentials for the daemon.
let (proxy_server, proxy_username, proxy_password) = if let Some(ref proxy_str) = flags.proxy {
let parsed = parse_proxy(proxy_str);
@@ -1959,6 +2005,32 @@ mod tests {
flags
}
+ #[test]
+ fn first_use_preparation_is_limited_to_local_browser_launches() {
+ let flags = neutral_launch_config_flags();
+ for action in ["launch", "navigate", "batch", "diff_url"] {
+ assert!(command_starts_local_browser(
+ &json!({ "action": action }),
+ &flags
+ ));
+ }
+ assert!(!command_starts_local_browser(
+ &json!({ "action": "snapshot" }),
+ &flags
+ ));
+ assert!(!command_starts_local_browser(
+ &json!({ "action": "launch", "cdpUrl": "http://127.0.0.1:9222" }),
+ &flags
+ ));
+
+ let mut external = neutral_launch_config_flags();
+ external.executable_path = Some("/explicit/chrome".to_string());
+ assert!(!command_starts_local_browser(
+ &json!({ "action": "navigate" }),
+ &external
+ ));
+ }
+
#[test]
fn test_attach_allowed_domains_to_launch_command() {
let mut flags = neutral_launch_config_flags();
diff --git a/crates/browser-driver/src/mcp.rs b/crates/browser-driver/src/mcp.rs
index 99fb2a30..c1585c07 100644
--- a/crates/browser-driver/src/mcp.rs
+++ b/crates/browser-driver/src/mcp.rs
@@ -352,6 +352,8 @@ const CORE_PROFILE_TOOLS: &[&str] = &[
TOOL_TAB_CLOSE,
TOOL_EVAL,
TOOL_CLOSE,
+ TOOL_DOCTOR,
+ TOOL_INSTALL,
];
const NETWORK_PROFILE_TOOLS: &[&str] = &[
@@ -3744,6 +3746,8 @@ mod tests {
assert!(names.contains(&TOOL_SNAPSHOT));
assert!(names.contains(&TOOL_CLICK));
assert!(names.contains(&TOOL_SCREENSHOT));
+ assert!(names.contains(&TOOL_DOCTOR));
+ assert!(names.contains(&TOOL_INSTALL));
assert!(names.contains(&TOOL_GET_CDP_URL));
assert!(names.contains(&TOOL_NETWORK_HAR_START));
assert!(names.contains(&TOOL_REACT_SUSPENSE));
diff --git a/crates/browser/Cargo.toml b/crates/browser/Cargo.toml
index 88f05d6d..4147e314 100644
--- a/crates/browser/Cargo.toml
+++ b/crates/browser/Cargo.toml
@@ -22,7 +22,7 @@ chrome = [
lightpanda = ["chrome"]
[dependencies]
-a3s-use-core = { version = "0.1.1", path = "../core" }
+a3s-use-core = { version = "0.1.2", path = "../core" }
async-trait.workspace = true
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], optional = true }
fs2 = { workspace = true, optional = true }
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 80fb2e7e..fcc4eaac 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
+use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
@@ -120,6 +121,86 @@ impl std::error::Error for UseError {}
pub type UseResult = Result;
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FirstUseInstallBlock {
+ Offline,
+ Disabled,
+}
+
+impl FirstUseInstallBlock {
+ pub const fn reason(self) -> &'static str {
+ match self {
+ Self::Offline => "offline mode",
+ Self::Disabled => "A3S_NO_AUTO_INSTALL",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct FirstUseInstallPolicy {
+ offline: bool,
+ disabled: bool,
+}
+
+impl FirstUseInstallPolicy {
+ pub const fn new(offline: bool, disabled: bool) -> Self {
+ Self { offline, disabled }
+ }
+
+ pub fn from_env() -> UseResult {
+ Self::from_values(
+ std::env::var_os("A3S_OFFLINE"),
+ std::env::var_os("A3S_NO_AUTO_INSTALL"),
+ )
+ }
+
+ pub const fn blocked_by(self) -> Option {
+ if self.offline {
+ Some(FirstUseInstallBlock::Offline)
+ } else if self.disabled {
+ Some(FirstUseInstallBlock::Disabled)
+ } else {
+ None
+ }
+ }
+
+ pub const fn allows_install(self) -> bool {
+ self.blocked_by().is_none()
+ }
+
+ fn from_values(offline: Option, disabled: Option) -> UseResult {
+ Ok(Self {
+ offline: parse_environment_boolean("A3S_OFFLINE", offline)?,
+ disabled: parse_environment_boolean("A3S_NO_AUTO_INSTALL", disabled)?,
+ })
+ }
+}
+
+fn parse_environment_boolean(name: &'static str, value: Option) -> UseResult {
+ let Some(value) = value else {
+ return Ok(false);
+ };
+ if value.is_empty() {
+ return Ok(true);
+ }
+ let value = value.into_string().map_err(|_| {
+ UseError::new(
+ "use.first_use.policy_invalid",
+ format!("{name} must contain a valid UTF-8 boolean value."),
+ )
+ .with_detail("variable", name)
+ })?;
+ match value.trim().to_ascii_lowercase().as_str() {
+ "1" | "true" | "yes" | "on" => Ok(true),
+ "0" | "false" | "no" | "off" => Ok(false),
+ _ => Err(UseError::new(
+ "use.first_use.policy_invalid",
+ format!("{name} must be a boolean value."),
+ )
+ .with_detail("variable", name)),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -143,4 +224,30 @@ mod tests {
.unwrap()
.contains("a3s install"));
}
+
+ #[test]
+ fn first_use_policy_uses_a3s_boolean_conventions() {
+ for value in [None, Some("0"), Some("false"), Some("no"), Some("off")] {
+ let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
+ assert!(policy.allows_install());
+ }
+ for value in [Some(""), Some("1"), Some("true"), Some("yes"), Some("on")] {
+ let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
+ assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
+ }
+ }
+
+ #[test]
+ fn offline_policy_takes_precedence_over_no_auto_install() {
+ let policy =
+ FirstUseInstallPolicy::from_values(Some("1".into()), Some("1".into())).unwrap();
+ assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
+ }
+
+ #[test]
+ fn invalid_first_use_policy_is_typed() {
+ let error = FirstUseInstallPolicy::from_values(Some("sometimes".into()), None).unwrap_err();
+ assert_eq!(error.code, "use.first_use.policy_invalid");
+ assert_eq!(error.details["variable"], "A3S_OFFLINE");
+ }
}
diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml
index 93f63891..5a70b363 100644
--- a/crates/extension/Cargo.toml
+++ b/crates/extension/Cargo.toml
@@ -9,12 +9,22 @@ rust-version.workspace = true
description = "ACL manifest and native surface contracts for A3S Use extensions"
[dependencies]
-a3s-acl = { git = "https://github.com/A3S-Lab/ACL", rev = "6e2a6469edc0f4c61b1e588d0ace873aaf15ce22" }
-a3s-use-core = { version = "0.1.1", path = "../core" }
+a3s-acl = "=0.2.2"
+a3s-use-core = { version = "0.1.2", path = "../core" }
fs2.workspace = true
+flate2.workspace = true
+reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
semver = "1"
sha2.workspace = true
+tar.workspace = true
tempfile.workspace = true
tokio.workspace = true
+tough.workspace = true
+url.workspace = true
+zip.workspace = true
+
+[dev-dependencies]
+olpc-cjson = "0.1"
+ring = "0.17"
diff --git a/crates/extension/src/digest.rs b/crates/extension/src/digest.rs
new file mode 100644
index 00000000..7f8fb87c
--- /dev/null
+++ b/crates/extension/src/digest.rs
@@ -0,0 +1,194 @@
+use std::fs::File;
+use std::io::{BufReader, Read};
+use std::path::{Path, PathBuf};
+
+use a3s_use_core::{UseError, UseResult};
+use sha2::{Digest, Sha256};
+
+use super::package::{io_error, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES};
+use super::source::sanitized_relative_path;
+
+struct PackageFile {
+ normalized: String,
+ path: PathBuf,
+ size: u64,
+}
+
+pub(crate) async fn package_sha256(root: &Path) -> UseResult {
+ let root = root.to_path_buf();
+ tokio::task::spawn_blocking(move || hash_package(&root))
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.io",
+ format!("Failed to hash extension package: blocking task failed: {error}"),
+ )
+ })?
+}
+
+fn hash_package(root: &Path) -> UseResult {
+ let mut files = Vec::new();
+ let mut entries = 0_usize;
+ let mut bytes = 0_u64;
+ collect_files(root, root, &mut files, &mut entries, &mut bytes)?;
+ files.sort_by(|left, right| left.normalized.cmp(&right.normalized));
+
+ let mut digest = Sha256::new();
+ digest.update(b"a3s-use-expanded-package-v1\0");
+ for package_file in files {
+ let path_bytes = package_file.normalized.as_bytes();
+ digest.update((path_bytes.len() as u64).to_be_bytes());
+ digest.update(path_bytes);
+ digest.update(package_file.size.to_be_bytes());
+
+ let file = File::open(&package_file.path)
+ .map_err(|error| io_error("open extension package file", &package_file.path, error))?;
+ let mut reader = BufReader::new(file);
+ let mut buffer = [0_u8; 64 * 1024];
+ let mut read_bytes = 0_u64;
+ loop {
+ let count = reader.read(&mut buffer).map_err(|error| {
+ io_error("hash extension package file", &package_file.path, error)
+ })?;
+ if count == 0 {
+ break;
+ }
+ read_bytes = read_bytes.saturating_add(count as u64);
+ if read_bytes > package_file.size {
+ return Err(package_changed(&package_file.path));
+ }
+ digest.update(&buffer[..count]);
+ }
+ if read_bytes != package_file.size {
+ return Err(package_changed(&package_file.path));
+ }
+ }
+ Ok(format!("{:x}", digest.finalize()))
+}
+
+fn collect_files(
+ root: &Path,
+ directory: &Path,
+ files: &mut Vec,
+ entries: &mut usize,
+ bytes: &mut u64,
+) -> UseResult<()> {
+ let children = std::fs::read_dir(directory)
+ .map_err(|error| io_error("read extension package directory", directory, error))?;
+ for child in children {
+ let child =
+ child.map_err(|error| io_error("read extension package entry", directory, error))?;
+ *entries = entries.saturating_add(1);
+ if *entries > MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let path = child.path();
+ let metadata = std::fs::symlink_metadata(&path)
+ .map_err(|error| io_error("inspect extension package entry", &path, error))?;
+ if metadata.file_type().is_symlink() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!(
+ "Extension package entry '{}' is a symbolic link.",
+ path.display()
+ ),
+ ));
+ }
+ if metadata.is_dir() {
+ collect_files(root, &path, files, entries, bytes)?;
+ continue;
+ }
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension package entry '{}' is not a regular file or directory.",
+ path.display()
+ ),
+ ));
+ }
+ *bytes = bytes.saturating_add(metadata.len());
+ if *bytes > MAX_PACKAGE_BYTES {
+ return Err(package_limit_error());
+ }
+ let relative = path.strip_prefix(root).map_err(|_| {
+ UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension package entry '{}' escapes its root.",
+ path.display()
+ ),
+ )
+ })?;
+ let relative = sanitized_relative_path(relative)?.ok_or_else(|| {
+ UseError::new(
+ "use.extension.package_entry_invalid",
+ "Extension package contains an empty file path.",
+ )
+ })?;
+ let normalized = relative
+ .iter()
+ .map(|segment| {
+ segment.to_str().ok_or_else(|| {
+ UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension package path '{}' is not valid UTF-8.",
+ relative.display()
+ ),
+ )
+ })
+ })
+ .collect::>>()?
+ .join("/");
+ files.push(PackageFile {
+ normalized,
+ path,
+ size: metadata.len(),
+ });
+ }
+ Ok(())
+}
+
+fn package_changed(path: &Path) -> UseError {
+ UseError::new(
+ "use.extension.package_changed",
+ format!(
+ "Extension package file '{}' changed while it was hashed.",
+ path.display()
+ ),
+ )
+}
+
+fn package_limit_error() -> UseError {
+ UseError::new(
+ "use.extension.package_too_large",
+ "The extension package exceeds the local installation limits.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn package_digest_is_order_independent_and_content_sensitive() {
+ let temp = tempfile::tempdir().unwrap();
+ let first = temp.path().join("first");
+ let second = temp.path().join("second");
+ std::fs::create_dir_all(first.join("bin")).unwrap();
+ std::fs::create_dir_all(second.join("bin")).unwrap();
+ std::fs::write(first.join("z.txt"), b"z").unwrap();
+ std::fs::write(first.join("bin/tool"), b"tool").unwrap();
+ std::fs::write(second.join("bin/tool"), b"tool").unwrap();
+ std::fs::write(second.join("z.txt"), b"z").unwrap();
+
+ let first_digest = package_sha256(&first).await.unwrap();
+ let second_digest = package_sha256(&second).await.unwrap();
+ assert_eq!(first_digest, second_digest);
+ assert_eq!(first_digest.len(), 64);
+
+ std::fs::write(second.join("bin/tool"), b"changed").unwrap();
+ assert_ne!(first_digest, package_sha256(&second).await.unwrap());
+ }
+}
diff --git a/crates/extension/src/lib.rs b/crates/extension/src/lib.rs
index ffc918a5..9b692c3c 100644
--- a/crates/extension/src/lib.rs
+++ b/crates/extension/src/lib.rs
@@ -5,11 +5,14 @@ use a3s_acl::{Block, Value};
use a3s_use_core::{RiskClass, UseError, UseResult};
use serde::{Deserialize, Serialize};
+mod digest;
mod package;
mod paths;
mod registry;
mod registry_io;
+mod remote;
mod route_lock;
+mod source;
pub use paths::ExtensionPaths;
pub use registry::{
@@ -17,12 +20,19 @@ pub use registry::{
ExtensionRouteBinding, ExtensionRouteLease, ExtensionTrust, InstallOptions, InstallResult,
InstalledExtension, UninstallResult,
};
+pub use remote::{
+ prepare_remote_package, refresh_remote_registry, DownloadedRemotePackage,
+ PreparedRemotePackage, ResolvedRemotePackage, TrustedRegistry, VerifiedRegistryMetadata,
+};
const RESERVED_ROUTES: &[&str] = &[
"browser",
"box",
"capability",
"office",
+ "office-compat",
+ "office-native",
+ "ocr",
"capabilities",
"component",
"extension",
@@ -437,7 +447,7 @@ extension "acme/slack" {
#[test]
fn rejects_reserved_routes() {
- for route in ["browser", "box"] {
+ for route in ["browser", "box", "ocr"] {
let manifest = MANIFEST.replace(
"route = \"slack\"",
&format!("route = \"{route}\""),
diff --git a/crates/extension/src/package.rs b/crates/extension/src/package.rs
index a939a430..9778ace7 100644
--- a/crates/extension/src/package.rs
+++ b/crates/extension/src/package.rs
@@ -12,9 +12,9 @@ use tokio::io::AsyncWriteExt;
use super::registry::ExtensionReceipt;
use super::{ExtensionManifest, ExtensionPaths};
-const MANIFEST_NAME: &str = "a3s-use-extension.acl";
-const MAX_PACKAGE_FILES: usize = 10_000;
-const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;
+pub(crate) const MANIFEST_NAME: &str = "a3s-use-extension.acl";
+pub(crate) const MAX_PACKAGE_FILES: usize = 10_000;
+pub(crate) const MAX_PACKAGE_BYTES: u64 = 1_073_741_824;
pub(crate) async fn read_manifest(package_root: &Path) -> UseResult<(ExtensionManifest, Vec)> {
let path = package_root.join(MANIFEST_NAME);
diff --git a/crates/extension/src/paths.rs b/crates/extension/src/paths.rs
index 62adef50..3232a610 100644
--- a/crates/extension/src/paths.rs
+++ b/crates/extension/src/paths.rs
@@ -93,6 +93,12 @@ impl ExtensionPaths {
path.set_extension("lock");
path
}
+
+ pub fn tuf_datastore(&self, registry_name: &str) -> PathBuf {
+ self.state_root
+ .join("remote-registries")
+ .join(registry_name)
+ }
}
fn configured_root(
@@ -163,5 +169,9 @@ mod tests {
paths.registry_snapshot_path(),
PathBuf::from("/state/use/registry.json")
);
+ assert_eq!(
+ paths.tuf_datastore("a3s"),
+ PathBuf::from("/state/use/remote-registries/a3s")
+ );
}
}
diff --git a/crates/extension/src/registry.rs b/crates/extension/src/registry.rs
index 5f3c0135..05e3dc97 100644
--- a/crates/extension/src/registry.rs
+++ b/crates/extension/src/registry.rs
@@ -7,12 +7,15 @@ use fs2::FileExt;
use serde::{Deserialize, Serialize};
use tokio::fs;
+use super::digest::package_sha256;
use super::package::{
copy_package, io_error, owned_package_path, read_manifest, sha256, unique_suffix,
unix_timestamp, validate_surface_files, write_receipt, RegistryLock,
};
use super::registry_io::{read_registry_snapshot, write_registry_snapshot};
+use super::remote::{prepare_remote_package, ResolvedRemotePackage, TrustedRegistry};
use super::route_lock::{acquire_drain_lock, deadline_after, open_route_lock};
+use super::source::prepare_package_source;
use super::{ExtensionManifest, ExtensionPaths, McpTransport};
const RECEIPT_SCHEMA_VERSION: u32 = 1;
@@ -24,6 +27,7 @@ const WATCH_INTERVAL: Duration = Duration::from_millis(50);
#[serde(rename_all = "kebab-case")]
pub enum ExtensionTrust {
LocalExplicit,
+ RegistryTuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -36,7 +40,11 @@ pub struct ExtensionReceipt {
pub version: String,
pub package_root: PathBuf,
pub manifest_sha256: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub package_sha256: Option,
pub trust: ExtensionTrust,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub registry: Option,
pub installed_at_unix: u64,
#[serde(default = "enabled_by_default")]
pub enabled: bool,
@@ -115,6 +123,8 @@ pub struct ExtensionRouteBinding {
#[serde(default)]
pub package_root: PathBuf,
pub manifest_sha256: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub package_sha256: Option,
pub enabled: bool,
pub surfaces: Vec,
}
@@ -368,21 +378,113 @@ impl ExtensionRegistry {
.with_suggestion("Rerun the explicit install with --allow-unsigned."));
}
- let source = fs::canonicalize(source)
- .await
- .map_err(|error| io_error("resolve extension package", source, error))?;
- let source_metadata = fs::metadata(&source)
- .await
- .map_err(|error| io_error("inspect extension package", &source, error))?;
- if !source_metadata.is_dir() {
- return Err(UseError::new(
- "use.extension.package_unsupported",
- "The initial local installer accepts a package directory.",
+ let source = prepare_package_source(source).await?;
+ self.install_prepared(
+ &expected_package_id,
+ source.root(),
+ options.force,
+ ExtensionTrust::LocalExplicit,
+ None,
+ )
+ .await
+ }
+
+ /// Install an extension selected through a fully verified TUF repository.
+ ///
+ /// Metadata is resolved and the optional reviewed plan is checked before
+ /// the target payload is downloaded. The package manifest must repeat the
+ /// exact ID and version carried by the signed target metadata.
+ pub async fn install_remote(
+ &self,
+ expected_package_id: &str,
+ registry: &TrustedRegistry,
+ requested_version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+ force: bool,
+ ) -> UseResult {
+ let expected_package_id = normalize_package_id(expected_package_id)?;
+ let prepared = prepare_remote_package(
+ registry,
+ &expected_package_id,
+ requested_version,
+ channel,
+ expected_plan_digest,
+ )
+ .await?;
+ if !force {
+ if let Some(result) = self
+ .converged_remote_install(&expected_package_id, prepared.resolved())
+ .await?
+ {
+ return Ok(result);
+ }
+ }
+ let downloaded = prepared.download().await?;
+ let provenance = downloaded.resolved().clone();
+ let source = prepare_package_source(downloaded.path()).await?;
+ self.install_prepared(
+ &expected_package_id,
+ source.root(),
+ force,
+ ExtensionTrust::RegistryTuf,
+ Some(provenance),
+ )
+ .await
+ }
+
+ async fn converged_remote_install(
+ &self,
+ expected_package_id: &str,
+ resolved: &ResolvedRemotePackage,
+ ) -> UseResult> {
+ let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?;
+ let Some(mut current) = self.get(expected_package_id).await? else {
+ return Ok(None);
+ };
+ let same_target = current.receipt.trust == ExtensionTrust::RegistryTuf
+ && current.receipt.version == resolved.version
+ && registry_identity(current.receipt.registry.as_ref())
+ == registry_identity(Some(resolved));
+ if !same_target {
+ return Ok(None);
+ }
+ verify_package_integrity(¤t).await?;
+ if current.receipt.registry.as_ref() != Some(resolved) {
+ current.receipt.registry = Some(resolved.clone());
+ write_receipt(
+ &self.paths.receipt_path(expected_package_id),
+ ¤t.receipt,
)
- .with_suggestion("Extract the package archive and pass its directory with --from."));
+ .await?;
+ }
+ let installed = self.list().await?;
+ self.publish_snapshot_locked(&installed).await?;
+ Ok(Some(InstallResult {
+ changed: false,
+ extension: current,
+ }))
+ }
+
+ async fn install_prepared(
+ &self,
+ expected_package_id: &str,
+ source: &Path,
+ force: bool,
+ trust: ExtensionTrust,
+ registry: Option,
+ ) -> UseResult {
+ match (trust, registry.as_ref()) {
+ (ExtensionTrust::LocalExplicit, None) | (ExtensionTrust::RegistryTuf, Some(_)) => {}
+ _ => {
+ return Err(UseError::new(
+ "use.extension.trust_invalid",
+ "Extension installation provenance is internally inconsistent.",
+ ))
+ }
}
- let (manifest, manifest_bytes) = read_manifest(&source).await?;
+ let (manifest, manifest_bytes) = read_manifest(source).await?;
if manifest.package_id != expected_package_id {
return Err(UseError::new(
"use.extension.identity_mismatch",
@@ -392,7 +494,22 @@ impl ExtensionRegistry {
),
));
}
- validate_surface_files(&manifest, &source).await?;
+ if let Some(registry) = ®istry {
+ if registry.package_id != manifest.package_id || registry.version != manifest.version {
+ return Err(UseError::new(
+ "use.extension.registry_identity_mismatch",
+ format!(
+ "Signed target '{}@{}' does not match package manifest '{}@{}'.",
+ registry.package_id,
+ registry.version,
+ manifest.package_id,
+ manifest.version
+ ),
+ ));
+ }
+ }
+ validate_surface_files(&manifest, source).await?;
+ let package_digest = package_sha256(source).await?;
let _lock = RegistryLock::acquire(&self.paths.registry_lock_path())?;
let installed = self.list().await?;
@@ -414,9 +531,17 @@ impl ExtensionRegistry {
.iter()
.find(|extension| extension.receipt.package_id == expected_package_id)
{
- if !options.force
+ let current_package_digest = match ¤t.receipt.package_sha256 {
+ Some(digest) => digest.clone(),
+ None => package_sha256(¤t.receipt.package_root).await?,
+ };
+ let same_provenance = current.receipt.trust == trust
+ && registry_identity(current.receipt.registry.as_ref())
+ == registry_identity(registry.as_ref());
+ if !force
&& current.receipt.version == manifest.version
- && current.receipt.manifest_sha256 == digest
+ && current_package_digest == package_digest
+ && same_provenance
{
self.publish_snapshot_locked(&installed).await?;
return Ok(InstallResult {
@@ -424,7 +549,10 @@ impl ExtensionRegistry {
extension: current.clone(),
});
}
- if !options.force && current.receipt.version == manifest.version {
+ if !force
+ && current.receipt.version == manifest.version
+ && current_package_digest != package_digest
+ {
return Err(UseError::new(
"use.extension.version_conflict",
format!(
@@ -436,7 +564,7 @@ impl ExtensionRegistry {
}
}
- let package_parent = self.paths.package_parent(&expected_package_id);
+ let package_parent = self.paths.package_parent(expected_package_id);
fs::create_dir_all(&package_parent).await.map_err(|error| {
io_error("create extension package directory", &package_parent, error)
})?;
@@ -446,7 +574,7 @@ impl ExtensionRegistry {
.map_err(|error| {
io_error("create extension staging directory", &package_parent, error)
})?;
- copy_package(&source, staging.path()).await?;
+ copy_package(source, staging.path()).await?;
let (staged_manifest, staged_bytes) = read_manifest(staging.path()).await?;
if staged_manifest != manifest || sha256(&staged_bytes) != digest {
return Err(UseError::new(
@@ -455,11 +583,17 @@ impl ExtensionRegistry {
));
}
validate_surface_files(&staged_manifest, staging.path()).await?;
+ if package_sha256(staging.path()).await? != package_digest {
+ return Err(UseError::new(
+ "use.extension.package_changed",
+ "The extension package changed while it was staged.",
+ ));
+ }
let activation = unique_suffix();
let target = self
.paths
- .package_root(&expected_package_id, &manifest.version, &activation);
+ .package_root(expected_package_id, &manifest.version, &activation);
let staging = staging.keep();
if let Err(error) = fs::rename(&staging, &target).await {
let _ = fs::remove_dir_all(&staging).await;
@@ -474,17 +608,19 @@ impl ExtensionRegistry {
let receipt = ExtensionReceipt {
schema_version: RECEIPT_SCHEMA_VERSION,
- package_id: expected_package_id.clone(),
+ package_id: expected_package_id.to_string(),
component_id: format!("use/{expected_package_id}"),
route: manifest.route.clone(),
version: manifest.version.clone(),
package_root: target.clone(),
manifest_sha256: digest,
- trust: ExtensionTrust::LocalExplicit,
+ package_sha256: Some(package_digest),
+ trust,
+ registry,
installed_at_unix: unix_timestamp(),
enabled,
};
- let receipt_path = self.paths.receipt_path(&expected_package_id);
+ let receipt_path = self.paths.receipt_path(expected_package_id);
if let Err(error) = write_receipt(&receipt_path, &receipt).await {
let _ = fs::remove_dir_all(&target).await;
return Err(error);
@@ -650,6 +786,7 @@ impl ExtensionRegistry {
let _ = FileExt::unlock(&file);
return Ok(None);
}
+ verify_package_integrity(&extension).await?;
Ok(Some(ExtensionRouteLease { extension, file }))
}
@@ -699,6 +836,46 @@ impl ExtensionRegistry {
),
));
}
+ if receipt.package_sha256.as_deref().is_some_and(|digest| {
+ digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit())
+ }) {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Extension receipt for '{}' has an invalid package digest.",
+ receipt.package_id
+ ),
+ ));
+ }
+ match (
+ receipt.trust,
+ receipt.registry.as_ref(),
+ receipt.package_sha256.as_ref(),
+ ) {
+ (ExtensionTrust::LocalExplicit, None, _) => {}
+ (ExtensionTrust::RegistryTuf, Some(registry), Some(_)) => {
+ registry.validate_provenance()?;
+ if registry.package_id != receipt.package_id || registry.version != receipt.version
+ {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Registry provenance for '{}' does not match its receipt.",
+ receipt.package_id
+ ),
+ ));
+ }
+ }
+ _ => {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ format!(
+ "Extension receipt for '{}' has inconsistent trust provenance.",
+ receipt.package_id
+ ),
+ ))
+ }
+ }
let package_id = normalize_package_id(&receipt.package_id)?;
if receipt.component_id != format!("use/{package_id}")
|| !owned_package_path(&self.paths, &package_id, &receipt.package_root)
@@ -730,6 +907,24 @@ impl ExtensionRegistry {
}
}
+async fn verify_package_integrity(extension: &InstalledExtension) -> UseResult<()> {
+ let Some(expected) = extension.receipt.package_sha256.as_deref() else {
+ return Ok(());
+ };
+ let actual = package_sha256(&extension.receipt.package_root).await?;
+ if actual != expected {
+ return Err(UseError::new(
+ "use.extension.package_digest_mismatch",
+ format!(
+ "Installed package '{}' no longer matches its recorded digest.",
+ extension.receipt.package_id
+ ),
+ )
+ .with_suggestion("Reinstall the extension from its trusted source."));
+ }
+ Ok(())
+}
+
fn route_bindings(installed: &[InstalledExtension]) -> Vec {
installed
.iter()
@@ -740,6 +935,7 @@ fn route_bindings(installed: &[InstalledExtension]) -> Vec UseResult {
Ok(value.to_string())
}
+fn registry_identity(registry: Option<&ResolvedRemotePackage>) -> Option<(&str, &str, &str, &str)> {
+ registry.map(|registry| {
+ (
+ registry.registry_name.as_str(),
+ registry.registry_url.as_str(),
+ registry.root_sha256.as_str(),
+ registry.sha256.as_str(),
+ )
+ })
+}
+
fn ensure_unique_routes(installed: &[InstalledExtension]) -> UseResult<()> {
for (index, extension) in installed.iter().enumerate() {
if let Some(conflict) = installed[index + 1..]
diff --git a/crates/extension/src/registry_tests.rs b/crates/extension/src/registry_tests.rs
index 59664552..875fd2bc 100644
--- a/crates/extension/src/registry_tests.rs
+++ b/crates/extension/src/registry_tests.rs
@@ -1,3 +1,5 @@
+use std::fs::File;
+use std::io::Write;
use std::time::Duration;
#[cfg(unix)]
@@ -48,6 +50,43 @@ fn registry(root: &Path) -> ExtensionRegistry {
ExtensionRegistry::new(ExtensionPaths::new(root.join("data"), root.join("state")))
}
+fn tar_package(source: &Path, archive: &Path) {
+ let file = File::create(archive).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+ builder.append_dir_all("package", source).unwrap();
+ builder.finish().unwrap();
+}
+
+fn zip_package(source: &Path, archive: &Path) {
+ let file = File::create(archive).unwrap();
+ let mut writer = zip::ZipWriter::new(file);
+ for relative in [
+ "a3s-use-extension.acl",
+ "bin/extension",
+ "skills/demo/SKILL.md",
+ ] {
+ let source_file = source.join(relative);
+ let mut options = zip::write::SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Deflated);
+ #[cfg(unix)]
+ {
+ let mode = std::fs::metadata(&source_file)
+ .unwrap()
+ .permissions()
+ .mode();
+ options = options.unix_permissions(mode);
+ }
+ writer
+ .start_file(format!("package/{relative}"), options)
+ .unwrap();
+ writer
+ .write_all(&std::fs::read(source_file).unwrap())
+ .unwrap();
+ }
+ writer.finish().unwrap();
+}
+
#[tokio::test]
async fn installs_lists_and_uninstalls_an_explicit_local_package() {
let temp = tempfile::tempdir().unwrap();
@@ -89,6 +128,63 @@ async fn installs_lists_and_uninstalls_an_explicit_local_package() {
assert!(registry.list().await.unwrap().is_empty());
}
+#[tokio::test]
+async fn installs_and_uninstalls_a_local_tar_package() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.2.0").await;
+ let archive = temp.path().join("acme-slack.tar.gz");
+ tar_package(&source, &archive);
+ let registry = registry(temp.path());
+
+ let result = registry
+ .install_local(
+ "acme/slack",
+ &archive,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(result.changed);
+ assert_eq!(result.extension.receipt.package_id, "acme/slack");
+ assert!(result.extension.cli_executable().unwrap().is_file());
+
+ let removed = registry.uninstall("acme/slack").await.unwrap();
+ assert!(removed.changed);
+ assert!(registry.list().await.unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn installs_and_uninstalls_a_local_zip_package() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.2.0").await;
+ let archive = temp.path().join("acme-slack.zip");
+ zip_package(&source, &archive);
+ let registry = registry(temp.path());
+
+ let result = registry
+ .install_local(
+ "acme/slack",
+ &archive,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(result.changed);
+ assert_eq!(result.extension.receipt.package_id, "acme/slack");
+ assert!(result.extension.cli_executable().unwrap().is_file());
+
+ assert!(registry.uninstall("acme/slack").await.unwrap().changed);
+ assert!(registry.list().await.unwrap().is_empty());
+}
+
#[tokio::test]
async fn rejects_route_conflicts_and_untrusted_installs() {
let temp = tempfile::tempdir().unwrap();
@@ -198,6 +294,8 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() {
let second = temp.path().join("second");
package(&first, "acme/slack", "slack", "1.0.0").await;
package(&second, "acme/slack", "slack", "2.0.0").await;
+ let second_archive = temp.path().join("second.tar.gz");
+ tar_package(&second, &second_archive);
let registry = registry(temp.path());
let first_install = registry
@@ -217,7 +315,7 @@ async fn hot_upgrade_keeps_the_previous_package_until_inflight_routes_drain() {
let second_install = registry
.install_local(
"acme/slack",
- &second,
+ &second_archive,
InstallOptions {
allow_unsigned: true,
force: false,
@@ -272,6 +370,16 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation()
second.extension.receipt.package_root,
first.extension.receipt.package_root
);
+ assert_eq!(
+ second.extension.receipt.package_sha256,
+ first.extension.receipt.package_sha256
+ );
+ assert!(second
+ .extension
+ .receipt
+ .package_sha256
+ .as_deref()
+ .is_some_and(|digest| digest.len() == 64));
let second_snapshot = registry.snapshot().await.unwrap();
assert_eq!(second_snapshot.generation, 2);
assert_eq!(
@@ -280,6 +388,148 @@ async fn forced_reactivation_of_identical_metadata_publishes_a_new_generation()
);
}
+#[tokio::test]
+async fn same_version_changed_executable_requires_force_and_changes_package_digest() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ let first = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ fs::write(
+ source.join("bin/extension"),
+ "#!/bin/sh\nprintf 'changed\\n'\n",
+ )
+ .await
+ .unwrap();
+
+ let error = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.version_conflict");
+
+ let second = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: true,
+ },
+ )
+ .await
+ .unwrap();
+ assert_ne!(
+ second.extension.receipt.package_root,
+ first.extension.receipt.package_root
+ );
+ assert_ne!(
+ second.extension.receipt.package_sha256,
+ first.extension.receipt.package_sha256
+ );
+ assert!(second.extension.receipt.package_sha256.is_some());
+ assert_eq!(
+ fs::read_to_string(second.extension.cli_executable().unwrap())
+ .await
+ .unwrap(),
+ "#!/bin/sh\nprintf 'changed\\n'\n"
+ );
+}
+
+#[tokio::test]
+async fn legacy_receipt_without_package_digest_remains_readable_and_idempotent() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut legacy: serde_json::Value =
+ serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap();
+ legacy.as_object_mut().unwrap().remove("packageSha256");
+ fs::write(&receipt_path, serde_json::to_vec_pretty(&legacy).unwrap())
+ .await
+ .unwrap();
+
+ let installed = registry.get("acme/slack").await.unwrap().unwrap();
+ assert_eq!(installed.receipt.package_sha256, None);
+
+ let unchanged = registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(!unchanged.changed);
+ assert_eq!(unchanged.extension.receipt.package_sha256, None);
+}
+
+#[tokio::test]
+async fn receipt_rejects_an_invalid_optional_package_digest() {
+ let temp = tempfile::tempdir().unwrap();
+ let source = temp.path().join("source");
+ package(&source, "acme/slack", "slack", "1.0.0").await;
+ let registry = registry(temp.path());
+
+ registry
+ .install_local(
+ "acme/slack",
+ &source,
+ InstallOptions {
+ allow_unsigned: true,
+ force: false,
+ },
+ )
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut invalid: serde_json::Value =
+ serde_json::from_slice(&fs::read(&receipt_path).await.unwrap()).unwrap();
+ invalid["packageSha256"] = serde_json::json!("not-a-sha256");
+ fs::write(&receipt_path, serde_json::to_vec_pretty(&invalid).unwrap())
+ .await
+ .unwrap();
+
+ let error = registry.get("acme/slack").await.unwrap_err();
+ assert_eq!(error.code, "use.extension.receipt_invalid");
+}
+
#[tokio::test]
async fn snapshot_reconciles_a_pre_activation_identity_binding() {
let temp = tempfile::tempdir().unwrap();
diff --git a/crates/extension/src/remote.rs b/crates/extension/src/remote.rs
new file mode 100644
index 00000000..8bd205b1
--- /dev/null
+++ b/crates/extension/src/remote.rs
@@ -0,0 +1,970 @@
+//! TUF-backed remote extension registry resolution.
+//!
+//! The trusted root is pinned out of band by SHA-256. Tough then verifies the
+//! complete root/timestamp/snapshot/targets chain, enforces expiration, and
+//! persists metadata versions in its datastore to reject rollback attacks.
+
+use std::collections::BTreeSet;
+use std::fs::{File, OpenOptions};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use a3s_use_core::{UseError, UseResult};
+use fs2::FileExt;
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tempfile::TempDir;
+use tokio::fs;
+use tokio::io::AsyncWriteExt;
+use tough::{ExpirationEnforcement, HttpTransportBuilder, Limits, Prefix, Repository};
+use tough::{RepositoryLoader, TargetName};
+use url::Url;
+
+use super::package::{activate_temporary_file, io_error, sync_parent_directory, unique_suffix};
+
+const ROOT_NAME: &str = "root.json";
+const ROOT_CACHE_NAME: &str = "bootstrap-root.json";
+const REGISTRY_METADATA_KEY: &str = "a3s";
+const REGISTRY_TARGET_SCHEMA_VERSION: u32 = 1;
+const MAX_BOOTSTRAP_ROOT_BYTES: u64 = 1024 * 1024;
+const MAX_REMOTE_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
+const MAX_ROOT_UPDATES: u64 = 64;
+
+/// One configured registry whose TUF root is pinned out of band.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TrustedRegistry {
+ name: String,
+ base_url: Url,
+ root_sha256: String,
+ trusted_root_path: Option,
+ datastore: PathBuf,
+}
+
+impl TrustedRegistry {
+ pub fn new(
+ name: impl Into,
+ base_url: impl AsRef,
+ root_sha256: impl AsRef,
+ trusted_root_path: Option,
+ datastore: PathBuf,
+ ) -> UseResult {
+ let name = name.into();
+ validate_registry_name(&name)?;
+ let base_url = normalize_registry_url(base_url.as_ref())?;
+ let root_sha256 = normalize_sha256(root_sha256.as_ref(), "registry trust root")?;
+ if !datastore.is_absolute() {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ "The TUF metadata datastore must be an absolute path.",
+ ));
+ }
+ if trusted_root_path
+ .as_ref()
+ .is_some_and(|path| !path.is_absolute())
+ {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ "The trusted TUF root path must be absolute.",
+ ));
+ }
+ Ok(Self {
+ name,
+ base_url,
+ root_sha256,
+ trusted_root_path,
+ datastore,
+ })
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn base_url(&self) -> &Url {
+ &self.base_url
+ }
+
+ pub fn root_sha256(&self) -> &str {
+ &self.root_sha256
+ }
+
+ pub fn datastore(&self) -> &Path {
+ &self.datastore
+ }
+
+ fn metadata_url(&self) -> UseResult {
+ self.base_url.join("metadata/").map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the registry metadata URL: {error}"),
+ )
+ })
+ }
+
+ fn targets_url(&self) -> UseResult {
+ self.base_url.join("targets/").map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the registry targets URL: {error}"),
+ )
+ })
+ }
+}
+
+/// Exact signed target selected from a verified TUF repository.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ResolvedRemotePackage {
+ pub registry_name: String,
+ pub registry_url: String,
+ pub root_sha256: String,
+ pub root_version: u64,
+ pub timestamp_version: u64,
+ pub snapshot_version: u64,
+ pub targets_version: u64,
+ pub package_id: String,
+ pub version: String,
+ pub channel: String,
+ pub target: String,
+ pub target_name: String,
+ pub archive_name: String,
+ pub length: u64,
+ pub sha256: String,
+}
+
+/// Signed metadata versions observed after a complete TUF refresh.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerifiedRegistryMetadata {
+ pub registry_name: String,
+ pub registry_url: String,
+ pub root_sha256: String,
+ pub root_version: u64,
+ pub timestamp_version: u64,
+ pub snapshot_version: u64,
+ pub targets_version: u64,
+ pub package_targets: u64,
+}
+
+impl ResolvedRemotePackage {
+ pub fn plan_digest(&self) -> UseResult {
+ let bytes = serde_json::to_vec(self).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_plan_invalid",
+ format!("Failed to encode the resolved registry plan: {error}"),
+ )
+ })?;
+ Ok(format!("{:x}", Sha256::digest(bytes)))
+ }
+
+ pub fn verify_expected_plan(&self, expected: Option<&str>) -> UseResult<()> {
+ let Some(expected) = expected else {
+ return Ok(());
+ };
+ let expected = normalize_sha256(expected, "expected registry plan")?;
+ let actual = self.plan_digest()?;
+ if expected == actual {
+ return Ok(());
+ }
+ Err(UseError::new(
+ "use.extension.registry_plan_mismatch",
+ "The signed registry target changed after review.",
+ )
+ .with_detail("expected", expected)
+ .with_detail("actual", actual))
+ }
+
+ pub(crate) fn validate_provenance(&self) -> UseResult<()> {
+ validate_registry_name(&self.registry_name)?;
+ let normalized_url = normalize_registry_url(&self.registry_url)?;
+ if normalized_url.as_str() != self.registry_url {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry URL in the extension receipt is not canonical.",
+ ));
+ }
+ normalize_sha256(&self.root_sha256, "registry trust root")?;
+ normalize_sha256(&self.sha256, "registry target")?;
+ if self.root_version == 0
+ || self.timestamp_version == 0
+ || self.snapshot_version == 0
+ || self.targets_version == 0
+ || self.length == 0
+ || self.length > MAX_REMOTE_ARCHIVE_BYTES
+ || !super::valid_package_id(&self.package_id)
+ || Version::parse(&self.version).is_err()
+ {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry provenance in the extension receipt is invalid.",
+ ));
+ }
+ validate_channel(&self.channel)?;
+ let host = host_target()?;
+ if self.target != host && self.target != "any" {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The installed registry target does not match this platform.",
+ ));
+ }
+ let target_name = TargetName::new(self.target_name.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.receipt_invalid",
+ format!("The registry target name in the receipt is invalid: {error}"),
+ )
+ })?;
+ validate_target_name(
+ &target_name,
+ &RegistryTargetMetadata {
+ schema_version: REGISTRY_TARGET_SCHEMA_VERSION,
+ package_id: self.package_id.clone(),
+ version: self.version.clone(),
+ channel: self.channel.clone(),
+ target: self.target.clone(),
+ },
+ )?;
+ if target_name.raw().rsplit('/').next() != Some(self.archive_name.as_str()) {
+ return Err(UseError::new(
+ "use.extension.receipt_invalid",
+ "The registry archive name does not match its signed target path.",
+ ));
+ }
+ Ok(())
+ }
+}
+
+/// Verified repository state retained until its exact target is downloaded.
+pub struct PreparedRemotePackage {
+ repository: Repository,
+ target_name: TargetName,
+ resolved: ResolvedRemotePackage,
+}
+
+impl std::fmt::Debug for PreparedRemotePackage {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("PreparedRemotePackage")
+ .field("resolved", &self.resolved)
+ .finish_non_exhaustive()
+ }
+}
+
+impl PreparedRemotePackage {
+ pub fn resolved(&self) -> &ResolvedRemotePackage {
+ &self.resolved
+ }
+
+ pub async fn download(self) -> UseResult {
+ let temporary = tokio::task::spawn_blocking(tempfile::tempdir)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to create the remote package staging task: {error}"),
+ )
+ })?
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to create remote package staging: {error}"),
+ )
+ })?;
+ self.repository
+ .save_target(&self.target_name, temporary.path(), Prefix::None)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!(
+ "Failed to download and verify TUF target '{}': {error}",
+ self.resolved.target_name
+ ),
+ )
+ })?;
+ let path = temporary.path().join(self.target_name.resolved());
+ let metadata = fs::metadata(&path)
+ .await
+ .map_err(|error| io_error("inspect downloaded TUF target", &path, error))?;
+ if !metadata.is_file() || metadata.len() != self.resolved.length {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The downloaded TUF target does not match its signed length.",
+ ));
+ }
+ Ok(DownloadedRemotePackage {
+ path,
+ resolved: self.resolved,
+ _temporary: temporary,
+ })
+ }
+}
+
+/// One downloaded archive kept alive through extension activation.
+#[derive(Debug)]
+pub struct DownloadedRemotePackage {
+ path: PathBuf,
+ resolved: ResolvedRemotePackage,
+ _temporary: TempDir,
+}
+
+impl DownloadedRemotePackage {
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn resolved(&self) -> &ResolvedRemotePackage {
+ &self.resolved
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct RegistryTargetMetadata {
+ schema_version: u32,
+ package_id: String,
+ version: String,
+ channel: String,
+ target: String,
+}
+
+struct MetadataLock(File);
+
+impl Drop for MetadataLock {
+ fn drop(&mut self) {
+ let _ = FileExt::unlock(&self.0);
+ }
+}
+
+/// Load and verify a TUF repository, then select one exact extension target.
+pub async fn prepare_remote_package(
+ registry: &TrustedRegistry,
+ package_id: &str,
+ requested_version: Option<&str>,
+ channel: &str,
+ expected_plan_digest: Option<&str>,
+) -> UseResult {
+ if !super::valid_package_id(package_id) {
+ return Err(UseError::new(
+ "use.extension.id_invalid",
+ "Extension IDs must be '/' lowercase identifiers.",
+ ));
+ }
+ let requested_version = requested_version
+ .map(|version| {
+ Version::parse(version).map_err(|error| {
+ UseError::new(
+ "use.extension.version_invalid",
+ format!("Invalid requested extension version: {error}"),
+ )
+ })
+ })
+ .transpose()?;
+ validate_channel(channel)?;
+ let repository = load_repository(registry).await?;
+
+ let host_target = host_target()?;
+ let mut candidates = Vec::new();
+ let mut identities = BTreeSet::new();
+ for (target_name, target) in repository.all_targets() {
+ let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else {
+ continue;
+ };
+ let metadata: RegistryTargetMetadata =
+ serde_json::from_value(metadata.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has invalid A3S metadata: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_target_metadata(target_name, target, &metadata)?;
+ let identity = (
+ metadata.package_id.clone(),
+ metadata.version.clone(),
+ metadata.channel.clone(),
+ metadata.target.clone(),
+ );
+ if !identities.insert(identity) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains duplicate A3S package targets.",
+ ));
+ }
+ if metadata.package_id != package_id
+ || metadata.channel != channel
+ || (metadata.target != host_target && metadata.target != "any")
+ {
+ continue;
+ }
+ let version = Version::parse(&metadata.version).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' declares an invalid version: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ if requested_version
+ .as_ref()
+ .is_some_and(|requested| requested != &version)
+ {
+ continue;
+ }
+ candidates.push((version, metadata, target_name.clone(), target.clone()));
+ }
+ candidates.sort_by(|left, right| {
+ left.0
+ .cmp(&right.0)
+ .then_with(|| (left.1.target == host_target).cmp(&(right.1.target == host_target)))
+ .then_with(|| left.2.raw().cmp(right.2.raw()))
+ });
+ let Some((version, metadata, target_name, target)) = candidates.pop() else {
+ return Err(UseError::new(
+ "use.extension.registry_package_missing",
+ format!(
+ "Registry '{}' has no '{}' package for channel '{}' and target '{}'.",
+ registry.name, package_id, channel, host_target
+ ),
+ ));
+ };
+ if candidates.last().is_some_and(|candidate| {
+ candidate.0 == version
+ && (candidate.1.target == host_target) == (metadata.target == host_target)
+ }) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository resolves the same package version to multiple targets.",
+ ));
+ }
+ let archive_name = target_name
+ .raw()
+ .rsplit('/')
+ .next()
+ .unwrap_or_default()
+ .to_string();
+ let resolved = ResolvedRemotePackage {
+ registry_name: registry.name.clone(),
+ registry_url: registry.base_url.to_string(),
+ root_sha256: registry.root_sha256.clone(),
+ root_version: repository.root().signed.version.get(),
+ timestamp_version: repository.timestamp().signed.version.get(),
+ snapshot_version: repository.snapshot().signed.version.get(),
+ targets_version: repository.targets().signed.version.get(),
+ package_id: package_id.to_string(),
+ version: version.to_string(),
+ channel: channel.to_string(),
+ target: metadata.target,
+ target_name: target_name.raw().to_string(),
+ archive_name,
+ length: target.length,
+ sha256: hex_lower(target.hashes.sha256.as_ref()),
+ };
+ resolved.verify_expected_plan(expected_plan_digest)?;
+ Ok(PreparedRemotePackage {
+ repository,
+ target_name,
+ resolved,
+ })
+}
+
+/// Refresh and fully verify a registry without downloading any package target.
+pub async fn refresh_remote_registry(
+ registry: &TrustedRegistry,
+) -> UseResult {
+ let repository = load_repository(registry).await?;
+ let mut identities = BTreeSet::new();
+ let mut package_targets = 0_u64;
+ for (target_name, target) in repository.all_targets() {
+ let Some(metadata) = target.custom.get(REGISTRY_METADATA_KEY) else {
+ continue;
+ };
+ let metadata: RegistryTargetMetadata =
+ serde_json::from_value(metadata.clone()).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has invalid A3S metadata: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_target_metadata(target_name, target, &metadata)?;
+ let identity = (
+ metadata.package_id,
+ metadata.version,
+ metadata.channel,
+ metadata.target,
+ );
+ if !identities.insert(identity) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains duplicate A3S package targets.",
+ ));
+ }
+ package_targets = package_targets.checked_add(1).ok_or_else(|| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ "The TUF repository contains too many package targets.",
+ )
+ })?;
+ }
+ Ok(VerifiedRegistryMetadata {
+ registry_name: registry.name.clone(),
+ registry_url: registry.base_url.to_string(),
+ root_sha256: registry.root_sha256.clone(),
+ root_version: repository.root().signed.version.get(),
+ timestamp_version: repository.timestamp().signed.version.get(),
+ snapshot_version: repository.snapshot().signed.version.get(),
+ targets_version: repository.targets().signed.version.get(),
+ package_targets,
+ })
+}
+
+async fn load_repository(registry: &TrustedRegistry) -> UseResult {
+ ensure_metadata_directory(®istry.datastore).await?;
+ let lock = acquire_metadata_lock(®istry.datastore)?;
+ let root = load_trusted_root(registry).await?;
+ let metadata_url = registry.metadata_url()?;
+ let targets_url = registry.targets_url()?;
+ let transport = HttpTransportBuilder::new()
+ .timeout(Duration::from_secs(300))
+ .connect_timeout(Duration::from_secs(15))
+ .tries(3)
+ .build();
+ let repository = RepositoryLoader::new(&root, metadata_url, targets_url)
+ .transport(transport)
+ .datastore(®istry.datastore)
+ .limits(Limits {
+ max_root_size: MAX_BOOTSTRAP_ROOT_BYTES,
+ max_targets_size: 10 * 1024 * 1024,
+ max_timestamp_size: 1024 * 1024,
+ max_snapshot_size: 1024 * 1024,
+ max_root_updates: MAX_ROOT_UPDATES,
+ })
+ .expiration_enforcement(ExpirationEnforcement::Safe)
+ .load()
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_untrusted",
+ format!(
+ "TUF verification failed for registry '{}': {error}",
+ registry.name
+ ),
+ )
+ })?;
+ drop(lock);
+ Ok(repository)
+}
+
+fn validate_target_metadata(
+ target_name: &TargetName,
+ target: &tough::schema::Target,
+ metadata: &RegistryTargetMetadata,
+) -> UseResult<()> {
+ if metadata.schema_version != REGISTRY_TARGET_SCHEMA_VERSION {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' uses unsupported A3S metadata schema {}.",
+ target_name.raw(),
+ metadata.schema_version
+ ),
+ ));
+ }
+ if !super::valid_package_id(&metadata.package_id) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has an invalid package ID.",
+ target_name.raw()
+ ),
+ ));
+ }
+ Version::parse(&metadata.version).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' has an invalid package version: {error}",
+ target_name.raw()
+ ),
+ )
+ })?;
+ validate_channel(&metadata.channel)?;
+ validate_target_name(target_name, metadata)?;
+ if target.length == 0 || target.length > MAX_REMOTE_ARCHIVE_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' exceeds the supported package size.",
+ target_name.raw()
+ ),
+ ));
+ }
+ let digest = target.hashes.sha256.as_ref();
+ if digest.len() != 32 {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!(
+ "TUF target '{}' does not have a valid SHA-256 digest.",
+ target_name.raw()
+ ),
+ ));
+ }
+ Ok(())
+}
+
+fn validate_target_name(
+ target_name: &TargetName,
+ metadata: &RegistryTargetMetadata,
+) -> UseResult<()> {
+ let raw = target_name.raw();
+ if raw != target_name.resolved()
+ || raw.starts_with('/')
+ || raw.contains('\\')
+ || raw.split('/').any(str::is_empty)
+ {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' is not a portable package path."),
+ ));
+ }
+ let archive = raw.rsplit('/').next().unwrap_or_default();
+ if !(archive.ends_with(".tar.gz") || archive.ends_with(".tgz") || archive.ends_with(".zip")) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' is not a supported package archive."),
+ ));
+ }
+ let expected_prefix = format!(
+ "extensions/{}/{}/{}/{}/",
+ metadata.package_id, metadata.version, metadata.channel, metadata.target
+ );
+ if !raw.starts_with(&expected_prefix) {
+ return Err(UseError::new(
+ "use.extension.registry_target_invalid",
+ format!("TUF target '{raw}' must be published below '{expected_prefix}'."),
+ ));
+ }
+ Ok(())
+}
+
+fn validate_channel(channel: &str) -> UseResult<()> {
+ if matches!(channel, "stable" | "beta" | "nightly") {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_channel_invalid",
+ format!("Unsupported extension release channel '{channel}'."),
+ ))
+ }
+}
+
+fn host_target() -> UseResult {
+ match (std::env::consts::OS, std::env::consts::ARCH) {
+ ("macos", "aarch64") => Ok("darwin-arm64".to_string()),
+ ("macos", "x86_64") => Ok("darwin-x86_64".to_string()),
+ ("linux", "aarch64") => Ok("linux-arm64".to_string()),
+ ("linux", "x86_64") => Ok("linux-x86_64".to_string()),
+ ("windows", "x86_64") => Ok("windows-x86_64".to_string()),
+ (os, arch) => Err(UseError::new(
+ "use.extension.registry_target_unsupported",
+ format!("Remote extension packages are unavailable for {os}-{arch}."),
+ )),
+ }
+}
+
+async fn ensure_metadata_directory(path: &Path) -> UseResult<()> {
+ fs::create_dir_all(path)
+ .await
+ .map_err(|error| io_error("create TUF metadata datastore", path, error))?;
+ let metadata = fs::symlink_metadata(path)
+ .await
+ .map_err(|error| io_error("inspect TUF metadata datastore", path, error))?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(UseError::new(
+ "use.extension.registry_path_invalid",
+ format!(
+ "The TUF metadata datastore '{}' must be a real directory.",
+ path.display()
+ ),
+ ));
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
+ .await
+ .map_err(|error| io_error("secure TUF metadata datastore", path, error))?;
+ }
+ Ok(())
+}
+
+fn acquire_metadata_lock(datastore: &Path) -> UseResult {
+ let path = datastore.join(".metadata.lock");
+ let file = OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .open(&path)
+ .map_err(|error| io_error("open TUF metadata lock", &path, error))?;
+ file.try_lock_exclusive().map_err(|error| {
+ UseError::new(
+ "use.extension.registry_busy",
+ format!(
+ "Another process is updating registry metadata '{}': {error}",
+ datastore.display()
+ ),
+ )
+ })?;
+ Ok(MetadataLock(file))
+}
+
+async fn load_trusted_root(registry: &TrustedRegistry) -> UseResult> {
+ let explicit = registry.trusted_root_path.as_deref();
+ let cache = registry.datastore.join(ROOT_CACHE_NAME);
+ let path = explicit.unwrap_or(&cache);
+ let bytes = match fs::read(path).await {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound && explicit.is_none() => {
+ let metadata_url = registry.metadata_url()?;
+ let root_url = metadata_url.join(ROOT_NAME).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Failed to resolve the bootstrap root URL: {error}"),
+ )
+ })?;
+ let bytes = download_bootstrap_root(&root_url).await?;
+ verify_root_digest(registry, &bytes)?;
+ write_bootstrap_root(&cache, &bytes).await?;
+ bytes
+ }
+ Err(error) => return Err(io_error("read trusted TUF root", path, error)),
+ };
+ if bytes.len() as u64 > MAX_BOOTSTRAP_ROOT_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The trusted TUF root exceeds the one MiB limit.",
+ ));
+ }
+ verify_root_digest(registry, &bytes)?;
+ Ok(bytes)
+}
+
+async fn download_bootstrap_root(url: &Url) -> UseResult> {
+ validate_download_url(url)?;
+ let client = reqwest::Client::builder()
+ .user_agent("a3s-use-extension/0.1")
+ .connect_timeout(Duration::from_secs(15))
+ .timeout(Duration::from_secs(30))
+ .redirect(reqwest::redirect::Policy::limited(5))
+ .build()
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to build the registry client: {error}"),
+ )
+ })?;
+ let mut response = client.get(url.clone()).send().await.map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to download the bootstrap TUF root: {error}"),
+ )
+ })?;
+ validate_download_url(response.url())?;
+ if !response.status().is_success() {
+ return Err(UseError::new(
+ "use.extension.registry_download_failed",
+ format!(
+ "Bootstrap TUF root download returned HTTP {}.",
+ response.status()
+ ),
+ ));
+ }
+ if response
+ .content_length()
+ .is_some_and(|length| length > MAX_BOOTSTRAP_ROOT_BYTES)
+ {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The bootstrap TUF root exceeds the one MiB limit.",
+ ));
+ }
+ let mut bytes = Vec::with_capacity(
+ response
+ .content_length()
+ .unwrap_or_default()
+ .min(MAX_BOOTSTRAP_ROOT_BYTES) as usize,
+ );
+ while let Some(chunk) = response.chunk().await.map_err(|error| {
+ UseError::new(
+ "use.extension.registry_download_failed",
+ format!("Failed to read the bootstrap TUF root: {error}"),
+ )
+ })? {
+ if bytes.len().saturating_add(chunk.len()) as u64 > MAX_BOOTSTRAP_ROOT_BYTES {
+ return Err(UseError::new(
+ "use.extension.registry_root_invalid",
+ "The bootstrap TUF root exceeds the one MiB limit.",
+ ));
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ Ok(bytes)
+}
+
+fn verify_root_digest(registry: &TrustedRegistry, bytes: &[u8]) -> UseResult<()> {
+ let actual = format!("{:x}", Sha256::digest(bytes));
+ if actual == registry.root_sha256 {
+ return Ok(());
+ }
+ Err(UseError::new(
+ "use.extension.registry_root_mismatch",
+ format!(
+ "Registry '{}' bootstrap root does not match its pinned SHA-256.",
+ registry.name
+ ),
+ )
+ .with_detail("expected", registry.root_sha256.clone())
+ .with_detail("actual", actual))
+}
+
+async fn write_bootstrap_root(path: &Path, bytes: &[u8]) -> UseResult<()> {
+ let parent = path.parent().ok_or_else(|| {
+ UseError::new(
+ "use.extension.registry_path_invalid",
+ "The bootstrap TUF root cache has no parent directory.",
+ )
+ })?;
+ let temporary = parent.join(format!(".root-{}.tmp", unique_suffix()));
+ let mut options = fs::OpenOptions::new();
+ options.create_new(true).write(true);
+ let mut file = options
+ .open(&temporary)
+ .await
+ .map_err(|error| io_error("create bootstrap TUF root cache", &temporary, error))?;
+ if let Err(error) = file.write_all(bytes).await {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(io_error(
+ "write bootstrap TUF root cache",
+ &temporary,
+ error,
+ ));
+ }
+ if let Err(error) = file.sync_all().await {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(io_error("sync bootstrap TUF root cache", &temporary, error));
+ }
+ drop(file);
+ if let Err(error) = activate_temporary_file(
+ temporary.clone(),
+ path.to_path_buf(),
+ "activate bootstrap TUF root cache",
+ )
+ .await
+ {
+ let _ = fs::remove_file(&temporary).await;
+ return Err(error);
+ }
+ sync_parent_directory(parent, "TUF metadata").await
+}
+
+fn normalize_registry_url(value: &str) -> UseResult {
+ let mut url = Url::parse(value).map_err(|error| {
+ UseError::new(
+ "use.extension.registry_url_invalid",
+ format!("Invalid registry URL: {error}"),
+ )
+ })?;
+ validate_download_url(&url)?;
+ if !url.username().is_empty()
+ || url.password().is_some()
+ || url.query().is_some()
+ || url.fragment().is_some()
+ {
+ return Err(UseError::new(
+ "use.extension.registry_url_invalid",
+ "Registry URLs must not contain credentials, query parameters, or fragments.",
+ ));
+ }
+ if !url.path().ends_with('/') {
+ let path = format!("{}/", url.path());
+ url.set_path(&path);
+ }
+ Ok(url)
+}
+
+fn validate_download_url(url: &Url) -> UseResult<()> {
+ let https = url.scheme() == "https";
+ let loopback_http = url.scheme() == "http"
+ && url.host_str().is_some_and(|host| {
+ host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|ip| ip.is_loopback())
+ });
+ if https || loopback_http {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_url_invalid",
+ "Registry downloads require HTTPS; HTTP is accepted only on loopback for local testing.",
+ ))
+ }
+}
+
+fn validate_registry_name(name: &str) -> UseResult<()> {
+ let mut characters = name.chars();
+ if characters
+ .next()
+ .is_some_and(|character| character.is_ascii_lowercase())
+ && characters.all(|character| {
+ character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
+ })
+ {
+ Ok(())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_name_invalid",
+ "Registry names use lowercase letters, digits, and hyphens and start with a letter.",
+ ))
+ }
+}
+
+fn normalize_sha256(value: &str, label: &str) -> UseResult {
+ let value = value.strip_prefix("sha256:").unwrap_or(value);
+ if value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+ {
+ Ok(value.to_string())
+ } else {
+ Err(UseError::new(
+ "use.extension.registry_digest_invalid",
+ format!("The {label} must be exactly 64 lowercase hexadecimal characters."),
+ ))
+ }
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ use std::fmt::Write as _;
+ let _ = write!(output, "{byte:02x}");
+ }
+ output
+}
+
+#[cfg(test)]
+#[path = "tuf_test_support.rs"]
+mod test_support;
+
+#[cfg(test)]
+#[path = "remote_tests.rs"]
+mod tests;
diff --git a/crates/extension/src/remote_tests.rs b/crates/extension/src/remote_tests.rs
new file mode 100644
index 00000000..0b538dc8
--- /dev/null
+++ b/crates/extension/src/remote_tests.rs
@@ -0,0 +1,310 @@
+use std::path::PathBuf;
+
+use super::test_support::{
+ extension_archive, find_subslice, TestRepository, TestServer, EXPIRED, FUTURE, PACKAGE_VERSION,
+};
+use super::*;
+use crate::{ExtensionPaths, ExtensionRegistry, ExtensionTrust};
+
+#[tokio::test]
+async fn tuf_refresh_verifies_metadata_without_downloading_targets() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 7, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let metadata = refresh_remote_registry(&trusted).await.unwrap();
+
+ assert_eq!(metadata.registry_name, "fixture");
+ assert_eq!(metadata.root_version, 1);
+ assert_eq!(metadata.timestamp_version, 7);
+ assert_eq!(metadata.snapshot_version, 7);
+ assert_eq!(metadata.targets_version, 7);
+ assert_eq!(metadata.package_targets, 1);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_install_records_signed_provenance_and_converges() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let repository = TestRepository::new(archive, 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+ let digest = prepared.resolved().plan_digest().unwrap();
+ drop(prepared);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ let installed = registry
+ .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false)
+ .await
+ .unwrap();
+ assert!(installed.changed);
+ assert_eq!(
+ installed.extension.receipt.trust,
+ ExtensionTrust::RegistryTuf
+ );
+ let provenance = installed.extension.receipt.registry.as_ref().unwrap();
+ assert_eq!(provenance.package_id, "acme/slack");
+ assert_eq!(provenance.version, PACKAGE_VERSION);
+ assert_eq!(provenance.sha256, repository.target_sha256);
+ assert!(installed.extension.cli_executable().unwrap().is_file());
+
+ server.clear_requests();
+ let second = registry
+ .install_remote("acme/slack", &trusted, None, "stable", Some(&digest), false)
+ .await
+ .unwrap();
+ assert!(!second.changed);
+ assert_eq!(registry.list().await.unwrap().len(), 1);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_convergence_refreshes_signed_provenance_without_downloading_the_target() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let first_repository = TestRepository::new(archive.clone(), 1, FUTURE);
+ let server = TestServer::start(first_repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &first_repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ let second_repository = TestRepository::new(archive, 2, FUTURE);
+ assert_eq!(
+ second_repository.target_sha256,
+ first_repository.target_sha256
+ );
+ server.replace_routes(second_repository.routes);
+ server.clear_requests();
+
+ let converged = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ assert!(!converged.changed);
+ let provenance = converged.extension.receipt.registry.unwrap();
+ assert_eq!(provenance.timestamp_version, 2);
+ assert_eq!(provenance.snapshot_version, 2);
+ assert_eq!(provenance.targets_version, 2);
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_install_rejects_modified_installed_content_before_dispatch_or_convergence() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ let installed = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+ std::fs::write(
+ installed.extension.cli_executable().unwrap(),
+ b"modified executable",
+ )
+ .unwrap();
+
+ let dispatch_error = match registry.acquire_route("slack").await {
+ Err(error) => error,
+ Ok(_) => panic!("modified signed content must not be dispatched"),
+ };
+ assert_eq!(dispatch_error.code, "use.extension.package_digest_mismatch");
+
+ server.clear_requests();
+ let convergence_error = registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap_err();
+ assert_eq!(
+ convergence_error.code,
+ "use.extension.package_digest_mismatch"
+ );
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_receipt_requires_an_expanded_package_digest() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+ let paths = ExtensionPaths::new(
+ temp.path().join("data"),
+ temp.path().join("extension-state"),
+ );
+ let registry = ExtensionRegistry::new(paths);
+ registry
+ .install_remote("acme/slack", &trusted, None, "stable", None, false)
+ .await
+ .unwrap();
+
+ let receipt_path = registry.paths().receipt_path("acme/slack");
+ let mut receipt: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap();
+ receipt.as_object_mut().unwrap().remove("packageSha256");
+ std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
+
+ let error = registry.get("acme/slack").await.unwrap_err();
+ assert_eq!(error.code, "use.extension.receipt_invalid");
+}
+
+#[tokio::test]
+async fn reviewed_registry_plan_fails_before_target_download() {
+ let repository = TestRepository::new(extension_archive(PACKAGE_VERSION), 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let trusted = trusted_registry(&server, &repository, temp.path().join("tuf"));
+
+ let error = prepare_remote_package(
+ &trusted,
+ "acme/slack",
+ None,
+ "stable",
+ Some(&"0".repeat(64)),
+ )
+ .await
+ .unwrap_err();
+
+ assert_eq!(error.code, "use.extension.registry_plan_mismatch");
+ assert!(server
+ .requests()
+ .iter()
+ .all(|request| !request.starts_with("/targets/")));
+}
+
+#[tokio::test]
+async fn tuf_rejects_wrong_root_and_tampered_target() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let repository = TestRepository::new(archive, 1, FUTURE);
+ let server = TestServer::start(repository.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let wrong = TrustedRegistry::new(
+ "fixture",
+ server.base_url(),
+ "f".repeat(64),
+ None,
+ temp.path().join("wrong-root"),
+ )
+ .unwrap();
+ let error = prepare_remote_package(&wrong, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_root_mismatch");
+
+ let mut routes = repository.routes.clone();
+ routes.insert(
+ format!("/targets/{}", repository.target_name),
+ b"tampered archive".to_vec(),
+ );
+ let tampered_server = TestServer::start(routes);
+ let trusted = trusted_registry(
+ &tampered_server,
+ &repository,
+ temp.path().join("tampered-target"),
+ );
+ let prepared = prepare_remote_package(&trusted, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+ let error = prepared.download().await.unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_download_failed");
+}
+
+#[tokio::test]
+async fn tuf_rejects_metadata_tampering_expiration_and_rollback() {
+ let archive = extension_archive(PACKAGE_VERSION);
+ let version_two = TestRepository::new(archive.clone(), 2, FUTURE);
+ let server_two = TestServer::start(version_two.routes.clone());
+ let temp = tempfile::tempdir().unwrap();
+ let datastore = temp.path().join("rollback-state");
+ let trusted_two = trusted_registry(&server_two, &version_two, datastore.clone());
+ prepare_remote_package(&trusted_two, "acme/slack", None, "stable", None)
+ .await
+ .unwrap();
+
+ let version_one = TestRepository::new(archive.clone(), 1, FUTURE);
+ assert_eq!(version_one.root_sha256, version_two.root_sha256);
+ let server_one = TestServer::start(version_one.routes.clone());
+ let trusted_one = trusted_registry(&server_one, &version_one, datastore);
+ let rollback = prepare_remote_package(&trusted_one, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(rollback.code, "use.extension.registry_untrusted");
+
+ let expired = TestRepository::new(archive.clone(), 1, EXPIRED);
+ let expired_server = TestServer::start(expired.routes.clone());
+ let expired_registry =
+ trusted_registry(&expired_server, &expired, temp.path().join("expired-state"));
+ let error = prepare_remote_package(&expired_registry, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_untrusted");
+
+ let mut tampered_routes = version_one.routes.clone();
+ let targets = tampered_routes.get_mut("/metadata/targets.json").unwrap();
+ let position = find_subslice(targets, b"stable").unwrap();
+ targets[position..position + 6].copy_from_slice(b"nightl");
+ let tampered_server = TestServer::start(tampered_routes);
+ let tampered_registry = trusted_registry(
+ &tampered_server,
+ &version_one,
+ temp.path().join("tampered-metadata"),
+ );
+ let error = prepare_remote_package(&tampered_registry, "acme/slack", None, "stable", None)
+ .await
+ .unwrap_err();
+ assert_eq!(error.code, "use.extension.registry_untrusted");
+}
+
+fn trusted_registry(
+ server: &TestServer,
+ repository: &TestRepository,
+ datastore: PathBuf,
+) -> TrustedRegistry {
+ TrustedRegistry::new(
+ "fixture",
+ server.base_url(),
+ &repository.root_sha256,
+ None,
+ datastore,
+ )
+ .unwrap()
+}
diff --git a/crates/extension/src/source.rs b/crates/extension/src/source.rs
new file mode 100644
index 00000000..e8132daf
--- /dev/null
+++ b/crates/extension/src/source.rs
@@ -0,0 +1,708 @@
+use std::collections::BTreeSet;
+use std::fs::{File, OpenOptions};
+use std::io::{self, Read, Write};
+use std::path::{Component, Path, PathBuf};
+
+use a3s_use_core::{UseError, UseResult};
+use tempfile::TempDir;
+use tokio::fs;
+
+use super::package::{io_error, MANIFEST_NAME, MAX_PACKAGE_BYTES, MAX_PACKAGE_FILES};
+
+const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
+const MAX_PATH_BYTES: usize = 4_096;
+const MAX_PATH_DEPTH: usize = 32;
+
+#[derive(Clone, Copy)]
+enum ArchiveKind {
+ TarGz,
+ Zip,
+}
+
+struct ExtractedEntry {
+ relative: PathBuf,
+ file: bool,
+}
+
+/// One validated local package source kept alive through installation.
+#[derive(Debug)]
+pub(crate) struct PreparedPackageSource {
+ root: PathBuf,
+ _temporary: Option,
+}
+
+impl PreparedPackageSource {
+ pub(crate) fn root(&self) -> &Path {
+ &self.root
+ }
+}
+
+pub(crate) async fn prepare_package_source(source: &Path) -> UseResult {
+ let source = fs::canonicalize(source)
+ .await
+ .map_err(|error| io_error("resolve extension package", source, error))?;
+ let metadata = fs::metadata(&source)
+ .await
+ .map_err(|error| io_error("inspect extension package", &source, error))?;
+ if metadata.is_dir() {
+ return Ok(PreparedPackageSource {
+ root: source,
+ _temporary: None,
+ });
+ }
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.extension.package_unsupported",
+ "The local extension source must be a package directory, .tar.gz, .tgz, or .zip archive.",
+ ));
+ }
+ if metadata.len() > MAX_ARCHIVE_BYTES {
+ return Err(UseError::new(
+ "use.extension.package_too_large",
+ format!(
+ "The extension archive exceeds the {MAX_ARCHIVE_BYTES} byte compressed-size limit."
+ ),
+ ));
+ }
+ let kind = archive_kind(&source)?;
+ let temporary = tokio::task::spawn_blocking(tempfile::tempdir)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.io",
+ format!("Failed to create extension archive staging task: {error}"),
+ )
+ })?
+ .map_err(|error| io_error("create extension archive staging directory", &source, error))?;
+ let extraction_root = temporary.path().join("package");
+ let blocking_source = source.clone();
+ let blocking_root = extraction_root.clone();
+ let package_relative = tokio::task::spawn_blocking(move || {
+ extract_archive(&blocking_source, &blocking_root, kind)
+ })
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.extension.package_archive_invalid",
+ format!("Extension archive extraction task failed: {error}"),
+ )
+ })??;
+ let extraction_root = fs::canonicalize(&extraction_root).await.map_err(|error| {
+ io_error(
+ "resolve extension archive staging directory",
+ &extraction_root,
+ error,
+ )
+ })?;
+ let root = extraction_root.join(package_relative);
+ let root = fs::canonicalize(&root)
+ .await
+ .map_err(|error| io_error("resolve extracted extension package", &root, error))?;
+ if !root.starts_with(&extraction_root) {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ "The extracted extension package root escapes its staging directory.",
+ ));
+ }
+ Ok(PreparedPackageSource {
+ root,
+ _temporary: Some(temporary),
+ })
+}
+
+fn archive_kind(path: &Path) -> UseResult {
+ let name = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
+ Ok(ArchiveKind::TarGz)
+ } else if name.ends_with(".zip") {
+ Ok(ArchiveKind::Zip)
+ } else {
+ Err(UseError::new(
+ "use.extension.package_unsupported",
+ "Extension package archives must use .tar.gz, .tgz, or .zip.",
+ ))
+ }
+}
+
+fn extract_archive(source: &Path, target: &Path, kind: ArchiveKind) -> UseResult {
+ std::fs::create_dir_all(target)
+ .map_err(|error| archive_io("create extraction directory", target, error))?;
+ let entries = match kind {
+ ArchiveKind::TarGz => extract_tar_gz(source, target)?,
+ ArchiveKind::Zip => extract_zip(source, target)?,
+ };
+ resolve_package_root(&entries)
+}
+
+fn extract_tar_gz(source: &Path, target: &Path) -> UseResult> {
+ let file = File::open(source).map_err(|error| archive_io("open", source, error))?;
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+ let mut extracted = Vec::new();
+ let mut seen = BTreeSet::new();
+ let mut extracted_bytes = 0_u64;
+ let entries = archive
+ .entries()
+ .map_err(|error| archive_invalid(format!("Failed to read tar entries: {error}")))?;
+ for (entry_count, entry) in entries.enumerate() {
+ if entry_count >= MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let mut entry =
+ entry.map_err(|error| archive_invalid(format!("Failed to read tar entry: {error}")))?;
+ let entry_path = entry
+ .path()
+ .map_err(|error| archive_invalid(format!("Failed to read tar entry path: {error}")))?
+ .into_owned();
+ let entry_type = entry.header().entry_type();
+ if ignored_macos_metadata_path(&entry_path)? {
+ if entry_type.is_dir() {
+ continue;
+ }
+ if entry_type.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut io::sink(),
+ remaining,
+ &entry_path,
+ )?);
+ continue;
+ }
+ }
+ let Some(relative) = sanitized_relative_path(&entry_path)? else {
+ if entry_type.is_dir() {
+ continue;
+ }
+ return Err(archive_invalid(
+ "The archive contains a non-directory root entry.",
+ ));
+ };
+ if !seen.insert(relative.clone()) {
+ return Err(archive_invalid(format!(
+ "The archive contains duplicate entry '{}'.",
+ relative.display()
+ )));
+ }
+ let output = target.join(&relative);
+ if entry_type.is_dir() {
+ std::fs::create_dir_all(&output)
+ .map_err(|error| archive_io("create archive directory", &output, error))?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: false,
+ });
+ } else if entry_type.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ if entry.size() > remaining {
+ return Err(package_limit_error());
+ }
+ if let Some(parent) = output.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| archive_io("create archive parent", parent, error))?;
+ }
+ let mut output_file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&output)
+ .map_err(|error| archive_io("create archive file", &output, error))?;
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut output_file,
+ remaining,
+ &output,
+ )?);
+ apply_unix_mode(&output, entry.header().mode().ok())?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: true,
+ });
+ } else if entry_type.is_symlink() || entry_type.is_hard_link() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!(
+ "Extension archive entry '{}' is a link.",
+ relative.display()
+ ),
+ ));
+ } else {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!(
+ "Extension archive entry '{}' is not a regular file or directory.",
+ relative.display()
+ ),
+ ));
+ }
+ }
+ Ok(extracted)
+}
+
+fn extract_zip(source: &Path, target: &Path) -> UseResult> {
+ let file = File::open(source).map_err(|error| archive_io("open", source, error))?;
+ let mut archive = zip::ZipArchive::new(file)
+ .map_err(|error| archive_invalid(format!("Failed to read ZIP archive: {error}")))?;
+ if archive.len() > MAX_PACKAGE_FILES {
+ return Err(package_limit_error());
+ }
+ let mut extracted = Vec::new();
+ let mut seen = BTreeSet::new();
+ let mut extracted_bytes = 0_u64;
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).map_err(|error| {
+ archive_invalid(format!("Failed to read ZIP entry {index}: {error}"))
+ })?;
+ if entry.is_symlink() {
+ return Err(UseError::new(
+ "use.extension.package_symlink",
+ format!("Extension archive entry '{}' is a link.", entry.name()),
+ ));
+ }
+ let enclosed = entry.enclosed_name().ok_or_else(|| {
+ UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive entry '{}' escapes the package.",
+ entry.name()
+ ),
+ )
+ })?;
+ if ignored_macos_metadata_path(&enclosed)? {
+ if entry.is_dir() {
+ continue;
+ }
+ if entry.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut io::sink(),
+ remaining,
+ &enclosed,
+ )?);
+ continue;
+ }
+ }
+ let Some(relative) = sanitized_relative_path(&enclosed)? else {
+ if entry.is_dir() {
+ continue;
+ }
+ return Err(archive_invalid(
+ "The ZIP archive contains a non-directory root entry.",
+ ));
+ };
+ if !seen.insert(relative.clone()) {
+ return Err(archive_invalid(format!(
+ "The ZIP archive contains duplicate entry '{}'.",
+ relative.display()
+ )));
+ }
+ let output = target.join(&relative);
+ if entry.is_dir() {
+ std::fs::create_dir_all(&output)
+ .map_err(|error| archive_io("create ZIP directory", &output, error))?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: false,
+ });
+ } else if entry.is_file() {
+ let remaining = MAX_PACKAGE_BYTES.saturating_sub(extracted_bytes);
+ if entry.size() > remaining {
+ return Err(package_limit_error());
+ }
+ if let Some(parent) = output.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| archive_io("create ZIP parent", parent, error))?;
+ }
+ let mut output_file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&output)
+ .map_err(|error| archive_io("create ZIP file", &output, error))?;
+ extracted_bytes = extracted_bytes.saturating_add(copy_bounded(
+ &mut entry,
+ &mut output_file,
+ remaining,
+ &output,
+ )?);
+ apply_unix_mode(&output, entry.unix_mode())?;
+ extracted.push(ExtractedEntry {
+ relative,
+ file: true,
+ });
+ } else {
+ return Err(UseError::new(
+ "use.extension.package_entry_invalid",
+ format!("Extension ZIP entry '{}' is unsupported.", entry.name()),
+ ));
+ }
+ }
+ Ok(extracted)
+}
+
+fn ignored_macos_metadata_path(path: &Path) -> UseResult {
+ if path.as_os_str().is_empty() {
+ return Err(archive_invalid("The archive contains an empty entry path."));
+ }
+ let encoded = path.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if encoded.len() > MAX_PATH_BYTES {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+
+ let mut first = None;
+ let mut last = None;
+ let mut depth = 0_usize;
+ for component in path.components() {
+ match component {
+ Component::Normal(segment) => {
+ depth += 1;
+ if depth > MAX_PATH_DEPTH {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' exceeds the depth limit.",
+ path.display()
+ )));
+ }
+ let segment = segment.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ first.get_or_insert(segment);
+ last = Some(segment);
+ }
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive path '{}' escapes the package.",
+ path.display()
+ ),
+ ));
+ }
+ }
+ }
+
+ Ok(first == Some("__MACOSX") || last.is_some_and(|segment| segment.starts_with("._")))
+}
+
+pub(crate) fn sanitized_relative_path(path: &Path) -> UseResult> {
+ if path.as_os_str().is_empty() {
+ return Err(archive_invalid("The archive contains an empty entry path."));
+ }
+ let encoded = path.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if encoded.len() > MAX_PATH_BYTES {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+ let mut sanitized = PathBuf::new();
+ let mut depth = 0_usize;
+ for component in path.components() {
+ match component {
+ Component::Normal(segment) => {
+ depth += 1;
+ if depth > MAX_PATH_DEPTH {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' exceeds the depth limit.",
+ path.display()
+ )));
+ }
+ validate_portable_segment(segment, path)?;
+ sanitized.push(segment);
+ }
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(UseError::new(
+ "use.extension.path_escape",
+ format!(
+ "Extension archive path '{}' escapes the package.",
+ path.display()
+ ),
+ ));
+ }
+ }
+ }
+ if sanitized.as_os_str().is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(sanitized))
+ }
+}
+
+fn validate_portable_segment(segment: &std::ffi::OsStr, path: &Path) -> UseResult<()> {
+ let segment = segment.to_str().ok_or_else(|| {
+ archive_invalid("Extension archive paths must be valid UTF-8 for portability.")
+ })?;
+ if segment.ends_with(['.', ' '])
+ || segment
+ .chars()
+ .any(|character| character.is_control() || r#"<>:"/\|?*"#.contains(character))
+ {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' is not portable.",
+ path.display()
+ )));
+ }
+ let device = segment
+ .split('.')
+ .next()
+ .unwrap_or_default()
+ .to_ascii_uppercase();
+ let reserved = matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL")
+ || device
+ .strip_prefix("COM")
+ .or_else(|| device.strip_prefix("LPT"))
+ .is_some_and(|number| {
+ matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
+ });
+ if reserved {
+ return Err(archive_invalid(format!(
+ "Extension archive path '{}' uses a reserved device name.",
+ path.display()
+ )));
+ }
+ Ok(())
+}
+
+fn copy_bounded(
+ reader: &mut impl Read,
+ writer: &mut impl Write,
+ remaining: u64,
+ path: &Path,
+) -> UseResult {
+ let mut bounded = reader.take(remaining.saturating_add(1));
+ let copied = io::copy(&mut bounded, writer)
+ .map_err(|error| archive_io("extract archive file", path, error))?;
+ if copied > remaining {
+ return Err(package_limit_error());
+ }
+ Ok(copied)
+}
+
+fn resolve_package_root(entries: &[ExtractedEntry]) -> UseResult {
+ let manifests = entries
+ .iter()
+ .filter(|entry| {
+ entry.file
+ && entry
+ .relative
+ .file_name()
+ .is_some_and(|name| name == MANIFEST_NAME)
+ })
+ .collect::>();
+ let [manifest] = manifests.as_slice() else {
+ return Err(UseError::new(
+ "use.extension.package_layout_invalid",
+ format!("Extension archives must contain exactly one regular {MANIFEST_NAME} file."),
+ ));
+ };
+ let root = manifest
+ .relative
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_default();
+ if !root.as_os_str().is_empty()
+ && entries
+ .iter()
+ .any(|entry| !entry.relative.starts_with(&root))
+ {
+ return Err(UseError::new(
+ "use.extension.package_layout_invalid",
+ "Extension archive entries must all belong to the directory containing its manifest.",
+ ));
+ }
+ Ok(root)
+}
+
+#[cfg(unix)]
+fn apply_unix_mode(path: &Path, mode: Option) -> UseResult<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ if let Some(mode) = mode {
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode & 0o777))
+ .map_err(|error| archive_io("set archive file permissions", path, error))?;
+ }
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn apply_unix_mode(_path: &Path, _mode: Option) -> UseResult<()> {
+ Ok(())
+}
+
+fn archive_io(action: &str, path: &Path, error: io::Error) -> UseError {
+ UseError::new(
+ "use.extension.package_archive_invalid",
+ format!(
+ "Failed to {action} extension archive entry '{}': {error}",
+ path.display()
+ ),
+ )
+}
+
+fn archive_invalid(message: impl Into) -> UseError {
+ UseError::new("use.extension.package_archive_invalid", message)
+}
+
+fn package_limit_error() -> UseError {
+ UseError::new(
+ "use.extension.package_too_large",
+ "The extension package exceeds the local installation limits.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::Write;
+
+ use super::*;
+
+ #[tokio::test]
+ async fn tar_package_accepts_an_explicit_current_directory_root() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("package.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let mut root = tar::Header::new_gnu();
+ root.set_path(".").unwrap();
+ root.set_entry_type(tar::EntryType::Directory);
+ root.set_size(0);
+ root.set_mode(0o755);
+ root.set_cksum();
+ builder.append(&root, io::empty()).unwrap();
+
+ let manifest = b"extension fixture";
+ let mut header = tar::Header::new_gnu();
+ header.set_path(format!("./{MANIFEST_NAME}")).unwrap();
+ header.set_size(manifest.len() as u64);
+ header.set_mode(0o644);
+ header.set_cksum();
+ builder.append(&header, &manifest[..]).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let prepared = prepare_package_source(&archive_path).await.unwrap();
+ assert_eq!(
+ std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(),
+ b"extension fixture"
+ );
+ }
+
+ #[tokio::test]
+ async fn tar_package_ignores_bounded_macos_appledouble_metadata() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("package.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let metadata = b"appledouble";
+ let mut metadata_header = tar::Header::new_gnu();
+ metadata_header.set_path("./._.").unwrap();
+ metadata_header.set_size(metadata.len() as u64);
+ metadata_header.set_mode(0o644);
+ metadata_header.set_cksum();
+ builder.append(&metadata_header, &metadata[..]).unwrap();
+
+ let manifest = b"extension fixture";
+ let mut manifest_header = tar::Header::new_gnu();
+ manifest_header
+ .set_path(format!("./{MANIFEST_NAME}"))
+ .unwrap();
+ manifest_header.set_size(manifest.len() as u64);
+ manifest_header.set_mode(0o644);
+ manifest_header.set_cksum();
+ builder.append(&manifest_header, &manifest[..]).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let prepared = prepare_package_source(&archive_path).await.unwrap();
+ assert_eq!(
+ std::fs::read(prepared.root().join(MANIFEST_NAME)).unwrap(),
+ b"extension fixture"
+ );
+ assert!(!prepared.root().join("._.").exists());
+ }
+
+ #[tokio::test]
+ async fn zip_package_rejects_parent_traversal() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("escape.zip");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let mut writer = zip::ZipWriter::new(file);
+ writer
+ .start_file(
+ "../a3s-use-extension.acl",
+ zip::write::SimpleFileOptions::default(),
+ )
+ .unwrap();
+ writer.write_all(b"escape").unwrap();
+ writer.finish().unwrap();
+ }
+
+ let error = prepare_package_source(&archive_path).await.unwrap_err();
+ assert_eq!(error.code, "use.extension.path_escape");
+ }
+
+ #[tokio::test]
+ async fn tar_package_rejects_symbolic_links() {
+ let temp = tempfile::tempdir().unwrap();
+ let archive_path = temp.path().join("link.tar.gz");
+ {
+ let file = File::create(&archive_path).unwrap();
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ let manifest = b"extension fixture";
+ let mut manifest_header = tar::Header::new_gnu();
+ manifest_header
+ .set_path(format!("package/{MANIFEST_NAME}"))
+ .unwrap();
+ manifest_header.set_size(manifest.len() as u64);
+ manifest_header.set_mode(0o644);
+ manifest_header.set_cksum();
+ builder.append(&manifest_header, &manifest[..]).unwrap();
+
+ let mut link = tar::Header::new_gnu();
+ link.set_entry_type(tar::EntryType::Symlink);
+ link.set_path("package/escape").unwrap();
+ link.set_link_name("../../outside").unwrap();
+ link.set_size(0);
+ link.set_cksum();
+ builder.append(&link, io::empty()).unwrap();
+ builder.finish().unwrap();
+ }
+
+ let error = prepare_package_source(&archive_path).await.unwrap_err();
+ assert_eq!(error.code, "use.extension.package_symlink");
+ }
+
+ #[test]
+ fn archive_paths_reject_cross_platform_escapes_and_device_names() {
+ for path in ["C:/escape", "..\\escape", "package/CON", "package/name. "] {
+ assert!(
+ sanitized_relative_path(Path::new(path)).is_err(),
+ "accepted unsafe path {path}"
+ );
+ }
+ assert_eq!(
+ sanitized_relative_path(Path::new("./package/bin/tool")).unwrap(),
+ Some(PathBuf::from("package/bin/tool"))
+ );
+ }
+}
diff --git a/crates/extension/src/tuf_test_support.rs b/crates/extension/src/tuf_test_support.rs
new file mode 100644
index 00000000..06767097
--- /dev/null
+++ b/crates/extension/src/tuf_test_support.rs
@@ -0,0 +1,324 @@
+#![allow(dead_code)]
+
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::net::{Shutdown, TcpListener, TcpStream};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread::JoinHandle;
+use std::time::Duration;
+
+use olpc_cjson::CanonicalFormatter;
+use ring::signature::{Ed25519KeyPair, KeyPair};
+use serde::Serialize;
+use serde_json::{json, Map, Value};
+use sha2::{Digest, Sha256};
+
+pub(crate) const FUTURE: &str = "2999-01-01T00:00:00Z";
+pub(crate) const EXPIRED: &str = "2000-01-01T00:00:00Z";
+pub(crate) const PACKAGE_VERSION: &str = "0.1.1";
+
+pub(crate) struct TestRepository {
+ pub(crate) routes: HashMap>,
+ pub(crate) root_sha256: String,
+ pub(crate) target_name: String,
+ pub(crate) target_sha256: String,
+}
+
+impl TestRepository {
+ pub(crate) fn new(archive: Vec, metadata_version: u64, expires: &str) -> Self {
+ Self::with_package_version(archive, PACKAGE_VERSION, metadata_version, expires)
+ }
+
+ pub(crate) fn with_package_version(
+ archive: Vec,
+ package_version: &str,
+ metadata_version: u64,
+ expires: &str,
+ ) -> Self {
+ let key = Ed25519KeyPair::from_seed_unchecked(&[7_u8; 32]).unwrap();
+ let public = hex_lower(key.public_key().as_ref());
+ let key_value = json!({
+ "keytype": "ed25519",
+ "scheme": "ed25519",
+ "keyval": {"public": public}
+ });
+ let key_id = sha256(&canonical(&key_value));
+ let role = json!({"keyids": [key_id.clone()], "threshold": 1});
+ let mut keys = Map::new();
+ keys.insert(key_id.clone(), key_value);
+ let root_signed = json!({
+ "_type": "root",
+ "spec_version": "1.0.0",
+ "consistent_snapshot": false,
+ "version": 1,
+ "expires": FUTURE,
+ "keys": keys,
+ "roles": {
+ "root": role.clone(),
+ "snapshot": role.clone(),
+ "targets": role.clone(),
+ "timestamp": role
+ }
+ });
+ let root = signed_document(&key, &key_id, root_signed);
+ let root_sha256 = sha256(&root);
+
+ let target = host_target();
+ let archive_name = format!("a3s-use-acme-slack-{package_version}-{target}.tar.gz");
+ let target_name =
+ format!("extensions/acme/slack/{package_version}/stable/{target}/{archive_name}");
+ let target_sha256 = sha256(&archive);
+ let mut targets_map = Map::new();
+ targets_map.insert(
+ target_name.clone(),
+ json!({
+ "length": archive.len(),
+ "hashes": {"sha256": target_sha256},
+ "custom": {
+ "a3s": {
+ "schemaVersion": 1,
+ "packageId": "acme/slack",
+ "version": package_version,
+ "channel": "stable",
+ "target": target
+ }
+ }
+ }),
+ );
+ let targets_signed = json!({
+ "_type": "targets",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "targets": targets_map
+ });
+ let targets = signed_document(&key, &key_id, targets_signed);
+ let snapshot_signed = json!({
+ "_type": "snapshot",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "meta": {
+ "targets.json": {
+ "version": metadata_version,
+ "length": targets.len(),
+ "hashes": {"sha256": sha256(&targets)}
+ }
+ }
+ });
+ let snapshot = signed_document(&key, &key_id, snapshot_signed);
+ let timestamp_signed = json!({
+ "_type": "timestamp",
+ "spec_version": "1.0.0",
+ "version": metadata_version,
+ "expires": expires,
+ "meta": {
+ "snapshot.json": {
+ "version": metadata_version,
+ "length": snapshot.len(),
+ "hashes": {"sha256": sha256(&snapshot)}
+ }
+ }
+ });
+ let timestamp = signed_document(&key, &key_id, timestamp_signed);
+
+ let routes = HashMap::from([
+ ("/metadata/root.json".to_string(), root),
+ ("/metadata/timestamp.json".to_string(), timestamp),
+ ("/metadata/snapshot.json".to_string(), snapshot),
+ ("/metadata/targets.json".to_string(), targets),
+ (format!("/targets/{target_name}"), archive),
+ ]);
+ Self {
+ routes,
+ root_sha256,
+ target_name,
+ target_sha256,
+ }
+ }
+}
+
+fn signed_document(key: &Ed25519KeyPair, key_id: &str, signed: Value) -> Vec {
+ let signature = key.sign(&canonical(&signed));
+ serde_json::to_vec(&json!({
+ "signatures": [{"keyid": key_id, "sig": hex_lower(signature.as_ref())}],
+ "signed": signed
+ }))
+ .unwrap()
+}
+
+fn canonical(value: &Value) -> Vec {
+ let mut bytes = Vec::new();
+ let mut serializer =
+ serde_json::Serializer::with_formatter(&mut bytes, CanonicalFormatter::new());
+ value.serialize(&mut serializer).unwrap();
+ bytes
+}
+
+fn sha256(bytes: &[u8]) -> String {
+ format!("{:x}", Sha256::digest(bytes))
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ use std::fmt::Write as _;
+ let _ = write!(output, "{byte:02x}");
+ }
+ output
+}
+
+pub(crate) fn extension_archive(version: &str) -> Vec {
+ let manifest = format!(
+ "extension \"acme/slack\" {{\n schema_version = 1\n version = \"{version}\"\n route = \"slack\"\n actions = [\"read\"]\n\n cli {{\n executable = \"bin/a3s-use-acme-slack\"\n json_output = true\n }}\n}}\n"
+ );
+ let mut bytes = Vec::new();
+ {
+ let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default());
+ let mut archive = tar::Builder::new(encoder);
+ append_tar_file(
+ &mut archive,
+ "package/a3s-use-extension.acl",
+ 0o644,
+ manifest.as_bytes(),
+ );
+ append_tar_file(
+ &mut archive,
+ "package/bin/a3s-use-acme-slack",
+ 0o755,
+ b"#!/bin/sh\nprintf 'slack fixture\\n'\n",
+ );
+ archive.finish().unwrap();
+ }
+ bytes
+}
+
+fn append_tar_file(archive: &mut tar::Builder, path: &str, mode: u32, body: &[u8]) {
+ let mut header = tar::Header::new_gnu();
+ header.set_path(path).unwrap();
+ header.set_size(body.len() as u64);
+ header.set_mode(mode);
+ header.set_cksum();
+ archive.append(&header, body).unwrap();
+}
+
+pub(crate) fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option {
+ haystack
+ .windows(needle.len())
+ .position(|window| window == needle)
+}
+
+fn host_target() -> &'static str {
+ match (std::env::consts::OS, std::env::consts::ARCH) {
+ ("macos", "aarch64") => "darwin-arm64",
+ ("macos", "x86_64") => "darwin-x86_64",
+ ("linux", "aarch64") => "linux-arm64",
+ ("linux", "x86_64") => "linux-x86_64",
+ ("windows", "x86_64") => "windows-x86_64",
+ (os, arch) => panic!("unsupported TUF test target {os}-{arch}"),
+ }
+}
+
+pub(crate) struct TestServer {
+ base_url: String,
+ routes: Arc>>>,
+ requests: Arc>>,
+ stop: Arc,
+ thread: Option>,
+}
+
+impl TestServer {
+ pub(crate) fn start(routes: HashMap>) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ listener.set_nonblocking(true).unwrap();
+ let base_url = format!("http://{}/", listener.local_addr().unwrap());
+ let routes = Arc::new(Mutex::new(routes));
+ let requests = Arc::new(Mutex::new(Vec::new()));
+ let stop = Arc::new(AtomicBool::new(false));
+ let thread_routes = Arc::clone(&routes);
+ let thread_requests = Arc::clone(&requests);
+ let thread_stop = Arc::clone(&stop);
+ let thread = std::thread::spawn(move || {
+ while !thread_stop.load(Ordering::Relaxed) {
+ match listener.accept() {
+ Ok((stream, _)) => {
+ stream.set_nonblocking(false).unwrap();
+ let routes = Arc::clone(&thread_routes);
+ let requests = Arc::clone(&thread_requests);
+ std::thread::spawn(move || serve(stream, &routes, &requests));
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+ Self {
+ base_url,
+ routes,
+ requests,
+ stop,
+ thread: Some(thread),
+ }
+ }
+
+ pub(crate) fn base_url(&self) -> &str {
+ &self.base_url
+ }
+
+ pub(crate) fn requests(&self) -> Vec {
+ self.requests.lock().unwrap().clone()
+ }
+
+ pub(crate) fn clear_requests(&self) {
+ self.requests.lock().unwrap().clear();
+ }
+
+ pub(crate) fn replace_routes(&self, routes: HashMap>) {
+ *self.routes.lock().unwrap() = routes;
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::Relaxed);
+ if let Some(thread) = self.thread.take() {
+ let _ = thread.join();
+ }
+ }
+}
+
+fn serve(
+ mut stream: TcpStream,
+ routes: &Mutex>>,
+ requests: &Mutex>,
+) {
+ let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
+ let mut buffer = [0_u8; 8192];
+ let Ok(size) = stream.read(&mut buffer) else {
+ return;
+ };
+ let request = String::from_utf8_lossy(&buffer[..size]);
+ let path = request
+ .lines()
+ .next()
+ .and_then(|line| line.split_whitespace().nth(1))
+ .unwrap_or("/")
+ .to_string();
+ requests.lock().unwrap().push(path.clone());
+ let body = routes.lock().unwrap().get(&path).cloned();
+ let (status, body) = body
+ .as_deref()
+ .map(|body| ("200 OK", body))
+ .unwrap_or(("404 Not Found", b"not found"));
+ let header = format!(
+ "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ if stream.write_all(header.as_bytes()).is_ok() && stream.write_all(body).is_ok() {
+ let _ = stream.flush();
+ let _ = stream.shutdown(Shutdown::Write);
+ }
+}
diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml
new file mode 100644
index 00000000..e9ba0dc7
--- /dev/null
+++ b/crates/ocr/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "a3s-use-ocr"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+description = "Typed built-in optical character recognition for A3S Use"
+
+[lib]
+name = "a3s_use_ocr"
+path = "src/lib.rs"
+
+[[bin]]
+name = "a3s-use-ocr"
+path = "src/main.rs"
+
+[dependencies]
+a3s-use-core = { version = "0.1.2", path = "../core" }
+clap.workspace = true
+clipper2.workspace = true
+fs2.workspace = true
+image.workspace = true
+imageproc.workspace = true
+ort.workspace = true
+reqwest = { workspace = true, features = ["json"] }
+rmcp.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_yaml.workspace = true
+sha2.workspace = true
+tar.workspace = true
+tokio.workspace = true
+url.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/ocr/README.md b/crates/ocr/README.md
new file mode 100644
index 00000000..4a7f5251
--- /dev/null
+++ b/crates/ocr/README.md
@@ -0,0 +1,58 @@
+# A3S Use OCR
+
+`a3s-use-ocr` implements the first-party built-in OCR domain for A3S Use. A3S
+Code receives it as `mcp__use_ocr__*` through the release-matched Use registry,
+without installing a separate extension. The native CLI and standard stdio MCP
+share one local PP-OCRv6 implementation.
+
+There is one OCR provider:
+
+- provider: `pp-ocr-v6`
+- engine: `onnx-runtime`
+- model bundle: `PP-OCRv6_small`
+
+The first extraction installs or repairs the pinned detection and recognition
+models when networking and first-use installation are allowed. Prepare them
+explicitly when deterministic startup or offline work is required:
+
+```bash
+a3s install use/ocr
+a3s install use/ocr --force
+```
+
+`A3S_OCR_MODEL_DIR` can point development builds at an explicit model bundle.
+`A3S_USE_OCR_HOME` overrides the managed model root for packaging, tests, or an
+isolated installation. Neither setting selects another OCR backend.
+
+## Workflow
+
+For each bounded local image, the native engine:
+
+1. decodes the image and applies PP-OCRv6 BGR normalization;
+2. runs `PP-OCRv6_small_det` through ONNX Runtime;
+3. applies DB post-processing, polygon unclipping, and reading-order sorting;
+4. perspective-rectifies each text polygon and rotates tall crops;
+5. runs batched `PP-OCRv6_small_rec` inference; and
+6. applies CTC decoding and returns text, recognition/detection confidence,
+ polygons, bounding boxes, and the source SHA-256.
+
+All inference stays in the local `a3s-use` process. It does not require Python
+or PaddlePaddle, does not call an OCR API, and does not transfer image bytes off
+the device.
+
+## Commands
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract ./scan.png --json
+a3s use mcp serve ocr
+```
+
+`doctor` is read-only and never downloads anything. Direct CLI extraction
+prepares missing or damaged A3S-managed models automatically. Through MCP, the
+`use` worker calls the separate `ocr_install` mutation, which must pass parent
+confirmation before extraction continues. `A3S_OFFLINE=1` and
+`A3S_NO_AUTO_INSTALL=1` prohibit this first-use download.
+
+Supported inputs are bounded local PNG, JPEG, WebP, GIF, BMP, and TIFF files.
+URLs and PDF rasterization are outside this crate.
diff --git a/crates/ocr/skills/a3s-use-ocr/SKILL.md b/crates/ocr/skills/a3s-use-ocr/SKILL.md
new file mode 100644
index 00000000..bbab0597
--- /dev/null
+++ b/crates/ocr/skills/a3s-use-ocr/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: a3s-use-ocr
+description: Extract text and layout evidence from local image files through the built-in A3S Use PP-OCRv6 domain. Use when an agent needs optical character recognition for a PNG, JPEG, WebP, GIF, BMP, or TIFF image and must preserve the source digest, confidence, polygon, and bounding-box evidence.
+---
+
+# A3S Use OCR
+
+Use the host-provided A3S Use surface. In an A3S Code `use` worker, call
+`mcp__use_ocr__ocr_doctor`, `mcp__use_ocr__ocr_install`, and
+`mcp__use_ocr__ocr_extract` directly. The host owns the MCP process; do not run
+a shell command or read the file through another tool.
+
+## Workflow
+
+1. Call `mcp__use_ocr__ocr_doctor`.
+2. If the pinned model bundle is missing or broken, call
+ `mcp__use_ocr__ocr_install`. This bounded network mutation must pass the
+ parent TUI confirmation. Do not replace it with a shell installation.
+3. Confirm that `pp-ocr-v6`, `onnx-runtime`, and `PP-OCRv6_small` are ready.
+4. Call `mcp__use_ocr__ocr_extract` with the exact local image path from the
+ task.
+5. Preserve the returned source path, media type, size, and SHA-256. Treat the
+ decoded text, recognition/detection confidence, polygons, and bounding boxes
+ as OCR evidence rather than verified source text.
+
+The engine runs detection, reading-order sorting, perspective crop correction,
+tall-crop rotation, recognition, and CTC decoding locally. It does not require
+Python or PaddlePaddle and never sends the source image off the device. Offline
+mode and `A3S_NO_AUTO_INSTALL=1` prohibit the bounded installer; return that
+typed policy failure to the parent instead of attempting a fallback.
+
+In a CLI-only host, equivalent commands are:
+
+```bash
+a3s use ocr doctor --json
+a3s use ocr extract "$IMAGE" --json
+```
+
+The first extract automatically installs or repairs the pinned models when
+networking and first-use installation are allowed. `doctor` remains read-only
+and never downloads anything. `a3s install use/ocr` is available for explicit
+preparation.
+
+`a3s-use-ocr` accepts the same arguments when invoked as a standalone
+development binary.
+
+## Boundaries
+
+- Only bounded local image files are accepted. URLs and PDF rasterization are
+ outside this domain.
+- Do not ask OCR to interpret unrelated content or present OCR output as
+ verified source text.
+- Do not hide empty results, warnings, model readiness failures, or source
+ digest evidence from the parent agent.
diff --git a/crates/ocr/src/assets.rs b/crates/ocr/src/assets.rs
new file mode 100644
index 00000000..e14ea852
--- /dev/null
+++ b/crates/ocr/src/assets.rs
@@ -0,0 +1,265 @@
+use std::path::{Path, PathBuf};
+
+use a3s_use_core::{UseError, UseResult};
+use serde::{Deserialize, Serialize};
+
+use crate::config::{load_detection, load_recognition, MODEL_FAMILY};
+
+pub(crate) const RECEIPT_FILE: &str = ".a3s-ppocr-v6.json";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+pub enum OcrInstallSource {
+ Environment,
+ Packaged,
+ Managed,
+ Missing,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrRuntimeStatus {
+ pub available: bool,
+ pub source: OcrInstallSource,
+ pub model: String,
+ pub model_dir: Option,
+ pub managed_root: Option,
+ pub detail: String,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ModelAssets {
+ pub(crate) root: PathBuf,
+ pub(crate) detection_model: PathBuf,
+ pub(crate) detection_config: PathBuf,
+ pub(crate) recognition_model: PathBuf,
+ pub(crate) recognition_config: PathBuf,
+ pub(crate) source: OcrInstallSource,
+}
+
+pub fn ocr_status() -> OcrRuntimeStatus {
+ let managed_root = managed_root().ok();
+ match resolve_model_assets() {
+ Ok(assets) => OcrRuntimeStatus {
+ available: true,
+ source: assets.source,
+ model: MODEL_FAMILY.to_string(),
+ model_dir: Some(assets.root),
+ managed_root,
+ detail: "ready".to_string(),
+ },
+ Err(error) => OcrRuntimeStatus {
+ available: false,
+ source: error
+ .details
+ .get("source")
+ .and_then(serde_json::Value::as_str)
+ .map(source_from_name)
+ .unwrap_or(OcrInstallSource::Missing),
+ model: MODEL_FAMILY.to_string(),
+ model_dir: error
+ .details
+ .get("modelDir")
+ .and_then(serde_json::Value::as_str)
+ .map(PathBuf::from),
+ managed_root,
+ detail: error.message,
+ },
+ }
+}
+
+pub(crate) fn resolve_model_assets() -> UseResult {
+ if let Some(path) = std::env::var_os("A3S_OCR_MODEL_DIR")
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ {
+ let path = absolute(path)?;
+ return validate_assets(&path, OcrInstallSource::Environment);
+ }
+
+ let managed = managed_model_dir()?;
+ if path_exists(&managed)? {
+ return validate_assets(&managed, OcrInstallSource::Managed);
+ }
+
+ if let Ok(executable) = std::env::current_exe() {
+ if let Some(parent) = executable.parent() {
+ let packaged = parent.join("ocr-models").join(MODEL_FAMILY);
+ if path_exists(&packaged)? {
+ return validate_assets(&packaged, OcrInstallSource::Packaged);
+ }
+ }
+ }
+
+ Err(UseError::new(
+ "use.ocr.model_missing",
+ format!("The local {MODEL_FAMILY} model bundle is not installed."),
+ )
+ .with_suggestion(
+ "Call the bounded ocr_install MCP tool, or run 'a3s install use/ocr' explicitly.",
+ )
+ .with_detail("source", "missing")
+ .with_detail("modelDir", managed.display().to_string()))
+}
+
+pub(crate) fn validate_assets(root: &Path, source: OcrInstallSource) -> UseResult {
+ let root = std::fs::canonicalize(root).map_err(|error| {
+ model_error(
+ source,
+ root,
+ format!(
+ "Failed to resolve the {MODEL_FAMILY} model directory '{}': {error}",
+ root.display()
+ ),
+ )
+ })?;
+ let detection_model = checked_file(&root, "det/inference.onnx", 256 * 1024 * 1024, source)?;
+ let detection_config = checked_file(&root, "det/inference.yml", 2 * 1024 * 1024, source)?;
+ let recognition_model = checked_file(&root, "rec/inference.onnx", 256 * 1024 * 1024, source)?;
+ let recognition_config = checked_file(&root, "rec/inference.yml", 2 * 1024 * 1024, source)?;
+
+ load_detection(&detection_config)?;
+ load_recognition(&recognition_config)?;
+
+ Ok(ModelAssets {
+ root,
+ detection_model,
+ detection_config,
+ recognition_model,
+ recognition_config,
+ source,
+ })
+}
+
+pub(crate) fn managed_root() -> UseResult {
+ if let Some(value) = std::env::var_os("A3S_USE_OCR_HOME") {
+ return absolute(PathBuf::from(value));
+ }
+ if let Some(value) = std::env::var_os("A3S_DATA_HOME") {
+ return Ok(absolute(PathBuf::from(value))?.join("use/ocr"));
+ }
+ if let Some(value) = std::env::var_os("XDG_DATA_HOME") {
+ return Ok(absolute(PathBuf::from(value))?.join("a3s/use/ocr"));
+ }
+ if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
+ return Ok(absolute(home)?.join(".local/share/a3s/use/ocr"));
+ }
+ #[cfg(windows)]
+ if let Some(value) = std::env::var_os("LOCALAPPDATA") {
+ return Ok(absolute(PathBuf::from(value))?.join("a3s/use/ocr"));
+ }
+ Err(UseError::new(
+ "use.ocr.data_home_missing",
+ "Cannot determine the A3S Use OCR data directory.",
+ ))
+}
+
+pub(crate) fn managed_model_dir() -> UseResult {
+ Ok(managed_root()?.join(MODEL_FAMILY))
+}
+
+fn checked_file(
+ root: &Path,
+ relative: &str,
+ max_bytes: u64,
+ source: OcrInstallSource,
+) -> UseResult {
+ let path = root.join(relative);
+ let canonical = std::fs::canonicalize(&path).map_err(|error| {
+ model_error(
+ source,
+ root,
+ format!(
+ "Required {MODEL_FAMILY} asset '{}' is unreadable: {error}",
+ path.display()
+ ),
+ )
+ })?;
+ if !canonical.starts_with(root) {
+ return Err(model_error(
+ source,
+ root,
+ format!(
+ "Required {MODEL_FAMILY} asset '{}' escapes its model directory.",
+ path.display()
+ ),
+ ));
+ }
+ let metadata = std::fs::metadata(&canonical).map_err(|error| {
+ model_error(
+ source,
+ root,
+ format!(
+ "Failed to inspect {MODEL_FAMILY} asset '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ if !metadata.is_file() || metadata.len() == 0 || metadata.len() > max_bytes {
+ return Err(model_error(
+ source,
+ root,
+ format!(
+ "{MODEL_FAMILY} asset '{}' must be a non-empty regular file no larger than {max_bytes} bytes.",
+ canonical.display()
+ ),
+ ));
+ }
+ Ok(canonical)
+}
+
+fn path_exists(path: &Path) -> UseResult {
+ match std::fs::symlink_metadata(path) {
+ Ok(_) => Ok(true),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(error) => Err(UseError::new(
+ "use.ocr.model_unreadable",
+ format!(
+ "Failed to inspect OCR model path '{}': {error}",
+ path.display()
+ ),
+ )),
+ }
+}
+
+fn model_error(source: OcrInstallSource, root: &Path, message: impl Into) -> UseError {
+ UseError::new("use.ocr.model_invalid", message)
+ .with_suggestion(
+ "Call the bounded ocr_install MCP tool, or run 'a3s install use/ocr --force' explicitly.",
+ )
+ .with_detail("source", source_name(source))
+ .with_detail("modelDir", root.display().to_string())
+}
+
+fn source_name(source: OcrInstallSource) -> &'static str {
+ match source {
+ OcrInstallSource::Environment => "environment",
+ OcrInstallSource::Packaged => "packaged",
+ OcrInstallSource::Managed => "managed",
+ OcrInstallSource::Missing => "missing",
+ }
+}
+
+fn source_from_name(value: &str) -> OcrInstallSource {
+ match value {
+ "environment" => OcrInstallSource::Environment,
+ "packaged" => OcrInstallSource::Packaged,
+ "managed" => OcrInstallSource::Managed,
+ _ => OcrInstallSource::Missing,
+ }
+}
+
+fn absolute(path: PathBuf) -> UseResult {
+ if path.is_absolute() {
+ Ok(path)
+ } else {
+ std::env::current_dir()
+ .map(|directory| directory.join(path))
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.path_resolution_failed",
+ format!("Failed to resolve OCR data path: {error}"),
+ )
+ })
+ }
+}
diff --git a/crates/ocr/src/cli.rs b/crates/ocr/src/cli.rs
new file mode 100644
index 00000000..e5550740
--- /dev/null
+++ b/crates/ocr/src/cli.rs
@@ -0,0 +1,160 @@
+use std::path::PathBuf;
+
+use a3s_use_core::{UseError, UseResult};
+use clap::error::ErrorKind;
+use clap::{Parser, Subcommand};
+use serde::Serialize;
+
+use crate::{OcrClient, OcrMcpServer, OcrRequest};
+
+#[derive(Debug)]
+pub struct CommandOutput {
+ pub human: String,
+ pub json: serde_json::Value,
+ pub exit_code: u8,
+ pub should_print: bool,
+}
+
+impl CommandOutput {
+ fn data(value: T) -> UseResult
+ where
+ T: Serialize,
+ {
+ let data = serde_json::to_value(value).map_err(output_error)?;
+ let human = serde_json::to_string_pretty(&data).map_err(output_error)?;
+ Ok(Self {
+ human,
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": data,
+ }),
+ exit_code: 0,
+ should_print: true,
+ })
+ }
+
+ fn text(value: String) -> Self {
+ Self {
+ human: value.clone(),
+ json: serde_json::json!({
+ "schemaVersion": 1,
+ "ok": true,
+ "data": { "text": value },
+ }),
+ exit_code: 0,
+ should_print: true,
+ }
+ }
+
+ fn silent() -> Self {
+ Self {
+ human: String::new(),
+ json: serde_json::Value::Null,
+ exit_code: 0,
+ should_print: false,
+ }
+ }
+}
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "a3s-use-ocr",
+ version,
+ about = "Typed built-in OCR for A3S Use",
+ arg_required_else_help = true
+)]
+struct Cli {
+ /// Emit one versioned JSON document.
+ #[arg(long, global = true)]
+ json: bool,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+enum Command {
+ /// Inspect local PP-OCRv6 readiness without reading an image.
+ Doctor,
+ /// Extract text and layout evidence from one local image.
+ Extract { path: PathBuf },
+ /// Run an extension protocol surface.
+ Serve {
+ /// Serve standard MCP over stdin/stdout.
+ #[arg(long)]
+ mcp: bool,
+ },
+}
+
+pub async fn run(args: Vec) -> UseResult {
+ let mut argv = vec!["a3s-use-ocr".to_string()];
+ argv.extend(args);
+ let cli = match Cli::try_parse_from(argv) {
+ Ok(cli) => cli,
+ Err(error)
+ if matches!(
+ error.kind(),
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
+ ) =>
+ {
+ return Ok(CommandOutput::text(error.to_string()));
+ }
+ Err(error) => return Err(usage_error(error.to_string())),
+ };
+
+ if let Command::Serve { mcp } = &cli.command {
+ if !mcp {
+ return Err(usage_error("serve requires --mcp"));
+ }
+ if cli.json {
+ return Err(usage_error("--json cannot be combined with serve --mcp"));
+ }
+ OcrMcpServer::from_env()?.serve_stdio().await?;
+ return Ok(CommandOutput::silent());
+ }
+
+ let client = OcrClient::from_env()?;
+ match cli.command {
+ Command::Doctor => CommandOutput::data(client.diagnostic()),
+ Command::Extract { path } => {
+ CommandOutput::data(client.extract_with_first_use(OcrRequest { path }).await?)
+ }
+ Command::Serve { .. } => Err(UseError::new(
+ "use.ocr.command_invalid",
+ "OCR MCP command dispatch reached an invalid state.",
+ )),
+ }
+}
+
+fn output_error(error: serde_json::Error) -> UseError {
+ UseError::new(
+ "use.ocr.output_invalid",
+ format!("Failed to encode OCR command output: {error}"),
+ )
+}
+
+fn usage_error(message: impl Into) -> UseError {
+ UseError::new("use.ocr.usage_invalid", message).with_suggestion("Run 'a3s use ocr --help'.")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn doctor_is_versioned_even_when_no_provider_is_ready() {
+ let output = run(vec!["doctor".to_string(), "--json".to_string()])
+ .await
+ .unwrap();
+ assert_eq!(output.json["schemaVersion"], 1);
+ assert_eq!(output.json["ok"], true);
+ assert!(output.json["data"]["readiness"].is_string());
+ }
+
+ #[tokio::test]
+ async fn serve_requires_an_explicit_protocol() {
+ let error = run(vec!["serve".to_string()]).await.unwrap_err();
+ assert_eq!(error.code, "use.ocr.usage_invalid");
+ }
+}
diff --git a/crates/ocr/src/client.rs b/crates/ocr/src/client.rs
new file mode 100644
index 00000000..7cf6f10a
--- /dev/null
+++ b/crates/ocr/src/client.rs
@@ -0,0 +1,320 @@
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+
+use a3s_use_core::{Artifact, Readiness, UseError, UseResult};
+use sha2::{Digest, Sha256};
+use tokio::io::AsyncReadExt;
+
+use crate::assets::{ocr_status, resolve_model_assets, OcrInstallSource};
+use crate::config::MODEL_FAMILY;
+use crate::engine::{EngineBlock, PpOcrV6Engine};
+use crate::install::ensure_ppocr_v6_ready;
+use crate::models::{
+ OcrBlock, OcrBoundingBox, OcrDiagnostic, OcrPoint, OcrProviderKind, OcrRequest, OcrResult,
+};
+use crate::preprocess::decode_image;
+
+const MAX_INPUT_BYTES: u64 = 32 * 1024 * 1024;
+const ENGINE_NAME: &str = "onnx-runtime";
+
+#[derive(Clone)]
+pub struct OcrClient {
+ loaded: Arc>>,
+}
+
+struct LoadedEngine {
+ model_dir: PathBuf,
+ engine: PpOcrV6Engine,
+}
+
+impl OcrClient {
+ pub fn from_env() -> UseResult {
+ Ok(Self {
+ loaded: Arc::new(Mutex::new(None)),
+ })
+ }
+
+ pub fn diagnostic(&self) -> OcrDiagnostic {
+ let status = ocr_status();
+ let (readiness, suggestions) = if status.available {
+ (Readiness::Ready, Vec::new())
+ } else if status.source == OcrInstallSource::Missing {
+ (
+ Readiness::Missing,
+ vec![
+ "Call the bounded ocr_install MCP tool, or run 'a3s install use/ocr' explicitly."
+ .to_string(),
+ ],
+ )
+ } else {
+ (
+ Readiness::Broken,
+ vec![
+ "Call the bounded ocr_install MCP tool, or run 'a3s install use/ocr --force' explicitly."
+ .to_string(),
+ ],
+ )
+ };
+ OcrDiagnostic {
+ readiness,
+ provider: Some(OcrProviderKind::PpOcrV6),
+ engine: Some(ENGINE_NAME.to_string()),
+ model: Some(status.model),
+ model_dir: status.model_dir,
+ sends_source_off_device: false,
+ message: if status.available {
+ "Local PP-OCRv6 detection and recognition models are ready.".to_string()
+ } else {
+ status.detail
+ },
+ suggestions,
+ }
+ }
+
+ pub async fn extract(&self, request: OcrRequest) -> UseResult {
+ let source = read_source(&request.path).await?;
+ self.extract_source(source).await
+ }
+
+ /// Validate the local source, prepare pinned models under first-use policy,
+ /// and then perform the same local extraction as [`Self::extract`].
+ pub async fn extract_with_first_use(&self, request: OcrRequest) -> UseResult {
+ let source = read_source(&request.path).await?;
+ ensure_ppocr_v6_ready().await?;
+ self.extract_source(source).await
+ }
+
+ async fn extract_source(&self, source: SourceImage) -> UseResult {
+ let loaded = Arc::clone(&self.loaded);
+ tokio::task::spawn_blocking(move || {
+ let image = decode_image(&source.bytes)?;
+ let assets = resolve_model_assets()?;
+ let mut loaded = loaded.lock().map_err(|_| {
+ UseError::new(
+ "use.ocr.runtime_failed",
+ "The local PP-OCRv6 engine lock is poisoned.",
+ )
+ })?;
+ let should_load = loaded
+ .as_ref()
+ .map(|loaded| loaded.model_dir != assets.root)
+ .unwrap_or(true);
+ if should_load {
+ *loaded = Some(LoadedEngine {
+ model_dir: assets.root.clone(),
+ engine: PpOcrV6Engine::load(&assets)?,
+ });
+ }
+ let engine = loaded.as_mut().ok_or_else(|| {
+ UseError::new(
+ "use.ocr.runtime_failed",
+ "The local PP-OCRv6 engine failed to initialize.",
+ )
+ })?;
+ let blocks = engine.engine.extract(&image)?;
+ build_result(source.artifact, blocks)
+ })
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.runtime_failed",
+ format!("The local PP-OCRv6 inference task failed: {error}"),
+ )
+ })?
+ }
+}
+
+fn build_result(source: Artifact, blocks: Vec) -> UseResult {
+ let blocks = blocks
+ .into_iter()
+ .map(|block| {
+ let [first, second, third, fourth] = block.polygon;
+ let polygon = [
+ ocr_point(first)?,
+ ocr_point(second)?,
+ ocr_point(third)?,
+ ocr_point(fourth)?,
+ ];
+ let min_x = polygon.iter().map(|point| point.x).min().unwrap_or(0);
+ let max_x = polygon.iter().map(|point| point.x).max().unwrap_or(0);
+ let min_y = polygon.iter().map(|point| point.y).min().unwrap_or(0);
+ let max_y = polygon.iter().map(|point| point.y).max().unwrap_or(0);
+ Ok(OcrBlock {
+ page: 1,
+ text: block.text,
+ confidence: block.confidence,
+ detection_confidence: block.detection_confidence,
+ polygon,
+ bounding_box: OcrBoundingBox {
+ x: min_x,
+ y: min_y,
+ width: max_x.saturating_sub(min_x),
+ height: max_y.saturating_sub(min_y),
+ },
+ })
+ })
+ .collect::>>()?;
+ let text = blocks
+ .iter()
+ .filter(|block| !block.text.trim().is_empty())
+ .map(|block| block.text.as_str())
+ .collect::>()
+ .join("\n");
+ Ok(OcrResult {
+ provider: OcrProviderKind::PpOcrV6,
+ engine: ENGINE_NAME.to_string(),
+ model: MODEL_FAMILY.to_string(),
+ source,
+ text,
+ blocks,
+ warnings: Vec::new(),
+ })
+}
+
+fn ocr_point(point: imageproc::point::Point) -> UseResult {
+ Ok(OcrPoint {
+ x: finite_coordinate(point.x)?,
+ y: finite_coordinate(point.y)?,
+ })
+}
+
+fn finite_coordinate(value: f32) -> UseResult {
+ if !value.is_finite() || value < 0.0 || value > u32::MAX as f32 {
+ return Err(UseError::new(
+ "use.ocr.provider_output_invalid",
+ "PP-OCRv6 returned an invalid polygon coordinate.",
+ ));
+ }
+ Ok(value.round() as u32)
+}
+
+struct SourceImage {
+ artifact: Artifact,
+ bytes: Vec,
+}
+
+async fn read_source(path: &Path) -> UseResult {
+ let canonical = tokio::fs::canonicalize(path).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!("Failed to resolve OCR source '{}': {error}", path.display()),
+ )
+ })?;
+ let metadata = tokio::fs::metadata(&canonical).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to inspect OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ if !metadata.is_file() {
+ return Err(UseError::new(
+ "use.ocr.source_invalid",
+ format!(
+ "OCR source '{}' is not a regular file.",
+ canonical.display()
+ ),
+ ));
+ }
+ if metadata.len() == 0 || metadata.len() > MAX_INPUT_BYTES {
+ return Err(UseError::new(
+ "use.ocr.source_too_large",
+ format!(
+ "OCR source '{}' must contain between 1 byte and 32 MiB.",
+ canonical.display()
+ ),
+ )
+ .with_detail("size", metadata.len()));
+ }
+ let file = tokio::fs::File::open(&canonical).await.map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to open OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ let mut bytes = Vec::with_capacity(metadata.len().min(MAX_INPUT_BYTES) as usize);
+ file.take(MAX_INPUT_BYTES + 1)
+ .read_to_end(&mut bytes)
+ .await
+ .map_err(|error| {
+ UseError::new(
+ "use.ocr.source_unreadable",
+ format!(
+ "Failed to read OCR source '{}': {error}",
+ canonical.display()
+ ),
+ )
+ })?;
+ if bytes.len() as u64 > MAX_INPUT_BYTES {
+ return Err(UseError::new(
+ "use.ocr.source_too_large",
+ format!(
+ "OCR source '{}' must not exceed 32 MiB.",
+ canonical.display()
+ ),
+ )
+ .with_detail("sizeAtLeast", MAX_INPUT_BYTES + 1));
+ }
+ let media_type = detect_image_type(&bytes).ok_or_else(|| {
+ UseError::new(
+ "use.ocr.source_type_unsupported",
+ "OCR accepts PNG, JPEG, WebP, GIF, BMP, and TIFF image bytes.",
+ )
+ })?;
+ let digest = Sha256::digest(&bytes);
+ Ok(SourceImage {
+ artifact: Artifact {
+ path: canonical,
+ media_type: media_type.to_string(),
+ size: bytes.len() as u64,
+ sha256: format!("{digest:x}"),
+ },
+ bytes,
+ })
+}
+
+fn detect_image_type(bytes: &[u8]) -> Option<&'static str> {
+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
+ Some("image/png")
+ } else if bytes.starts_with(b"\xff\xd8\xff") {
+ Some("image/jpeg")
+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
+ Some("image/gif")
+ } else if bytes.starts_with(b"BM") {
+ Some("image/bmp")
+ } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
+ Some("image/tiff")
+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
+ Some("image/webp")
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn detects_supported_image_signatures() {
+ assert_eq!(
+ detect_image_type(b"\x89PNG\r\n\x1a\nrest"),
+ Some("image/png")
+ );
+ assert_eq!(detect_image_type(b"\xff\xd8\xffrest"), Some("image/jpeg"));
+ assert_eq!(detect_image_type(b"not an image"), None);
+ }
+
+ #[test]
+ fn diagnostic_never_discloses_an_off_device_provider() {
+ let diagnostic = OcrClient::from_env().unwrap().diagnostic();
+ assert_eq!(diagnostic.provider, Some(OcrProviderKind::PpOcrV6));
+ assert!(!diagnostic.sends_source_off_device);
+ assert_eq!(diagnostic.engine.as_deref(), Some(ENGINE_NAME));
+ }
+}
diff --git a/crates/ocr/src/config.rs b/crates/ocr/src/config.rs
new file mode 100644
index 00000000..9395ed07
--- /dev/null
+++ b/crates/ocr/src/config.rs
@@ -0,0 +1,261 @@
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use a3s_use_core::{UseError, UseResult};
+use serde::Deserialize;
+use serde_yaml::Value;
+
+pub(crate) const MODEL_FAMILY: &str = "PP-OCRv6_small";
+pub(crate) const DETECTION_MODEL: &str = "PP-OCRv6_small_det";
+pub(crate) const RECOGNITION_MODEL: &str = "PP-OCRv6_small_rec";
+
+#[derive(Debug, Clone)]
+pub(crate) struct DetectionConfig {
+ pub(crate) scale: f32,
+ pub(crate) mean: [f32; 3],
+ pub(crate) std: [f32; 3],
+ pub(crate) threshold: f32,
+ pub(crate) box_threshold: f32,
+ pub(crate) max_candidates: usize,
+ pub(crate) unclip_ratio: f32,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct RecognitionConfig {
+ pub(crate) channels: usize,
+ pub(crate) height: usize,
+ pub(crate) default_width: usize,
+ pub(crate) characters: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct RawConfig {
+ #[serde(rename = "Global")]
+ global: RawGlobal,
+ #[serde(rename = "PreProcess")]
+ pre_process: RawPreProcess,
+ #[serde(rename = "PostProcess")]
+ post_process: RawPostProcess,
+}
+
+#[derive(Debug, Deserialize)]
+struct RawGlobal {
+ model_name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct RawPreProcess {
+ transform_ops: Vec>,
+}
+
+#[derive(Debug, Deserialize)]
+struct RawPostProcess {
+ #[serde(default)]
+ name: String,
+ #[serde(default = "default_threshold")]
+ thresh: f32,
+ #[serde(default = "default_box_threshold")]
+ box_thresh: f32,
+ #[serde(default = "default_max_candidates")]
+ max_candidates: usize,
+ #[serde(default = "default_unclip_ratio")]
+ unclip_ratio: f32,
+ #[serde(default)]
+ character_dict: Vec,
+}
+
+pub(crate) fn load_detection(path: &Path) -> UseResult {
+ let raw = load(path)?;
+ if raw.global.model_name != DETECTION_MODEL {
+ return Err(config_error(format!(
+ "Expected detection model '{DETECTION_MODEL}', found '{}'.",
+ raw.global.model_name
+ )));
+ }
+ if raw.post_process.name != "DBPostProcess" {
+ return Err(config_error(format!(
+ "Expected DBPostProcess, found '{}'.",
+ raw.post_process.name
+ )));
+ }
+ let normalize = transform(&raw.pre_process.transform_ops, "NormalizeImage")
+ .ok_or_else(|| config_error("Detection config has no NormalizeImage transform."))?;
+ let scale = normalize
+ .get("scale")
+ .and_then(parse_scale)
+ .unwrap_or(1.0 / 255.0);
+ let mean = float_triplet(normalize.get("mean"), [0.485, 0.456, 0.406])?;
+ let std = float_triplet(normalize.get("std"), [0.229, 0.224, 0.225])?;
+ if std.iter().any(|value| *value <= 0.0) {
+ return Err(config_error(
+ "Detection normalization standard deviations must be positive.",
+ ));
+ }
+ Ok(DetectionConfig {
+ scale,
+ mean,
+ std,
+ threshold: raw.post_process.thresh,
+ box_threshold: raw.post_process.box_thresh,
+ max_candidates: raw.post_process.max_candidates.min(10_000),
+ unclip_ratio: raw.post_process.unclip_ratio,
+ })
+}
+
+pub(crate) fn load_recognition(path: &Path) -> UseResult {
+ let raw = load(path)?;
+ if raw.global.model_name != RECOGNITION_MODEL {
+ return Err(config_error(format!(
+ "Expected recognition model '{RECOGNITION_MODEL}', found '{}'.",
+ raw.global.model_name
+ )));
+ }
+ if raw.post_process.name != "CTCLabelDecode" {
+ return Err(config_error(format!(
+ "Expected CTCLabelDecode, found '{}'.",
+ raw.post_process.name
+ )));
+ }
+ if raw.post_process.character_dict.is_empty() || raw.post_process.character_dict.len() > 100_000
+ {
+ return Err(config_error(
+ "Recognition character dictionary is empty or unreasonably large.",
+ ));
+ }
+ let resize = transform(&raw.pre_process.transform_ops, "RecResizeImg")
+ .ok_or_else(|| config_error("Recognition config has no RecResizeImg transform."))?;
+ let shape = resize
+ .get("image_shape")
+ .and_then(Value::as_sequence)
+ .ok_or_else(|| config_error("RecResizeImg.image_shape must be an integer triplet."))?;
+ if shape.len() != 3 {
+ return Err(config_error(
+ "RecResizeImg.image_shape must contain channels, height, and width.",
+ ));
+ }
+ let dimensions = shape
+ .iter()
+ .map(|value| value.as_u64().and_then(|value| usize::try_from(value).ok()))
+ .collect::>>()
+ .ok_or_else(|| config_error("RecResizeImg.image_shape contains an invalid dimension."))?;
+ if dimensions[0] != 3
+ || !(16..=256).contains(&dimensions[1])
+ || !(32..=4096).contains(&dimensions[2])
+ {
+ return Err(config_error(format!(
+ "Unsupported PP-OCRv6 recognition input shape {:?}.",
+ dimensions
+ )));
+ }
+ Ok(RecognitionConfig {
+ channels: dimensions[0],
+ height: dimensions[1],
+ default_width: dimensions[2],
+ characters: raw.post_process.character_dict,
+ })
+}
+
+fn load(path: &Path) -> UseResult {
+ let metadata = std::fs::metadata(path).map_err(|error| {
+ config_error(format!(
+ "Failed to inspect PP-OCRv6 config '{}': {error}",
+ path.display()
+ ))
+ })?;
+ if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 2 * 1024 * 1024 {
+ return Err(config_error(format!(
+ "PP-OCRv6 config '{}' must be a non-empty regular file no larger than 2 MiB.",
+ path.display()
+ )));
+ }
+ let text = std::fs::read_to_string(path).map_err(|error| {
+ config_error(format!(
+ "Failed to read PP-OCRv6 config '{}': {error}",
+ path.display()
+ ))
+ })?;
+ serde_yaml::from_str(&text).map_err(|error| {
+ config_error(format!(
+ "Failed to parse PP-OCRv6 config '{}': {error}",
+ path.display()
+ ))
+ })
+}
+
+fn transform<'a>(
+ transforms: &'a [BTreeMap],
+ name: &str,
+) -> Option<&'a serde_yaml::Mapping> {
+ transforms
+ .iter()
+ .find_map(|transform| transform.get(name))
+ .and_then(Value::as_mapping)
+}
+
+fn float_triplet(value: Option<&Value>, default: [f32; 3]) -> UseResult<[f32; 3]> {
+ let Some(values) = value.and_then(Value::as_sequence) else {
+ return Ok(default);
+ };
+ if values.len() != 3 {
+ return Err(config_error(
+ "Detection normalization mean and std must contain three values.",
+ ));
+ }
+ let mut output = [0.0_f32; 3];
+ for (index, value) in values.iter().enumerate() {
+ output[index] = yaml_f32(value)
+ .ok_or_else(|| config_error("Detection normalization contains a non-number."))?;
+ }
+ Ok(output)
+}
+
+fn parse_scale(value: &Value) -> Option {
+ if let Some(value) = yaml_f32(value) {
+ return Some(value);
+ }
+ let value = value.as_str()?.trim();
+ if let Some((numerator, denominator)) = value.split_once('/') {
+ let numerator = numerator.trim_matches('.').parse::().ok()?;
+ let denominator = denominator.trim_matches('.').parse::().ok()?;
+ return (denominator != 0.0).then_some(numerator / denominator);
+ }
+ value.parse().ok()
+}
+
+fn yaml_f32(value: &Value) -> Option {
+ value
+ .as_f64()
+ .map(|value| value as f32)
+ .filter(|value| value.is_finite())
+}
+
+fn default_threshold() -> f32 {
+ 0.3
+}
+
+fn default_box_threshold() -> f32 {
+ 0.6
+}
+
+fn default_max_candidates() -> usize {
+ 1_000
+}
+
+fn default_unclip_ratio() -> f32 {
+ 1.5
+}
+
+fn config_error(message: impl Into) -> UseError {
+ UseError::new("use.ocr.model_config_invalid", message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_fractional_detection_scale() {
+ let value = Value::String("1./255.".to_string());
+ assert!((parse_scale(&value).unwrap() - 1.0 / 255.0).abs() < f32::EPSILON);
+ }
+}
diff --git a/crates/ocr/src/engine.rs b/crates/ocr/src/engine.rs
new file mode 100644
index 00000000..4c72f1fa
--- /dev/null
+++ b/crates/ocr/src/engine.rs
@@ -0,0 +1,261 @@
+use std::path::Path;
+
+use a3s_use_core::{UseError, UseResult};
+use image::{imageops, ImageBuffer, Rgb, RgbImage};
+use imageproc::geometric_transformations::{warp_into, Interpolation, Projection};
+use imageproc::point::Point;
+use ort::session::builder::GraphOptimizationLevel;
+use ort::session::Session;
+use ort::value::TensorRef;
+
+use crate::assets::ModelAssets;
+use crate::config::{load_detection, load_recognition, DetectionConfig, RecognitionConfig};
+use crate::postprocess::{decode_ctc, detection_boxes, Detection};
+use crate::preprocess::{detection_input, recognition_input};
+
+const RECOGNITION_BATCH_SIZE: usize = 8;
+const MAX_CROP_PIXELS: u64 = 64 * 1024 * 1024;
+
+#[derive(Debug, Clone)]
+pub(crate) struct EngineBlock {
+ pub(crate) polygon: [Point; 4],
+ pub(crate) detection_confidence: f32,
+ pub(crate) text: String,
+ pub(crate) confidence: f32,
+}
+
+pub(crate) struct PpOcrV6Engine {
+ detection: Session,
+ recognition: Session,
+ detection_config: DetectionConfig,
+ recognition_config: RecognitionConfig,
+}
+
+impl PpOcrV6Engine {
+ pub(crate) fn load(assets: &ModelAssets) -> UseResult {
+ let detection_config = load_detection(&assets.detection_config)?;
+ let recognition_config = load_recognition(&assets.recognition_config)?;
+ let detection = load_session(&assets.detection_model, "detection")?;
+ let recognition = load_session(&assets.recognition_model, "recognition")?;
+ Ok(Self {
+ detection,
+ recognition,
+ detection_config,
+ recognition_config,
+ })
+ }
+
+ pub(crate) fn extract(&mut self, image: &RgbImage) -> UseResult> {
+ let input = detection_input(image, &self.detection_config)?;
+ let (shape, output) =
+ run_session(&mut self.detection, &input.data, input.shape, "detection")?;
+ let detections = detection_boxes(
+ &output,
+ &shape,
+ input.original_width,
+ input.original_height,
+ &self.detection_config,
+ )?;
+ if detections.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let crops = detections
+ .iter()
+ .map(|detection| perspective_crop(image, detection))
+ .collect::>>()?;
+ let mut blocks = Vec::with_capacity(detections.len());
+ for (detection_batch, crop_batch) in detections
+ .chunks(RECOGNITION_BATCH_SIZE)
+ .zip(crops.chunks(RECOGNITION_BATCH_SIZE))
+ {
+ let input = recognition_input(crop_batch, &self.recognition_config)?;
+ let (shape, output) = run_session(
+ &mut self.recognition,
+ &input.data,
+ input.shape,
+ "recognition",
+ )?;
+ if shape.len() != 3 || shape[0] != detection_batch.len() {
+ return Err(engine_error(
+ "use.ocr.provider_output_invalid",
+ format!(
+ "PP-OCRv6 recognition output shape must be [N, T, C] for N={}, found {shape:?}.",
+ detection_batch.len()
+ ),
+ ));
+ }
+ let item_len = shape[1].checked_mul(shape[2]).ok_or_else(|| {
+ engine_error(
+ "use.ocr.provider_output_invalid",
+ "PP-OCRv6 recognition output dimensions overflowed.",
+ )
+ })?;
+ if output.len() != detection_batch.len().saturating_mul(item_len) {
+ return Err(engine_error(
+ "use.ocr.provider_output_invalid",
+ "PP-OCRv6 recognition output length does not match its batch shape.",
+ ));
+ }
+ for (index, detection) in detection_batch.iter().enumerate() {
+ let start = index * item_len;
+ let recognition = decode_ctc(
+ &output[start..start + item_len],
+ &[1, shape[1], shape[2]],
+ &self.recognition_config,
+ )?;
+ blocks.push(EngineBlock {
+ polygon: detection.polygon,
+ detection_confidence: detection.confidence,
+ text: recognition.text,
+ confidence: recognition.confidence,
+ });
+ }
+ }
+ Ok(blocks)
+ }
+}
+
+fn load_session(path: &Path, role: &str) -> UseResult {
+ let session = Session::builder()
+ .map_err(|error| runtime_error(role, "create an ONNX Runtime session", error))?
+ .with_optimization_level(GraphOptimizationLevel::Level3)
+ .map_err(|error| runtime_error(role, "configure graph optimization", error))?
+ .commit_from_file(path)
+ .map_err(|error| runtime_error(role, "load the ONNX model", error))?;
+ if session.inputs.len() != 1 || session.outputs.len() != 1 {
+ return Err(engine_error(
+ "use.ocr.model_invalid",
+ format!(
+ "PP-OCRv6 {role} model must expose exactly one input and one output; found {} inputs and {} outputs.",
+ session.inputs.len(),
+ session.outputs.len()
+ ),
+ ));
+ }
+ Ok(session)
+}
+
+fn run_session(
+ session: &mut Session,
+ data: &[f32],
+ shape: [usize; 4],
+ role: &str,
+) -> UseResult<(Vec, Vec)> {
+ let expected = shape
+ .iter()
+ .try_fold(1_usize, |total, dimension| total.checked_mul(*dimension));
+ if expected != Some(data.len()) {
+ return Err(engine_error(
+ "use.ocr.provider_input_invalid",
+ format!("PP-OCRv6 {role} tensor length does not match its shape."),
+ ));
+ }
+ let input = TensorRef::from_array_view((shape, data))
+ .map_err(|error| runtime_error(role, "create an ONNX Runtime input tensor", error))?;
+ let outputs = session
+ .run(ort::inputs![input])
+ .map_err(|error| runtime_error(role, "run ONNX inference", error))?;
+ if outputs.len() != 1 {
+ return Err(engine_error(
+ "use.ocr.provider_output_invalid",
+ format!(
+ "PP-OCRv6 {role} inference returned {} outputs instead of one.",
+ outputs.len()
+ ),
+ ));
+ }
+ let output = outputs.values().next().ok_or_else(|| {
+ engine_error(
+ "use.ocr.provider_output_invalid",
+ format!("PP-OCRv6 {role} inference returned no output tensor."),
+ )
+ })?;
+ let (output_shape, output_data) = output
+ .try_extract_tensor::()
+ .map_err(|error| runtime_error(role, "read the ONNX output tensor", error))?;
+ let output_shape = output_shape
+ .iter()
+ .map(|dimension| {
+ usize::try_from(*dimension).map_err(|_| {
+ engine_error(
+ "use.ocr.provider_output_invalid",
+ format!("PP-OCRv6 {role} output contains an invalid dimension {dimension}."),
+ )
+ })
+ })
+ .collect::>>()?;
+ if output_data.iter().any(|value| !value.is_finite()) {
+ return Err(engine_error(
+ "use.ocr.provider_output_invalid",
+ format!("PP-OCRv6 {role} output contains a non-finite value."),
+ ));
+ }
+ Ok((output_shape, output_data.to_vec()))
+}
+
+fn perspective_crop(image: &RgbImage, detection: &Detection) -> UseResult {
+ let polygon = detection.polygon;
+ let width = distance(polygon[0], polygon[1])
+ .max(distance(polygon[2], polygon[3]))
+ .round()
+ .max(1.0) as u32;
+ let height = distance(polygon[0], polygon[3])
+ .max(distance(polygon[1], polygon[2]))
+ .round()
+ .max(1.0) as u32;
+ let pixels = u64::from(width)
+ .checked_mul(u64::from(height))
+ .ok_or_else(|| crop_error("PP-OCRv6 text crop dimensions overflowed."))?;
+ if pixels > MAX_CROP_PIXELS {
+ return Err(crop_error(
+ "PP-OCRv6 text crop exceeds the 64 megapixel safety limit.",
+ ));
+ }
+
+ let source = polygon.map(|point| (point.x, point.y));
+ let destination = [
+ (0.0, 0.0),
+ (width.saturating_sub(1) as f32, 0.0),
+ (
+ width.saturating_sub(1) as f32,
+ height.saturating_sub(1) as f32,
+ ),
+ (0.0, height.saturating_sub(1) as f32),
+ ];
+ let projection = Projection::from_control_points(source, destination).ok_or_else(|| {
+ crop_error("PP-OCRv6 detected a degenerate text polygon that cannot be rectified.")
+ })?;
+ let mut crop = ImageBuffer::new(width, height);
+ warp_into(
+ image,
+ &projection,
+ Interpolation::Bicubic,
+ Rgb([255, 255, 255]),
+ &mut crop,
+ );
+ if f64::from(height) / f64::from(width) >= 1.5 {
+ Ok(imageops::rotate270(&crop))
+ } else {
+ Ok(crop)
+ }
+}
+
+fn distance(left: Point, right: Point) -> f32 {
+ (left.x - right.x).hypot(left.y - right.y)
+}
+
+fn runtime_error(role: &str, action: &str, error: impl std::fmt::Display) -> UseError {
+ engine_error(
+ "use.ocr.runtime_failed",
+ format!("Failed to {action} for PP-OCRv6 {role}: {error}"),
+ )
+}
+
+fn crop_error(message: impl Into) -> UseError {
+ engine_error("use.ocr.crop_invalid", message)
+}
+
+fn engine_error(code: &str, message: impl Into) -> UseError {
+ UseError::new(code, message)
+}
diff --git a/crates/ocr/src/install.rs b/crates/ocr/src/install.rs
new file mode 100644
index 00000000..17ecc8b8
--- /dev/null
+++ b/crates/ocr/src/install.rs
@@ -0,0 +1,789 @@
+use std::fs::OpenOptions;
+use std::io::{Read, Write};
+use std::path::{Component, Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use a3s_use_core::{FirstUseInstallPolicy, UseError, UseResult};
+use fs2::FileExt;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tokio::io::AsyncWriteExt;
+
+use crate::assets::{
+ managed_model_dir, managed_root, ocr_status, validate_assets, OcrInstallSource,
+ OcrRuntimeStatus, RECEIPT_FILE,
+};
+use crate::config::MODEL_FAMILY;
+
+const INSTALL_LOCK: &str = ".install.lock";
+const STAGE_PREFIX: &str = ".stage-";
+const BACKUP_PREFIX: &str = ".backup-";
+const DOWNLOAD_HOST: &str = "paddle-model-ecology.bj.bcebos.com";
+const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
+
+const DETECTION_ARCHIVE: PinnedArchive = PinnedArchive {
+ role: "det",
+ directory: "PP-OCRv6_small_det_onnx_infer",
+ url: "https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv6_small_det_onnx_infer.tar",
+ bytes: 9_891_840,
+ sha256: "d218f6fbf0f1c23d2161bd6ac7f5eaa6104fa89955c09290497e31008e2618e4",
+};
+const RECOGNITION_ARCHIVE: PinnedArchive = PinnedArchive {
+ role: "rec",
+ directory: "PP-OCRv6_small_rec_onnx_infer",
+ url: "https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv6_small_rec_onnx_infer.tar",
+ bytes: 21_319_680,
+ sha256: "d267ab077a44a0eedb1ea8f8c542d263f211de8e9d7a029bf9fcfff7e5a88fb1",
+};
+
+#[derive(Debug, Clone, Copy)]
+struct PinnedArchive {
+ role: &'static str,
+ directory: &'static str,
+ url: &'static str,
+ bytes: u64,
+ sha256: &'static str,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct InstallReceipt {
+ schema_version: u32,
+ provider: String,
+ model: String,
+ detection_url: String,
+ detection_sha256: String,
+ recognition_url: String,
+ recognition_sha256: String,
+}
+
+struct InstallLock {
+ _file: std::fs::File,
+}
+
+struct Downloaded {
+ bytes: u64,
+ sha256: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AutoInstallAction {
+ Ready,
+ Install,
+}
+
+/// Ensure the pinned PP-OCRv6 bundle is ready for an actual OCR operation.
+///
+/// Read-only diagnostics deliberately do not call this function. Direct OCR
+/// extraction and the bounded MCP install tool use it so first use installs or
+/// repairs A3S-managed models while preserving offline, no-auto-install, and
+/// explicit-model-directory boundaries.
+pub async fn ensure_ppocr_v6_ready() -> UseResult {
+ let status = ocr_status();
+ match automatic_install_action(&status, FirstUseInstallPolicy::from_env()?)? {
+ AutoInstallAction::Ready => Ok(status),
+ AutoInstallAction::Install => install_ppocr_v6(false).await,
+ }
+}
+
+pub async fn install_ppocr_v6(force: bool) -> UseResult {
+ let current = ocr_status();
+ if !force && current.available {
+ return Ok(current);
+ }
+
+ let root = managed_root()?;
+ let _lock = acquire_lock(&root).await?;
+ cleanup_stale(&root).await?;
+
+ let current = ocr_status();
+ if !force && current.available {
+ return Ok(current);
+ }
+
+ let stage = create_stage(&root).await?;
+ let install_result = install_into(&stage).await;
+ if install_result.is_err() {
+ let _ = tokio::fs::remove_dir_all(&stage).await;
+ }
+ install_result?;
+
+ let target = managed_model_dir()?;
+ activate(&stage, &target).await?;
+ validate_assets(&target, OcrInstallSource::Managed)?;
+
+ let status = ocr_status();
+ if status.available {
+ Ok(status)
+ } else {
+ Err(ocr_error(
+ "use.ocr.install_failed",
+ "PP-OCRv6 installation completed without a usable model bundle.",
+ ))
+ }
+}
+
+pub async fn repair_ppocr_v6() -> UseResult {
+ let status = ocr_status();
+ if status.available {
+ Ok(status)
+ } else {
+ install_ppocr_v6(true).await
+ }
+}
+
+pub async fn uninstall_managed_ppocr_v6() -> UseResult {
+ let root = managed_root()?;
+ let _lock = acquire_lock(&root).await?;
+ let target = managed_model_dir()?;
+ if !owned_install(&target) {
+ return Ok(false);
+ }
+ tokio::fs::remove_dir_all(&target).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.uninstall_failed",
+ format!(
+ "Failed to remove managed PP-OCRv6 bundle '{}': {error}",
+ target.display()
+ ),
+ )
+ })?;
+ Ok(true)
+}
+
+async fn install_into(stage: &Path) -> UseResult<()> {
+ let client = download_client()?;
+ for archive in [DETECTION_ARCHIVE, RECOGNITION_ARCHIVE] {
+ let archive_path = stage.join(format!("{}.tar", archive.role));
+ let downloaded = download(&client, archive.url, &archive_path).await?;
+ if downloaded.bytes != archive.bytes || downloaded.sha256 != archive.sha256 {
+ return Err(ocr_error(
+ "use.ocr.integrity_mismatch",
+ format!(
+ "{} archive integrity mismatch: expected {} bytes and {}, got {} bytes and {}.",
+ archive.directory,
+ archive.bytes,
+ archive.sha256,
+ downloaded.bytes,
+ downloaded.sha256
+ ),
+ ));
+ }
+ let archive_path_for_task = archive_path.clone();
+ let destination = stage.join(archive.role);
+ tokio::task::spawn_blocking(move || {
+ extract_archive(&archive_path_for_task, &destination, archive)
+ })
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!("PP-OCRv6 archive extraction task failed: {error}"),
+ )
+ })??;
+ tokio::fs::remove_file(&archive_path)
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to remove staged archive '{}': {error}",
+ archive_path.display()
+ ),
+ )
+ })?;
+ }
+ write_receipt(stage).await?;
+ validate_assets(stage, OcrInstallSource::Managed)?;
+ Ok(())
+}
+
+fn download_client() -> UseResult {
+ let redirects = reqwest::redirect::Policy::custom(|attempt| {
+ let approved = attempt.previous().len() < 5
+ && attempt.url().scheme() == "https"
+ && attempt.url().host_str() == Some(DOWNLOAD_HOST);
+ if approved {
+ attempt.follow()
+ } else {
+ attempt.error("PP-OCRv6 download redirected to an unapproved host")
+ }
+ });
+ reqwest::Client::builder()
+ .user_agent(concat!("a3s-use-ocr/", env!("CARGO_PKG_VERSION")))
+ .redirect(redirects)
+ .timeout(std::time::Duration::from_secs(300))
+ .build()
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.download_failed",
+ format!("Failed to create PP-OCRv6 download client: {error}"),
+ )
+ })
+}
+
+async fn download(
+ client: &reqwest::Client,
+ value: &str,
+ destination: &Path,
+) -> UseResult {
+ let url = reqwest::Url::parse(value).map_err(|error| {
+ ocr_error(
+ "use.ocr.download_source_invalid",
+ format!("Invalid PP-OCRv6 download URL: {error}"),
+ )
+ })?;
+ if url.scheme() != "https" || url.host_str() != Some(DOWNLOAD_HOST) {
+ return Err(ocr_error(
+ "use.ocr.download_source_invalid",
+ "PP-OCRv6 download source is not the pinned official HTTPS host.",
+ ));
+ }
+ let mut response = client
+ .get(url)
+ .send()
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.download_failed",
+ format!("Failed to download PP-OCRv6: {error}"),
+ )
+ })?
+ .error_for_status()
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.download_failed",
+ format!("PP-OCRv6 download failed: {error}"),
+ )
+ })?;
+ if response
+ .content_length()
+ .is_some_and(|length| length > MAX_ARCHIVE_BYTES)
+ {
+ return Err(ocr_error(
+ "use.ocr.download_too_large",
+ "PP-OCRv6 archive exceeds the 256 MiB limit.",
+ ));
+ }
+ let mut file = tokio::fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(destination)
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create PP-OCRv6 download '{}': {error}",
+ destination.display()
+ ),
+ )
+ })?;
+ let mut hasher = Sha256::new();
+ let mut total = 0_u64;
+ while let Some(chunk) = response.chunk().await.map_err(|error| {
+ ocr_error(
+ "use.ocr.download_failed",
+ format!("Failed to read PP-OCRv6 download: {error}"),
+ )
+ })? {
+ total = total
+ .checked_add(chunk.len() as u64)
+ .ok_or_else(|| ocr_error("use.ocr.download_too_large", "Download size overflowed."))?;
+ if total > MAX_ARCHIVE_BYTES {
+ return Err(ocr_error(
+ "use.ocr.download_too_large",
+ "PP-OCRv6 archive exceeds the 256 MiB limit.",
+ ));
+ }
+ hasher.update(&chunk);
+ file.write_all(&chunk).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to write PP-OCRv6 download '{}': {error}",
+ destination.display()
+ ),
+ )
+ })?;
+ }
+ file.flush().await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to flush PP-OCRv6 download '{}': {error}",
+ destination.display()
+ ),
+ )
+ })?;
+ file.sync_all().await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to sync PP-OCRv6 download '{}': {error}",
+ destination.display()
+ ),
+ )
+ })?;
+ Ok(Downloaded {
+ bytes: total,
+ sha256: format!("{:x}", hasher.finalize()),
+ })
+}
+
+fn extract_archive(archive_path: &Path, destination: &Path, spec: PinnedArchive) -> UseResult<()> {
+ std::fs::create_dir(destination).map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create PP-OCRv6 model directory '{}': {error}",
+ destination.display()
+ ),
+ )
+ })?;
+ let file = std::fs::File::open(archive_path).map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to open PP-OCRv6 archive '{}': {error}",
+ archive_path.display()
+ ),
+ )
+ })?;
+ let mut archive = tar::Archive::new(file);
+ let mut extracted = [false; 2];
+ for entry in archive.entries().map_err(archive_error)? {
+ let entry = entry.map_err(archive_error)?;
+ let path = entry.path().map_err(archive_error)?;
+ let components = path.components().collect::>();
+ if components.len() == 1
+ && matches!(components[0], Component::Normal(value) if value == spec.directory)
+ && entry.header().entry_type().is_dir()
+ {
+ continue;
+ }
+ if components.len() != 2
+ || !matches!(components[0], Component::Normal(value) if value == spec.directory)
+ || !entry.header().entry_type().is_file()
+ {
+ return Err(ocr_error(
+ "use.ocr.archive_invalid",
+ format!(
+ "PP-OCRv6 archive contains an unexpected entry '{}'.",
+ path.display()
+ ),
+ ));
+ }
+ let name = match components[1] {
+ Component::Normal(name) if name == "inference.onnx" => {
+ extracted[0] = true;
+ "inference.onnx"
+ }
+ Component::Normal(name) if name == "inference.yml" => {
+ extracted[1] = true;
+ "inference.yml"
+ }
+ _ => {
+ return Err(ocr_error(
+ "use.ocr.archive_invalid",
+ format!(
+ "PP-OCRv6 archive contains an unexpected entry '{}'.",
+ path.display()
+ ),
+ ))
+ }
+ };
+ let max = if name.ends_with(".onnx") {
+ 256 * 1024 * 1024
+ } else {
+ 2 * 1024 * 1024
+ };
+ if entry.size() == 0 || entry.size() > max {
+ return Err(ocr_error(
+ "use.ocr.archive_invalid",
+ format!("PP-OCRv6 archive entry '{name}' has an invalid size."),
+ ));
+ }
+ let expected_size = entry.size();
+ let output_path = destination.join(name);
+ let mut output = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&output_path)
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create PP-OCRv6 asset '{}': {error}",
+ output_path.display()
+ ),
+ )
+ })?;
+ let copied = std::io::copy(&mut entry.take(max + 1), &mut output).map_err(archive_error)?;
+ if copied != expected_size {
+ return Err(ocr_error(
+ "use.ocr.archive_invalid",
+ format!("PP-OCRv6 archive entry '{name}' was truncated."),
+ ));
+ }
+ output.flush().map_err(archive_error)?;
+ output.sync_all().map_err(archive_error)?;
+ }
+ if !extracted.into_iter().all(|present| present) {
+ return Err(ocr_error(
+ "use.ocr.archive_invalid",
+ "PP-OCRv6 archive is missing inference.onnx or inference.yml.",
+ ));
+ }
+ Ok(())
+}
+
+async fn write_receipt(stage: &Path) -> UseResult<()> {
+ let receipt = InstallReceipt {
+ schema_version: 1,
+ provider: "pp-ocr-v6".to_string(),
+ model: MODEL_FAMILY.to_string(),
+ detection_url: DETECTION_ARCHIVE.url.to_string(),
+ detection_sha256: DETECTION_ARCHIVE.sha256.to_string(),
+ recognition_url: RECOGNITION_ARCHIVE.url.to_string(),
+ recognition_sha256: RECOGNITION_ARCHIVE.sha256.to_string(),
+ };
+ let bytes = serde_json::to_vec_pretty(&receipt).map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!("Failed to encode PP-OCRv6 install receipt: {error}"),
+ )
+ })?;
+ let path = stage.join(RECEIPT_FILE);
+ let mut file = tokio::fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&path)
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create PP-OCRv6 receipt '{}': {error}",
+ path.display()
+ ),
+ )
+ })?;
+ file.write_all(&bytes).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to write PP-OCRv6 receipt '{}': {error}",
+ path.display()
+ ),
+ )
+ })?;
+ file.sync_all().await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to sync PP-OCRv6 receipt '{}': {error}",
+ path.display()
+ ),
+ )
+ })
+}
+
+async fn acquire_lock(root: &Path) -> UseResult {
+ tokio::fs::create_dir_all(root).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create OCR data root '{}': {error}",
+ root.display()
+ ),
+ )
+ })?;
+ let path = root.join(INSTALL_LOCK);
+ tokio::task::spawn_blocking(move || {
+ let file = OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .open(&path)
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to open OCR install lock '{}': {error}",
+ path.display()
+ ),
+ )
+ })?;
+ file.lock_exclusive().map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to acquire OCR install lock '{}': {error}",
+ path.display()
+ ),
+ )
+ })?;
+ Ok(InstallLock { _file: file })
+ })
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!("OCR install lock task failed: {error}"),
+ )
+ })?
+}
+
+async fn create_stage(root: &Path) -> UseResult {
+ static NEXT_STAGE: AtomicU64 = AtomicU64::new(1);
+ for _ in 0..32 {
+ let path = root.join(format!(
+ "{STAGE_PREFIX}{}-{}",
+ std::process::id(),
+ NEXT_STAGE.fetch_add(1, Ordering::Relaxed)
+ ));
+ match tokio::fs::create_dir(&path).await {
+ Ok(()) => return Ok(path),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(error) => {
+ return Err(ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to create OCR staging directory '{}': {error}",
+ path.display()
+ ),
+ ))
+ }
+ }
+ }
+ Err(ocr_error(
+ "use.ocr.install_failed",
+ "Failed to allocate a unique OCR staging directory.",
+ ))
+}
+
+async fn cleanup_stale(root: &Path) -> UseResult<()> {
+ let mut entries = tokio::fs::read_dir(root).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to inspect OCR data root '{}': {error}",
+ root.display()
+ ),
+ )
+ })?;
+ while let Some(entry) = entries.next_entry().await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to inspect OCR data root '{}': {error}",
+ root.display()
+ ),
+ )
+ })? {
+ let name = entry.file_name();
+ let name = name.to_string_lossy();
+ let is_owned_backup = name.starts_with(BACKUP_PREFIX) && owned_install(&entry.path());
+ if name.starts_with(STAGE_PREFIX) || is_owned_backup {
+ tokio::fs::remove_dir_all(entry.path())
+ .await
+ .map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!("Failed to remove stale OCR staging directory: {error}"),
+ )
+ })?;
+ }
+ }
+ Ok(())
+}
+
+async fn activate(stage: &Path, target: &Path) -> UseResult<()> {
+ static NEXT_BACKUP: AtomicU64 = AtomicU64::new(1);
+ let parent = target.parent().ok_or_else(|| {
+ ocr_error(
+ "use.ocr.install_failed",
+ "OCR install target has no parent directory.",
+ )
+ })?;
+ let backup = parent.join(format!(
+ "{BACKUP_PREFIX}{}-{}",
+ std::process::id(),
+ NEXT_BACKUP.fetch_add(1, Ordering::Relaxed)
+ ));
+ let had_target = tokio::fs::try_exists(target).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to inspect OCR install target '{}': {error}",
+ target.display()
+ ),
+ )
+ })?;
+ if had_target {
+ if !owned_install(target) {
+ return Err(ocr_error(
+ "use.ocr.install_target_unowned",
+ format!(
+ "Refusing to replace unowned OCR model directory '{}'.",
+ target.display()
+ ),
+ ));
+ }
+ tokio::fs::rename(target, &backup).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to stage existing OCR install '{}': {error}",
+ target.display()
+ ),
+ )
+ })?;
+ }
+ if let Err(error) = tokio::fs::rename(stage, target).await {
+ if had_target {
+ let _ = tokio::fs::rename(&backup, target).await;
+ }
+ return Err(ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Failed to activate OCR install '{}': {error}",
+ target.display()
+ ),
+ ));
+ }
+ if had_target {
+ tokio::fs::remove_dir_all(&backup).await.map_err(|error| {
+ ocr_error(
+ "use.ocr.install_failed",
+ format!(
+ "Activated OCR but failed to remove backup '{}': {error}",
+ backup.display()
+ ),
+ )
+ })?;
+ }
+ Ok(())
+}
+
+fn owned_install(path: &Path) -> bool {
+ let Ok(bytes) = std::fs::read(path.join(RECEIPT_FILE)) else {
+ return false;
+ };
+ serde_json::from_slice::(&bytes).is_ok_and(|receipt| {
+ receipt.schema_version == 1
+ && receipt.provider == "pp-ocr-v6"
+ && receipt.model == MODEL_FAMILY
+ })
+}
+
+fn automatic_install_action(
+ status: &OcrRuntimeStatus,
+ policy: FirstUseInstallPolicy,
+) -> UseResult {
+ if status.available {
+ return Ok(AutoInstallAction::Ready);
+ }
+ if status.source == OcrInstallSource::Environment {
+ return Err(ocr_error(
+ "use.ocr.model_unreadable",
+ format!(
+ "The explicit A3S_OCR_MODEL_DIR is not usable: {}",
+ status.detail
+ ),
+ )
+ .with_suggestion("Fix or unset A3S_OCR_MODEL_DIR before retrying OCR."));
+ }
+ if let Some(block) = policy.blocked_by() {
+ let reason = block.reason();
+ return Err(ocr_error(
+ "use.ocr.auto_install_disabled",
+ format!(
+ "The local {MODEL_FAMILY} bundle is not ready and automatic installation is disabled by {reason}."
+ ),
+ )
+ .with_suggestion(
+ "Enable first-use installation or run 'a3s install use/ocr' explicitly while online.",
+ )
+ .with_detail("reason", reason));
+ }
+ Ok(AutoInstallAction::Install)
+}
+
+fn archive_error(error: impl std::fmt::Display) -> UseError {
+ ocr_error(
+ "use.ocr.archive_invalid",
+ format!("Failed to extract PP-OCRv6 archive: {error}"),
+ )
+}
+
+fn ocr_error(code: &str, message: impl Into) -> UseError {
+ UseError::new(code, message)
+}
+
+#[cfg(test)]
+mod automatic_install_tests {
+ use super::*;
+
+ fn status(available: bool, source: OcrInstallSource) -> OcrRuntimeStatus {
+ OcrRuntimeStatus {
+ available,
+ source,
+ model: MODEL_FAMILY.to_string(),
+ model_dir: None,
+ managed_root: None,
+ detail: if available {
+ "ready".to_string()
+ } else {
+ "missing".to_string()
+ },
+ }
+ }
+
+ #[test]
+ fn ready_models_never_require_an_install() {
+ let action = automatic_install_action(
+ &status(true, OcrInstallSource::Managed),
+ FirstUseInstallPolicy::new(true, true),
+ )
+ .unwrap();
+
+ assert_eq!(action, AutoInstallAction::Ready);
+ }
+
+ #[test]
+ fn missing_models_install_when_first_use_mutation_is_allowed() {
+ let action = automatic_install_action(
+ &status(false, OcrInstallSource::Missing),
+ FirstUseInstallPolicy::new(false, false),
+ )
+ .unwrap();
+
+ assert_eq!(action, AutoInstallAction::Install);
+ }
+
+ #[test]
+ fn offline_and_no_auto_install_are_strict_boundaries() {
+ for policy in [
+ FirstUseInstallPolicy::new(true, false),
+ FirstUseInstallPolicy::new(false, true),
+ ] {
+ let error = automatic_install_action(&status(false, OcrInstallSource::Missing), policy)
+ .unwrap_err();
+ assert_eq!(error.code, "use.ocr.auto_install_disabled");
+ }
+ }
+
+ #[test]
+ fn an_invalid_explicit_model_directory_is_never_replaced_implicitly() {
+ let error = automatic_install_action(
+ &status(false, OcrInstallSource::Environment),
+ FirstUseInstallPolicy::new(false, false),
+ )
+ .unwrap_err();
+
+ assert_eq!(error.code, "use.ocr.model_unreadable");
+ }
+}
diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs
new file mode 100644
index 00000000..cc36b31f
--- /dev/null
+++ b/crates/ocr/src/lib.rs
@@ -0,0 +1,29 @@
+//! Typed optical character recognition for A3S Use.
+//!
+//! OCR is a first-party built-in Use domain and remains process-isolated from
+//! A3S Code through its standard MCP server. Detection and recognition run
+//! locally with the pinned PP-OCRv6_small ONNX models. There is no alternate
+//! OCR provider or off-device fallback.
+
+mod assets;
+pub mod cli;
+mod client;
+mod config;
+mod engine;
+mod install;
+pub mod mcp;
+mod models;
+mod postprocess;
+mod preprocess;
+
+pub use assets::{ocr_status, OcrInstallSource, OcrRuntimeStatus};
+pub use client::OcrClient;
+pub use install::{
+ ensure_ppocr_v6_ready, install_ppocr_v6, repair_ppocr_v6, uninstall_managed_ppocr_v6,
+};
+pub use mcp::OcrMcpServer;
+pub use models::{
+ OcrBlock, OcrBoundingBox, OcrDiagnostic, OcrPoint, OcrProviderKind, OcrRequest, OcrResult,
+};
+
+pub use a3s_use_core::{Artifact, Readiness, UseError, UseResult};
diff --git a/crates/ocr/src/main.rs b/crates/ocr/src/main.rs
new file mode 100644
index 00000000..4bba3c58
--- /dev/null
+++ b/crates/ocr/src/main.rs
@@ -0,0 +1,39 @@
+use std::process::ExitCode;
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ let args = std::env::args().skip(1).collect::>();
+ let json = args.iter().any(|argument| argument == "--json");
+ match a3s_use_ocr::cli::run(args).await {
+ Ok(output) => {
+ if output.should_print && json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output.json).unwrap_or_default()
+ );
+ } else if output.should_print && !output.human.is_empty() {
+ println!("{}", output.human);
+ }
+ ExitCode::from(output.exit_code)
+ }
+ Err(error) => {
+ if json {
+ let output = serde_json::json!({
+ "schemaVersion": 1,
+ "ok": false,
+ "error": error,
+ });
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output).unwrap_or_default()
+ );
+ } else {
+ eprintln!("a3s-use-ocr: {error}");
+ if let Some(suggestion) = &error.suggestion {
+ eprintln!("suggestion: {suggestion}");
+ }
+ }
+ ExitCode::from(1)
+ }
+ }
+}
diff --git a/crates/ocr/src/mcp.rs b/crates/ocr/src/mcp.rs
new file mode 100644
index 00000000..229bb609
--- /dev/null
+++ b/crates/ocr/src/mcp.rs
@@ -0,0 +1,207 @@
+//! Standard MCP tools for the built-in OCR domain.
+
+use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
+use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo};
+use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
+use serde::Serialize;
+
+use crate::{
+ ensure_ppocr_v6_ready, OcrClient, OcrDiagnostic, OcrRequest, OcrResult, OcrRuntimeStatus,
+ UseError, UseResult,
+};
+
+#[derive(Clone)]
+pub struct OcrMcpServer {
+ client: OcrClient,
+ tool_router: ToolRouter,
+}
+
+impl OcrMcpServer {
+ pub fn new(client: OcrClient) -> Self {
+ Self {
+ client,
+ tool_router: Self::tool_router(),
+ }
+ }
+
+ pub fn from_env() -> UseResult {
+ Ok(Self::new(OcrClient::from_env()?))
+ }
+
+ /// Serve standard MCP framing over stdin/stdout until the peer disconnects.
+ pub async fn serve_stdio(self) -> UseResult<()> {
+ let service = self
+ .serve(rmcp::transport::stdio())
+ .await
+ .map_err(|error| mcp_error("start", error))?;
+ service
+ .waiting()
+ .await
+ .map_err(|error| mcp_error("run", error))?;
+ Ok(())
+ }
+}
+
+#[tool_router]
+impl OcrMcpServer {
+ #[tool(
+ name = "ocr_doctor",
+ description = "Inspect local PP-OCRv6 model readiness without reading an image or making a network request",
+ output_schema = rmcp::handler::server::tool::cached_schema_for_type::(),
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
+ )]
+ async fn ocr_doctor(&self) -> Result {
+ Ok(tool_result(Ok(self.client.diagnostic())))
+ }
+
+ #[tool(
+ name = "ocr_install",
+ description = "Install or repair the pinned local PP-OCRv6 model bundle from its official HTTPS source with fixed size and SHA-256 checks",
+ output_schema = rmcp::handler::server::tool::cached_schema_for_type::(),
+ annotations(
+ read_only_hint = false,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = true
+ )
+ )]
+ async fn ocr_install(&self) -> Result {
+ Ok(tool_result(ensure_ppocr_v6_ready().await))
+ }
+
+ #[tool(
+ name = "ocr_extract",
+ description = "Extract text, polygons, bounding boxes, and confidence from one bounded local image with PP-OCRv6; source bytes remain on this device",
+ output_schema = rmcp::handler::server::tool::cached_schema_for_type::(),
+ annotations(
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false
+ )
+ )]
+ async fn ocr_extract(
+ &self,
+ Parameters(request): Parameters,
+ ) -> Result {
+ Ok(tool_result(self.client.extract(request).await))
+ }
+}
+
+#[tool_handler]
+impl ServerHandler for OcrMcpServer {
+ fn get_info(&self) -> ServerInfo {
+ ServerInfo {
+ capabilities: ServerCapabilities::builder().enable_tools().build(),
+ server_info: Implementation {
+ name: "a3s-use-ocr".to_string(),
+ title: Some("A3S Use OCR".to_string()),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ icons: None,
+ website_url: Some("https://github.com/A3S-Lab/Use".to_string()),
+ },
+ instructions: Some(
+ "Call ocr_doctor first. When the pinned models are missing or broken, request ocr_install through the host confirmation path, then call ocr_extract with the local image path supplied by the task. PP-OCRv6 detection and recognition run locally through ONNX Runtime and never send source bytes off device. Preserve the source SHA-256 and distinguish OCR text from verified source text."
+ .to_string(),
+ ),
+ ..Default::default()
+ }
+ }
+}
+
+fn tool_result(result: UseResult) -> CallToolResult
+where
+ T: Serialize,
+{
+ match result {
+ Ok(output) => match serde_json::to_value(output) {
+ Ok(value) => CallToolResult::structured(value),
+ Err(error) => tool_error(UseError::new(
+ "use.ocr.output_invalid",
+ format!("Failed to encode OCR MCP output: {error}"),
+ )),
+ },
+ Err(error) => tool_error(error),
+ }
+}
+
+fn tool_error(error: UseError) -> CallToolResult {
+ CallToolResult::structured_error(serde_json::to_value(error).unwrap_or_else(|_| {
+ serde_json::json!({
+ "code": "use.error_encoding_failed",
+ "message": "Failed to encode A3S Use error."
+ })
+ }))
+}
+
+fn mcp_error(action: &str, error: impl std::fmt::Display) -> UseError {
+ UseError::new(
+ "use.ocr.mcp_failed",
+ format!("Failed to {action} the OCR MCP server: {error}"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn server_exposes_typed_annotated_ocr_tools() {
+ let client = OcrClient::from_env().unwrap();
+ let server = OcrMcpServer::new(client);
+ let mut tools = server.tool_router.list_all();
+ tools.sort_by(|left, right| left.name.cmp(&right.name));
+ assert_eq!(
+ tools
+ .iter()
+ .map(|tool| tool.name.as_ref())
+ .collect::>(),
+ ["ocr_doctor", "ocr_extract", "ocr_install"]
+ );
+ let doctor = tools.iter().find(|tool| tool.name == "ocr_doctor").unwrap();
+ let extract = tools
+ .iter()
+ .find(|tool| tool.name == "ocr_extract")
+ .unwrap();
+ let install = tools
+ .iter()
+ .find(|tool| tool.name == "ocr_install")
+ .unwrap();
+ assert!(doctor.output_schema.is_some());
+ assert!(extract.output_schema.is_some());
+ assert!(install.output_schema.is_some());
+ assert_eq!(
+ doctor
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.open_world_hint),
+ Some(false)
+ );
+ assert_eq!(
+ extract
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.open_world_hint),
+ Some(false)
+ );
+ assert_eq!(
+ install
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.read_only_hint),
+ Some(false)
+ );
+ assert_eq!(
+ install
+ .annotations
+ .as_ref()
+ .and_then(|annotations| annotations.open_world_hint),
+ Some(true)
+ );
+ }
+}
diff --git a/crates/ocr/src/models.rs b/crates/ocr/src/models.rs
new file mode 100644
index 00000000..29b41198
--- /dev/null
+++ b/crates/ocr/src/models.rs
@@ -0,0 +1,100 @@
+use std::path::PathBuf;
+
+use a3s_use_core::{Artifact, Readiness};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+pub enum OcrProviderKind {
+ PpOcrV6,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrRequest {
+ #[schemars(description = "Local PNG, JPEG, WebP, GIF, BMP, or TIFF image path")]
+ pub path: PathBuf,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrPoint {
+ pub x: u32,
+ pub y: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrBoundingBox {
+ pub x: u32,
+ pub y: u32,
+ pub width: u32,
+ pub height: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrBlock {
+ pub page: u32,
+ pub text: String,
+ #[schemars(description = "PP-OCRv6 text recognition confidence from 0 through 1")]
+ pub confidence: f32,
+ #[schemars(description = "PP-OCRv6 DB text detection confidence from 0 through 1")]
+ pub detection_confidence: f32,
+ #[schemars(description = "Four PP-OCRv6 polygon vertices in source-image coordinates")]
+ pub polygon: [OcrPoint; 4],
+ pub bounding_box: OcrBoundingBox,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrResult {
+ pub provider: OcrProviderKind,
+ pub engine: String,
+ pub model: String,
+ #[schemars(with = "OcrArtifactSchema")]
+ pub source: Artifact,
+ pub text: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub blocks: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub warnings: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OcrDiagnostic {
+ #[schemars(with = "OcrReadinessSchema")]
+ pub readiness: Readiness,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub provider: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub engine: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub model: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub model_dir: Option,
+ pub sends_source_off_device: bool,
+ pub message: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub suggestions: Vec,
+}
+
+#[derive(schemars::JsonSchema)]
+#[allow(dead_code)]
+struct OcrArtifactSchema {
+ path: PathBuf,
+ media_type: String,
+ size: u64,
+ sha256: String,
+}
+
+#[derive(schemars::JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+#[allow(dead_code)]
+enum OcrReadinessSchema {
+ Ready,
+ Missing,
+ Broken,
+ Unknown,
+}
diff --git a/crates/ocr/src/postprocess.rs b/crates/ocr/src/postprocess.rs
new file mode 100644
index 00000000..6104ae94
--- /dev/null
+++ b/crates/ocr/src/postprocess.rs
@@ -0,0 +1,326 @@
+use a3s_use_core::{UseError, UseResult};
+use clipper2::{Centi, EndType, JoinType};
+use image::{GrayImage, Luma};
+use imageproc::contours::find_contours;
+use imageproc::geometry::{contour_area, min_area_rect};
+use imageproc::point::Point;
+
+use crate::config::{DetectionConfig, RecognitionConfig};
+
+#[derive(Debug, Clone)]
+pub(crate) struct Detection {
+ pub(crate) polygon: [Point; 4],
+ pub(crate) confidence: f32,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct Recognition {
+ pub(crate) text: String,
+ pub(crate) confidence: f32,
+}
+
+pub(crate) fn detection_boxes(
+ output: &[f32],
+ shape: &[usize],
+ original_width: u32,
+ original_height: u32,
+ config: &DetectionConfig,
+) -> UseResult> {
+ if shape.len() != 4 || shape[0] != 1 || shape[1] != 1 {
+ return Err(output_error(format!(
+ "PP-OCRv6 detection output shape must be [1, 1, H, W], found {shape:?}."
+ )));
+ }
+ let height = shape[2];
+ let width = shape[3];
+ let map_len = height
+ .checked_mul(width)
+ .ok_or_else(|| output_error("PP-OCRv6 detection output dimensions overflowed."))?;
+ if width == 0 || height == 0 || output.len() != map_len {
+ return Err(output_error(
+ "PP-OCRv6 detection output length does not match its shape.",
+ ));
+ }
+ let width_u32 = u32::try_from(width)
+ .map_err(|_| output_error("PP-OCRv6 detection output width is too large."))?;
+ let height_u32 = u32::try_from(height)
+ .map_err(|_| output_error("PP-OCRv6 detection output height is too large."))?;
+ let mask = GrayImage::from_fn(width_u32, height_u32, |x, y| {
+ let index = y as usize * width + x as usize;
+ Luma([if output[index] > config.threshold {
+ 255
+ } else {
+ 0
+ }])
+ });
+
+ let mut detections = Vec::new();
+ for contour in find_contours::(&mask)
+ .into_iter()
+ .take(config.max_candidates)
+ {
+ if contour.points.len() < 3 {
+ continue;
+ }
+ let mini = order_points(min_area_rect(&contour.points));
+ if minimum_side(&mini) < 3.0 {
+ continue;
+ }
+ let score = box_score(output, width, height, &mini);
+ if score < config.box_threshold {
+ continue;
+ }
+ let area = contour_area(&mini);
+ let perimeter = polygon_perimeter(&mini);
+ if !area.is_finite() || !perimeter.is_finite() || perimeter <= f64::EPSILON {
+ continue;
+ }
+ let distance = area * f64::from(config.unclip_ratio) / perimeter;
+ let path = mini
+ .iter()
+ .map(|point| (f64::from(point.x), f64::from(point.y)))
+ .collect::>();
+ let inflated: Vec> =
+ clipper2::inflate::(path, distance, JoinType::Round, EndType::Polygon, 2.0)
+ .into();
+ if inflated.len() != 1 || inflated[0].len() < 3 {
+ continue;
+ }
+ let inflated = inflated[0]
+ .iter()
+ .filter(|(x, y)| x.is_finite() && y.is_finite())
+ .map(|(x, y)| {
+ Point::new(
+ x.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32,
+ y.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32,
+ )
+ })
+ .collect::>();
+ if inflated.len() < 3 {
+ continue;
+ }
+ let expanded = order_points(min_area_rect(&inflated));
+ if minimum_side(&expanded) < 5.0 {
+ continue;
+ }
+ let polygon = expanded.map(|point| {
+ Point::new(
+ (point.x as f32 / width as f32 * original_width as f32)
+ .round()
+ .clamp(0.0, original_width.saturating_sub(1) as f32),
+ (point.y as f32 / height as f32 * original_height as f32)
+ .round()
+ .clamp(0.0, original_height.saturating_sub(1) as f32),
+ )
+ });
+ detections.push(Detection {
+ polygon,
+ confidence: score.clamp(0.0, 1.0),
+ });
+ }
+ sort_reading_order(&mut detections);
+ Ok(detections)
+}
+
+pub(crate) fn decode_ctc(
+ output: &[f32],
+ shape: &[usize],
+ config: &RecognitionConfig,
+) -> UseResult {
+ if shape.len() != 3 || shape[0] != 1 || shape[1] == 0 || shape[2] == 0 {
+ return Err(output_error(format!(
+ "PP-OCRv6 recognition output shape must be [1, T, C], found {shape:?}."
+ )));
+ }
+ let timesteps = shape[1];
+ let classes = shape[2];
+ let expected_classes = config.characters.len() + 2;
+ if classes != expected_classes {
+ return Err(output_error(format!(
+ "PP-OCRv6 recognition class count is {classes}, but the model dictionary requires {expected_classes}."
+ )));
+ }
+ if output.len() != timesteps.saturating_mul(classes) {
+ return Err(output_error(
+ "PP-OCRv6 recognition output length does not match its shape.",
+ ));
+ }
+
+ let mut text = String::new();
+ let mut confidence = 0.0_f32;
+ let mut selected = 0_usize;
+ let mut previous = usize::MAX;
+ for timestep in 0..timesteps {
+ let row = &output[timestep * classes..(timestep + 1) * classes];
+ let (index, score) = row
+ .iter()
+ .copied()
+ .enumerate()
+ .max_by(|left, right| left.1.total_cmp(&right.1))
+ .ok_or_else(|| output_error("PP-OCRv6 recognition output row is empty."))?;
+ if index != 0 && index != previous {
+ if index == config.characters.len() + 1 {
+ text.push(' ');
+ } else if let Some(character) = config.characters.get(index - 1) {
+ text.push_str(character);
+ }
+ confidence += score;
+ selected += 1;
+ }
+ previous = index;
+ }
+ Ok(Recognition {
+ text,
+ confidence: if selected == 0 {
+ 0.0
+ } else {
+ (confidence / selected as f32).clamp(0.0, 1.0)
+ },
+ })
+}
+
+fn box_score(output: &[f32], width: usize, height: usize, polygon: &[Point; 4]) -> f32 {
+ let min_x = polygon
+ .iter()
+ .map(|point| point.x)
+ .min()
+ .unwrap_or(0)
+ .clamp(0, width.saturating_sub(1) as i32) as usize;
+ let max_x = polygon
+ .iter()
+ .map(|point| point.x)
+ .max()
+ .unwrap_or(0)
+ .clamp(0, width.saturating_sub(1) as i32) as usize;
+ let min_y = polygon
+ .iter()
+ .map(|point| point.y)
+ .min()
+ .unwrap_or(0)
+ .clamp(0, height.saturating_sub(1) as i32) as usize;
+ let max_y = polygon
+ .iter()
+ .map(|point| point.y)
+ .max()
+ .unwrap_or(0)
+ .clamp(0, height.saturating_sub(1) as i32) as usize;
+ let polygon = polygon.map(|point| Point::new(point.x as f32, point.y as f32));
+ let mut sum = 0.0_f32;
+ let mut count = 0_usize;
+ for y in min_y..=max_y {
+ for x in min_x..=max_x {
+ if point_in_convex_polygon(Point::new(x as f32 + 0.5, y as f32 + 0.5), &polygon) {
+ sum += output[y * width + x];
+ count += 1;
+ }
+ }
+ }
+ if count == 0 {
+ 0.0
+ } else {
+ sum / count as f32
+ }
+}
+
+fn point_in_convex_polygon(point: Point, polygon: &[Point; 4]) -> bool {
+ let mut sign = 0_i8;
+ for index in 0..4 {
+ let start = polygon[index];
+ let end = polygon[(index + 1) % 4];
+ let cross =
+ (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x);
+ if cross.abs() <= f32::EPSILON {
+ continue;
+ }
+ let current = if cross > 0.0 { 1 } else { -1 };
+ if sign != 0 && sign != current {
+ return false;
+ }
+ sign = current;
+ }
+ true
+}
+
+fn minimum_side(points: &[Point; 4]) -> f64 {
+ (0..4)
+ .map(|index| distance(points[index], points[(index + 1) % 4]))
+ .fold(f64::INFINITY, f64::min)
+}
+
+fn polygon_perimeter(points: &[Point; 4]) -> f64 {
+ (0..4)
+ .map(|index| distance(points[index], points[(index + 1) % 4]))
+ .sum()
+}
+
+fn distance(left: Point, right: Point) -> f64 {
+ let x = f64::from(left.x - right.x);
+ let y = f64::from(left.y - right.y);
+ x.hypot(y)
+}
+
+fn order_points(mut points: [Point; 4]) -> [Point; 4] {
+ points.sort_by(|left, right| left.x.cmp(&right.x).then(left.y.cmp(&right.y)));
+ let (top_left, bottom_left) = if points[0].y <= points[1].y {
+ (points[0], points[1])
+ } else {
+ (points[1], points[0])
+ };
+ let (top_right, bottom_right) = if points[2].y <= points[3].y {
+ (points[2], points[3])
+ } else {
+ (points[3], points[2])
+ };
+ [top_left, top_right, bottom_right, bottom_left]
+}
+
+fn sort_reading_order(detections: &mut [Detection]) {
+ detections.sort_by(|left, right| {
+ left.polygon[0]
+ .y
+ .total_cmp(&right.polygon[0].y)
+ .then_with(|| left.polygon[0].x.total_cmp(&right.polygon[0].x))
+ });
+ for index in 1..detections.len() {
+ let mut cursor = index;
+ while cursor > 0 {
+ let current = detections[cursor].polygon[0];
+ let previous = detections[cursor - 1].polygon[0];
+ if (current.y - previous.y).abs() < 10.0 && current.x < previous.x {
+ detections.swap(cursor, cursor - 1);
+ cursor -= 1;
+ } else {
+ break;
+ }
+ }
+ }
+}
+
+fn output_error(message: impl Into) -> UseError {
+ UseError::new("use.ocr.provider_output_invalid", message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ctc_decoder_removes_blanks_and_repeated_classes() {
+ let config = RecognitionConfig {
+ channels: 3,
+ height: 48,
+ default_width: 320,
+ characters: vec!["A".to_string(), "B".to_string()],
+ };
+ let output = [
+ 0.9, 0.1, 0.0, 0.0, // blank
+ 0.1, 0.8, 0.1, 0.0, // A
+ 0.9, 0.1, 0.0, 0.0, // blank
+ 0.1, 0.8, 0.1, 0.0, // A
+ 0.1, 0.1, 0.8, 0.0, // B
+ ];
+ let result = decode_ctc(&output, &[1, 5, 4], &config).unwrap();
+ assert_eq!(result.text, "AAB");
+ assert!((result.confidence - 0.8).abs() < f32::EPSILON);
+ }
+}
diff --git a/crates/ocr/src/preprocess.rs b/crates/ocr/src/preprocess.rs
new file mode 100644
index 00000000..4cdcd9f7
--- /dev/null
+++ b/crates/ocr/src/preprocess.rs
@@ -0,0 +1,192 @@
+use std::io::Cursor;
+
+use a3s_use_core::{UseError, UseResult};
+use image::imageops::FilterType;
+use image::{DynamicImage, ImageReader, Limits, RgbImage};
+
+use crate::config::{DetectionConfig, RecognitionConfig};
+
+const MAX_IMAGE_SIDE: u32 = 16_384;
+const MAX_DECODED_BYTES: u64 = 256 * 1024 * 1024;
+const DETECTION_MIN_SIDE: u32 = 736;
+const DETECTION_MAX_SIDE: u32 = 4_000;
+const RECOGNITION_MAX_WIDTH: u32 = 3_200;
+
+pub(crate) struct DetectionInput {
+ pub(crate) data: Vec,
+ pub(crate) shape: [usize; 4],
+ pub(crate) original_width: u32,
+ pub(crate) original_height: u32,
+}
+
+pub(crate) struct RecognitionInput {
+ pub(crate) data: Vec,
+ pub(crate) shape: [usize; 4],
+}
+
+pub(crate) fn decode_image(bytes: &[u8]) -> UseResult {
+ let cursor = Cursor::new(bytes);
+ let mut reader = ImageReader::new(cursor)
+ .with_guessed_format()
+ .map_err(|error| image_error(format!("Failed to detect OCR image format: {error}")))?;
+ let mut limits = Limits::default();
+ limits.max_image_width = Some(MAX_IMAGE_SIDE);
+ limits.max_image_height = Some(MAX_IMAGE_SIDE);
+ limits.max_alloc = Some(MAX_DECODED_BYTES);
+ reader.limits(limits);
+ let image = reader
+ .decode()
+ .map_err(|error| image_error(format!("Failed to decode OCR image: {error}")))?;
+ let width = image.width();
+ let height = image.height();
+ if width == 0
+ || height == 0
+ || u64::from(width)
+ .checked_mul(u64::from(height))
+ .and_then(|pixels| pixels.checked_mul(4))
+ .is_none_or(|bytes| bytes > MAX_DECODED_BYTES)
+ {
+ return Err(image_error(
+ "Decoded OCR image dimensions exceed the 256 MiB pixel limit.",
+ ));
+ }
+ Ok(image.to_rgb8())
+}
+
+pub(crate) fn detection_input(
+ image: &RgbImage,
+ config: &DetectionConfig,
+) -> UseResult {
+ let original_width = image.width();
+ let original_height = image.height();
+ let (width, height) = detection_dimensions(original_width, original_height)?;
+ let resized = if width == original_width && height == original_height {
+ image.clone()
+ } else {
+ DynamicImage::ImageRgb8(image.clone())
+ .resize_exact(width, height, FilterType::Triangle)
+ .to_rgb8()
+ };
+ let plane = usize::try_from(u64::from(width) * u64::from(height))
+ .map_err(|_| image_error("Detection tensor dimensions overflowed."))?;
+ let mut data = vec![0.0_f32; plane * 3];
+ for (index, pixel) in resized.pixels().enumerate() {
+ let channels = [pixel[2], pixel[1], pixel[0]];
+ for channel in 0..3 {
+ data[channel * plane + index] = (f32::from(channels[channel]) * config.scale
+ - config.mean[channel])
+ / config.std[channel];
+ }
+ }
+ Ok(DetectionInput {
+ data,
+ shape: [1, 3, height as usize, width as usize],
+ original_width,
+ original_height,
+ })
+}
+
+pub(crate) fn recognition_input(
+ images: &[RgbImage],
+ config: &RecognitionConfig,
+) -> UseResult {
+ if images.is_empty() || images.len() > 8 {
+ return Err(image_error(
+ "PP-OCRv6 recognition batches must contain from 1 through 8 text crops.",
+ ));
+ }
+ if images
+ .iter()
+ .any(|image| image.width() == 0 || image.height() == 0)
+ {
+ return Err(image_error("PP-OCRv6 text crop has zero width or height."));
+ }
+ let model_height = u32::try_from(config.height)
+ .map_err(|_| image_error("Recognition model height is invalid."))?;
+ let default_width = u32::try_from(config.default_width)
+ .map_err(|_| image_error("Recognition model width is invalid."))?;
+ let resized_widths = images
+ .iter()
+ .map(|image| {
+ ((f64::from(model_height) * f64::from(image.width()) / f64::from(image.height())).ceil()
+ as u32)
+ .clamp(1, RECOGNITION_MAX_WIDTH)
+ })
+ .collect::>();
+ let widest = resized_widths.iter().copied().max().unwrap_or(1);
+ let canvas_width = default_width.max(widest).min(RECOGNITION_MAX_WIDTH);
+ let target_plane = usize::try_from(u64::from(canvas_width) * u64::from(model_height))
+ .map_err(|_| image_error("Recognition tensor dimensions overflowed."))?;
+ let batch_stride = config
+ .channels
+ .checked_mul(target_plane)
+ .ok_or_else(|| image_error("Recognition tensor dimensions overflowed."))?;
+ let mut data = vec![0.0_f32; images.len() * batch_stride];
+ for (batch, (image, resized_width)) in images.iter().zip(resized_widths).enumerate() {
+ let resized = DynamicImage::ImageRgb8(image.clone())
+ .resize_exact(resized_width, model_height, FilterType::Triangle)
+ .to_rgb8();
+ for y in 0..model_height {
+ for x in 0..resized_width {
+ let pixel = resized.get_pixel(x, y);
+ let target = y as usize * canvas_width as usize + x as usize;
+ let channels = [pixel[2], pixel[1], pixel[0]];
+ for channel in 0..config.channels {
+ data[batch * batch_stride + channel * target_plane + target] =
+ f32::from(channels[channel]) / 127.5 - 1.0;
+ }
+ }
+ }
+ }
+ Ok(RecognitionInput {
+ data,
+ shape: [
+ images.len(),
+ config.channels,
+ config.height,
+ canvas_width as usize,
+ ],
+ })
+}
+
+fn detection_dimensions(width: u32, height: u32) -> UseResult<(u32, u32)> {
+ if width == 0 || height == 0 {
+ return Err(image_error("OCR image has zero width or height."));
+ }
+ let mut ratio = 1.0_f64;
+ let min_side = width.min(height);
+ let max_side = width.max(height);
+ if min_side < DETECTION_MIN_SIDE {
+ ratio = f64::from(DETECTION_MIN_SIDE) / f64::from(min_side);
+ }
+ if f64::from(max_side) * ratio > f64::from(DETECTION_MAX_SIDE) {
+ ratio = f64::from(DETECTION_MAX_SIDE) / f64::from(max_side);
+ }
+ let resized_width = round_stride(f64::from(width) * ratio, 32);
+ let resized_height = round_stride(f64::from(height) * ratio, 32);
+ Ok((resized_width, resized_height))
+}
+
+fn round_stride(value: f64, stride: u32) -> u32 {
+ let rounded = (value / f64::from(stride)).round_ties_even() as u32 * stride;
+ rounded.max(stride)
+}
+
+fn image_error(message: impl Into) -> UseError {
+ UseError::new("use.ocr.image_invalid", message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn detection_dimensions_are_bounded_stride_multiples() {
+ assert_eq!(detection_dimensions(10, 20).unwrap(), (736, 1_472));
+ assert_eq!(detection_dimensions(4_000, 1_000).unwrap(), (4_000, 992));
+ assert_eq!(
+ detection_dimensions(20_000, 10_000).unwrap(),
+ (4_000, 1_984)
+ );
+ }
+}
diff --git a/crates/ocr/tests/ppocr_v6_contract.rs b/crates/ocr/tests/ppocr_v6_contract.rs
new file mode 100644
index 00000000..8f27ccd2
--- /dev/null
+++ b/crates/ocr/tests/ppocr_v6_contract.rs
@@ -0,0 +1,19 @@
+use std::path::PathBuf;
+
+use a3s_use_ocr::{OcrProviderKind, OcrRequest};
+
+#[test]
+fn public_contract_names_only_pp_ocr_v6() {
+ assert_eq!(
+ serde_json::to_value(OcrProviderKind::PpOcrV6).unwrap(),
+ serde_json::json!("pp-ocr-v6")
+ );
+
+ let request = OcrRequest {
+ path: PathBuf::from("scan.png"),
+ };
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ serde_json::json!({ "path": "scan.png" })
+ );
+}
diff --git a/crates/office/Cargo.toml b/crates/office/Cargo.toml
index e848dfb6..478c69ff 100644
--- a/crates/office/Cargo.toml
+++ b/crates/office/Cargo.toml
@@ -9,7 +9,7 @@ rust-version.workspace = true
description = "Native OOXML operations and temporary OfficeCLI compatibility for A3S Use"
[dependencies]
-a3s-use-core = { version = "0.1.1", path = "../core" }
+a3s-use-core = { version = "0.1.2", path = "../core" }
async-trait.workspace = true
base64.workspace = true
fs2.workspace = true
diff --git a/crates/office/skills/a3s-use-office/SKILL.md b/crates/office/skills/a3s-use-office/SKILL.md
index 6fe1056f..37aa35bb 100644
--- a/crates/office/skills/a3s-use-office/SKILL.md
+++ b/crates/office/skills/a3s-use-office/SKILL.md
@@ -9,10 +9,28 @@ Use A3S Use as the application boundary for Office documents. Prefer the
in-process native engine and its typed operations. Use the compatibility route
only when the requested operation is not yet native.
+Use the host surface that is already available:
+
+- In an A3S Code `use` worker, call the available
+ `mcp__use_office__*` tools directly. The host has already started the native
+ MCP server and owns its lifecycle; do not run shell commands.
+- If a requested operation is absent from the native tools, use an available
+ `mcp__use_office_compat__*` tool only as an explicit compatibility fallback.
+ If that route is absent, request
+ `mcp__use_office__office_install_compat`. This bounded network mutation must
+ pass parent TUI confirmation. Use the compatibility route only after the host
+ projects it; never replace the installer with a shell command.
+- In a CLI-only agent host, use the `a3s use office native ...` commands below.
+
## Workflow
1. Identify and inspect the document before changing it.
+ In an A3S Code `use` worker, begin with
+ `mcp__use_office__office_validate`, then open a session and use
+ `mcp__use_office__office_view`, `office_get`, or `office_query` as needed.
+ In a CLI-only host, use:
+
```bash
a3s use office native validate "$FILE" --json
a3s use office native view "$FILE" annotated --limit 200 --json
@@ -20,7 +38,9 @@ only when the requested operation is not yet native.
a3s use office native view "$FILE" issues --json
```
-2. Load the format reference relevant to the task:
+2. In a CLI-only agent host, load the format reference relevant to the task.
+ An A3S Code `use` worker cannot read Skill reference files; rely on this
+ guidance and the available MCP tool schemas instead.
- Read [references/word.md](references/word.md) for `.docx`.
- Read [references/spreadsheet.md](references/spreadsheet.md) for `.xlsx`.
@@ -52,13 +72,19 @@ when an agent must bound its lifetime.
## Choose the Surface
+- In an A3S Code `use` worker, use `mcp__use_office__*` and keep the returned
+ Office session ID stable until the document is saved and closed.
+- Use `mcp__use_office_compat__*` only when the native route lacks the requested
+ operation. If the tools are absent, request
+ `mcp__use_office__office_install_compat` through parent confirmation first.
- Use `a3s use office native ... --json` for local automation and scripts.
- Use `a3s use mcp serve office-native` for typed, stateful agent sessions.
Read [references/mcp.md](references/mcp.md) before using its session tools.
- Use the typed Rust API when embedding Office behavior in Rust.
- Use `a3s use office ...` only for an operation absent from the native route.
- Check `a3s use office doctor --json` first. Never install or repair the
- compatibility provider without explicit user authority.
+ Check `a3s use office doctor --json` first. The first real compatibility
+ command prepares the pinned OfficeCLI provider when policy allows it; help,
+ version, doctor, Skills, and native commands remain non-installing.
`a3s-use` accepts the same arguments when the umbrella `a3s` executable is not
available.
@@ -71,8 +97,16 @@ available.
is unavoidable, inspect the exact part, preserve its root QName, write to a
distinct output, and validate the result.
- Do not evaluate formulas through a shell or general-purpose script runtime.
- Native formula writes request spreadsheet recalculation; they do not promise
- a computed cached value.
+ Native cell-formula writes validate and store the expression but do not
+ compute a cached value implicitly. When fresh results are required, use
+ `office native recalculate` or the typed
+ `recalculate-spreadsheet-formulas` mutation. The closed native function
+ registry must reject unsupported functions instead of falling back to code
+ execution.
+- Treat dynamic-array spill children as read-only calculated output. Find and
+ edit or remove the formula anchor whose `formulaRef` contains the child;
+ recalculation, cache writes, spill cleanup, and every sibling mutation in
+ the batch roll back together on failure.
- Treat external OOXML relationships as inert. Do not fetch linked resources
while inspecting or rendering a document. Native hyperlink writes accept
only absolute HTTP, HTTPS, or mailto URIs without embedded credentials.
@@ -101,8 +135,18 @@ available.
`namedrange` first and use the returned `@name` plus `@scope` path for
update/remove. Do not edit `_xlnm.*` or `Slicer_*` names, collide with a table
name, add a formula-bar leading `=`, or use raw XML to bypass a typed
- identity/ref error. Defined-name formulas are stored and marked for
- recalculation, not evaluated by A3S.
+ identity/ref error. Defined-name mutations store the definition and request
+ recalculation; supported names referenced by cell formulas are resolved only
+ during an explicit native recalculation pass.
+- Import CSV or TSV only through the bounded typed import. Use exactly one
+ regular source file or `--stdin`, make `--format` explicit for stdin, and
+ inspect the target worksheet, `/Sheet/autofilter`, and `/Sheet/freeze` before
+ enabling `--header`: header mode intentionally replaces the worksheet filter
+ range and canonical frozen pane in one transaction. Explicit empty fields
+ clear existing cells; missing trailing fields in ragged rows do not. Treat
+ inferred formulas as parsed but not implicitly recalculated; run the explicit
+ native pass when cached values are required. Never bypass a malformed-quote,
+ formula-syntax, range, type, or unknown-view error with `raw-set`.
- Treat Spreadsheet AutoFilters as typed worksheet or table structure. Query
`autofilter` or `filtercolumn` first and inspect `nativeMutable`; use the
stable `/Sheet/autofilter` or `/Sheet/table[N]` path for updates. Every
@@ -126,9 +170,14 @@ available.
not overlap another table, a merge, or a worksheet AutoFilter, and do not use
raw XML to bypass `nativeMutable=false` or an unknown-content/relationship
error. Table criteria use the same typed filter-column values as worksheet
- AutoFilters. Exact mutable table or data ranges without totals rows can use
- the separate physical sort contract; unsupported embedded/imported sort
- state remains non-mutable.
+ AutoFilters. Table set automatically rewrites common explicit structured
+ references and provably owned table-local column references when aliases or
+ position-mapped columns change. Do not bypass
+ `use.office.spreadsheet_table_formula_rewrite_unsupported` for unsafe local
+ geometry or ownership, or `use.office.spreadsheet_table_referenced` when
+ removal is blocked. Exact mutable table or data ranges without totals rows
+ can use the separate physical sort contract; unsupported embedded/imported
+ sort state remains non-mutable.
- Keep the default OfficeCLI compatibility route separate from the native
engine. Do not depend on OfficeCLI's private resident protocol.
@@ -152,6 +201,12 @@ typed thresholds, stable paths, semantic queries, and exact canonical replay.
It owns workbook-global and
worksheet-local Spreadsheet defined names with stable scoped paths, typed
add/set/remove, semantic readback, and exact replay. It owns typed Spreadsheet
+formula parsing, bounded dependency graphs, a closed typed function registry,
+read-only calculation, atomic cached-value and dynamic-array spill writeback,
+CLI/MCP/batch recalculation, and exact replay. It owns typed Spreadsheet
+CSV/TSV import with bounded strict parsing, typed cell inference, explicit
+empty-cell semantics, optional header AutoFilter/frozen-pane setup, semantic
+`/Sheet/freeze` state, and exact canonical replay. It owns typed Spreadsheet
worksheet and table AutoFilters with closed value, comparison, top/bottom, and
dynamic criteria, stable filter paths, add/set/remove, and exact replay. It
owns stable ordered multi-key Spreadsheet physical sorting over an explicit or
@@ -168,12 +223,12 @@ cells or bounded ranges and internal locations, and external Presentation shape
clicks or internal jumps to existing slides. Remaining boundaries include
modern threaded comments, replies/resolution, writable comment dates,
rich comment bodies, Word header/footer comment anchors,
-gradient/pattern/theme fills, advanced x14 conditional-format visuals, named styles, complete formula
-calculation, formula-bearing or table-totals sorting, table calculated
+gradient/pattern/theme fills, advanced x14 conditional-format visuals, named
+styles, complete Excel function/structured-reference/external-workbook formula
+compatibility, formula-bearing or table-totals sorting, table calculated
columns/totals functions, date-group/color/icon filters and unsupported
embedded/imported sort-state variants, custom table styles, query
-tables/external data, advanced charts, pivots,
-and media,
+tables/external data, advanced charts, pivots, and media,
interactive preview editing/annotations, and full Office layout fidelity. Fail
closed or use the explicit compatibility route rather than inventing
unsupported native behavior.
diff --git a/crates/office/skills/a3s-use-office/references/mcp.md b/crates/office/skills/a3s-use-office/references/mcp.md
index 5d1da61b..dc7f791f 100644
--- a/crates/office/skills/a3s-use-office/references/mcp.md
+++ b/crates/office/skills/a3s-use-office/references/mcp.md
@@ -8,7 +8,11 @@
## Session Workflow
-Start the explicit native standard MCP server:
+In an A3S Code `use` worker, the host has already started the native server.
+Call the available `mcp__use_office__office_*` tools; do not start a process or
+run a shell command. Tool names below omit the host prefix for readability.
+
+In a CLI-only MCP host, start the explicit native standard MCP server:
```bash
a3s use mcp serve office-native
@@ -25,6 +29,8 @@ Use its typed tools rather than passing shell command strings:
- `office_save` persists a mutable session.
- `office_close` refuses unsaved changes unless `discard=true` is explicit.
- `office_list` reports sessions owned by this server process.
+- `office_install_compat` prepares the optional pinned compatibility provider;
+ in Code it must pass parent confirmation before network access.
Mutations remain unsaved until `office_save`. Do not discard a dirty session
unless the user explicitly accepts losing its changes. Release the session as
@@ -117,6 +123,50 @@ mutations. Unknown fields, invalid values, empty format objects, and
non-Spreadsheet targets fail the entire in-memory batch; no change persists
until `office_save`.
+Spreadsheet cell formulas use `set-cell-value` with
+`{"type":"formula","expression":"SUM(A1:B2)"}`. One optional leading `=` is
+removed before storage. The bounded native parser validates literals,
+operators, calls, names, structured references, qualified A1 references, and
+range/intersection/union syntax. Invalid syntax returns
+`use.office.spreadsheet_formula_invalid` with zero-based `byteOffset` and
+`characterOffset` details and rolls back every mutation in that
+`office_apply_batch`. Successful writes invalidate stale calculation caches and
+request recalculation; they do not calculate implicitly.
+
+Add the explicit recalculation mutation after dependent formula writes when
+fresh cached values are required:
+
+```json
+{
+ "session": "workbook",
+ "mutations": [
+ {
+ "operation": "set-cell-value",
+ "path": "/Sheet1/C1",
+ "value": {"type": "formula", "expression": "SEQUENCE(2,2,1,1)"}
+ },
+ {
+ "operation": "recalculate-spreadsheet-formulas"
+ }
+ ]
+}
+```
+
+The result includes one `spreadsheetCalculations` receipt with
+`formulaCount`, `spillCellCount`, deterministic `calculationOrder`, and typed
+calculated cells. Calculation and OOXML cache/spill writeback are part of the
+same atomic in-memory batch; a cycle, unsupported function, qualified function,
+missing structured-reference table, column, or requested header/totals row,
+external-workbook reference, or blocked storage condition rolls back every
+sibling mutation. `Table[Column]`, contiguous table column ranges, common
+`#All`/`#Data`/`#Headers`/`#Totals` row items, current-row `@` forms, and
+table-local current-row references from inside a table are supported.
+Spreadsheet error results remain typed cell values. Dynamic-array spill
+children are read-only; mutate their formula anchor. The closed native registry
+and limits are documented in
+[spreadsheet.md](spreadsheet.md#values-and-formulas); the server never invokes
+a shell, script runtime, or external workbook.
+
Spreadsheet merged cells use the separate `merge-cells` and `unmerge-cells`
mutations:
@@ -179,7 +229,8 @@ operator, and only `between` or `notBetween` accept and require `formula2`.
Rules and ranges are bounded, normalized, and globally non-overlapping within
one worksheet. Invalid formulas, flags, messages, XML text, ranges, or overlap
fail the complete `office_apply_batch`. Inline lists, ISO dates, and clock
-times are normalized but formulas are never evaluated. Query
+times are normalized, but data-validation formula predicates are not executed
+by the validation feature or the cell-formula recalculation pass. Query
`dataValidation[type=list]` or call `office_get` on the returned path for
unsaved semantic readback. Covered observed and virtual blank cells expose
`dataValidation` and `validationType`. Updates retain unknown attributes and
@@ -273,11 +324,12 @@ identity, ListObject table-name collisions, reserved `_xlnm.*`/`Slicer_*`
names, and unsupported cross-workbook refs are validated before mutation.
Workbook-scoped bare A1 refs are rejected; worksheet-local bare A1 refs are
qualified automatically by the domain layer. The mutation requests workbook
-recalculation but does not evaluate the expression. Unknown OOXML attributes
-are retained, while unknown content that cannot be preserved fails the whole
-batch. Call `office_get` or `office_query` before `office_save` to verify the
-unsaved scoped value, then save explicitly. Closing a dirty session still
-requires save or explicit discard.
+recalculation but does not calculate by itself; a later explicit native
+recalculation resolves supported names referenced by cell formulas. Unknown
+OOXML attributes are retained, while unknown content that cannot be preserved
+fails the whole batch. Call `office_get` or `office_query` before `office_save`
+to verify the unsaved scoped value, then save explicitly. Closing a dirty
+session still requires save or explicit discard.
Spreadsheet worksheet AutoFilters use the separate
`add-spreadsheet-auto-filter` and `set-spreadsheet-auto-filter` mutations.
@@ -323,6 +375,60 @@ color/icon, extension, unknown-content, and embedded sort-state imports fail
closed. Physical sorting is the separate mutation below and does not flatten an
unsupported imported AutoFilter.
+Spreadsheet delimited import embeds bounded UTF-8 content directly in the
+typed mutation; filesystem paths remain at the CLI boundary:
+
+```json
+{
+ "session": "workbook",
+ "mutations": [{
+ "operation": "import-spreadsheet-delimited",
+ "sheet": "/Sheet1",
+ "import": {
+ "content": "Name,Amount,Date\nAlpha,42,2026-07-17",
+ "format": "csv",
+ "header": true,
+ "startCell": "A1"
+ }
+ }]
+}
+```
+
+`format` is `csv` or `tsv`; omitted `startCell` defaults to `A1`. Input is
+limited to 8 MiB and a 100,000-cell rectangular extent. Malformed quoting,
+invalid geometry or typed values, and unsupported target state fail the whole
+in-memory batch. Explicit empty fields clear existing target values; ragged
+missing trailing fields preserve them. Formula, finite-number, boolean, and ISO
+date/time inference is deterministic, but import does not implicitly calculate
+formulas. Append `recalculate-spreadsheet-formulas` to the same batch when
+fresh caches are required.
+
+When `header=true`, the import atomically adds or replaces the worksheet
+AutoFilter and canonical frozen pane. Inspect `/Sheet1/autofilter` and
+`/Sheet1/freeze` before using header mode on a populated worksheet. A pane can
+also be set independently:
+
+```json
+{
+ "session": "workbook",
+ "mutations": [{
+ "operation": "set-spreadsheet-frozen-pane",
+ "sheet": "/Sheet1",
+ "pane": {
+ "frozenRows": 1,
+ "frozenColumns": 0,
+ "topLeftCell": "A2"
+ }
+ }]
+}
+```
+
+Use `office_get` or `office_query` for unsaved readback and ordinary typed
+`remove` on `/Sheet1/freeze` for deletion. Do not mutate a pane whose semantic
+`nativeMutable` value is false. See
+[spreadsheet.md](spreadsheet.md#delimited-import-and-frozen-panes) for parsing,
+typing, and preservation boundaries.
+
Spreadsheet physical sorting uses `sort-spreadsheet-range` inside the same
atomic `office_apply_batch` boundary:
@@ -408,6 +514,15 @@ Names, columns, built-in style families/numbers, flags, table/defined-name
identity, table/merge/worksheet-AutoFilter overlap, and relationship ownership
are validated before mutation.
+When table aliases or position-mapped columns change, `set-spreadsheet-table`
+atomically rewrites common structured references in cells, defined names,
+conditional formats, data validations, charts, and table-formula carriers.
+String literals and external-workbook references are preserved. Unsafe
+table-local rewrites or geometry changes fail with
+`use.office.spreadsheet_table_formula_rewrite_unsupported`; removing a table
+with a remaining structured reference fails with
+`use.office.spreadsheet_table_referenced`.
+
Use `office_get` with depth 1 or `office_query` with `table[name=Sales]` to
inspect the unsaved table and its column children. Do not replace a node whose
semantic `nativeMutable` flag is false. Header stamping, OPC table parts, and
@@ -504,6 +619,12 @@ Annotated reads include unsaved mutations in the current typed session.
Screenshot output requires a no-clobber `.png` path and a ready A3S Browser
provider; other native Office tools do not require Browser or OfficeCLI.
-Use `a3s use mcp serve office` only for the pinned OfficeCLI compatibility
-server. It is a separate standard MCP target and is not the native session
+In an A3S Code `use` worker, use an available
+`mcp__use_office_compat__*` tool only when the native vocabulary lacks the
+requested operation. If that surface is missing, call
+`mcp__use_office__office_install_compat` through parent confirmation and wait
+for the host to project the ready compatibility route. In a CLI-only MCP host,
+`a3s use mcp serve office-compat` prepares and starts the pinned OfficeCLI
+compatibility server; the legacy `a3s use mcp serve office` alias remains
+supported. It is a separate standard MCP target and is not the native session
engine.
diff --git a/crates/office/skills/a3s-use-office/references/spreadsheet.md b/crates/office/skills/a3s-use-office/references/spreadsheet.md
index 672d86da..3b75be3c 100644
--- a/crates/office/skills/a3s-use-office/references/spreadsheet.md
+++ b/crates/office/skills/a3s-use-office/references/spreadsheet.md
@@ -7,6 +7,7 @@ Use stable worksheet and A1 paths such as `/Sheet1`, `/Sheet1/A1`, and
- [Inspect](#inspect)
- [Values and Formulas](#values-and-formulas)
+- [Delimited Import and Frozen Panes](#delimited-import-and-frozen-panes)
- [Cell Text Formatting](#cell-text-formatting)
- [Cell Presentation Formatting](#cell-presentation-formatting)
- [Merged Cells](#merged-cells)
@@ -37,6 +38,7 @@ a3s use office native set workbook.xlsx /Sheet1/A1:C20 --find Draft --replace Fi
a3s use office native set workbook.xlsx /Sheet1/B1 --number 42.5 --json
a3s use office native set workbook.xlsx /Sheet1/C1 --boolean true --json
a3s use office native set workbook.xlsx /Sheet1/D1 --formula 'SUM(B1:B12)' --json
+a3s use office native recalculate workbook.xlsx --output calculated.xlsx --json
a3s use office native set workbook.xlsx /Sheet1/E1 --url https://example.com/data --display Data --tooltip 'Open data' --json
a3s use office native set workbook.xlsx /Sheet1/F1 --location 'Sheet1!B2' --display B2 --json
a3s use office native set workbook.xlsx /Sheet1/G2:H4 --url https://example.com/range --display Range --json
@@ -56,10 +58,54 @@ the scope. Rich runs and unknown XML survive, and phonetic text is excluded.
Numeric, boolean, formula, and error values are not coerced. Zero matches are
reported as an unchanged success.
-Formula writes store validated formula text, invalidate stale calculation
-caches, and request application recalculation. The native engine does not yet
-provide a complete formula evaluator. Check `formula_not_evaluated` and
-`formula_eval_error` issue records before delivery.
+Formula writes remove one optional leading `=`, parse the body with bounded
+Excel operator/reference syntax, store the normalized formula text, invalidate
+stale calculation caches, and request recalculation. They do not calculate
+implicitly. A syntax failure returns
+`use.office.spreadsheet_formula_invalid` with byte and character offsets and
+leaves the document unchanged.
+
+Run `office native recalculate` in place or with `--output` to build the
+dependency graph, calculate supported formulas, and atomically write typed
+cached values and dynamic-array spills. The same operation is available as the
+`recalculate-spreadsheet-formulas` batch/MCP mutation and as read-only or
+writeback Rust APIs. Supported functions are `SUM`, `AVERAGE`, `MIN`, `MAX`,
+`COUNT`, `COUNTA`, `ABS`, `SQRT`, `POWER`, `MOD`, `ROUND`, `IF`, `IFERROR`,
+`AND`, `OR`, `NOT`, `CONCAT`, `CONCATENATE`, `ROW`, `COLUMN`, `SEQUENCE`,
+`TRANSPOSE`, `PI`, and `NA`. Cross-sheet ranges, scoped names, typed errors,
+array broadcasting, spill references, and ordinary Excel operators are
+supported. ListObject structured references resolve a table `name` or
+`displayName`: `Sales[Qty]` selects one data column,
+`Sales[[Qty]:[Price]]` selects a contiguous data-column range, and `#All`,
+`#Data`, `#Headers`, or `#Totals` selects structural rows. `Sales[@Qty]`,
+`Sales[[#This Row],[Qty]]`, and table-local `[@Qty]` select the current data
+row; table-local forms require the formula cell to be inside the inferred
+table.
+
+Spill children are read-only; update or remove the anchor instead. A blocked
+spill produces typed `#SPILL!`, while formula error values such as `#DIV/0!`
+remain typed cell results. Circular dependencies, unsupported or qualified
+functions, missing tables, columns, or requested header/totals rows, disjoint
+or non-canonical structured-reference forms, and external-workbook reads fail
+with stable errors and leave the complete mutation batch unchanged. No shell,
+script runtime, or external workbook is invoked. Limits are 8,192 formula
+characters, depth 128 across both AST and nested named-reference resolution,
+8,192 AST nodes, 100,000 reference areas per value, 100,000 graph formulas,
+1,000,000 dependency edges, 1,000,000 graph reference visits, 100,000
+materialized cells per array or function call, 100,000 cumulative spill
+children per pass, 200,000 OOXML cell writes, and 1 MiB per text result. All
+formula text results together are limited to 8 MiB per pass. Check
+`formula_not_evaluated` and `formula_eval_error` issue records after the pass.
+
+Semantic cell reads expose string-valued `formulaCached` on formula anchors and
+`valuePresent` on every cell. A recalculated anchor reports
+`formulaCached=true`; a formula stored without `` reports `false`. Spill
+children contain cached values but no independent `formula` field.
+
+Exact replay accepts canonical formula storage and canonical array anchors only
+when the array result is natively cached. It fails closed with
+`use.office.dump_unsupported` for non-reproducible physical storage such as
+explicit `t="normal"` formulas and uncached or malformed array anchors.
Hyperlinks target one cell or a bounded rectangular range. A missing single
cell is auto-created; a range link neither creates cells nor rewrites their
@@ -79,6 +125,77 @@ and slide coordinates instead of ignoring them. Native removal also cleans up
the matching VML note shape and removes unused comment/VML parts. Threaded
comments, replies, writable dates, and rich bodies are not yet native.
+## Delimited Import and Frozen Panes
+
+Import one bounded UTF-8 CSV or TSV source into an existing worksheet:
+
+```bash
+# .tsv and .tab infer TSV; every other file extension defaults to CSV.
+a3s use office native import workbook.xlsx /Sheet1 source.csv \
+ --header \
+ --start-cell B2 \
+ --json
+
+# Stdin is bounded too. State the format instead of relying on its CSV default.
+a3s use office native import workbook.xlsx /Sheet1 \
+ --stdin \
+ --format tsv \
+ --output imported.xlsx \
+ --json
+```
+
+Supply exactly one positional source, `--file `, or `--stdin`. Files
+must be regular, non-symlink files. One request accepts at most 8 MiB and a
+100,000-cell rectangular target within Excel's row and column bounds. The
+parser accepts a leading UTF-8 BOM, CRLF, quoted delimiters, embedded quoted
+newlines, and doubled quotes. It rejects unclosed quotes, quotes inside
+unquoted fields, and non-boundary content after a closing quote rather than
+guessing.
+
+An explicit empty field clears an existing target cell value while retaining
+its unrelated style and extension content. A missing trailing field in a
+ragged source row leaves that target cell unchanged, and a blank target is not
+materialized just to represent emptiness. Import infers leading-`=` formulas,
+finite numbers, booleans, ISO dates/times, and otherwise text. Dates honor the
+workbook's 1900/1904 date system and receive the canonical native date number
+format. Inferred formulas pass the same bounded syntax parser as direct cell
+writes, are stored, and are marked for recalculation. Import does not calculate
+them implicitly; run `office native recalculate` when fresh cached values are
+required.
+
+`--header` treats the first imported row as headers. In the same atomic
+transaction it adds or replaces the worksheet AutoFilter over the imported
+extent and adds or replaces one canonical frozen pane below the header. Inspect
+existing `/Sheet1/autofilter` and `/Sheet1/freeze` state first when importing
+into a populated worksheet.
+
+Read or remove the frozen pane through its stable semantic path:
+
+```bash
+a3s use office native get workbook.xlsx /Sheet1/freeze --json
+a3s use office native query workbook.xlsx frozen-pane --json
+a3s use office native remove workbook.xlsx /Sheet1/freeze --json
+```
+
+Rust, versioned batch, and standard MCP can set a canonical pane independently:
+
+```json
+{
+ "operation": "set-spreadsheet-frozen-pane",
+ "sheet": "/Sheet1",
+ "pane": {
+ "frozenRows": 1,
+ "frozenColumns": 0,
+ "topLeftCell": "A2"
+ }
+}
+```
+
+`topLeftCell` must be below and to the right of every frozen split. Imported
+split panes, vendor attributes, unknown children, or unsupported view state
+remain readable with `nativeMutable=false` and fail closed on set/remove.
+Strict and transitional SpreadsheetML are preserved.
+
## Cell Text Formatting
```bash
@@ -391,6 +508,18 @@ a3s use office native set workbook.xlsx '/Sheet1/table[1]' \
a3s use office native remove workbook.xlsx '/Sheet1/table[1]' --json
```
+Table `set` rewrites common structured references when `name`, effective
+`displayName`, or position-mapped column names change. The audit covers cell
+formulas, workbook defined names, conditional-format and data-validation
+formulas, charts, and formula carriers in table parts. String literals and
+external-workbook references remain unchanged. Table-local forms such as
+`[@Qty]` are rewritten only with provable ListObject ownership; an unsafe local
+rewrite or local reference across a range/header/totals-row change fails with
+`use.office.spreadsheet_table_formula_rewrite_unsupported`. Removing a table
+still targeted by a structured reference fails with
+`use.office.spreadsheet_table_referenced`. Both failures roll back the complete
+mutation.
+
Provide exactly one non-empty, case-insensitively unique column name for every
range column. Table `name` and optional `displayName` use Excel identifier
grammar, are limited to 255 characters, may not resemble A1/R1C1 references,
@@ -512,7 +641,9 @@ rejected; use cells as the list source instead. Date inputs in valid
workbook's declared 1900 or 1904 date system. Time inputs in `HH:MM` or
`HH:MM:SS` form become day fractions. Range, defined-name, dynamic spill, and
function sources such as `INDIRECT(...)` remain formulas. Other formula text is
-stored after removing one optional leading `=` and is never evaluated by A3S.
+stored after removing one optional leading `=`; data-validation rule predicates
+are not executed by either the validation writer or the cell-formula
+recalculation pass.
Each rule accepts 1–1,024 normalized rectangular A1 areas and a worksheet
accepts at most 65,534 rules. Formula fields are limited to 255 characters;
@@ -556,8 +687,8 @@ children would be lost, and final removal fails if unknown collection data
would be discarded. Strict/transitional OOXML, atomic batch rollback, and
exact replay are supported. This capability does not add table calculated
columns/totals functions, date-group/color/icon filters, unsupported imported
-sort-state variants, charts, pivots, formula evaluation, or Excel layout
-fidelity.
+sort-state variants, charts, pivots, data-validation predicate execution, or
+Excel layout fidelity.
## Conditional Formatting
@@ -668,9 +799,10 @@ survive a set/remove operation fails closed. Imported multi-rule carriers share
one range: keep that range unchanged when updating one child rule.
Canonical replay, atomic rollback, CLI, and standard MCP are supported. This
-does not calculate formulas, reproduce Excel's rendered appearance, or support
-x14-only negative data-bar axes/colors, custom icon sets, table/chart/pivot
-formatting, or complete OfficeCLI/Spreadsheet parity.
+conditional-format feature does not evaluate rule formulas or reproduce
+Excel's rendered appearance, and it does not support x14-only negative data-bar
+axes/colors, custom icon sets, table/chart/pivot formatting, or complete
+OfficeCLI/Spreadsheet parity.
## Named Ranges
@@ -733,15 +865,16 @@ The identity is case-insensitively unique by `(name, scope)`. A defined name
also may not collide with a ListObject table `name` or `displayName`. Do not
edit or remove `_xlnm.*` print/filter definitions or `Slicer_*` sentinels;
manage the owning typed feature instead. `--volatile true` maps to the OOXML
-defined-name function flag and requests recalculation. No named-range formula
-is evaluated by A3S.
+defined-name function flag and requests recalculation. The name mutation does
+not itself calculate anything; supported names referenced by cell formulas are
+resolved by an explicit native recalculation pass.
Batch, standard MCP, and Rust use one complete typed value for add/set and
ordinary typed `remove` for deletion. The writer preserves strict/transitional
SpreadsheetML and unknown attributes. Unknown collection or child content
fails closed when an edit cannot retain it. Exact replay includes supported
defined names. This remains defined-name lifecycle support, not external-link
-authoring, formula evaluation, or complete Spreadsheet parity.
+authoring or complete Spreadsheet parity.
## Structure
@@ -757,8 +890,8 @@ a3s use office native add workbook.xlsx /Sheet1/A1 --type picture --input chart.
Supported structural edits rewrite bounded A1 references and related metadata.
Pivot-table changes, unsafe 3D references, x14-only conditional-format
-extensions, full chart authoring, and complete recalculation remain outside the
-native subset and fail closed where safety cannot be proven.
+extensions, full chart authoring, and complete Excel formula compatibility
+remain outside the native subset and fail closed where safety cannot be proven.
## Verify
@@ -772,4 +905,4 @@ a3s use office native watch workbook.xlsx --port 0
HTML, SVG, and screenshots are sparse semantic previews, not Excel layout or
print fidelity. Watch reloads saved revisions; it does not provide inline cell
-editing or calculate formulas.
+editing or trigger formula recalculation.
diff --git a/crates/office/src/editor.rs b/crates/office/src/editor.rs
index c023f96b..3f8b0110 100644
--- a/crates/office/src/editor.rs
+++ b/crates/office/src/editor.rs
@@ -47,14 +47,17 @@ pub use types::{
NativeSpreadsheetConditionalFormatThresholdKind, NativeSpreadsheetConditionalFormatTimePeriod,
NativeSpreadsheetDataValidation, NativeSpreadsheetDataValidationErrorStyle,
NativeSpreadsheetDataValidationOperator, NativeSpreadsheetDataValidationType,
+ NativeSpreadsheetDelimitedFormat, NativeSpreadsheetDelimitedImport,
NativeSpreadsheetDifferentialFormat, NativeSpreadsheetDynamicFilter, NativeSpreadsheetFill,
- NativeSpreadsheetFilterColumn, NativeSpreadsheetFilterCriteria, NativeSpreadsheetNamedRange,
- NativeSpreadsheetNamedRangeScope, NativeSpreadsheetReadingOrder, NativeSpreadsheetSort,
- NativeSpreadsheetSortDirection, NativeSpreadsheetSortKey, NativeSpreadsheetTable,
- NativeSpreadsheetTableColumn, NativeSpreadsheetTableStyle, NativeSpreadsheetVerticalAlignment,
- SpreadsheetCellValue, MAX_NATIVE_OFFICE_FIND_BYTES, MAX_NATIVE_OFFICE_REPLACEMENT_BYTES,
+ NativeSpreadsheetFilterColumn, NativeSpreadsheetFilterCriteria, NativeSpreadsheetFrozenPane,
+ NativeSpreadsheetImportResult, NativeSpreadsheetNamedRange, NativeSpreadsheetNamedRangeScope,
+ NativeSpreadsheetReadingOrder, NativeSpreadsheetSort, NativeSpreadsheetSortDirection,
+ NativeSpreadsheetSortKey, NativeSpreadsheetTable, NativeSpreadsheetTableColumn,
+ NativeSpreadsheetTableStyle, NativeSpreadsheetVerticalAlignment, SpreadsheetCellValue,
+ MAX_NATIVE_OFFICE_FIND_BYTES, MAX_NATIVE_OFFICE_REPLACEMENT_BYTES,
MAX_NATIVE_OFFICE_TEXT_MATCHES, MAX_NATIVE_OFFICE_TEXT_REPLACEMENT_OUTPUT_BYTES,
- MAX_NATIVE_OFFICE_TEXT_SCOPE_CELLS,
+ MAX_NATIVE_OFFICE_TEXT_SCOPE_CELLS, MAX_NATIVE_SPREADSHEET_IMPORT_BYTES,
+ MAX_NATIVE_SPREADSHEET_IMPORT_CELLS,
};
/// Loss-preserving OOXML editor with transactional in-memory batches.
@@ -202,6 +205,24 @@ impl NativeOfficeEditor {
Ok(())
}
+ /// Calculates every supported Spreadsheet formula and atomically writes
+ /// typed cached values and dynamic-array spill cells into the package.
+ pub fn recalculate_spreadsheet_formulas(
+ &mut self,
+ ) -> UseResult {
+ let result = self.apply_batch(&[NativeOfficeMutation::RecalculateSpreadsheetFormulas])?;
+ result
+ .spreadsheet_calculations
+ .into_iter()
+ .next()
+ .ok_or_else(|| {
+ editor_error(
+ "use.office.batch_validation_failed",
+ "Native Spreadsheet recalculation returned no calculation receipt.",
+ )
+ })
+ }
+
/// Adds one complete typed Spreadsheet ListObject table.
pub fn add_spreadsheet_table(
&mut self,
@@ -262,6 +283,40 @@ impl NativeOfficeEditor {
})
}
+ /// Imports bounded CSV or TSV content into one Spreadsheet worksheet.
+ pub fn import_spreadsheet_delimited(
+ &mut self,
+ sheet: impl Into,
+ import: NativeSpreadsheetDelimitedImport,
+ ) -> UseResult {
+ let result = self.apply_batch(&[NativeOfficeMutation::ImportSpreadsheetDelimited {
+ sheet: sheet.into(),
+ import,
+ }])?;
+ result
+ .spreadsheet_imports
+ .into_iter()
+ .next()
+ .ok_or_else(|| {
+ editor_error(
+ "use.office.batch_validation_failed",
+ "Native Spreadsheet import returned no receipt.",
+ )
+ })
+ }
+
+ /// Creates or replaces one canonical frozen pane on a Spreadsheet sheet.
+ pub fn set_spreadsheet_frozen_pane(
+ &mut self,
+ sheet: impl Into,
+ pane: NativeSpreadsheetFrozenPane,
+ ) -> UseResult {
+ self.single_path(NativeOfficeMutation::SetSpreadsheetFrozenPane {
+ sheet: sheet.into(),
+ pane,
+ })
+ }
+
/// Adds one complete typed Spreadsheet defined name.
pub fn add_named_range(
&mut self,
@@ -710,6 +765,8 @@ impl NativeOfficeEditor {
created_parts: Vec::new(),
created_images: Vec::new(),
text_replacements: Vec::new(),
+ spreadsheet_imports: Vec::new(),
+ spreadsheet_calculations: Vec::new(),
});
}
let original = self.package.clone();
@@ -718,11 +775,15 @@ impl NativeOfficeEditor {
let mut created_parts = Vec::new();
let mut created_images = Vec::new();
let mut text_replacements = Vec::new();
+ let mut spreadsheet_imports = Vec::new();
+ let mut spreadsheet_calculations = Vec::new();
for mutation in mutations {
let mut created_part = None;
let mut created_image = None;
let mut swap = None;
let mut text_replacement = None;
+ let mut spreadsheet_import = None;
+ let mut spreadsheet_calculation = None;
let result = match mutation {
NativeOfficeMutation::ReplaceText { path, replacement } => {
text_replace::replace(&mut self.package, path, replacement).map(|receipt| {
@@ -766,6 +827,12 @@ impl NativeOfficeEditor {
spreadsheet::set_cell_value(&mut self.package, path, value)
.map(|()| path.clone())
}
+ NativeOfficeMutation::RecalculateSpreadsheetFormulas => {
+ spreadsheet::recalculate_formulas(&mut self.package).map(|receipt| {
+ spreadsheet_calculation = Some(receipt);
+ "/".to_string()
+ })
+ }
NativeOfficeMutation::AddSpreadsheetTable { sheet, table } => {
spreadsheet::add_table(&mut self.package, sheet, table)
}
@@ -781,6 +848,16 @@ impl NativeOfficeEditor {
NativeOfficeMutation::SortSpreadsheetRange { path, sort } => {
spreadsheet::sort_range(&mut self.package, path, sort)
}
+ NativeOfficeMutation::ImportSpreadsheetDelimited { sheet, import } => {
+ spreadsheet::import_delimited(&mut self.package, sheet, import).map(|receipt| {
+ let path = receipt.path.clone();
+ spreadsheet_import = Some(receipt);
+ path
+ })
+ }
+ NativeOfficeMutation::SetSpreadsheetFrozenPane { sheet, pane } => {
+ spreadsheet::set_frozen_pane(&mut self.package, sheet, pane)
+ }
NativeOfficeMutation::AddNamedRange { named_range } => {
spreadsheet::add_named_range(&mut self.package, named_range)
}
@@ -953,6 +1030,12 @@ impl NativeOfficeEditor {
if let Some(receipt) = text_replacement {
text_replacements.push(receipt);
}
+ if let Some(receipt) = spreadsheet_import {
+ spreadsheet_imports.push(receipt);
+ }
+ if let Some(receipt) = spreadsheet_calculation {
+ spreadsheet_calculations.push(receipt);
+ }
}
Err(error) => {
self.package = original;
@@ -974,6 +1057,8 @@ impl NativeOfficeEditor {
created_parts,
created_images,
text_replacements,
+ spreadsheet_imports,
+ spreadsheet_calculations,
})
}
diff --git a/crates/office/src/editor/spreadsheet.rs b/crates/office/src/editor/spreadsheet.rs
index dfc98664..2730cdff 100644
--- a/crates/office/src/editor/spreadsheet.rs
+++ b/crates/office/src/editor/spreadsheet.rs
@@ -18,18 +18,27 @@ mod auto_filter;
mod conditional_formatting;
mod data_validation;
mod filter_xml;
+mod formula;
+mod import;
mod merge;
mod named_range;
mod sort;
mod structure;
mod style;
mod table;
+mod view;
mod worksheet;
pub(super) use arrange::{copy_node, move_node, swap_nodes};
pub(super) use structure::{delete_columns, delete_rows, insert_columns, insert_rows};
pub(super) use worksheet::{copy_worksheet, move_worksheet, rename_worksheet};
+pub(super) fn recalculate_formulas(
+ package: &mut NativeOfficePackage,
+) -> UseResult {
+ formula::recalculate(package)
+}
+
pub(super) fn add_auto_filter(
package: &mut NativeOfficePackage,
sheet: &str,
@@ -54,6 +63,22 @@ pub(super) fn sort_range(
sort::sort(package, path, value)
}
+pub(super) fn set_frozen_pane(
+ package: &mut NativeOfficePackage,
+ sheet: &str,
+ pane: &super::NativeSpreadsheetFrozenPane,
+) -> UseResult {
+ view::set(package, sheet, pane)
+}
+
+pub(super) fn import_delimited(
+ package: &mut NativeOfficePackage,
+ sheet: &str,
+ import: &super::NativeSpreadsheetDelimitedImport,
+) -> UseResult {
+ import::apply(package, sheet, import)
+}
+
pub(super) fn add_conditional_format(
package: &mut NativeOfficePackage,
sheet: &str,
@@ -191,6 +216,12 @@ pub(super) fn set_cell_value(
})?;
let part = package.xml_part(part_name)?;
let index = index_xml(&part)?;
+ let sheet_data = index
+ .descendant("sheetData")
+ .ok_or_else(|| node_not_found(path))?;
+ let prepared = formula::prepare_for_value_write(&part, sheet_data, sheet, range)?;
+ let part = crate::LosslessXmlPart::parse(part_name.to_string(), prepared)?;
+ let index = index_xml(&part)?;
let sheet_data = index
.descendant("sheetData")
.ok_or_else(|| node_not_found(path))?;
@@ -201,7 +232,9 @@ pub(super) fn set_cell_value(
}
pub(super) fn remove(package: &mut NativeOfficePackage, path: &str) -> UseResult<()> {
- if sort::is_path(path) {
+ if view::is_path(path) {
+ view::remove(package, path)
+ } else if sort::is_path(path) {
sort::remove(package, path)
} else if auto_filter::is_path(path) {
auto_filter::remove(package, path)
@@ -221,8 +254,6 @@ pub(super) fn remove(package: &mut NativeOfficePackage, path: &str) -> UseResult
}
fn remove_cell(package: &mut NativeOfficePackage, path: &str) -> UseResult<()> {
- super::comment::remove_spreadsheet_range_comments(package, path)?;
- super::hyperlink::remove_spreadsheet_range_links(package, path)?;
let (sheet_path, reference) = path.rsplit_once('/').ok_or_else(|| node_not_found(path))?;
let range = CellRange::parse(reference)?;
validate_range_size(range)?;
@@ -250,6 +281,15 @@ fn remove_cell(package: &mut NativeOfficePackage, path: &str) -> UseResult<()> {
})?;
let part = package.xml_part(part_name)?;
let index = index_xml(&part)?;
+ let sheet_data = index
+ .descendant("sheetData")
+ .ok_or_else(|| node_not_found(path))?;
+ let prepared = formula::prepare_for_remove(&part, sheet_data, sheet, range)?;
+ package.set_part(part_name, prepared)?;
+ super::comment::remove_spreadsheet_range_comments(package, path)?;
+ super::hyperlink::remove_spreadsheet_range_links(package, path)?;
+ let part = package.xml_part(part_name)?;
+ let index = index_xml(&part)?;
let sheet_data = index
.descendant("sheetData")
.ok_or_else(|| node_not_found(path))?;
@@ -638,16 +678,8 @@ fn normalize_cell_value(value: &SpreadsheetCellValue) -> UseResult {
- let expression = expression.strip_prefix('=').unwrap_or(expression);
- if expression.is_empty()
- || expression.chars().count() > 8_192
- || expression.chars().any(char::is_control)
- {
- return Err(editor_error(
- "use.office.spreadsheet_formula_invalid",
- "Spreadsheet formulas must contain 1-8192 non-control characters.",
- ));
- }
+ let expression =
+ crate::spreadsheet_formula::validate_and_normalize_formula(expression)?;
Ok(SpreadsheetCellValue::Formula {
expression: expression.to_string(),
})
diff --git a/crates/office/src/editor/spreadsheet/formula.rs b/crates/office/src/editor/spreadsheet/formula.rs
new file mode 100644
index 00000000..2b9f79f9
--- /dev/null
+++ b/crates/office/src/editor/spreadsheet/formula.rs
@@ -0,0 +1,169 @@
+mod planning;
+mod write;
+
+use std::collections::BTreeMap;
+
+use a3s_use_core::{UseError, UseResult};
+
+use crate::semantic::{DocumentNode, NativeOfficeDocument};
+use crate::spreadsheet_reference::{CellRange, CellReference};
+use crate::xml_edit::{index_xml, IndexedXmlElement};
+use crate::{
+ NativeOfficePackage, SpreadsheetFormulaCalculation, SpreadsheetFormulaValue,
+ MAX_SPREADSHEET_FORMULA_CELLS, MAX_SPREADSHEET_FORMULA_SPILL_CELLS,
+};
+
+use super::{editor_error, update_dimension};
+use planning::{plan_writes, worksheet_cells};
+use write::{apply_cell_writes, mark_workbook_calculated};
+
+const MAX_CALCULATION_WRITES: usize =
+ MAX_SPREADSHEET_FORMULA_CELLS + MAX_SPREADSHEET_FORMULA_SPILL_CELLS;
+
+#[derive(Debug, Clone)]
+enum CellWrite {
+ Clear,
+ Cached(SpreadsheetFormulaValue),
+ Formula {
+ expression: String,
+ value: SpreadsheetFormulaValue,
+ spill_range: Option,
+ },
+}
+
+pub(super) fn prepare_for_value_write(
+ part: &crate::LosslessXmlPart,
+ sheet_data: &IndexedXmlElement,
+ sheet: &DocumentNode,
+ target: CellRange,
+) -> UseResult> {
+ prepare_spill_edit(part, sheet_data, sheet, target, false)
+}
+
+pub(super) fn prepare_for_remove(
+ part: &crate::LosslessXmlPart,
+ sheet_data: &IndexedXmlElement,
+ sheet: &DocumentNode,
+ target: CellRange,
+) -> UseResult> {
+ prepare_spill_edit(part, sheet_data, sheet, target, true)
+}
+
+fn prepare_spill_edit(
+ part: &crate::LosslessXmlPart,
+ sheet_data: &IndexedXmlElement,
+ sheet: &DocumentNode,
+ target: CellRange,
+ remove: bool,
+) -> UseResult> {
+ let cells = worksheet_cells(sheet)?;
+ let mut writes = BTreeMap::new();
+ for (anchor, cell) in &cells {
+ let Some(reference) = cell.format.get("formulaRef") else {
+ continue;
+ };
+ let spill = CellRange::parse(reference)?;
+ if !target.intersects(spill) {
+ continue;
+ }
+ if !target.contains(*anchor)
+ || (!remove && !intersection_is_anchor_only(target, spill, *anchor))
+ {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_spill_cell_read_only",
+ format!(
+ "Cell range '{}' intersects spill '{}' outside formula anchor '{}'.",
+ target.a1(),
+ spill.a1(),
+ anchor.a1()
+ ),
+ )
+ .with_suggestion(
+ "Edit or remove the spill formula anchor; spilled result cells are read-only.",
+ ));
+ }
+ let spill_cells = spill.cell_count()?;
+ if spill_cells > MAX_SPREADSHEET_FORMULA_SPILL_CELLS {
+ return Err(calculation_write_limit().with_detail("cells", spill_cells));
+ }
+ for row in spill.start.row..=spill.end.row {
+ for column in spill.start.column..=spill.end.column {
+ let reference = CellReference { column, row };
+ if reference != *anchor {
+ insert_clear_write(&mut writes, reference)?;
+ }
+ }
+ }
+ }
+ if writes.is_empty() {
+ Ok(part.raw().to_vec())
+ } else {
+ apply_cell_writes(part, sheet_data, &writes)
+ }
+}
+
+fn intersection_is_anchor_only(left: CellRange, right: CellRange, anchor: CellReference) -> bool {
+ let start_column = left.start.column.max(right.start.column);
+ let start_row = left.start.row.max(right.start.row);
+ let end_column = left.end.column.min(right.end.column);
+ let end_row = left.end.row.min(right.end.row);
+ start_column == anchor.column
+ && end_column == anchor.column
+ && start_row == anchor.row
+ && end_row == anchor.row
+}
+
+pub(super) fn recalculate(
+ package: &mut NativeOfficePackage,
+) -> UseResult {
+ let document = NativeOfficeDocument::from_package(package.clone())?;
+ let calculation = document.calculate_spreadsheet_formulas()?;
+ let plans = plan_writes(&document, &calculation)?;
+ for (part_name, writes) in plans {
+ if writes.is_empty() {
+ continue;
+ }
+ let part = package.xml_part(&part_name)?;
+ let index = index_xml(&part)?;
+ let sheet_data = index.descendant("sheetData").ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Worksheet part '{part_name}' has no sheetData element."),
+ )
+ })?;
+ let edited = apply_cell_writes(&part, sheet_data, &writes)?;
+ let edited = update_dimension(&part_name, edited)?;
+ package.set_part(&part_name, edited)?;
+ }
+ mark_workbook_calculated(package)?;
+ Ok(calculation)
+}
+
+fn calculation_write_limit() -> UseError {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_write_limit",
+ format!("Native formula recalculation writes at most {MAX_CALCULATION_WRITES} cells."),
+ )
+}
+
+fn insert_clear_write(
+ writes: &mut BTreeMap,
+ reference: CellReference,
+) -> UseResult<()> {
+ if writes.contains_key(&reference) {
+ return Ok(());
+ }
+ let cells = writes
+ .len()
+ .checked_add(1)
+ .ok_or_else(calculation_write_limit)?;
+ if cells > MAX_CALCULATION_WRITES {
+ return Err(calculation_write_limit().with_detail("cells", cells));
+ }
+ writes.insert(reference, CellWrite::Clear);
+ Ok(())
+}
+
+fn calculation_storage_error(code: &str, message: impl Into) -> UseError {
+ editor_error(code, message)
+}
diff --git a/crates/office/src/editor/spreadsheet/formula/planning.rs b/crates/office/src/editor/spreadsheet/formula/planning.rs
new file mode 100644
index 00000000..ea2f17f2
--- /dev/null
+++ b/crates/office/src/editor/spreadsheet/formula/planning.rs
@@ -0,0 +1,337 @@
+use std::collections::BTreeMap;
+
+use a3s_use_core::UseResult;
+
+use crate::semantic::{DocumentNode, NativeOfficeDocument, OfficeNodeType};
+use crate::spreadsheet_reference::{CellRange, CellReference};
+use crate::{
+ SpreadsheetFormulaCalculatedCell, SpreadsheetFormulaCalculation, SpreadsheetFormulaValue,
+ MAX_SPREADSHEET_FORMULA_SPILL_CELLS,
+};
+
+use super::{
+ calculation_storage_error, calculation_write_limit, insert_clear_write, CellWrite,
+ MAX_CALCULATION_WRITES,
+};
+
+pub(super) fn plan_writes(
+ document: &NativeOfficeDocument,
+ calculation: &SpreadsheetFormulaCalculation,
+) -> UseResult>> {
+ let mut plans = BTreeMap::new();
+ let mut planned_write_count = 0_usize;
+ for sheet in document
+ .root()
+ .children
+ .iter()
+ .filter(|node| node.node_type == OfficeNodeType::Worksheet)
+ {
+ let sheet_name = sheet.path.strip_prefix('/').ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Worksheet path '{}' is invalid.", sheet.path),
+ )
+ })?;
+ let part_name = sheet.format.get("part").cloned().ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Worksheet '{}' has no source part.", sheet.path),
+ )
+ })?;
+ let cells = worksheet_cells(sheet)?;
+ let mut writes = BTreeMap::new();
+ plan_old_spill_cleanup(&cells, &mut writes)?;
+ for calculated in calculation
+ .cells
+ .iter()
+ .filter(|cell| cell.cell.sheet.eq_ignore_ascii_case(sheet_name))
+ {
+ plan_calculated_cell(calculated, &cells, &mut writes)?;
+ }
+ validate_planned_writes(&cells, &writes)?;
+ if writes.len() > MAX_CALCULATION_WRITES {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_write_limit",
+ format!(
+ "Native formula recalculation writes at most {MAX_CALCULATION_WRITES} cells."
+ ),
+ )
+ .with_detail("cells", writes.len()));
+ }
+ planned_write_count = planned_write_count
+ .checked_add(writes.len())
+ .ok_or_else(calculation_write_limit)?;
+ if planned_write_count > MAX_CALCULATION_WRITES {
+ return Err(calculation_write_limit().with_detail("cells", planned_write_count));
+ }
+ plans.insert(part_name, writes);
+ }
+ let planned_formulas = plans
+ .values()
+ .flat_map(BTreeMap::values)
+ .filter(|write| matches!(write, CellWrite::Formula { .. }))
+ .count();
+ if planned_formulas != calculation.formula_count {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ "Calculation results do not match the workbook formula cells.",
+ )
+ .with_detail("expectedFormulas", calculation.formula_count)
+ .with_detail("plannedFormulas", planned_formulas));
+ }
+ Ok(plans)
+}
+
+pub(super) fn worksheet_cells(
+ sheet: &DocumentNode,
+) -> UseResult> {
+ let mut cells = BTreeMap::new();
+ for cell in sheet
+ .children
+ .iter()
+ .filter(|node| node.node_type == OfficeNodeType::Row)
+ .flat_map(|row| &row.children)
+ .filter(|node| node.node_type == OfficeNodeType::Cell)
+ {
+ let reference = cell
+ .path
+ .rsplit_once('/')
+ .and_then(|(_, reference)| CellReference::parse(reference).ok())
+ .ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Spreadsheet cell path '{}' is invalid.", cell.path),
+ )
+ })?;
+ if cells.insert(reference, cell).is_some() {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Worksheet contains duplicate cell '{}'.", reference.a1()),
+ ));
+ }
+ }
+ Ok(cells)
+}
+
+fn plan_old_spill_cleanup(
+ cells: &BTreeMap,
+ writes: &mut BTreeMap,
+) -> UseResult<()> {
+ for (anchor, cell) in cells {
+ let Some(reference) = cell.format.get("formulaRef") else {
+ continue;
+ };
+ let range = CellRange::parse(reference).map_err(|error| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!(
+ "Formula cell '{}' has invalid spill range '{reference}': {error}",
+ cell.path
+ ),
+ )
+ })?;
+ if !range.contains(*anchor) {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!(
+ "Formula spill range '{}' does not contain anchor '{}'.",
+ range.a1(),
+ anchor.a1()
+ ),
+ ));
+ }
+ let spill_cells = range.cell_count()?;
+ if spill_cells > MAX_SPREADSHEET_FORMULA_SPILL_CELLS {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_spill_limit",
+ format!(
+ "Stored formula spill '{}' exceeds {MAX_SPREADSHEET_FORMULA_SPILL_CELLS} cells.",
+ range.a1()
+ ),
+ )
+ .with_detail("cells", spill_cells));
+ }
+ for row in range.start.row..=range.end.row {
+ for column in range.start.column..=range.end.column {
+ let reference = CellReference { column, row };
+ if reference != *anchor {
+ insert_clear_write(writes, reference)?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn plan_calculated_cell(
+ calculated: &SpreadsheetFormulaCalculatedCell,
+ cells: &BTreeMap,
+ writes: &mut BTreeMap,
+) -> UseResult<()> {
+ let anchor = CellReference {
+ column: calculated.cell.column,
+ row: calculated.cell.row,
+ };
+ let cell = cells.get(&anchor).ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!(
+ "Calculated formula cell '{}' is missing.",
+ calculated.cell.path()
+ ),
+ )
+ })?;
+ let expression = cell.format.get("formula").cloned().ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Calculated cell '{}' has no formula.", cell.path),
+ )
+ })?;
+ if cell.format.get("formulaType").is_some_and(|value| {
+ !value.eq_ignore_ascii_case("normal") && !value.eq_ignore_ascii_case("array")
+ }) {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_unsupported",
+ format!(
+ "Formula storage type '{}' at '{}' is not supported by native recalculation.",
+ cell.format.get("formulaType").map_or("", String::as_str),
+ cell.path
+ ),
+ ));
+ }
+ match &calculated.value {
+ SpreadsheetFormulaValue::Array { rows } => {
+ let spill = calculated.spill_range.as_deref().ok_or_else(|| {
+ calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!("Array result '{}' has no spill range.", cell.path),
+ )
+ })?;
+ let range = CellRange::parse(spill)?;
+ let height = usize::try_from(range.end.row - range.start.row + 1)
+ .map_err(|_| calculation_write_limit())?;
+ let width = usize::try_from(range.end.column - range.start.column + 1)
+ .map_err(|_| calculation_write_limit())?;
+ if range.start != anchor
+ || rows.len() != height
+ || rows.iter().any(|row| row.len() != width)
+ {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!(
+ "Array result shape does not match spill range '{}' at '{}'.",
+ range.a1(),
+ cell.path
+ ),
+ ));
+ }
+ for (row_offset, row) in rows.iter().enumerate() {
+ for (column_offset, value) in row.iter().enumerate() {
+ require_scalar_value(value)?;
+ let reference = CellReference {
+ column: range.start.column
+ + u32::try_from(column_offset)
+ .map_err(|_| calculation_write_limit())?,
+ row: range.start.row
+ + u32::try_from(row_offset).map_err(|_| calculation_write_limit())?,
+ };
+ let write = if reference == anchor {
+ CellWrite::Formula {
+ expression: expression.clone(),
+ value: value.clone(),
+ spill_range: Some(range.a1()),
+ }
+ } else {
+ CellWrite::Cached(value.clone())
+ };
+ insert_planned_write(writes, reference, write)?;
+ }
+ }
+ }
+ value => {
+ require_scalar_value(value)?;
+ if calculated.spill_range.is_some() {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ format!(
+ "Scalar result '{}' unexpectedly has a spill range.",
+ cell.path
+ ),
+ ));
+ }
+ insert_planned_write(
+ writes,
+ anchor,
+ CellWrite::Formula {
+ expression,
+ value: value.clone(),
+ spill_range: None,
+ },
+ )?;
+ }
+ }
+ Ok(())
+}
+
+fn insert_planned_write(
+ writes: &mut BTreeMap,
+ reference: CellReference,
+ write: CellWrite,
+) -> UseResult<()> {
+ match writes.get(&reference) {
+ None | Some(CellWrite::Clear) => {
+ if !writes.contains_key(&reference) {
+ let cells = writes
+ .len()
+ .checked_add(1)
+ .ok_or_else(calculation_write_limit)?;
+ if cells > MAX_CALCULATION_WRITES {
+ return Err(calculation_write_limit().with_detail("cells", cells));
+ }
+ }
+ writes.insert(reference, write);
+ Ok(())
+ }
+ Some(_) => Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_spill_overlap",
+ format!(
+ "Calculated formula results overlap at '{}'.",
+ reference.a1()
+ ),
+ )),
+ }
+}
+
+fn validate_planned_writes(
+ cells: &BTreeMap,
+ writes: &BTreeMap,
+) -> UseResult<()> {
+ for (reference, write) in writes {
+ if matches!(write, CellWrite::Formula { .. }) {
+ continue;
+ }
+ if cells
+ .get(reference)
+ .is_some_and(|cell| cell.format.contains_key("formula"))
+ {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_spill_overlap",
+ format!(
+ "Calculated spill at '{}' overlaps another formula cell.",
+ reference.a1()
+ ),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn require_scalar_value(value: &SpreadsheetFormulaValue) -> UseResult<()> {
+ if matches!(value, SpreadsheetFormulaValue::Array { .. }) {
+ return Err(calculation_storage_error(
+ "use.office.spreadsheet_formula_storage_invalid",
+ "Nested Spreadsheet formula arrays cannot be written to OOXML cells.",
+ ));
+ }
+ Ok(())
+}
diff --git a/crates/office/src/editor/spreadsheet/formula/write.rs b/crates/office/src/editor/spreadsheet/formula/write.rs
new file mode 100644
index 00000000..254fc0a0
--- /dev/null
+++ b/crates/office/src/editor/spreadsheet/formula/write.rs
@@ -0,0 +1,354 @@
+use std::collections::BTreeMap;
+
+use a3s_use_core::UseResult;
+
+use crate::spreadsheet_reference::CellReference;
+use crate::xml_edit::{insert_child, IndexedXmlElement, XmlPatch};
+use crate::{NativeOfficePackage, SpreadsheetFormulaValue};
+
+use super::super::{
+ escape_attribute, expanded_element, indexed_cells_in_row, indexed_rows, prefix, qualified,
+ remove_calculation_chain,
+};
+use super::{calculation_storage_error, CellWrite};
+
+pub(super) fn apply_cell_writes(
+ part: &crate::LosslessXmlPart,
+ sheet_data: &IndexedXmlElement,
+ writes: &BTreeMap,
+) -> UseResult> {
+ let mut by_row = BTreeMap::>::new();
+ for (reference, write) in writes {
+ by_row
+ .entry(reference.row)
+ .or_default()
+ .push((*reference, write));
+ }
+ if sheet_data.empty {
+ let rows = by_row
+ .into_iter()
+ .filter_map(|(row_number, writes)| {
+ let cells = writes
+ .into_iter()
+ .filter_map(|(reference, write)| {
+ new_cell_fragment(prefix(&sheet_data.qualified_name), reference, write)
+ .transpose()
+ })
+ .collect::>();
+ match cells {
+ Ok(cells) if cells.is_empty() => None,
+ Ok(cells) => {
+ let tag = qualified(prefix(&sheet_data.qualified_name), "row");
+ Some(Ok(format!("<{tag} r=\"{row_number}\">{cells}{tag}>")))
+ }
+ Err(error) => Some(Err(error)),
+ }
+ })
+ .collect::>()?;
+ return insert_child(part, sheet_data, rows);
+ }
+
+ let rows = indexed_rows(sheet_data);
+ let row_map = rows.iter().copied().collect::>();
+ let mut patches = Vec::new();
+ let mut insertions = BTreeMap::>::new();
+ for (row_number, row_writes) in by_row {
+ let Some(row) = row_map.get(&row_number).copied() else {
+ let cells = row_writes
+ .into_iter()
+ .filter_map(|(reference, write)| {
+ new_cell_fragment(prefix(&sheet_data.qualified_name), reference, write)
+ .transpose()
+ })
+ .collect::>()?;
+ if cells.is_empty() {
+ continue;
+ }
+ let tag = qualified(prefix(&sheet_data.qualified_name), "row");
+ let fragment = format!("<{tag} r=\"{row_number}\">{cells}{tag}>");
+ let position = rows
+ .iter()
+ .find(|(existing, _)| *existing > row_number)
+ .map_or(sheet_data.content_range.end, |(_, next)| {
+ next.full_range.start
+ });
+ insertions
+ .entry(position)
+ .or_default()
+ .push((row_number, 0, fragment));
+ continue;
+ };
+ if row.empty {
+ let cells = row_writes
+ .into_iter()
+ .filter_map(|(reference, write)| {
+ new_cell_fragment(prefix(&row.qualified_name), reference, write).transpose()
+ })
+ .collect::>()?;
+ if !cells.is_empty() {
+ patches.push(XmlPatch::new(
+ row.full_range.clone(),
+ expanded_element(row, &cells),
+ ));
+ }
+ continue;
+ }
+ let cells = indexed_cells_in_row(row_number, row);
+ for (reference, write) in row_writes {
+ if let Some((_, cell)) = cells
+ .iter()
+ .find(|(existing, _)| existing.column == reference.column)
+ {
+ let replacement = existing_cell_fragment(part, cell, reference, write)?;
+ patches.push(XmlPatch::new(
+ cell.full_range.clone(),
+ replacement.unwrap_or_default(),
+ ));
+ continue;
+ }
+ let Some(fragment) = new_cell_fragment(prefix(&row.qualified_name), reference, write)?
+ else {
+ continue;
+ };
+ let position = cells
+ .iter()
+ .find(|(existing, _)| existing.column > reference.column)
+ .map(|(_, next)| next.full_range.start)
+ .or_else(|| {
+ row.children
+ .iter()
+ .find(|child| child.local_name != "c")
+ .map(|child| child.full_range.start)
+ })
+ .unwrap_or(row.content_range.end);
+ insertions
+ .entry(position)
+ .or_default()
+ .push((row_number, reference.column, fragment));
+ }
+ }
+ for (position, mut fragments) in insertions {
+ fragments.sort_by_key(|(row, column, _)| (*row, *column));
+ patches.push(XmlPatch::new(
+ position..position,
+ fragments
+ .into_iter()
+ .map(|(_, _, fragment)| fragment)
+ .collect::