mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 08:53:03 -06:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea80720c36 | ||
|
|
2b5a3e4a61 | ||
|
|
37941f0624 | ||
|
|
b1bd8d8b20 | ||
|
|
794b65ecac | ||
|
|
6308fc9edb | ||
|
|
48659c1668 | ||
|
|
e8845f648a | ||
|
|
3858482465 | ||
|
|
73a18c4f4c | ||
|
|
0b4b36b865 | ||
|
|
4251dad939 | ||
|
|
1b7e4f00dc | ||
|
|
8d4fb0b12e | ||
|
|
203872b4db | ||
|
|
d3954beacf | ||
|
|
26e7112e0f | ||
|
|
3ba4945e72 | ||
|
|
0752f3181b | ||
|
|
b24ec5df6d | ||
|
|
d0c567992e | ||
|
|
1c84e32f58 | ||
|
|
cf3b11c858 | ||
|
|
1e6c8b8b72 | ||
|
|
3e0f073e9a | ||
|
|
eec5db66f0 | ||
|
|
a440cfac28 | ||
|
|
92a79db048 | ||
|
|
652c08a89b | ||
|
|
aeb3fb19ce | ||
|
|
e4ec0d7ed1 | ||
|
|
23487b0cb5 | ||
|
|
b45698d109 | ||
|
|
c913bf6615 | ||
|
|
0431741359 | ||
|
|
ae7fbd81b7 | ||
|
|
f800472b0a | ||
|
|
9c87c22662 | ||
|
|
c1adf9b239 |
@@ -0,0 +1,29 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml': []
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
Generated
+106
-14
@@ -739,6 +739,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "cmpv2"
|
||||
version = "0.2.0"
|
||||
@@ -757,7 +763,7 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
@@ -827,6 +833,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -912,15 +924,23 @@ name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-csv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -940,10 +960,38 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"craft-cfb",
|
||||
"craft-xml",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-opc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -962,7 +1010,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1009,7 +1057,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
@@ -1020,7 +1068,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=119415f46f601e20b9bc6b1b4d6979ceda86d88e#119415f46f601e20b9bc6b1b4d6979ceda86d88e"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1058,6 +1106,25 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-zip"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1138,6 +1205,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1150,7 +1226,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
@@ -1215,7 +1291,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.3",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1906,9 +1984,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2123,6 +2203,7 @@ dependencies = [
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2263,7 +2344,10 @@ dependencies = [
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-cfb",
|
||||
"craft-csv",
|
||||
"craft-offcrypto",
|
||||
"craft-opc",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2271,7 +2355,6 @@ dependencies = [
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip 3.0.0",
|
||||
@@ -2376,6 +2459,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -4838,7 +4930,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"pem",
|
||||
@@ -4912,7 +5004,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"cmpv2",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
@@ -4951,7 +5043,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"hex",
|
||||
"pem",
|
||||
"rustls-pki-types",
|
||||
@@ -6676,7 +6768,7 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"sha1 0.10.7",
|
||||
"signature",
|
||||
|
||||
+8
-3
@@ -31,6 +31,14 @@ gridcraft-math = { path = "crates/math" }
|
||||
# craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
|
||||
# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]).
|
||||
craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# OPC zip packages (parts, relationships, the zip writer) under `crates/xlsx/src/package.rs`.
|
||||
craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
@@ -47,9 +55,6 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
|
||||
|
||||
@@ -18,8 +18,8 @@ gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e", version = "0.1.0" }
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -35,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
|
||||
@@ -21,6 +21,7 @@ web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
|
||||
+37
-301
@@ -1,156 +1,57 @@
|
||||
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
|
||||
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
|
||||
//!
|
||||
//! - `ID` starts the file; `E` ends it.
|
||||
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
|
||||
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
|
||||
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
|
||||
//!
|
||||
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
|
||||
//! the same relative and absolute references, as Excel does when it opens the file.
|
||||
//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to
|
||||
//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed
|
||||
//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats
|
||||
//! interned as styles and widths converted from characters.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use craft_csv::sylk;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The largest SYLK file read, as for other text imports: past it the file is refused.
|
||||
const MAX_BYTES: usize = 512 << 20;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
|
||||
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
|
||||
}
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
|
||||
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
};
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
if !text.starts_with("ID") {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
|
||||
}
|
||||
let file = sylk::read(bytes).map_err(|e| match e {
|
||||
sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)),
|
||||
_ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")),
|
||||
})?;
|
||||
let mut wb = Workbook::new();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = Vec::new();
|
||||
// Cells' number formats by index into `formats`, applied once every format is known.
|
||||
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
|
||||
let mut formulas: HashMap<CellRef, String> = HashMap::new();
|
||||
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
|
||||
let (mut row, mut col) = (0u32, 0u32);
|
||||
let mut skipped = 0usize;
|
||||
for line in text.lines() {
|
||||
let fields = split_fields(line);
|
||||
let Some((kind, rest)) = fields.split_first() else { continue };
|
||||
match kind.as_str() {
|
||||
"C" => {
|
||||
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
|
||||
let mut bad = false;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => match position(arg, MAX_ROWS) {
|
||||
Some(r) => row = r,
|
||||
None => bad = true,
|
||||
},
|
||||
"X" => match position(arg, MAX_COLS) {
|
||||
Some(c) => col = c,
|
||||
None => bad = true,
|
||||
},
|
||||
"K" => value = Some(parse_value(arg)),
|
||||
"E" => formula = Some(arg.to_string()),
|
||||
"S" => shared = Some((None, None)),
|
||||
// In a shared-formula cell, R and C name the cell whose formula it shares.
|
||||
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
|
||||
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let at = CellRef::new(row, col);
|
||||
if let Some((Some(r), Some(c))) = shared {
|
||||
formula = formulas.get(&CellRef::new(r, c)).cloned();
|
||||
}
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
if let Some(v) = value {
|
||||
cell.value = v;
|
||||
}
|
||||
if let Some(r1c1) = formula {
|
||||
let a1 = r1c1_formula_to_a1(&r1c1, at);
|
||||
let f = Formula::new(&a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
formulas.insert(at, r1c1);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_cell(at, cell);
|
||||
let mut skipped = file.skipped;
|
||||
let mut styles = HashMap::new();
|
||||
for (&(row, col), c) in &file.cells {
|
||||
let value = match &c.value {
|
||||
sylk::Value::Number(n) => Value::Number(*n),
|
||||
sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())),
|
||||
sylk::Value::Bool(b) => Value::Bool(*b),
|
||||
sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error),
|
||||
_ => Value::Empty,
|
||||
};
|
||||
let mut cell = Cell::value(value);
|
||||
if let Some(a1) = &c.formula {
|
||||
let f = Formula::new(a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
"F" => {
|
||||
let mut format = None;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
|
||||
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
|
||||
"P" => format = arg.parse::<usize>().ok(),
|
||||
"W" => {
|
||||
let mut parts = arg.split_whitespace();
|
||||
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Some(first), Some(last), Ok(width)) =
|
||||
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
widths.push((first, last, width));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(format) = format {
|
||||
cell_formats.push((CellRef::new(row, col), format));
|
||||
}
|
||||
}
|
||||
// Number formats only: `P;F…` and `P;E…` are fonts.
|
||||
"P" => {
|
||||
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
|
||||
formats.push(code.to_string());
|
||||
}
|
||||
}
|
||||
"E" => break,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(code) = &c.format {
|
||||
cell.style = *styles
|
||||
.entry(code.as_str())
|
||||
.or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
}
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
for w in &file.column_widths {
|
||||
let size = crate::cmd::format::chars_to_points(w.width) as f32;
|
||||
for c in w.first..=w.last {
|
||||
sheet.cols.entry(c).or_default().size = Some(size);
|
||||
}
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let mut styles = HashMap::new();
|
||||
for (at, index) in cell_formats {
|
||||
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
|
||||
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
cell.style = style;
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
if let Some(first) = wb.sheet_mut(0) {
|
||||
sheet.name = std::mem::take(&mut first.name);
|
||||
*first = sheet;
|
||||
@@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields = vec![String::new()];
|
||||
let mut chars = line.trim_end_matches('\r').chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ';' {
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
if let Some(f) = fields.last_mut() {
|
||||
f.push(';');
|
||||
}
|
||||
} else {
|
||||
fields.push(String::new());
|
||||
}
|
||||
} else if let Some(f) = fields.last_mut() {
|
||||
f.push(c);
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// A 1-based row or column number → 0-based, within `max`.
|
||||
fn position(arg: &str, max: u32) -> Option<u32> {
|
||||
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
|
||||
}
|
||||
|
||||
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
|
||||
/// boolean, an error or a number.
|
||||
fn parse_value(arg: &str) -> Value {
|
||||
if let Some(text) = arg.strip_prefix('"') {
|
||||
let text = text.strip_suffix('"').unwrap_or(text);
|
||||
return Value::Text(Arc::from(text));
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
|
||||
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
|
||||
}
|
||||
if let Some(e) = CellError::parse(arg) {
|
||||
return Value::Error(e);
|
||||
}
|
||||
match arg.trim().parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Value::Number(n),
|
||||
_ => Value::Text(Arc::from(arg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
|
||||
/// nothing (the cell's own row or column).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Part {
|
||||
Absolute(u32),
|
||||
Relative(i64),
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
|
||||
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
|
||||
match self {
|
||||
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
|
||||
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
|
||||
/// the index after it, or `None` when the text isn't a reference part.
|
||||
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
|
||||
match chars.get(i) {
|
||||
Some('[') => {
|
||||
let close = chars[i..].iter().position(|&c| c == ']')? + i;
|
||||
let inner: String = chars[i + 1..close].iter().collect();
|
||||
Some((Part::Relative(inner.parse().ok()?), close + 1))
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
|
||||
let n: String = chars[i..end].iter().collect();
|
||||
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
|
||||
}
|
||||
_ => Some((Part::Relative(0), i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
|
||||
}
|
||||
|
||||
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
|
||||
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
|
||||
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
|
||||
/// off the sheet becomes `#REF!`.
|
||||
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let mut out = String::with_capacity(formula.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '"' || c == '\'' {
|
||||
// Copy the quoted run as it is; a doubled quote stays inside it.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
|
||||
j += if chars[j] == c { 2 } else { 1 };
|
||||
}
|
||||
let stop = (j + 1).min(chars.len());
|
||||
out.extend(&chars[i..stop]);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
|
||||
if starts_word
|
||||
&& matches!(c, 'R' | 'r' | 'C' | 'c')
|
||||
&& let Some((text, next)) = reference_at(&chars, i, at)
|
||||
{
|
||||
out.push_str(&text);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
|
||||
if is_word_char(c) {
|
||||
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
|
||||
out.extend(&chars[i..end]);
|
||||
i = end;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
|
||||
/// word there isn't a reference (a function or a name).
|
||||
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
|
||||
let mut j = i;
|
||||
let mut row = None;
|
||||
let mut col = None;
|
||||
if matches!(chars.get(j), Some('R' | 'r')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
row = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if matches!(chars.get(j), Some('C' | 'c')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
col = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if row.is_none() && col.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
|
||||
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
|
||||
return None;
|
||||
}
|
||||
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
|
||||
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
|
||||
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
|
||||
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
|
||||
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
|
||||
let text = match (row, col) {
|
||||
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
|
||||
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
|
||||
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
|
||||
(None, None) => None,
|
||||
};
|
||||
Some((text.unwrap_or_else(|| "#REF!".into()), j))
|
||||
}
|
||||
|
||||
@@ -763,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() {
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||
# craft-libs dependency uses (see the root Cargo.toml).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "119415f46f601e20b9bc6b1b4d6979ceda86d88e" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
|
||||
@@ -18,12 +18,14 @@ gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
gridcraft-model = { workspace = true }
|
||||
craft-cfb = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
craft-offcrypto = { workspace = true }
|
||||
craft-opc = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
//! Just enough of the Compound File Binary (CFB/OLE2) container to tell what a non-zip
|
||||
//! spreadsheet file is: Excel wraps password-protected workbooks in a CFB holding an
|
||||
//! `EncryptedPackage` stream, while Excel 97-2003 `.xls` (and other legacy Office files) are
|
||||
//! CFB files without one. Every read is bounds-checked and every loop bounded.
|
||||
|
||||
/// The CFB signature at the start of the file.
|
||||
pub(crate) const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors.
|
||||
const MAX_REG_SECT: u32 = 0xFFFF_FFFA;
|
||||
/// Directory sectors followed at most (4096 entries with 512-byte sectors).
|
||||
const MAX_DIR_SECTORS: usize = 1024;
|
||||
/// DIFAT sectors followed at most.
|
||||
const MAX_DIFAT_SECTORS: usize = 1024;
|
||||
|
||||
/// Stream names that mark an encrypted (password-protected) OOXML package.
|
||||
const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"];
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// Whether the compound file holds an encryption stream. Walks the directory through the FAT;
|
||||
/// when the structure can't be followed (truncated or malformed), falls back to scanning the
|
||||
/// bytes for the UTF-16LE stream names.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
match directory_names(bytes) {
|
||||
Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))),
|
||||
None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_utf16(hay: &[u8], needle: &str) -> bool {
|
||||
let n: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
!n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice())
|
||||
}
|
||||
|
||||
/// Names of every directory entry, or `None` when the directory can't be read.
|
||||
fn directory_names(b: &[u8]) -> Option<Vec<String>> {
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return None;
|
||||
}
|
||||
let shift = u16_at(b, 30)?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return None;
|
||||
}
|
||||
let size = 1usize << shift;
|
||||
let per = size / 4;
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(size)?;
|
||||
b.get(start..start.checked_add(size)?)
|
||||
};
|
||||
// The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors.
|
||||
let mut difat: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68)?;
|
||||
for _ in 0..MAX_DIFAT_SECTORS {
|
||||
if next >= MAX_REG_SECT {
|
||||
break;
|
||||
}
|
||||
let s = sector(next)?;
|
||||
difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4)?;
|
||||
}
|
||||
let fat_next = |id: u32| -> Option<u32> {
|
||||
let idx = id as usize;
|
||||
let fat_sector = *difat.get(idx / per)?;
|
||||
u32_at(sector(fat_sector)?, (idx % per) * 4)
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
let mut id = u32_at(b, 48)?;
|
||||
for _ in 0..MAX_DIR_SECTORS {
|
||||
if id == END_OF_CHAIN {
|
||||
return Some(names);
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return None;
|
||||
}
|
||||
let s = sector(id)?;
|
||||
for e in s.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64)?);
|
||||
if !(2..=64).contains(&len) {
|
||||
continue;
|
||||
}
|
||||
let units: Vec<u16> = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
names.push(String::from_utf16_lossy(&units));
|
||||
}
|
||||
id = fat_next(id)?;
|
||||
}
|
||||
// A directory longer than the bound (or a cyclic chain): report what was read.
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Directory entry type of a stream object ([MS-CFB] 2.6.1).
|
||||
const STREAM_OBJECT: u8 = 2;
|
||||
/// "No entry" in the directory tree (NOSTREAM).
|
||||
const NO_STREAM: u32 = 0xFFFF_FFFF;
|
||||
/// Streams larger than this are refused (the format allows more, a workbook never needs it).
|
||||
const MAX_STREAM: u64 = 1 << 30;
|
||||
|
||||
/// One directory entry ([MS-CFB] 2.6).
|
||||
struct DirEntry {
|
||||
name: String,
|
||||
kind: u8,
|
||||
left: u32,
|
||||
right: u32,
|
||||
child: u32,
|
||||
start: u32,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked.
|
||||
/// Implemented from Microsoft's public [MS-CFB] specification.
|
||||
pub(crate) struct Compound<'a> {
|
||||
bytes: &'a [u8],
|
||||
sector_size: usize,
|
||||
mini_sector_size: usize,
|
||||
mini_cutoff: u64,
|
||||
fat: Vec<u32>,
|
||||
mini_fat: Vec<u32>,
|
||||
entries: Vec<DirEntry>,
|
||||
}
|
||||
|
||||
impl<'a> Compound<'a> {
|
||||
/// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed.
|
||||
pub(crate) fn open(b: &'a [u8]) -> Result<Compound<'a>, String> {
|
||||
let bad = |m: &str| format!("damaged compound file: {m}");
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return Err(bad("missing signature"));
|
||||
}
|
||||
let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?;
|
||||
let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return Err(bad("unsupported sector size"));
|
||||
}
|
||||
let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_shift != 6 {
|
||||
return Err(bad("unsupported mini sector size"));
|
||||
}
|
||||
let sector_size = 1usize << shift;
|
||||
let per = sector_size / 4;
|
||||
// Sectors that physically exist after the header sector: every chain is bounded by it.
|
||||
let sectors = (b.len() / sector_size).saturating_sub(1);
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?;
|
||||
b.get(start..start.checked_add(sector_size)?)
|
||||
};
|
||||
// DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors.
|
||||
let mut fat_sectors: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?;
|
||||
let mut hops = 0usize;
|
||||
while next < MAX_REG_SECT {
|
||||
hops += 1;
|
||||
if hops > sectors.min(MAX_DIFAT_SECTORS * 64) {
|
||||
return Err(bad("DIFAT chain loops"));
|
||||
}
|
||||
let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?;
|
||||
fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?;
|
||||
}
|
||||
if fat_sectors.len() > sectors {
|
||||
return Err(bad("more FAT sectors than the file holds"));
|
||||
}
|
||||
let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per));
|
||||
for id in fat_sectors {
|
||||
let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?;
|
||||
fat.extend((0..per).filter_map(|i| u32_at(s, i * 4)));
|
||||
}
|
||||
let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] };
|
||||
if let Some(c) = u32_at(b, 56) {
|
||||
cf.mini_cutoff = u64::from(c);
|
||||
}
|
||||
// Directory.
|
||||
let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?;
|
||||
let dir = cf.read_chain(dir_start, u64::MAX)?;
|
||||
for e in dir.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64);
|
||||
let units: Vec<u16> = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0);
|
||||
if major >= 4 {
|
||||
size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32;
|
||||
}
|
||||
cf.entries.push(DirEntry {
|
||||
name: String::from_utf16_lossy(&units),
|
||||
kind: e.get(66).copied().unwrap_or(0),
|
||||
left: u32_at(e, 68).unwrap_or(NO_STREAM),
|
||||
right: u32_at(e, 72).unwrap_or(NO_STREAM),
|
||||
child: u32_at(e, 76).unwrap_or(NO_STREAM),
|
||||
start: u32_at(e, 116).unwrap_or(END_OF_CHAIN),
|
||||
size,
|
||||
});
|
||||
}
|
||||
if cf.entries.first().is_none_or(|r| r.kind != 5) {
|
||||
return Err(bad("missing root entry"));
|
||||
}
|
||||
// Mini FAT.
|
||||
let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_start < MAX_REG_SECT {
|
||||
let raw = cf.read_chain(mini_start, u64::MAX)?;
|
||||
cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect();
|
||||
}
|
||||
Ok(cf)
|
||||
}
|
||||
|
||||
/// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the
|
||||
/// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the
|
||||
/// chain's last sector (some writers don't pad it, and Excel opens those files): that sector
|
||||
/// reads short, and the caller checks the stream's declared size against what was read.
|
||||
fn read_chain(&self, start: u32, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX);
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: sector out of range (the file may be truncated)".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: sector chain loops".into()),
|
||||
None => return Err("damaged compound file: sector missing from the FAT".into()),
|
||||
}
|
||||
let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size));
|
||||
let s = start
|
||||
.and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len())))
|
||||
.ok_or("damaged compound file: sector out of range (the file may be truncated)")?;
|
||||
cut = s.len() < self.sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the
|
||||
/// container may end inside the chain's last mini sector.
|
||||
fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.mini_fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: mini sector out of range".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken mini sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: mini sector chain loops".into()),
|
||||
None => return Err("damaged compound file: mini sector missing from the mini FAT".into()),
|
||||
}
|
||||
let at = (id as usize).checked_mul(self.mini_sector_size);
|
||||
let s = at
|
||||
.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len())))
|
||||
.ok_or("damaged compound file: mini sector out of range")?;
|
||||
cut = s.len() < self.mini_sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
/// The directory entry of the stream called `name` (case-insensitive) directly under the
|
||||
/// root storage.
|
||||
fn root_entry(&self, name: &str) -> Result<Option<&DirEntry>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
// Walk the root's children (a red-black tree through left/right siblings), bounded.
|
||||
let mut stack = vec![root.child];
|
||||
let mut seen = 0usize;
|
||||
while let Some(i) = stack.pop() {
|
||||
if i == NO_STREAM {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen > self.entries.len() {
|
||||
return Err("damaged compound file: directory tree loops".into());
|
||||
}
|
||||
let Some(e) = self.entries.get(i as usize) else { continue };
|
||||
if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) {
|
||||
return Ok(Some(e));
|
||||
}
|
||||
stack.push(e.left);
|
||||
stack.push(e.right);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
pub(crate) fn root_stream(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
let found = self.root_entry(name)?;
|
||||
let Some(e) = found else { return Ok(None) };
|
||||
if e.size > MAX_STREAM {
|
||||
return Err("the workbook stream is larger than 1 GiB".into());
|
||||
}
|
||||
let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?;
|
||||
let mut data = if e.size < self.mini_cutoff {
|
||||
let container = self.read_chain(root.start, root.size)?;
|
||||
self.read_mini_chain(&container, e.start, size)?
|
||||
} else {
|
||||
self.read_chain(e.start, e.size)?
|
||||
};
|
||||
if data.len() < size {
|
||||
return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into());
|
||||
}
|
||||
data.truncate(size);
|
||||
Ok(Some(data))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1)
|
||||
/// whose directory holds a root entry plus the given stream names.
|
||||
pub(crate) fn build(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
b[..8].copy_from_slice(&SIGNATURE);
|
||||
b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version
|
||||
b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version
|
||||
b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order
|
||||
b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift
|
||||
b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift
|
||||
b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors
|
||||
b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector
|
||||
b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT
|
||||
b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0
|
||||
// FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain.
|
||||
for i in 0..128 {
|
||||
b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes());
|
||||
b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
|
||||
// Directory (sector 1, at byte 1024).
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() {
|
||||
let e = 1024 + k * 128;
|
||||
let units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
|
||||
b[e + 66] = if k == 0 { 5 } else { 2 };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_directory_names() {
|
||||
let b = build(&["EncryptionInfo", "EncryptedPackage"]);
|
||||
assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()]));
|
||||
assert!(is_encrypted(&b));
|
||||
assert!(!is_encrypted(&build(&["Workbook", "\u{5}SummaryInformation"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_never_panic() {
|
||||
let mut b = build(&["Workbook"]);
|
||||
for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] {
|
||||
for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] {
|
||||
let mut c = b.clone();
|
||||
c[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
let _ = is_encrypted(&c);
|
||||
for cut in [0usize, 8, 100, 600, 1100] {
|
||||
let _ = is_encrypted(&c[..cut.min(c.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A directory chain that loops back on itself stops at the bound.
|
||||
b[516..520].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(!is_encrypted(&b));
|
||||
}
|
||||
|
||||
/// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory
|
||||
/// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way
|
||||
/// some writers leave the final sector unpadded.
|
||||
fn unpadded(len: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
file.truncate(512 * 3 + len);
|
||||
(file, data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_sector_still_reads() {
|
||||
let (file, data) = unpadded(5000);
|
||||
assert_ne!(file.len() % 512, 0);
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// Bytes the stream needs that the file doesn't hold are still damage.
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_mini_sector_still_reads() {
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
// Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes.
|
||||
file.truncate(512 * 4 + data.len());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_sector_inside_a_chain_is_damage() {
|
||||
let (mut file, _) = unpadded(5000);
|
||||
// The cut sector (11) points on to the directory sector and the stream claims those
|
||||
// bytes too: the partial sector would splice 392 bytes into the middle of the stream.
|
||||
file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes());
|
||||
file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_mini_sector_inside_a_chain_is_damage() {
|
||||
let small = [7u8; 100];
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]);
|
||||
// "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3.
|
||||
file.truncate(512 * 4 + 128 + data.len());
|
||||
assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// The cut mini sector (17) points on to sector 0 and the stream claims those bytes too.
|
||||
file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes());
|
||||
file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
}
|
||||
+19
-511
@@ -1,42 +1,7 @@
|
||||
//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with
|
||||
//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells
|
||||
//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption
|
||||
//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and
|
||||
//! certificate-only encryption are reported as unsupported.
|
||||
//!
|
||||
//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is
|
||||
//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped.
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, KeyInit};
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::xml::El;
|
||||
|
||||
/// Spin counts above this are refused (the cap sheet-protection hashes use too).
|
||||
const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
/// Standard encryption always iterates this many times.
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
/// Agile packages are encrypted in segments of this many bytes, each with its own IV.
|
||||
pub(crate) const SEGMENT: usize = 4096;
|
||||
/// AES block size.
|
||||
const BLOCK: usize = 16;
|
||||
|
||||
/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password.
|
||||
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
|
||||
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
|
||||
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
|
||||
/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field.
|
||||
pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6];
|
||||
pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33];
|
||||
|
||||
/// The password key encryptor's URI (the other kind is certificate-based).
|
||||
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
|
||||
|
||||
/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1).
|
||||
pub(crate) const F_CRYPTOAPI: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
pub(crate) const F_AES: u32 = 0x20;
|
||||
//! two streams, `EncryptionInfo` and `EncryptedPackage`. `craft-offcrypto` reads them (Agile and
|
||||
//! Standard encryption; RC4, Extensible, certificate-only and rights-managed files are named as
|
||||
//! unsupported); this module keeps GridCraft's error type.
|
||||
|
||||
/// Why an encrypted workbook couldn't be decrypted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -50,489 +15,32 @@ pub enum DecryptError {
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
fn malformed(m: impl Into<String>) -> DecryptError {
|
||||
DecryptError::Malformed(m.into())
|
||||
impl From<craft_offcrypto::Error> for DecryptError {
|
||||
fn from(e: craft_offcrypto::Error) -> Self {
|
||||
use craft_offcrypto::Error as E;
|
||||
match e {
|
||||
E::WrongPassword | E::PasswordRequired => DecryptError::WrongPassword,
|
||||
E::Unsupported(scheme) => DecryptError::Unsupported(scheme),
|
||||
E::Malformed(detail) | E::TooLarge(detail) => DecryptError::Malformed(detail),
|
||||
e => DecryptError::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain OOXML package (a zip) inside an encrypted workbook.
|
||||
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let (info, package) = streams(bytes)?;
|
||||
decrypt_streams(&info, &package, password)
|
||||
craft_offcrypto::decrypt(bytes, Some(password)).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// Whether the right password could decrypt this encrypted workbook, checked without one. An
|
||||
/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can
|
||||
/// say so instead of asking for a password first.
|
||||
pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> {
|
||||
let (info, _) = streams(bytes)?;
|
||||
match scheme(&info)? {
|
||||
Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()),
|
||||
Scheme::Standard(info) => standard_header(info).map(|_| ()),
|
||||
}
|
||||
craft_offcrypto::check(bytes).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the
|
||||
/// package but describes its encryption elsewhere, with no `EncryptionInfo`.
|
||||
fn streams(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), DecryptError> {
|
||||
let cf = Compound::open(bytes).map_err(malformed)?;
|
||||
let info = cf.root_stream("EncryptionInfo").map_err(malformed)?;
|
||||
let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?;
|
||||
let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?;
|
||||
Ok((info, package))
|
||||
/// Whether a compound file holds an encryption stream, at any depth. A damaged directory falls
|
||||
/// back to a search for the stream names, so the file is still reported as encrypted.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
["EncryptedPackage", "EncryptionInfo"].iter().any(|n| craft_cfb::has_entry_named(bytes, n, &craft_cfb::Limits::default()))
|
||||
}
|
||||
|
||||
/// Decrypts from the two streams' contents.
|
||||
pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
match scheme(info)? {
|
||||
Scheme::Agile(xml) => agile(xml, package, password),
|
||||
Scheme::Standard(info) => standard(info, package, password),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two schemes read, with the part of `EncryptionInfo` each one parses.
|
||||
enum Scheme<'a> {
|
||||
/// The XML descriptor after the version and reserved field.
|
||||
Agile(&'a [u8]),
|
||||
/// Everything after the version.
|
||||
Standard(&'a [u8]),
|
||||
}
|
||||
|
||||
fn scheme(info: &[u8]) -> Result<Scheme<'_>, DecryptError> {
|
||||
let short = || malformed("truncated EncryptionInfo");
|
||||
let major = u16_at(info, 0).ok_or_else(short)?;
|
||||
let minor = u16_at(info, 2).ok_or_else(short)?;
|
||||
match (major, minor) {
|
||||
(4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)),
|
||||
(2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)),
|
||||
(3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())),
|
||||
_ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger
|
||||
/// than the ciphertext is damage, so it never drives an allocation.
|
||||
fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> {
|
||||
let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?;
|
||||
let data = package.get(8..).unwrap_or_default();
|
||||
let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX);
|
||||
if size > data.len() {
|
||||
return Err(malformed("the package is shorter than its stated size"));
|
||||
}
|
||||
Ok((size, data))
|
||||
}
|
||||
|
||||
// ---- hashing -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha384,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
fn from_name(name: &str) -> Result<Hash, DecryptError> {
|
||||
Ok(match name {
|
||||
"SHA1" | "SHA-1" => Hash::Sha1,
|
||||
"SHA256" | "SHA-256" => Hash::Sha256,
|
||||
"SHA384" | "SHA-384" => Hash::Sha384,
|
||||
"SHA512" | "SHA-512" => Hash::Sha512,
|
||||
other => return Err(DecryptError::Unsupported(format!("{other} hash"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// The digest of the concatenated `parts`.
|
||||
pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => run::<sha1::Sha1>(parts),
|
||||
Hash::Sha256 => run::<sha2::Sha256>(parts),
|
||||
Hash::Sha384 => run::<sha2::Sha384>(parts),
|
||||
Hash::Sha512 => run::<sha2::Sha512>(parts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest's length in bytes.
|
||||
fn digest_len(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha384 => 48,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
|
||||
fn block_size(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha384 | Hash::Sha512 => 128,
|
||||
Hash::Sha1 | Hash::Sha256 => 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
|
||||
pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut h = hash.digest(&[salt, &pw]);
|
||||
for i in 0..spin {
|
||||
h = hash.digest(&[&i.to_le_bytes(), &h]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs.
|
||||
pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut k = hash.digest(&[a, b]);
|
||||
k.resize(len, 0x36);
|
||||
k
|
||||
}
|
||||
|
||||
/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses.
|
||||
pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec<u8> {
|
||||
let bs = hash.block_size();
|
||||
let mut k = key.to_vec();
|
||||
if k.len() > bs {
|
||||
k = hash.digest(&[&k]);
|
||||
}
|
||||
k.resize(bs, 0);
|
||||
let pad = |x: u8| k.iter().map(|b| b ^ x).collect::<Vec<u8>>();
|
||||
let inner = hash.digest(&[&pad(0x36), message]);
|
||||
hash.digest(&[&pad(0x5C), &inner])
|
||||
}
|
||||
|
||||
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
|
||||
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
|
||||
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
|
||||
let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT);
|
||||
let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]);
|
||||
let expand = |pad: u8| {
|
||||
let mut buf = [pad; 64];
|
||||
for (b, x) in buf.iter_mut().zip(&h) {
|
||||
*b ^= x;
|
||||
}
|
||||
Hash::Sha1.digest(&[&buf])
|
||||
};
|
||||
let mut key = expand(0x36);
|
||||
key.extend(expand(0x5C));
|
||||
key.truncate(key_len);
|
||||
key
|
||||
}
|
||||
|
||||
// ---- AES -----------------------------------------------------------------------------------
|
||||
|
||||
enum Aes {
|
||||
A128(aes::Aes128),
|
||||
A192(aes::Aes192),
|
||||
A256(aes::Aes256),
|
||||
}
|
||||
|
||||
impl Aes {
|
||||
fn new(key: &[u8]) -> Result<Aes, DecryptError> {
|
||||
let bad = |_| malformed("bad AES key");
|
||||
Ok(match key.len() {
|
||||
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?),
|
||||
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?),
|
||||
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?),
|
||||
n => return Err(malformed(format!("a {n}-byte AES key"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_block(&self, block: &mut [u8; BLOCK]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match self {
|
||||
Aes::A128(c) => c.decrypt_block(b),
|
||||
Aes::A192(c) => c.decrypt_block(b),
|
||||
Aes::A256(c) => c.decrypt_block(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// ECB: each block on its own. `data` must be whole blocks.
|
||||
fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
blocks.iter_mut().for_each(|b| self.decrypt_block(b));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CBC: each block XORed with the previous ciphertext block (the IV for the first).
|
||||
fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?;
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
for b in blocks {
|
||||
let cipher = *b;
|
||||
self.decrypt_block(b);
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
prev = cipher;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standard encryption -------------------------------------------------------------------
|
||||
|
||||
/// What a Standard `EncryptionInfo` holds: the key size and the password verifier.
|
||||
struct StandardHeader<'a> {
|
||||
key_len: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
/// `info` starts after the version: flags, header size, header, verifier.
|
||||
fn standard_header(info: &[u8]) -> Result<StandardHeader<'_>, DecryptError> {
|
||||
let short = || malformed("truncated Standard encryption header");
|
||||
let flags = u32_at(info, 0).ok_or_else(short)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(DecryptError::Unsupported("external".into()));
|
||||
}
|
||||
if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 {
|
||||
return Err(DecryptError::Unsupported("RC4".into()));
|
||||
}
|
||||
let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?;
|
||||
let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?;
|
||||
let alg_id = u32_at(header, 8).ok_or_else(short)?;
|
||||
let alg_hash = u32_at(header, 12).ok_or_else(short)?;
|
||||
let key_bits = u32_at(header, 16).ok_or_else(short)?;
|
||||
let key_len = match (alg_id, key_bits) {
|
||||
(0x660E | 0, 128) => 16,
|
||||
(0x660F, 192) => 24,
|
||||
(0x6610, 256) => 32,
|
||||
(0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))),
|
||||
_ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))),
|
||||
};
|
||||
if alg_hash != 0x8004 && alg_hash != 0 {
|
||||
return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}")));
|
||||
}
|
||||
// Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash.
|
||||
let v = info.get(8 + header_size..).ok_or_else(short)?;
|
||||
if u32_at(v, 0) != Some(16) {
|
||||
return Err(malformed("a Standard salt that isn't 16 bytes"));
|
||||
}
|
||||
let salt = v.get(4..20).ok_or_else(short)?;
|
||||
let verifier = v.get(20..36).ok_or_else(short)?;
|
||||
let verifier_hash = v.get(40..72).ok_or_else(short)?;
|
||||
Ok(StandardHeader { key_len, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
fn standard(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let h = standard_header(info)?;
|
||||
let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec());
|
||||
let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?;
|
||||
aes.decrypt_ecb(&mut verifier)?;
|
||||
aes.decrypt_ecb(&mut verifier_hash)?;
|
||||
if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec();
|
||||
aes.decrypt_ecb(&mut out)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Agile encryption ----------------------------------------------------------------------
|
||||
|
||||
/// The cipher parameters shared by `keyData` and a password `encryptedKey` element.
|
||||
pub(crate) struct Params {
|
||||
pub(crate) hash: Hash,
|
||||
pub(crate) salt: Vec<u8>,
|
||||
pub(crate) key_len: usize,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn read(e: &El) -> Result<Params, DecryptError> {
|
||||
let cipher = e.attr("cipherAlgorithm").unwrap_or("AES");
|
||||
if cipher != "AES" {
|
||||
return Err(DecryptError::Unsupported(cipher.into()));
|
||||
}
|
||||
let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC");
|
||||
if chaining != "ChainingModeCBC" {
|
||||
return Err(DecryptError::Unsupported(format!("AES {chaining}")));
|
||||
}
|
||||
if e.attr_u32("blockSize") != Some(16) {
|
||||
return Err(malformed("an AES block size that isn't 16"));
|
||||
}
|
||||
let key_len = match e.attr_u32("keyBits") {
|
||||
Some(128) => 16,
|
||||
Some(192) => 24,
|
||||
Some(256) => 32,
|
||||
other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))),
|
||||
};
|
||||
let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?;
|
||||
let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?;
|
||||
if salt.is_empty() || salt.len() > 64 {
|
||||
return Err(malformed("a salt that isn't 1 to 64 bytes"));
|
||||
}
|
||||
Ok(Params { hash, salt, key_len })
|
||||
}
|
||||
|
||||
/// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block.
|
||||
pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec<u8> {
|
||||
match block_key {
|
||||
Some(k) => derive(self.hash, &self.salt, k, BLOCK),
|
||||
None => {
|
||||
let mut iv = self.salt.clone();
|
||||
iv.resize(BLOCK, 0x36);
|
||||
iv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its
|
||||
/// three encrypted values (verifier input, verifier hash, package key), and the package's
|
||||
/// integrity pair, which Excel always writes and the reader requires.
|
||||
struct AgileDescriptor {
|
||||
key_data: Params,
|
||||
key: Params,
|
||||
spin: u32,
|
||||
verifier_input: Vec<u8>,
|
||||
verifier_hash: Vec<u8>,
|
||||
key_value: Vec<u8>,
|
||||
hmac_key: Vec<u8>,
|
||||
hmac_value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
|
||||
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
|
||||
let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?;
|
||||
let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?;
|
||||
let encryptor = root
|
||||
.child("keyEncryptors")
|
||||
.and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR)))
|
||||
.ok_or_else(|| DecryptError::Unsupported("certificate".into()))?
|
||||
.child("encryptedKey")
|
||||
.ok_or_else(|| malformed("no encryptedKey"))?;
|
||||
let key = Params::read(encryptor)?;
|
||||
let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?;
|
||||
if spin > MAX_SPIN_COUNT {
|
||||
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
|
||||
}
|
||||
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
|
||||
// A descriptor without dataIntegrity can't prove its ciphertext is the one the password
|
||||
// protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14).
|
||||
let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?;
|
||||
let hmac_field = |attr: &str| {
|
||||
let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?;
|
||||
if v.is_empty() || v.len() % BLOCK != 0 {
|
||||
return Err(malformed(format!("a whole-block {attr}")));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
Ok(AgileDescriptor {
|
||||
key_data,
|
||||
key,
|
||||
spin,
|
||||
verifier_input: field("encryptedVerifierHashInput")?,
|
||||
verifier_hash: field("encryptedVerifierHashValue")?,
|
||||
key_value: field("encryptedKeyValue")?,
|
||||
hmac_key: hmac_field("encryptedHmacKey")?,
|
||||
hmac_value: hmac_field("encryptedHmacValue")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the
|
||||
/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments.
|
||||
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let descriptor = agile_descriptor(xml)?;
|
||||
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
|
||||
let h = iterated_hash(key.hash, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
|
||||
let mut data = data.to_vec();
|
||||
Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?;
|
||||
Ok(data)
|
||||
};
|
||||
let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?;
|
||||
let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?;
|
||||
let input = input.get(..key.salt.len()).unwrap_or(&input);
|
||||
let actual = key.hash.digest(&[input]);
|
||||
if expected.get(..actual.len()) != Some(actual.as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let secret = field(&key_value, &BLOCK_KEY)?;
|
||||
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
|
||||
// Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the
|
||||
// package key (the verifier above only authenticates the password), computed over the whole
|
||||
// EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a
|
||||
// random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so
|
||||
// both lengths are tried.
|
||||
let mut mac_field = hmac_key;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
|
||||
let mut declared = hmac_value;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
|
||||
let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| {
|
||||
let Some(key) = mac_field.get(..len) else { return false };
|
||||
let mac = hmac(key_data.hash, key, package);
|
||||
declared.get(..mac.len()) == Some(mac.as_slice())
|
||||
});
|
||||
if !verified {
|
||||
return Err(malformed("the decrypted data failed its integrity check"));
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
for (i, segment) in data.chunks(SEGMENT).enumerate() {
|
||||
if out.len() >= size {
|
||||
break;
|
||||
}
|
||||
let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?;
|
||||
let start = out.len();
|
||||
out.extend_from_slice(segment);
|
||||
let block = out.get_mut(start..).unwrap_or_default();
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- base64 --------------------------------------------------------------------------------
|
||||
|
||||
/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value.
|
||||
fn base64(s: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut buf, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return Err(malformed("invalid base64")),
|
||||
};
|
||||
buf = (buf << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Decryption tests. The expected keys and IVs below were computed independently with
|
||||
//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted
|
||||
//! files are built in code by `crate::testing`. No Excel-produced file is used.
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, encrypt_standard, with_agile_header};
|
||||
|
||||
const PASSWORD: &str = "Pass1234";
|
||||
/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values
|
||||
/// can't be reached anyway (scheme errors, descriptor-parse damage).
|
||||
const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn salt() -> Vec<u8> {
|
||||
(0u8..16).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_decrypts_the_fips_197_vectors() {
|
||||
let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_keys_match_msoffcrypto() {
|
||||
let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6"));
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03"));
|
||||
let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b"));
|
||||
let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 };
|
||||
assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_pads_short_hashes_with_0x36() {
|
||||
// SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow.
|
||||
let k = derive(Hash::Sha1, b"a", b"b", 32);
|
||||
assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]);
|
||||
assert_eq!(k[20..], [0x36; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_keys_match_msoffcrypto() {
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4"));
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_and_rejects_junk() {
|
||||
assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt());
|
||||
assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]);
|
||||
assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// A payload that isn't a whole number of segments or blocks.
|
||||
fn payload(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 7 + i / 251) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_standard(&plain, PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_round_trip() {
|
||||
// UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP.
|
||||
let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}";
|
||||
let (info, package) = encrypt_agile(&payload(100), pw, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), pw);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_reported_as_such() {
|
||||
let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword));
|
||||
assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_named() {
|
||||
let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) {
|
||||
Err(DecryptError::Unsupported(s)) => s,
|
||||
other => panic!("expected Unsupported, got {other:?}"),
|
||||
};
|
||||
let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD);
|
||||
rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES
|
||||
assert_eq!(unsupported(&rc4), "RC4");
|
||||
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
|
||||
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
|
||||
let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
|
||||
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
|
||||
let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_malformed_not_panics() {
|
||||
let is_malformed = |r: Result<Vec<u8>, DecryptError>| matches!(r, Err(DecryptError::Malformed(_)));
|
||||
let plain = payload(5000);
|
||||
for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] {
|
||||
// Every truncation of either stream.
|
||||
for cut in (0..info.len()).step_by(7) {
|
||||
let _ = decrypt_streams(&info[..cut], &package, PASSWORD);
|
||||
}
|
||||
for cut in (0..package.len()).step_by(97) {
|
||||
let _ = decrypt_streams(&info, &package[..cut], PASSWORD);
|
||||
}
|
||||
// A stated size past the ciphertext never drives an allocation.
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD)));
|
||||
// Ciphertext that isn't whole AES blocks.
|
||||
let mut ragged = package.clone();
|
||||
ragged.truncate(ragged.len() - 3);
|
||||
let len = (ragged.len() - 8) as u64;
|
||||
ragged[..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
|
||||
}
|
||||
let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB);
|
||||
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
|
||||
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
|
||||
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
|
||||
assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\"")));
|
||||
assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\"")));
|
||||
assert!(bad(xml.replace("<keyData", "<other")));
|
||||
assert!(bad("<encryption".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
|
||||
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
|
||||
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
|
||||
assert_eq!(
|
||||
h,
|
||||
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_the_rfc2104_test_vectors() {
|
||||
use crate::crypto::hmac as mac;
|
||||
let h = |v: Vec<u8>| v.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
// RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors.
|
||||
assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00");
|
||||
assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
|
||||
// RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first.
|
||||
assert_eq!(
|
||||
h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
|
||||
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips the `<dataIntegrity …/>` element from an Agile descriptor.
|
||||
fn without_integrity(info: &[u8]) -> Vec<u8> {
|
||||
let xml = std::str::from_utf8(&info[8..]).unwrap().to_string();
|
||||
let start = xml.find("<dataIntegrity").unwrap();
|
||||
let end = start + xml[start..].find("/>").unwrap() + 2;
|
||||
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
|
||||
with_agile_header(&stripped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_is_required_and_enforced() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// An attacker can't just delete the integrity element to get the package verified.
|
||||
assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity")));
|
||||
// An empty or ragged field is damage, not a pass.
|
||||
for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] {
|
||||
let xml =
|
||||
agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1));
|
||||
let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD);
|
||||
assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_ciphertext_fails_the_integrity_check() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// The correct password proves the verifier can't vouch for a modified package.
|
||||
for at in [8usize, 100, package.len() - 5] {
|
||||
let mut tampered = package.clone();
|
||||
tampered[at] ^= 0x80;
|
||||
assert!(
|
||||
matches!(decrypt_streams(&info, &tampered, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("integrity")),
|
||||
"byte {at}"
|
||||
);
|
||||
}
|
||||
// And neither can the descriptor: a swapped integrity value is refused.
|
||||
let xml = String::from_utf8_lossy(&info[8..]).to_string();
|
||||
let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len();
|
||||
let mut xml: Vec<u8> = xml.into_bytes();
|
||||
xml[at] ^= b'A' ^ b'B';
|
||||
assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_matches_the_stream_not_the_plaintext() {
|
||||
// The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the
|
||||
// stated size alone must fail, while the actual bytes round-trip.
|
||||
let plain = payload(4200);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
let mut ours = package.clone();
|
||||
ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes());
|
||||
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() {
|
||||
// Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size
|
||||
// key. Both verify, and neither lets a modified package through.
|
||||
let plain = payload(5000);
|
||||
for key in [vec![7u8; 64], vec![7u8; 16]] {
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len());
|
||||
let mut tampered = package.clone();
|
||||
tampered[100] ^= 1;
|
||||
assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
// Another length (a key that matches neither) fails.
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]);
|
||||
assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
+10
-251
@@ -1,4 +1,6 @@
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency).
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency). Decoding, delimiter
|
||||
//! sniffing, the record parser and field quoting are `craft-csv`'s; this module turns records into
|
||||
//! typed cells (with each column's date order) and writes a sheet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,241 +14,11 @@ use crate::{CsvOptions, IoError, ReadReport};
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
|
||||
const CP1252: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
pub use craft_csv::{decode_text, sniff_delimiter};
|
||||
|
||||
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
|
||||
pub(crate) const CP1256: [u32; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
|
||||
#[derive(Default)]
|
||||
struct Cp1256Evidence {
|
||||
/// High bytes that decode to different characters in the two code pages, apart from invisible
|
||||
/// marks and ordinals after a digit.
|
||||
differing: usize,
|
||||
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
|
||||
arabic: usize,
|
||||
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
|
||||
arabic_touching_latin: usize,
|
||||
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
|
||||
/// Windows-1255 letters land there, Arabic and Western text rarely do.
|
||||
shared_letters_in_runs: usize,
|
||||
}
|
||||
|
||||
impl Cp1256Evidence {
|
||||
fn of(bytes: &[u8]) -> Self {
|
||||
let mut e = Self::default();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b < 0x80 {
|
||||
continue;
|
||||
}
|
||||
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
|
||||
let after = bytes.get(i + 1).copied();
|
||||
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
|
||||
if same_in_cp1252_and_cp1256(b) {
|
||||
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
|
||||
e.shared_letters_in_runs += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
|
||||
continue;
|
||||
}
|
||||
e.differing += 1;
|
||||
if is_cp1256_arabic(b) {
|
||||
e.arabic += 1;
|
||||
if next_to(|x| x.is_ascii_alphabetic()) {
|
||||
e.arabic_touching_latin += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
|
||||
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
|
||||
fn is_arabic(&self) -> bool {
|
||||
self.arabic >= 2
|
||||
&& self.arabic * 10 >= self.differing * 9
|
||||
&& self.arabic_touching_latin * 10 <= self.arabic * 3
|
||||
&& self.shared_letters_in_runs * 20 <= self.arabic
|
||||
}
|
||||
}
|
||||
|
||||
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
|
||||
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
|
||||
)
|
||||
}
|
||||
|
||||
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
|
||||
/// either way.
|
||||
fn is_invisible_mark(b: u8) -> bool {
|
||||
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
|
||||
}
|
||||
|
||||
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
|
||||
/// as `؛` and `ھ`; either way they are no evidence.
|
||||
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
|
||||
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_shared_latin_letter(b: u8) -> bool {
|
||||
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
|
||||
}
|
||||
|
||||
fn is_cp1256_arabic(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
|
||||
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
|
||||
/// Windows-1252.
|
||||
pub fn decode_text(bytes: &[u8]) -> String {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
let utf16 = |b: &[u8], le: bool| -> String {
|
||||
let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
|
||||
char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
|
||||
};
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return utf16(rest, true);
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return utf16(rest, false);
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0x80..=0xFF if use_windows_1256 => {
|
||||
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
|
||||
_ => b as char,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
|
||||
/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
|
||||
pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
|
||||
let mut best = (b',', 0usize, 0usize);
|
||||
for d in [',', ';', '\t'] {
|
||||
let mut counts = vec![];
|
||||
let mut n = 0usize;
|
||||
let mut in_q = false;
|
||||
let mut blank = true;
|
||||
for ch in text.chars().take(64 * 1024) {
|
||||
if !in_q && ch == '\n' {
|
||||
// Blank lines carry no delimiter evidence.
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
n = 0;
|
||||
blank = true;
|
||||
if counts.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch != '\r' {
|
||||
blank = false;
|
||||
}
|
||||
if ch == quote {
|
||||
in_q = !in_q;
|
||||
} else if !in_q && ch == d {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
let min = counts.iter().copied().min().unwrap_or(0);
|
||||
let total: usize = counts.iter().sum();
|
||||
if min > 0 && (min, total) > (best.1, best.2) {
|
||||
best = (d as u8, min, total);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
|
||||
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
|
||||
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
let mut field = String::new();
|
||||
let mut record: Vec<String> = vec![];
|
||||
let mut in_q = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut field_started_quoted = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_q {
|
||||
if c == quote {
|
||||
if chars.peek() == Some("e) {
|
||||
field.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = false;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == quote && field.is_empty() && !field_started_quoted {
|
||||
in_q = true;
|
||||
field_started_quoted = true;
|
||||
} else if c == delim {
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
} else if c == '\r' || c == '\n' {
|
||||
if c == '\r' && chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
if !on_record(std::mem::take(&mut record)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
if !field.is_empty() || !record.is_empty() || field_started_quoted {
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(field);
|
||||
}
|
||||
on_record(record);
|
||||
}
|
||||
/// [`craft_csv::parse_records`] with a record kept to the grid's [`MAX_COLS`] fields.
|
||||
fn parse_records(text: &str, delim: char, quote: char, on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
craft_csv::parse_records(text, delim, quote, MAX_COLS as usize, on_record);
|
||||
}
|
||||
|
||||
/// How many rows and bytes of a text file are read to decide each column's date order.
|
||||
@@ -587,25 +359,12 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|
||||
out.push(delim);
|
||||
}
|
||||
let t = cell_text(CellRef::new(row, col));
|
||||
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
|
||||
// The exact size of the field as written (the delimiter was pushed above), so a row
|
||||
// never grows past the cap unseen and no doubled-quote copy of the text is built.
|
||||
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
|
||||
if out.len().saturating_add(field_len) > limit {
|
||||
// never grows past the cap unseen.
|
||||
if out.len().saturating_add(craft_csv::field_len(&t, delim)) > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
if quoted {
|
||||
out.push('"');
|
||||
for ch in t.chars() {
|
||||
if ch == '"' {
|
||||
out.push('"');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(&t);
|
||||
}
|
||||
craft_csv::push_field(&mut out, &t, delim);
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod controls;
|
||||
@@ -272,8 +271,8 @@ pub fn sniff(bytes: &[u8]) -> Format {
|
||||
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
|
||||
// stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
|
||||
// legacy binary (.doc…).
|
||||
if bytes.starts_with(&cfb::SIGNATURE) {
|
||||
return if cfb::is_encrypted(bytes) {
|
||||
if bytes.starts_with(&craft_cfb::SIGNATURE) {
|
||||
return if crypto::is_encrypted(bytes) {
|
||||
Format::Encrypted
|
||||
} else if xls::is_xls(bytes) {
|
||||
Format::Xls
|
||||
|
||||
@@ -280,7 +280,7 @@ impl Import {
|
||||
Event::GeneralRef(r) => {
|
||||
let ch = match r.resolve_char_ref().map_err(xml_error)? {
|
||||
Some(c) => c,
|
||||
None => match r.as_ref() {
|
||||
None => match &*r {
|
||||
b"amp" => '&',
|
||||
b"lt" => '<',
|
||||
b"gt" => '>',
|
||||
|
||||
+40
-167
@@ -1,12 +1,12 @@
|
||||
//! OPC package access: reading zip parts with decompression limits, relationships and part-name
|
||||
//! resolution; writing a zip package deterministically.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
//! OPC package access through `craft-opc`: zip parts read against GridCraft's limits,
|
||||
//! relationships with GridCraft's `Rel`, part-name resolution, and a deterministic zip writer.
|
||||
//! Parts are parsed with this crate's XML reader.
|
||||
|
||||
use crate::IoError;
|
||||
use crate::xml::{self, El};
|
||||
|
||||
pub use craft_opc::{normalize, resolve, split_dir};
|
||||
|
||||
/// Largest decompressed part accepted.
|
||||
pub const MAX_PART: u64 = 512 * 1024 * 1024;
|
||||
/// Largest total decompressed size read from one package.
|
||||
@@ -14,11 +14,10 @@ pub const MAX_TOTAL: u64 = 1024 * 1024 * 1024;
|
||||
/// Most entries accepted in the zip directory.
|
||||
pub const MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
const LIMITS: craft_opc::Limits = craft_opc::Limits::new(MAX_ENTRIES, MAX_PART, MAX_TOTAL);
|
||||
|
||||
pub struct Package<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
/// Lower-case normalized part name → zip index.
|
||||
names: HashMap<String, usize>,
|
||||
total: u64,
|
||||
opc: craft_opc::LazyPackage<'a>,
|
||||
}
|
||||
|
||||
/// A relationship.
|
||||
@@ -33,88 +32,49 @@ pub struct Rel {
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
pub fn normalize(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
/// GridCraft's wording of a package error.
|
||||
fn error(e: craft_opc::Error) -> IoError {
|
||||
use craft_opc::Error as E;
|
||||
match e {
|
||||
E::Zip(m) | E::Write(m) => IoError::Zip(m),
|
||||
E::TooManyEntries { declared, .. } => IoError::TooLarge(format!("{declared} zip entries")),
|
||||
E::PartTooLarge { part, limit } => IoError::TooLarge(format!("{part} expands to more than {limit} bytes")),
|
||||
E::PackageTooLarge { .. } => IoError::TooLarge("package expands to more than 1 GB".into()),
|
||||
E::Xml { part, error } => IoError::Xml(format!("{part}: {error}")),
|
||||
e if e.is_limit() => IoError::TooLarge(e.to_string()),
|
||||
e => IoError::Zip(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
pub fn open(bytes: &'a [u8]) -> Result<Package<'a>, IoError> {
|
||||
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(IoError::TooLarge(format!("{} zip entries", zip.len())));
|
||||
}
|
||||
let mut names = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(n) = zip.name_for_index(i) {
|
||||
names.entry(normalize(n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
Ok(Package { zip, names, total: 0 })
|
||||
Ok(Package { opc: craft_opc::LazyPackage::open(bytes, &LIMITS).map_err(error)? })
|
||||
}
|
||||
|
||||
/// Every part's (normalized) name, sorted.
|
||||
#[cfg(test)]
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.names.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
self.opc.names().into_iter().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn has(&self, part: &str) -> bool {
|
||||
self.names.contains_key(&normalize(part))
|
||||
self.opc.has(part)
|
||||
}
|
||||
|
||||
/// Reads a part's bytes; `Ok(None)` when it doesn't exist.
|
||||
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
|
||||
self.read_limited(part, MAX_PART)
|
||||
self.opc.read(part).map_err(error)
|
||||
}
|
||||
|
||||
/// Reads a part the reader can do without (the caller warns and goes on when it fails). A read
|
||||
/// that fails partway is charged this part's whole allowance (`maximum`, and the byte that
|
||||
/// detects an overrun) instead of ending the package's: the decompression work stays bounded,
|
||||
/// and the parts read after it still open.
|
||||
/// that fails partway is charged this part's whole allowance instead of ending the package's.
|
||||
pub fn read_optional_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
|
||||
let before = self.total;
|
||||
let read = self.read_limited(part, maximum);
|
||||
if let Err(IoError::Zip(_)) = &read {
|
||||
self.total = before.saturating_add(maximum.min(MAX_PART).saturating_add(1)).min(MAX_TOTAL);
|
||||
}
|
||||
read
|
||||
self.opc.read_optional_limited(part, maximum).map_err(error)
|
||||
}
|
||||
|
||||
/// Reads a small metadata part without allowing it to expand to the general part limit.
|
||||
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
|
||||
let maximum = maximum.min(MAX_PART);
|
||||
let Some(&idx) = self.names.get(&normalize(part)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
if f.size() > maximum {
|
||||
return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
|
||||
}
|
||||
let remaining = MAX_TOTAL.saturating_sub(self.total);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
|
||||
// A read that fails partway has already spent decompression work and buffered data
|
||||
// the package budget never sees: count the allowance as spent, so a loop over many
|
||||
// broken parts can't decompress it over and over.
|
||||
self.total = MAX_TOTAL;
|
||||
IoError::Zip(format!("{part}: {e}"))
|
||||
});
|
||||
if out.len() as u64 > limit {
|
||||
// The allowance was spent getting here; make the exhaustion terminal instead of
|
||||
// letting later reads pay the same decompression cost again.
|
||||
self.total = MAX_TOTAL;
|
||||
return Err(IoError::TooLarge(if remaining < maximum {
|
||||
"package expands to more than 1 GB".into()
|
||||
} else {
|
||||
format!("{part} expands to more than {maximum} bytes")
|
||||
}));
|
||||
}
|
||||
read?;
|
||||
self.total += out.len() as u64;
|
||||
Ok(Some(out))
|
||||
self.opc.read_limited(part, maximum).map_err(error)
|
||||
}
|
||||
|
||||
pub fn read_xml(&mut self, part: &str) -> Result<Option<El>, IoError> {
|
||||
@@ -127,25 +87,13 @@ impl<'a> Package<'a> {
|
||||
/// Relationships of a part (`xl/workbook.xml` → `xl/_rels/workbook.xml.rels`). Missing or
|
||||
/// broken rels parts give an empty list.
|
||||
pub fn rels(&mut self, part: &str) -> Result<Vec<Rel>, IoError> {
|
||||
let (dir, file) = split_dir(part);
|
||||
let rels_name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
|
||||
let root = match self.read(&rels_name)? {
|
||||
Some(b) => match xml::parse(&b) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(vec![]),
|
||||
},
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
Ok(root
|
||||
.kids("Relationship")
|
||||
.filter_map(|r| {
|
||||
let id = r.attr("Id")?.to_string();
|
||||
let ty = r.attr("Type").unwrap_or("");
|
||||
let kind = ty.rsplit('/').next().unwrap_or("").to_string();
|
||||
let target_raw = r.attr("Target")?;
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
let target = if external { target_raw.to_string() } else { resolve(dir, target_raw) };
|
||||
Some(Rel { id, kind, target, external })
|
||||
let rels = self.opc.rels(part).map_err(error)?;
|
||||
Ok(rels
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let kind = r.kind().to_string();
|
||||
Rel { id: r.id, kind, target: r.target, external: r.external }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -153,11 +101,7 @@ impl<'a> Package<'a> {
|
||||
|
||||
/// The first part whose declared unpacked size is over [`MAX_PART`], with that size.
|
||||
pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
|
||||
(0..zip.len().min(MAX_ENTRIES)).find_map(|i| {
|
||||
let f = zip.by_index_raw(i).ok()?;
|
||||
(f.size() > MAX_PART).then(|| (f.name().to_string(), f.size()))
|
||||
})
|
||||
craft_opc::declared_oversized(bytes, &LIMITS)
|
||||
}
|
||||
|
||||
fn prefix(part: &str, e: IoError) -> IoError {
|
||||
@@ -167,54 +111,22 @@ fn prefix(part: &str, e: IoError) -> IoError {
|
||||
}
|
||||
}
|
||||
|
||||
/// `xl/worksheets/sheet1.xml` → (`xl/worksheets`, `sheet1.xml`).
|
||||
pub fn split_dir(part: &str) -> (&str, &str) {
|
||||
let p = part.trim_start_matches('/');
|
||||
match p.rfind('/') {
|
||||
Some(i) => (p.get(..i).unwrap_or(""), p.get(i + 1..).unwrap_or("")),
|
||||
None => ("", p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a relationship target against the source part's directory.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.replace('\\', "/");
|
||||
let target = target.split('#').next().unwrap_or("").to_string();
|
||||
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
|
||||
for s in target.split('/') {
|
||||
match s {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segs.pop();
|
||||
}
|
||||
s => segs.push(s),
|
||||
}
|
||||
}
|
||||
segs.join("/")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
||||
/// A zip package being written: every entry deflated, with one fixed timestamp.
|
||||
pub struct ZipOut {
|
||||
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
|
||||
zip: craft_opc::ZipOut,
|
||||
}
|
||||
|
||||
impl ZipOut {
|
||||
pub fn new() -> ZipOut {
|
||||
ZipOut { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) }
|
||||
ZipOut { zip: craft_opc::ZipOut::new() }
|
||||
}
|
||||
pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), IoError> {
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.compression_level(Some(6))
|
||||
.last_modified_time(zip::DateTime::default())
|
||||
.large_file(data.len() as u64 >= u32::MAX as u64);
|
||||
self.zip.start_file(name, opts).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
self.zip.write_all(data).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
Ok(())
|
||||
self.zip.add(name, data, craft_opc::Compression::Deflate).map_err(error)
|
||||
}
|
||||
pub fn finish(self) -> Result<Vec<u8>, IoError> {
|
||||
self.zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Zip(e.to_string()))
|
||||
self.zip.finish().map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,43 +144,4 @@ mod tests {
|
||||
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
|
||||
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
|
||||
}
|
||||
|
||||
/// An over-budget read must spend the package's allowance once and for all: loops over many
|
||||
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
|
||||
/// remaining allowance again and again.
|
||||
#[test]
|
||||
fn a_failed_part_ends_the_package_budget() {
|
||||
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(pkg.read("a.xml").unwrap().is_some());
|
||||
// Nothing left to spend within the per-part limit either.
|
||||
pkg.total = MAX_TOTAL - 1;
|
||||
assert!(pkg.read("b.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
|
||||
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
|
||||
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
|
||||
}
|
||||
|
||||
/// A part the reader can do without, damaged inside an intact archive, costs its own
|
||||
/// allowance: the parts after it are still read.
|
||||
#[test]
|
||||
fn a_damaged_optional_part_costs_only_its_allowance() {
|
||||
use std::io::Write as _;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("a.xml", stored).unwrap();
|
||||
w.write_all(b"<a>damaged</a>").unwrap();
|
||||
w.start_file("b.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(b"hello").unwrap();
|
||||
let mut z = w.finish().unwrap().into_inner();
|
||||
let at = z.windows(7).position(|x| x == b"damaged").unwrap();
|
||||
z[at] ^= 0x20;
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(matches!(pkg.read_optional_limited("a.xml", 1024), Err(IoError::Zip(_))));
|
||||
assert_eq!(pkg.total, 1025, "the failed read is charged its allowance");
|
||||
assert_eq!(pkg.read("b.xml").unwrap().as_deref(), Some(&b"hello"[..]));
|
||||
// An ordinary read of it still ends the package's budget.
|
||||
assert!(pkg.read("a.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-263
@@ -1,282 +1,25 @@
|
||||
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
|
||||
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
|
||||
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
|
||||
//!
|
||||
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
|
||||
//! use: a fixed key protects nothing.
|
||||
//! The encryption is `craft-offcrypto`'s and the compound file `craft-cfb`'s.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
|
||||
use crate::crypto::{
|
||||
BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params,
|
||||
SEGMENT, derive, hmac, iterated_hash, standard_key,
|
||||
};
|
||||
|
||||
/// The spin count `encrypt_workbook` uses: Excel's default.
|
||||
pub const SPIN_COUNT: u32 = 100_000;
|
||||
|
||||
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||
const SECRET_KEY: [u8; 32] =
|
||||
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
|
||||
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
|
||||
/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the
|
||||
/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes.
|
||||
const HMAC_KEY: [u8; 64] = [
|
||||
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
|
||||
141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
|
||||
170, 171, 172, 173, 174, 175,
|
||||
];
|
||||
|
||||
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
||||
/// (AES-256, SHA-512) inside a compound file.
|
||||
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_with_spin_count(package, password, SPIN_COUNT).unwrap()
|
||||
}
|
||||
|
||||
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
|
||||
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_standard(package, password);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_standard(package, password, 128).unwrap()
|
||||
}
|
||||
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
|
||||
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
|
||||
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root: streams under
|
||||
/// 4096 bytes in the mini stream, larger ones in regular sectors.
|
||||
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
const SECTOR: usize = 512;
|
||||
const MINI: usize = 64;
|
||||
const CUTOFF: usize = 4096;
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END: u32 = 0xFFFF_FFFE;
|
||||
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
|
||||
let is_mini = |d: &[u8]| d.len() < CUTOFF;
|
||||
|
||||
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
|
||||
let mut container = vec![];
|
||||
let mut mini_fat: Vec<u32> = vec![];
|
||||
let mut mini_starts = vec![];
|
||||
for (_, d) in streams {
|
||||
if !is_mini(d) || d.is_empty() {
|
||||
mini_starts.push(END);
|
||||
continue;
|
||||
}
|
||||
let first = mini_fat.len();
|
||||
let n = d.len().div_ceil(MINI);
|
||||
for k in 0..n {
|
||||
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
|
||||
}
|
||||
mini_starts.push(first as u32);
|
||||
container.extend_from_slice(d);
|
||||
container.resize((first + n) * MINI, 0);
|
||||
}
|
||||
|
||||
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
|
||||
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
|
||||
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
|
||||
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
|
||||
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
|
||||
let body: usize = counts.iter().sum();
|
||||
let mut fat_sectors = 1;
|
||||
while fat_sectors * 128 < fat_sectors + body {
|
||||
fat_sectors += 1;
|
||||
}
|
||||
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
|
||||
let mut fat = vec![FREE; fat_sectors * 128];
|
||||
fat[..fat_sectors].fill(FAT_SECTOR);
|
||||
let mut starts = vec![];
|
||||
let mut next = fat_sectors;
|
||||
for &n in &counts {
|
||||
if n == 0 {
|
||||
starts.push(END);
|
||||
continue;
|
||||
}
|
||||
starts.push(next as u32);
|
||||
for k in 0..n {
|
||||
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
|
||||
}
|
||||
next += n;
|
||||
}
|
||||
|
||||
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
|
||||
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
|
||||
let dir = &mut blobs[0];
|
||||
for k in 0..dir_len / 128 {
|
||||
for at in [68, 72, 76] {
|
||||
put32(dir, k * 128 + at, FREE);
|
||||
}
|
||||
}
|
||||
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
|
||||
let e = k * 128;
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
dir[e..e + units.len()].copy_from_slice(&units);
|
||||
put16(dir, e + 64, units.len() as u16 + 2);
|
||||
dir[e + 66] = kind;
|
||||
dir[e + 67] = 1; // black
|
||||
if kind == 5 && !streams.is_empty() {
|
||||
put32(dir, e + 76, 1);
|
||||
}
|
||||
if kind == 2 && k < streams.len() {
|
||||
put32(dir, e + 72, k as u32 + 1);
|
||||
}
|
||||
put32(dir, e + 116, start);
|
||||
put32(dir, e + 120, size as u32);
|
||||
};
|
||||
entry(0, "Root Entry", 5, starts[2], container.len());
|
||||
for (k, (name, data)) in streams.iter().enumerate() {
|
||||
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
|
||||
entry(k + 1, name, 2, start, data.len());
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
|
||||
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
|
||||
put16(&mut out, 24, 0x3E); // minor version
|
||||
put16(&mut out, 26, 3); // major version
|
||||
put16(&mut out, 28, 0xFFFE); // byte order
|
||||
put16(&mut out, 30, 9); // 512-byte sectors
|
||||
put16(&mut out, 32, 6); // 64-byte mini sectors
|
||||
put32(&mut out, 44, fat_sectors as u32);
|
||||
put32(&mut out, 48, starts[0]);
|
||||
put32(&mut out, 56, CUTOFF as u32);
|
||||
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
|
||||
put32(&mut out, 64, counts[1] as u32);
|
||||
put32(&mut out, 68, END); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
|
||||
}
|
||||
for (i, v) in fat.iter().enumerate() {
|
||||
put32(&mut out, SECTOR + i * 4, *v);
|
||||
}
|
||||
for (blob, start) in blobs.iter().zip(&starts) {
|
||||
if !blob.is_empty() {
|
||||
let at = SECTOR * (1 + *start as usize);
|
||||
out[at..at + blob.len()].copy_from_slice(blob);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match key.len() {
|
||||
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
n => panic!("no {n}-byte AES in the test encryptor"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(data: &[u8]) -> Vec<u8> {
|
||||
let mut v = data.to_vec();
|
||||
v.resize(data.len().div_ceil(16) * 16, 0);
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
let mut prev: [u8; 16] = iv.try_into().unwrap();
|
||||
for b in v.as_chunks_mut::<16>().0 {
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
encrypt_block(key, b);
|
||||
prev = *b;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
|
||||
let mut p = (size as u64).to_le_bytes().to_vec();
|
||||
p.extend(body);
|
||||
p
|
||||
}
|
||||
|
||||
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the
|
||||
/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries.
|
||||
pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String {
|
||||
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
|
||||
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
|
||||
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
|
||||
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
|
||||
let secret = field(&BLOCK_KEY, &SECRET_KEY);
|
||||
let params = |salt: &[u8]| {
|
||||
format!(
|
||||
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
|
||||
base64_encode(salt)
|
||||
)
|
||||
};
|
||||
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
|
||||
params(KEY_DATA_SALT.as_slice()),
|
||||
params(&PASSWORD_SALT),
|
||||
)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
|
||||
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
||||
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
||||
info.extend_from_slice(xml.as_bytes());
|
||||
info
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
|
||||
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
|
||||
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY)
|
||||
}
|
||||
|
||||
/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`.
|
||||
pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
|
||||
let mut body = vec![];
|
||||
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
|
||||
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
|
||||
}
|
||||
let stream = package(plain.len(), body);
|
||||
let (hmac_key_b64, hmac_value_b64) = (
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)),
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))),
|
||||
);
|
||||
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
|
||||
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let key = standard_key(password, &PASSWORD_SALT, 16);
|
||||
let mut header = vec![];
|
||||
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
|
||||
header.extend_from_slice(&u32::to_le_bytes(v));
|
||||
}
|
||||
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let mut info = vec![4, 0, 2, 0];
|
||||
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend(header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes());
|
||||
info.extend(PASSWORD_SALT);
|
||||
info.extend(encrypt_ecb(&key, &VERIFIER));
|
||||
info.extend_from_slice(&20u32.to_le_bytes());
|
||||
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
|
||||
(info, package(plain.len(), encrypt_ecb(&key, plain)))
|
||||
craft_cfb::write_streams(craft_cfb::Version::V3, streams).unwrap()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod sheet_data_pin;
|
||||
mod sheet_names;
|
||||
mod smooth_roundtrip;
|
||||
mod trendline_roundtrip;
|
||||
mod xls_dv_tests;
|
||||
mod xls_style_tests;
|
||||
mod xls_tests;
|
||||
mod xlsb_tests;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, parse_records, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited};
|
||||
use craft_csv::{decode_text, parse_records, sniff_delimiter};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
@@ -148,33 +148,6 @@ fn encodings() {
|
||||
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1256_table_matches_the_unicode_mapping() {
|
||||
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
|
||||
let expected = [
|
||||
(0x80, '€'),
|
||||
(0x81, 'پ'),
|
||||
(0x8D, 'چ'),
|
||||
(0x98, 'ک'),
|
||||
(0x9D, '\u{200C}'),
|
||||
(0xA1, '،'),
|
||||
(0xBA, '؛'),
|
||||
(0xBF, '؟'),
|
||||
(0xC7, 'ا'),
|
||||
(0xC8, 'ب'),
|
||||
(0xDC, 'ـ'),
|
||||
(0xE1, 'ل'),
|
||||
(0xE3, 'م'),
|
||||
(0xE9, 'é'),
|
||||
(0xFD, '\u{200E}'),
|
||||
(0xFE, '\u{200F}'),
|
||||
(0xFF, 'ے'),
|
||||
];
|
||||
for (b, c) in expected {
|
||||
assert_eq!(at(b), Some(c), "0x{b:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_csv_in_windows_1256_reads_as_arabic() {
|
||||
let bytes = [
|
||||
@@ -302,8 +275,9 @@ fn sniffing_formats() {
|
||||
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
|
||||
// An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage
|
||||
// stream; any other compound file is a legacy binary (.xls), not an encrypted workbook.
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted);
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary);
|
||||
let compound = crate::testing::compound_file;
|
||||
assert_eq!(sniff(&compound(&[("EncryptionInfo", b"x"), ("EncryptedPackage", b"x")])), Format::Encrypted);
|
||||
assert_eq!(sniff(&compound(&[("WordDocument", b"x")])), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown);
|
||||
assert_eq!(sniff(b""), Format::Unknown);
|
||||
@@ -393,14 +367,14 @@ fn parser_stops_collecting_fields_at_max_cols() {
|
||||
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
|
||||
text.push_str("end\nx,y\n");
|
||||
let mut lens = vec![];
|
||||
parse_records(&text, ',', '"', |rec| {
|
||||
parse_records(&text, ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
|
||||
// An unterminated last row is capped the same way.
|
||||
let mut lens = vec![];
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
|
||||
@@ -3,24 +3,11 @@
|
||||
use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard};
|
||||
use crate::{DecryptError, decrypt};
|
||||
|
||||
#[test]
|
||||
fn the_test_compound_writer_reads_back() {
|
||||
let big: Vec<u8> = (0..3_000_000u32).map(|i| (i % 253) as u8).collect();
|
||||
let mid: Vec<u8> = (0..4095u32).map(|i| i as u8).collect();
|
||||
let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]);
|
||||
let cf = crate::cfb::Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream");
|
||||
assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello");
|
||||
assert_eq!(cf.root_stream("big").unwrap().unwrap(), big);
|
||||
assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b"");
|
||||
assert_eq!(cf.root_stream("Missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_files_decrypt_to_the_package() {
|
||||
let package: Vec<u8> = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect();
|
||||
let agile = encrypt_workbook(&package, "Pass1234");
|
||||
assert!(crate::cfb::is_encrypted(&agile));
|
||||
assert!(crate::crypto::is_encrypted(&agile));
|
||||
assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package);
|
||||
assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword));
|
||||
let standard = encrypt_workbook_standard(&package, "Pass1234");
|
||||
@@ -49,16 +36,19 @@ fn a_gridcraft_workbook_survives_encryption() {
|
||||
#[test]
|
||||
fn schemes_no_password_opens_are_named_before_asking_for_one() {
|
||||
use crate::check_encryption;
|
||||
use crate::testing::{agile_info, with_agile_header};
|
||||
let unsupported = |s: &str| Err(DecryptError::Unsupported(s.into()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook(b"package", "pw")), Ok(()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook_standard(b"package", "pw")), Ok(()));
|
||||
let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
|
||||
assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4"));
|
||||
assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible"));
|
||||
let cert =
|
||||
agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate"));
|
||||
// An Agile descriptor whose only key encryptor is a certificate.
|
||||
let agile = encrypt_workbook(b"package", "pw");
|
||||
let cf = craft_cfb::CompoundFile::open(&agile).unwrap();
|
||||
let info = cf.read(cf.child(0, "EncryptionInfo").unwrap()).unwrap();
|
||||
let mut cert = info[..8].to_vec();
|
||||
cert.extend(String::from_utf8(info[8..].to_vec()).unwrap().replace("keyEncryptor/password", "keyEncryptor/certificate").into_bytes());
|
||||
assert_eq!(check_encryption(&file(&cert)), unsupported("certificate"));
|
||||
assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_))));
|
||||
// Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo.
|
||||
let irm = compound_file(&[("EncryptedPackage", &[0; 8])]);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! `.xls` data validation: DVal and Dv records ([MS-XLS] 2.4.96, 2.4.95). Every record is
|
||||
//! assembled here from the spec's structures; no Excel-produced file is used. The expected
|
||||
//! rules are what the `.xlsx` reader gives for the same rule.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{CfOperator, ErrorStyle, Validation, ValidationKind, Workbook};
|
||||
|
||||
use super::xls_tests::{cat, compound, rec, u16s};
|
||||
use crate::{ReadReport, read_xls};
|
||||
|
||||
fn bof(dt: u16) -> Vec<u8> {
|
||||
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
|
||||
}
|
||||
|
||||
fn eof() -> Vec<u8> {
|
||||
rec(0x000A, &[])
|
||||
}
|
||||
|
||||
/// A workbook of one worksheet, "Sheet1", holding `records`, with defined names (Lbl records,
|
||||
/// each a name and its formula) in the globals.
|
||||
fn workbook(names: &[(&str, &[u8])], records: &[Vec<u8>]) -> (Workbook, ReadReport) {
|
||||
let lbl = |name: &str, rgce: &[u8]| {
|
||||
rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce]))
|
||||
};
|
||||
let lbls: Vec<u8> = names.iter().flat_map(|(n, f)| lbl(n, f)).collect();
|
||||
let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &lbls, &eof()]);
|
||||
let body: Vec<u8> = records.concat();
|
||||
let sheet = cat(&[&bof(0x0010), &body, &eof()]);
|
||||
let stream = cat(&[&globals(globals(0).len() as u32), &sheet]);
|
||||
read_xls(&compound("Workbook", &stream)).unwrap()
|
||||
}
|
||||
|
||||
/// DVal: flags, the input window's position, no drop-down object (-1), then `count` rules.
|
||||
fn dval(count: u32) -> Vec<u8> {
|
||||
rec(0x01B2, &cat(&[&[0, 0], &[0; 8], &(-1i32).to_le_bytes(), &count.to_le_bytes()]))
|
||||
}
|
||||
|
||||
// Dv flag bits ([MS-XLS] 2.4.95).
|
||||
const STR_LOOKUP: u32 = 1 << 7;
|
||||
const ALLOW_BLANK: u32 = 1 << 8;
|
||||
const SUPPRESS_COMBO: u32 = 1 << 9;
|
||||
const SHOW_INPUT: u32 = 1 << 18;
|
||||
const SHOW_ERROR: u32 = 1 << 19;
|
||||
/// What Excel sets on a new rule: blanks allowed, input message and error alert shown.
|
||||
const DEFAULTS: u32 = ALLOW_BLANK | SHOW_INPUT | SHOW_ERROR;
|
||||
|
||||
/// Dv flags: rule type, alert style, operator, plus `bits`.
|
||||
fn flags(kind: u32, alert: u32, op: u32, bits: u32) -> u32 {
|
||||
kind | (alert << 4) | (op << 20) | bits
|
||||
}
|
||||
|
||||
/// XLUnicodeString with compressed characters; "" is stored as Excel stores an empty
|
||||
/// message, a single NUL character.
|
||||
fn msg(s: &str) -> Vec<u8> {
|
||||
let s = if s.is_empty() { "\0" } else { s };
|
||||
cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()])
|
||||
}
|
||||
|
||||
/// XLUnicodeString with UTF-16 characters.
|
||||
fn wide(s: &str) -> Vec<u8> {
|
||||
let units: Vec<u16> = s.encode_utf16().collect();
|
||||
cat(&[&(units.len() as u16).to_le_bytes(), &[1], &u16s(&units)])
|
||||
}
|
||||
|
||||
/// DVParsedFormula: size, 2 unused bytes (Excel leaves stray data there), the tokens.
|
||||
fn formula(rgce: &[u8]) -> Vec<u8> {
|
||||
cat(&[&(rgce.len() as u16).to_le_bytes(), &[0x6E, 0x74], rgce])
|
||||
}
|
||||
|
||||
/// SqRefU: a count, then each Ref8U as rows (first, last) and columns (first, last).
|
||||
fn sqref(ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
let list: Vec<u8> = ranges.iter().flat_map(|r| u16s(r)).collect();
|
||||
cat(&[&(ranges.len() as u16).to_le_bytes(), &list])
|
||||
}
|
||||
|
||||
/// A Dv record with its four messages (prompt title, error title, prompt, error).
|
||||
fn dv(flags: u32, msgs: [Vec<u8>; 4], f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
let [pt, et, p, e] = msgs;
|
||||
rec(0x01BE, &cat(&[&flags.to_le_bytes(), &pt, &et, &p, &e, &formula(f1), &formula(f2), &sqref(ranges)]))
|
||||
}
|
||||
|
||||
fn no_msgs() -> [Vec<u8>; 4] {
|
||||
[msg(""), msg(""), msg(""), msg("")]
|
||||
}
|
||||
|
||||
/// A Dv with Excel's default flags and no messages.
|
||||
fn rule(kind: u32, alert: u32, op: u32, f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
dv(flags(kind, alert, op, DEFAULTS), no_msgs(), f1, f2, ranges)
|
||||
}
|
||||
|
||||
fn range(a1: &str) -> RangeRef {
|
||||
RangeRef::parse(a1).unwrap()
|
||||
}
|
||||
|
||||
/// The `.xlsx` reader's result for a rule with Excel's default flags and no messages.
|
||||
fn expected(ranges: &[&str], kind: ValidationKind, op: CfOperator, f1: &str, f2: Option<&str>) -> Validation {
|
||||
Validation { ranges: ranges.iter().map(|r| range(r)).collect(), kind, op, f1: f1.into(), f2: f2.map(Into::into), ..Validation::default() }
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
const SKIPPED: &str = "Some data validation rules in .xls files could not be read and were skipped.";
|
||||
const IGNORED: &str = "Some data validation records in .xls files were ignored, as Excel ignores them: they have no DVal record before them, or come after the number of rules it gives.";
|
||||
|
||||
/// Each kind of rule, as Excel writes it, gives what the `.xlsx` reader gives for the same rule.
|
||||
#[test]
|
||||
fn reads_every_kind_of_rule() {
|
||||
use CfOperator::*;
|
||||
use ValidationKind::*;
|
||||
let rules = [
|
||||
// Whole number between 1 and 10, with all four messages and the stop alert.
|
||||
dv(
|
||||
flags(1, 0, 0, DEFAULTS),
|
||||
[msg("Whole"), msg("Oops"), msg("Enter 1 to 10"), msg("Not 1..10")],
|
||||
&hex("1e0100"),
|
||||
&hex("1e0a00"),
|
||||
&[[1, 5, 1, 1]],
|
||||
),
|
||||
// Decimal >= 0.5, warning alert.
|
||||
rule(2, 1, 6, &hex("1f000000000000e03f"), &[], &[[1, 5, 2, 2]]),
|
||||
// Literal list (NUL-separated items), information alert.
|
||||
dv(flags(3, 2, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170e0052656400477265656e00426c7565"), &[], &[[1, 5, 3, 3]]),
|
||||
// List from an absolute range ($H$2:$H$5).
|
||||
rule(3, 0, 0, &hex("250100040007000700"), &[], &[[1, 5, 4, 4]]),
|
||||
// Date between two serials, time < 0.5, text length <= 5, whole not between 5 and 7.
|
||||
rule(4, 0, 0, &hex("1e37ab"), &hex("1ea4ac"), &[[1, 5, 5, 5]]),
|
||||
rule(5, 0, 5, &hex("1f000000000000e03f"), &[], &[[1, 5, 6, 6]]),
|
||||
rule(6, 0, 7, &hex("1e0500"), &[], &[[1, 5, 8, 8]]),
|
||||
rule(1, 0, 1, &hex("1e0500"), &hex("1e0700"), &[[1, 5, 9, 9]]),
|
||||
// Custom formula K2>A2 on K2:K6 (relative PtgRefN offsets 0 and -10 columns).
|
||||
rule(7, 0, 0, &hex("4c000000c04c0000f6c00d"), &[], &[[1, 5, 10, 10]]),
|
||||
// Any value, with an input message only.
|
||||
dv(flags(0, 0, 0, DEFAULTS), [msg("Note"), msg(""), msg("Anything goes"), msg("")], &[], &[], &[[1, 5, 13, 13]]),
|
||||
// A list from a defined name.
|
||||
rule(3, 0, 0, &hex("2301000000"), &[], &[[1, 5, 16, 16]]),
|
||||
// A dependent list: INDIRECT($S2) on T2:T6.
|
||||
rule(3, 0, 0, &hex("190100004c0000128022019400"), &[], &[[1, 5, 19, 19]]),
|
||||
// Decimal between two absolute cells.
|
||||
rule(2, 0, 0, &hex("4406000700"), &hex("4407000700"), &[[1, 5, 22, 22]]),
|
||||
];
|
||||
let mut records = vec![dval(rules.len() as u32)];
|
||||
records.extend(rules);
|
||||
let (wb, report) = workbook(&[("Colors", &[0x1E, 1, 0])], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
let mut whole = expected(&["B2:B6"], Whole, Between, "1", Some("10"));
|
||||
(whole.input_title, whole.error_title, whole.input_message, whole.error_message) =
|
||||
("Whole".into(), "Oops".into(), "Enter 1 to 10".into(), "Not 1..10".into());
|
||||
let mut decimal = expected(&["C2:C6"], Decimal, GreaterOrEqual, "0.5", None);
|
||||
decimal.error_style = ErrorStyle::Warning;
|
||||
let mut literal = expected(&["D2:D6"], List, Between, "\"Red,Green,Blue\"", None);
|
||||
literal.error_style = ErrorStyle::Information;
|
||||
let mut any = expected(&["N2:N6"], Any, Between, "", None);
|
||||
(any.input_title, any.input_message) = ("Note".into(), "Anything goes".into());
|
||||
let want = vec![
|
||||
whole,
|
||||
decimal,
|
||||
literal,
|
||||
expected(&["E2:E6"], List, Between, "=$H$2:$H$5", None),
|
||||
expected(&["F2:F6"], Date, Between, "43831", Some("44196")),
|
||||
expected(&["G2:G6"], Time, Less, "0.5", None),
|
||||
expected(&["I2:I6"], TextLength, LessOrEqual, "5", None),
|
||||
expected(&["J2:J6"], Whole, NotBetween, "5", Some("7")),
|
||||
expected(&["K2:K6"], Custom, Between, "K2>A2", None),
|
||||
any,
|
||||
expected(&["Q2:Q6"], List, Between, "=Colors", None),
|
||||
expected(&["T2:T6"], List, Between, "=INDIRECT($S2)", None),
|
||||
expected(&["W2:W6"], Decimal, Between, "$H$7", Some("$H$8")),
|
||||
];
|
||||
assert_eq!(got.len(), want.len(), "{:?}", report.warnings);
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert_eq!(g, w);
|
||||
}
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
assert!(report.warnings[0].contains("data validation were read"), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// The flags that switch features off; an operator stored on a list (where it means nothing);
|
||||
/// messages in UTF-16; literal items with quotes and spaces; a whole column.
|
||||
#[test]
|
||||
fn reads_flags_messages_lists_and_whole_columns() {
|
||||
let off = flags(3, 0, 0, STR_LOOKUP | SUPPRESS_COMBO);
|
||||
let records = [
|
||||
dval(4),
|
||||
dv(off, no_msgs(), &hex("170300610062"), &[], &[[1, 5, 14, 14]]),
|
||||
dv(
|
||||
flags(1, 0, 2, DEFAULTS),
|
||||
[wide("Café"), wide("Ошибка"), wide("日本語のテスト é ü"), wide("Nur 3 ✓")],
|
||||
&hex("1e0300"),
|
||||
&[],
|
||||
&[[1, 5, 20, 20]],
|
||||
),
|
||||
// typOperator 6 on a list, as found in real files: ignored.
|
||||
dv(flags(3, 0, 6, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170c00736179202268692200627965"), &[], &[[1, 5, 23, 23]]),
|
||||
// Rows 0-65535: every row of the BIFF8 grid.
|
||||
dv(flags(3, 0, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170500780079007a"), &[], &[[0, 0xFFFF, 21, 21]]),
|
||||
];
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
assert_eq!(got.len(), 4, "{:?}", report.warnings);
|
||||
let mut flags_off = expected(&["O2:O6"], ValidationKind::List, CfOperator::Between, "\"a,b\"", None);
|
||||
(flags_off.allow_blank, flags_off.in_cell_dropdown, flags_off.show_input, flags_off.show_error) = (false, false, false, false);
|
||||
assert_eq!(got[0], flags_off);
|
||||
let mut unicode = expected(&["U2:U6"], ValidationKind::Whole, CfOperator::Equal, "3", None);
|
||||
(unicode.input_title, unicode.error_title, unicode.input_message, unicode.error_message) =
|
||||
("Café".into(), "Ошибка".into(), "日本語のテスト é ü".into(), "Nur 3 ✓".into());
|
||||
assert_eq!(got[1], unicode);
|
||||
// The item `say "hi"` keeps its quotes, doubled as in `.xlsx`; the operator is ignored.
|
||||
assert_eq!(got[2], expected(&["X2:X6"], ValidationKind::List, CfOperator::Between, "\"say \"\"hi\"\",bye\"", None));
|
||||
let column = &got[3];
|
||||
assert_eq!(column.ranges, vec![RangeRef::cols(21, 21)]);
|
||||
assert!(column.ranges[0].is_full_cols());
|
||||
}
|
||||
|
||||
/// Relative references are offsets from the top-left cell of all the rule's ranges, whatever
|
||||
/// order the ranges are listed in (AF5:AF9 then AG1:AG3 has its formula written for AF1). An
|
||||
/// offset Excel couldn't keep inside the grid is stored as PtgRefErr and reads as #REF!.
|
||||
#[test]
|
||||
fn relative_references_start_from_the_top_left_of_all_ranges() {
|
||||
let records = [
|
||||
dval(3),
|
||||
// =AE1>0 for AF5:AF9 AG1:AG3 (column offset -1 from AF1).
|
||||
rule(7, 0, 0, &hex("4c0000ffc01e00000d"), &[], &[[4, 8, 31, 31], [0, 2, 32, 32]]),
|
||||
// =AB1>0 for AC1:AC3 AA5:AA9 (column offset +1 from AA1).
|
||||
rule(7, 0, 0, &hex("4c000001c01e00000d"), &[], &[[0, 2, 28, 28], [4, 8, 26, 26]]),
|
||||
// PtgRefErr: a reference that fell off the grid.
|
||||
rule(7, 0, 0, &hex("2a0000f4c01e00000d"), &[], &[[4, 8, 12, 12], [0, 2, 11, 11]]),
|
||||
];
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
assert_eq!(got.len(), 3, "{:?}", report.warnings);
|
||||
assert_eq!(got[0].ranges, vec![range("AF5:AF9"), range("AG1:AG3")]);
|
||||
assert_eq!(got[0].f1, "AE1>0");
|
||||
assert_eq!(got[1].f1, "AB1>0");
|
||||
assert_eq!(got[2].f1, "#REF!>0");
|
||||
}
|
||||
|
||||
/// As in Excel, a DVal's count is authoritative: Dv records past it, or with no DVal before
|
||||
/// them, aren't read. That's said once, and not as a rule that failed to read.
|
||||
#[test]
|
||||
fn only_the_dvals_count_of_rules_is_read() {
|
||||
let a = || rule(1, 0, 4, &hex("1e0000"), &[], &[[0, 0, 0, 0]]);
|
||||
let b = || rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 1, 0, 0]]);
|
||||
let (wb, report) = workbook(&[], &[dval(1), a(), b(), b()]);
|
||||
assert_eq!(wb.sheets[0].validations.iter().map(|v| v.ranges.clone()).collect::<Vec<_>>(), vec![vec![range("A1")]]);
|
||||
assert_eq!(report.warnings.iter().filter(|w| *w == IGNORED).count(), 1, "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
let (wb, report) = workbook(&[], &[a(), b()]);
|
||||
assert!(wb.sheets[0].validations.is_empty());
|
||||
assert!(report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// Malformed rules never fail the import. Where Excel opens such a file, the result is what
|
||||
/// Excel shows; where Excel refuses the whole file, that one rule is skipped with a warning.
|
||||
#[test]
|
||||
fn malformed_rules_are_repaired_or_skipped() {
|
||||
let ok = |row: u16| rule(1, 0, 4, &hex("1e0000"), &[], &[[row, row, 0, 0]]);
|
||||
let cases: Vec<(&str, Vec<u8>, Option<&str>)> = vec![
|
||||
// Excel opens an unknown type as "any value".
|
||||
("unknown type", rule(9, 0, 0, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), Some("B2:B6")),
|
||||
// Excel refuses an unknown alert style, or an unknown operator on a rule that uses one.
|
||||
("unknown alert", rule(1, 5, 0, &hex("1e0100"), &hex("1e0a00"), &[[1, 5, 1, 1]]), None),
|
||||
("unknown operator", rule(1, 0, 9, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), None),
|
||||
// Excel drops a reversed range, and clamps columns past IV.
|
||||
("reversed range", rule(1, 0, 4, &hex("1e0000"), &[], &[[5, 1, 2, 2]]), None),
|
||||
("past column IV", rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 5, 3, 0x100]]), Some("D2:IV6")),
|
||||
// Excel refuses a range count or a formula size that runs past the record.
|
||||
(
|
||||
"too many ranges",
|
||||
rec(
|
||||
0x01BE,
|
||||
&cat(&[
|
||||
&flags(1, 0, 4, DEFAULTS).to_le_bytes(),
|
||||
&no_msgs().concat(),
|
||||
&formula(&hex("1e0000")),
|
||||
&formula(&[]),
|
||||
&u16s(&[2, 1, 5, 4, 4]),
|
||||
]),
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"formula past the record",
|
||||
rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &no_msgs().concat(), &u16s(&[200, 0]), &hex("1e0000")])),
|
||||
None,
|
||||
),
|
||||
("truncated message", rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &u16s(&[40]), &[0], b"short"])), None),
|
||||
("empty record", rec(0x01BE, &[]), None),
|
||||
// A formula GridCraft can't decode (an unknown token).
|
||||
("unknown token", rule(7, 0, 0, &[0xFF], &[], &[[1, 5, 1, 1]]), None),
|
||||
// No ranges at all.
|
||||
("no ranges", rule(1, 0, 4, &hex("1e0000"), &[], &[]), None),
|
||||
];
|
||||
for (what, bad, want) in cases {
|
||||
let (wb, report) = workbook(&[], &[dval(3), ok(10), bad, ok(11)]);
|
||||
let got = &wb.sheets[0].validations;
|
||||
let ranges: Vec<RangeRef> = got.iter().flat_map(|v| v.ranges.clone()).collect();
|
||||
let mut expect = vec![range("A11")];
|
||||
expect.extend(want.map(range));
|
||||
expect.push(range("A12"));
|
||||
assert_eq!(ranges, expect, "{what}: {:?}", report.warnings);
|
||||
assert_eq!(report.warnings.iter().any(|w| w == SKIPPED), want.is_none(), "{what}: {:?}", report.warnings);
|
||||
if what == "unknown type" {
|
||||
assert_eq!((got[1].kind, got[1].f1.as_str(), got[1].f2.as_deref()), (ValidationKind::Any, "", None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile counts and sizes never panic or allocate from the file's numbers.
|
||||
#[test]
|
||||
fn hostile_validation_records_never_panic() {
|
||||
let f = flags(1, 0, 4, DEFAULTS).to_le_bytes();
|
||||
let big = [
|
||||
rec(0x01B2, &[0xFF; 18]),
|
||||
rec(0x01BE, &cat(&[&f, &u16s(&[0xFFFF]), &[1]])),
|
||||
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &u16s(&[0xFFFF, 0xFFFF])])),
|
||||
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &formula(&[]), &formula(&[]), &u16s(&[0xFFFF])])),
|
||||
// A literal list whose PtgStr claims 255 UTF-16 characters and holds none.
|
||||
rec(
|
||||
0x01BE,
|
||||
&cat(&[
|
||||
&flags(3, 0, 0, DEFAULTS | STR_LOOKUP).to_le_bytes(),
|
||||
&no_msgs().concat(),
|
||||
&formula(&[0x17, 0xFF, 1]),
|
||||
&formula(&[]),
|
||||
&sqref(&[[0, 0, 0, 0]]),
|
||||
]),
|
||||
),
|
||||
// Every flag bit set: type 15, alert 7, operator 15.
|
||||
rec(0x01BE, &cat(&[&[0xFF; 4], &no_msgs().concat(), &formula(&[]), &formula(&[]), &sqref(&[[0, 0, 0, 0]])])),
|
||||
];
|
||||
let (wb, _) = workbook(&[], &big);
|
||||
assert!(wb.sheets[0].validations.is_empty());
|
||||
let mut many = vec![dval(u32::MAX)];
|
||||
many.extend((0..200u16).map(|r| rule(1, 0, 4, &hex("1e0000"), &[], &[[r, r, 0, 0]])));
|
||||
let (wb, _) = workbook(&[], &many);
|
||||
assert_eq!(wb.sheets[0].validations.len(), 200);
|
||||
assert_eq!(wb.sheets[0].validations[199].ranges, vec![RangeRef::cell(CellRef::new(199, 0))]);
|
||||
}
|
||||
|
||||
/// Rules with no messages cost no text, so a crafted file could otherwise keep millions of them:
|
||||
/// the workbook keeps 65,536 and says it skipped the rest.
|
||||
#[test]
|
||||
fn rules_past_the_workbook_limit_are_skipped() {
|
||||
let one = rule(0, 0, 0, &[], &[], &[[0, 0, 0, 0]]);
|
||||
let mut records = vec![dval(65_537)];
|
||||
records.extend(std::iter::repeat_n(one, 65_537));
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
assert_eq!(wb.sheets[0].validations.len(), 65_536);
|
||||
// craft-xls counts the rules past its cap with the ones it could not read.
|
||||
assert_eq!(report.warnings.iter().filter(|w| *w == SKIPPED).count(), 1, "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
|
||||
}
|
||||
@@ -133,18 +133,18 @@ pub(crate) fn compound(name: &str, data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
pub(super) fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = id.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn cat(parts: &[&[u8]]) -> Vec<u8> {
|
||||
pub(super) fn cat(parts: &[&[u8]]) -> Vec<u8> {
|
||||
parts.concat()
|
||||
}
|
||||
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
pub(super) fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
|
||||
+69
-14
@@ -13,8 +13,9 @@ use craft_xls as x;
|
||||
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
|
||||
use gridcraft_model::style::Protection;
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, CalcMode, CalcSettings, Cell, Color, DefinedName, Fill, Font, Formula, HAlign, LineInfo, NumFmt,
|
||||
PatternType, Sheet, Style, StyleId, StyleTable, Underline, VAlign, VertAlign, Visibility, Workbook,
|
||||
Alignment, BorderLine, BorderStyle, Borders, CalcMode, CalcSettings, Cell, CfOperator, Color, DefinedName, ErrorStyle, Fill, Font, Formula,
|
||||
HAlign, LineInfo, NumFmt, PatternType, Sheet, Style, StyleId, StyleTable, Underline, VAlign, Validation, ValidationKind, VertAlign, Visibility,
|
||||
Workbook,
|
||||
};
|
||||
|
||||
use crate::tables::{col_width_to_px, pt_to_px};
|
||||
@@ -72,8 +73,8 @@ fn warning(w: &x::Warning) -> String {
|
||||
W::Charts => "Charts in .xls files are not imported yet.".into(),
|
||||
W::PicturesSkipped => "Some pictures in .xls files were skipped.".into(),
|
||||
W::ConditionalFormattingSkipped | W::ConditionalFormatting12 => "Some conditional formatting in .xls files was not imported.".into(),
|
||||
W::DataValidationSkipped => "Some data validation in .xls files could not be read and was skipped.".into(),
|
||||
W::DataValidationIgnored => "Some data validation records in .xls files were ignored, as Excel ignores them.".into(),
|
||||
W::DataValidationSkipped => "Some data validation rules in .xls files could not be read and were skipped.".into(),
|
||||
W::DataValidationIgnored => "Some data validation records in .xls files were ignored, as Excel ignores them: they have no DVal record before them, or come after the number of rules it gives.".into(),
|
||||
W::Hyperlinks => "Hyperlinks in .xls files are not imported yet.".into(),
|
||||
W::PivotTables => "PivotTables in .xls files are not imported yet; their values were kept.".into(),
|
||||
W::CellsOutsideGrid(n) => format!("{n} cells outside the grid were skipped."),
|
||||
@@ -113,15 +114,15 @@ pub(crate) fn read_xls_budgeted(bytes: &[u8], limits: x::Limits, functions: Func
|
||||
}
|
||||
let mut unparsed = false;
|
||||
for s in &book.sheets {
|
||||
// craft-xls keeps these rules in its read model, but GridCraft's XLS adapter does not
|
||||
// import conditional formatting. Its own warnings cover rules it could not decode; this
|
||||
// reports the additional rules that this adapter drops.
|
||||
if !s.conditional_formats.is_empty() {
|
||||
report.warn(warning(&x::Warning::ConditionalFormattingSkipped));
|
||||
}
|
||||
// Data validation is not yet imported by this adapter.
|
||||
if !s.validations.is_empty() {
|
||||
report.warn(warning(&x::Warning::DataValidationSkipped));
|
||||
// Report rules craft-xls kept that the adapter cannot represent. craft-xls already warns
|
||||
// about records it could not decode.
|
||||
for (dropped, skipped) in [
|
||||
(!s.conditional_formats.is_empty(), x::Warning::ConditionalFormattingSkipped),
|
||||
(s.validations.iter().any(|v| validation_of(v).is_none()), x::Warning::DataValidationSkipped),
|
||||
] {
|
||||
if dropped {
|
||||
report.warn(warning(&skipped));
|
||||
}
|
||||
}
|
||||
let sheet = sheet_of(s, &styles, &mut unparsed);
|
||||
wb.sheets.push(Arc::new(sheet));
|
||||
@@ -145,7 +146,7 @@ pub(crate) fn read_xls_budgeted(bytes: &[u8], limits: x::Limits, functions: Func
|
||||
// The overview comes first.
|
||||
report.warnings.insert(
|
||||
0,
|
||||
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
|
||||
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights, defined names and data validation were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
|
||||
);
|
||||
Ok((wb, report))
|
||||
}
|
||||
@@ -257,6 +258,7 @@ fn sheet_of(s: &x::Sheet, styles: &[StyleId], unparsed: &mut bool) -> Sheet {
|
||||
sheet.show_zeros = v.show_zeros;
|
||||
sheet.right_to_left = v.right_to_left;
|
||||
}
|
||||
sheet.validations = s.validations.iter().filter_map(validation_of).collect();
|
||||
if let Some(text) = &s.print_area {
|
||||
sheet.print.print_area = crate::read::parse_print_area(text);
|
||||
}
|
||||
@@ -268,6 +270,59 @@ fn sheet_of(s: &x::Sheet, styles: &[StyleId], unparsed: &mut bool) -> Sheet {
|
||||
sheet
|
||||
}
|
||||
|
||||
/// A data validation rule, as `sheet_read` maps a SpreadsheetML `dataValidation`: a list that
|
||||
/// is not a literal refers to cells (`=…`).
|
||||
fn validation_of(v: &x::DataValidation) -> Option<Validation> {
|
||||
let ranges: Vec<RangeRef> = v.ranges.iter().map(|r| range(*r)).collect();
|
||||
if ranges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let kind = match v.kind {
|
||||
x::ValidationType::Whole => ValidationKind::Whole,
|
||||
x::ValidationType::Decimal => ValidationKind::Decimal,
|
||||
x::ValidationType::List => ValidationKind::List,
|
||||
x::ValidationType::Date => ValidationKind::Date,
|
||||
x::ValidationType::Time => ValidationKind::Time,
|
||||
x::ValidationType::TextLength => ValidationKind::TextLength,
|
||||
x::ValidationType::Custom => ValidationKind::Custom,
|
||||
_ => ValidationKind::Any,
|
||||
};
|
||||
let f1 = if kind == ValidationKind::List && !v.formula1.is_empty() && !v.formula1.starts_with('"') {
|
||||
format!("={}", v.formula1)
|
||||
} else {
|
||||
v.formula1.clone()
|
||||
};
|
||||
Some(Validation {
|
||||
ranges,
|
||||
kind,
|
||||
op: match v.operator {
|
||||
x::Operator::NotBetween => CfOperator::NotBetween,
|
||||
x::Operator::Equal => CfOperator::Equal,
|
||||
x::Operator::NotEqual => CfOperator::NotEqual,
|
||||
x::Operator::Greater => CfOperator::Greater,
|
||||
x::Operator::Less => CfOperator::Less,
|
||||
x::Operator::GreaterOrEqual => CfOperator::GreaterOrEqual,
|
||||
x::Operator::LessOrEqual => CfOperator::LessOrEqual,
|
||||
_ => CfOperator::Between,
|
||||
},
|
||||
f1,
|
||||
f2: v.formula2.clone(),
|
||||
allow_blank: v.allow_blank,
|
||||
in_cell_dropdown: v.in_cell_dropdown,
|
||||
input_title: v.prompt_title.clone(),
|
||||
input_message: v.prompt.clone(),
|
||||
show_input: v.show_prompt,
|
||||
error_title: v.error_title.clone(),
|
||||
error_message: v.error.clone(),
|
||||
error_style: match v.error_style {
|
||||
x::ErrorStyle::Warning => ErrorStyle::Warning,
|
||||
x::ErrorStyle::Information => ErrorStyle::Information,
|
||||
_ => ErrorStyle::Stop,
|
||||
},
|
||||
show_error: v.show_error,
|
||||
})
|
||||
}
|
||||
|
||||
fn color(c: x::Color) -> Color {
|
||||
match c {
|
||||
x::Color::Rgb(rgb) => Color::Rgb(rgb),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -92,7 +92,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
|
||||
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
|
||||
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
|
||||
| `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), data validation rules, sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs`, `xls_dv_tests.rs` |
|
||||
| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — |
|
||||
| `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — |
|
||||
| `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — |
|
||||
@@ -161,6 +161,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | `.xls` files open with their data validation (drop-down lists, input messages, error alerts and the rules), read by craft-xls and mapped as the `.xlsx` reader maps the same rules. A rule Excel would refuse, or one past 65,536 in a workbook, is skipped with a warning (#3634, from #3636) |
|
||||
| 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped |
|
||||
| 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) |
|
||||
| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, `<controls>`, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) |
|
||||
|
||||
@@ -4,3 +4,12 @@ sonar.organization=storytold
|
||||
# The region table is one record per locale with the same field names and many shared values
|
||||
# (month names, formats); that repetition is data, not copy-pasted logic.
|
||||
sonar.cpd.exclusions=crates/core/src/region.rs
|
||||
|
||||
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
|
||||
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
|
||||
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
|
||||
# the rule flags the tag refs anyway:
|
||||
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
|
||||
sonar.issue.ignore.multicriteria=gha-lockfile
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**
|
||||
|
||||
Reference in New Issue
Block a user