Merge upstream main into i18n-scripts

# Conflicts:
#	crates/i18n/README.md
This commit is contained in:
aiongg
2026-10-23 23:58:43 +00:00
94 changed files with 5195 additions and 349 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- name: Install ACL tools
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- run: cargo fmt --check
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- run: cargo fmt --check
+8 -4
View File
@@ -22,11 +22,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-media-lint
- run: cargo fmt -p craft-media --check
@@ -42,8 +44,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-media-test
- name: Build tests while installing ffmpeg
+6 -6
View File
@@ -27,10 +27,10 @@ jobs:
RUSTFLAGS: --cfg craft_pdf_unicode_fixtures
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-pdf
- uses: actions/setup-python@v5
@@ -51,8 +51,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-pdf-backends
- run: bash scripts/test-pdf-backends.sh
@@ -64,10 +64,10 @@ jobs:
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-pdf-wasm
- uses: actions/setup-python@v5
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- run: cargo fmt --check
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+4 -4
View File
@@ -32,10 +32,10 @@ jobs:
sudo apt-get update
sudo apt-get install -y mesa-vulkan-drivers libvulkan1
echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-ui
- run: cargo fmt --check
@@ -49,10 +49,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-ui-wasm
- run: cargo check -p craft-ui --all-features --locked --target wasm32-unknown-unknown
+36
View File
@@ -0,0 +1,36 @@
name: Window decorations
on:
workflow_dispatch:
push:
branches: [main]
paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml']
pull_request:
paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml']
permissions:
contents: read
jobs:
window:
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
# The token isn't needed after checkout; cargo runs build scripts and proc macros from
# dependencies, so don't leave it in .git/config for that third-party code to read.
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
- run: cargo fmt --check
# Default (no egui) and the egui feature both build and lint cleanly.
- run: cargo clippy -p craft-window --all-targets --locked -- -D warnings
- run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings
- run: cargo test -p craft-window --locked -- --nocapture
- run: cargo test -p craft-window --locked --features egui -- --nocapture
+1
View File
@@ -5,3 +5,4 @@ plans/
.DS_Store
/.claude/
.idea/
/vendor/*/target
+1
View File
@@ -10,5 +10,6 @@ Shared Rust libraries for the Craft Apps. The shared standards live in
- **New crates** go in `crates/<name>`, named `craft-<name>`, inherit the workspace package fields
and lints, and are listed in `[workspace.dependencies]`.
- **No app code.** A crate here must be useful to more than one app and must not depend on any app.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Before merging:** `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo test --workspace`.
Generated
+404
View File
@@ -135,6 +135,126 @@ dependencies = [
"libloading",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.5",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.5",
]
[[package]]
name = "async-recursion"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.5",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -273,6 +393,19 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.8.1"
@@ -629,6 +762,7 @@ dependencies = [
name = "craft-ai-core"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
]
@@ -657,6 +791,7 @@ name = "craft-i18n"
version = "0.1.0"
dependencies = [
"intl_pluralrules",
"log",
"sys-locale",
"unic-langid",
"unicode-bidi",
@@ -687,6 +822,23 @@ dependencies = [
"wasm-bindgen-test",
]
[[package]]
name = "craft-platform"
version = "0.1.0"
dependencies = [
"async-channel",
"async-io",
"async-process",
"egui",
"futures-lite",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
"web-sys",
"zbus",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -733,6 +885,14 @@ dependencies = [
"serde_json",
]
[[package]]
name = "craft-window"
version = "0.1.0"
dependencies = [
"egui",
"wayland-client",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
@@ -1106,6 +1266,12 @@ dependencies = [
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "2.7.3"
@@ -1126,6 +1292,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "epaint"
version = "0.36.2"
@@ -1188,6 +1375,26 @@ dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1373,6 +1580,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
@@ -2292,6 +2512,15 @@ dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2841,6 +3070,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2998,6 +3243,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -3803,6 +4059,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
@@ -3852,6 +4119,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@@ -4710,6 +4987,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
@@ -4854,6 +5142,11 @@ name = "uuid"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vello_common"
@@ -5918,6 +6211,76 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.5",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [
"serde",
"winnow",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.62"
@@ -6039,3 +6402,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.6",
"winnow",
]
+4
View File
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-platform = { path = "crates/platform" }
craft-ai-core = { path = "crates/ai-core" }
craft-pdf = { path = "crates/pdf", version = "=0.1.0" }
krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
@@ -29,11 +30,14 @@ craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
craft-window = { path = "crates/window" }
egui = { version = "0.36", default-features = false }
rayon = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
proptest = "1"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+17
View File
@@ -106,3 +106,20 @@ Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Cl
Their licences and per-file hashes are retained in vendor/hayro-interpret/assets,
licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets;
applications retain their own document-font selection and licensing policy.
Platform lifecycle source provenance
The optional crates/platform services adapt the preserved local platform candidate
9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and browser resource ownership),
and current application implementations:
- PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f.
- CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b.
- ArtCraft Launcher apps/artcraft-launcher/src/system_theme.rs, f1676dfb41c226b5be490721f486ee6427419d80.
- Earlier PhotoCraft, FilmCraft and EffectCraft appearance services at revisions
0c72d95425dece90ef9a1cceb49e3315c96e22d5, 794a25fc9ed2e3f6fab51a254625bc9b99a88015
and 1b4d47f34f2b1c1b4a89f4b7348889def8fd648b respectively.
Sources: https://github.com/storytold/pdfcraft, https://github.com/storytold/cadcraft,
https://github.com/storytold/craft-launcher, https://github.com/storytold/photocraft,
https://github.com/storytold/filmcraft, https://github.com/storytold/effectcraft.
Copyright (c) 2026 ArtCraft Team and the respective application and craft-libs contributors.
All adapted source is licensed MIT OR Apache-2.0.
+4
View File
@@ -1,6 +1,8 @@
Craft App Libraries
===================
<a href="https://sonarcloud.io/project/overview?id=storytold_craft-libs"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
This repo contains libraries used by the various Craft Apps. It's a Rust monorepo workspace, and the libraries can be included by any Craft Apps that need them.
In the future this may grow to include non-Rust library code.
@@ -23,6 +25,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle, Linux/BSD desktop appearance observation and browser download ownership; application policy stays in the apps.
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
+3
View File
@@ -10,6 +10,9 @@ repository.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
# History day/month grouping and ISO/CSV timestamps go through chrono (no hand-rolled calendar
# code); std-only, so ai-core still compiles for wasm.
chrono = { workspace = true }
[lints]
workspace = true
+3 -2
View File
@@ -202,8 +202,9 @@ impl Connection {
return Err(ConnectionError::KeyHeader("a header name needs a connection that uses a key"));
}
}
for (i, m) in self.models.iter().enumerate() {
if self.models.iter().skip(i + 1).any(|o| o.id == m.id) {
let mut seen = std::collections::HashSet::with_capacity(self.models.len());
for m in &self.models {
if !seen.insert(&m.id) {
return Err(ConnectionError::DuplicateModel(m.id.to_string()));
}
}
+16 -1
View File
@@ -67,9 +67,13 @@ impl Disclosure {
}
}
/// The distinct kinds of data this request sends.
/// The distinct kinds of data this request sends. Parameters carry values the app chose,
/// free text among them, so a request with any counts as sending text even with no inputs.
pub fn kinds(&self) -> Vec<PayloadKind> {
let mut kinds: Vec<PayloadKind> = self.items.iter().map(|i| i.kind).collect();
if !self.param_names.is_empty() {
kinds.push(PayloadKind::Text);
}
kinds.sort();
kinds.dedup();
kinds
@@ -242,6 +246,17 @@ mod tests {
assert!(ledger.needs_consent(&d, 0));
}
#[test]
fn parameters_alone_still_need_consent() {
let only_params = Request::new(CapabilityId::parse("image.inpaint").unwrap()).with_param("negative", "a private note".into());
let d = disclosure_for(&only_params);
assert_eq!(d.kinds(), vec![PayloadKind::Text]);
let mut ledger = ConsentLedger::default();
assert!(ledger.needs_consent(&d, 0));
ledger.grant(&d);
assert!(!ledger.needs_consent(&d, 0));
}
fn upscale_req() -> Request {
Request::new(CapabilityId::parse("image.upscale").unwrap()).with_input(InputRole::Source, Payload::inline("image/png", vec![0; 10]).unwrap())
}
+26 -19
View File
@@ -22,17 +22,18 @@ pub struct Day {
}
impl Day {
/// Days since 1970-01-01 (negative before it).
/// Days since 1970-01-01 (negative before it); a day that doesn't exist (2025-02-30) reads as 0.
pub fn to_days(self) -> i64 {
let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day));
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
use chrono::Datelike;
// chrono covers years up to about ±262143, but `Day` holds any `i32` year. The Gregorian
// calendar repeats every 400 years, so chrono handles the year within its cycle and
// whole cycles are added back.
let cycles = i64::from(self.year.div_euclid(400));
chrono::NaiveDate::from_ymd_opt(self.year.rem_euclid(400), u32::from(self.month), u32::from(self.day))
.map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE + cycles * DAYS_PER_400_YEARS)
}
/// The calendar day at `days` since 1970-01-01; a day whose year doesn't fit `i32` reads as 0000-01-01.
pub fn from_days(days: i64) -> Day {
civil_from_days(days)
}
@@ -61,18 +62,24 @@ impl fmt::Display for Month {
}
}
/// Days since 1970-01-01 to a calendar day (proleptic Gregorian).
/// `NaiveDate::num_days_from_ce` of 1970-01-01, so the count can be rebased onto the Unix epoch.
const UNIX_EPOCH_DAYS_FROM_CE: i64 = 719_163;
/// Days in one 400-year Gregorian cycle (the calendar repeats after it).
const DAYS_PER_400_YEARS: i64 = 146_097;
/// Days since 1970-01-01 to a calendar day (proleptic Gregorian). chrono converts the day
/// within its 400-year cycle and whole cycles are added back as years, so every `i64` count
/// works; a year that doesn't fit `i32` reads as 0000-01-01.
fn civil_from_days(days: i64) -> Day {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
Day { year: i32::try_from(y).unwrap_or(0), month: u8::try_from(m).unwrap_or(1), day: u8::try_from(d).unwrap_or(1) }
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(DAYS_PER_400_YEARS), days.rem_euclid(DAYS_PER_400_YEARS));
let date = i32::try_from(rest + UNIX_EPOCH_DAYS_FROM_CE).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let year = date.and_then(|d| i32::try_from(i64::from(d.year()) + cycles * 400).ok());
match (date, year) {
(Some(d), Some(year)) => Day { year, month: u8::try_from(d.month()).unwrap_or(1), day: u8::try_from(d.day()).unwrap_or(1) },
_ => Day { year: 0, month: 1, day: 1 },
}
}
/// The calendar day of a Unix-millisecond time, at a fixed offset from UTC in minutes (east is
@@ -213,6 +213,22 @@ fn days_convert_to_and_from_the_epoch_count_both_ways() {
assert_eq!(day_of(0, 0).to_days(), 0);
assert_eq!(day_of(DAY_MS_FOR_TESTS, 0).to_days(), 1);
assert_eq!(day_of(0, -60).to_days(), -1);
// A day count past what chrono can represent reads as the 0000-01-01 fallback, not a stray
// valid date: `i64::MAX` days would overflow the CE `i32` (which once became 0000-12-31).
let fallback = Day { year: 0, month: 1, day: 1 };
assert_eq!(Day::from_days(i64::MAX), fallback);
assert_eq!(Day::from_days(i64::MIN), fallback);
// Valid `i32` years past chrono's ±262143 still convert, both ways, as before chrono.
for (day, days) in [
(Day { year: 262_144, month: 1, day: 1 }, 95_026_602),
(Day { year: -262_145, month: 3, day: 1 }, -96465964),
(Day { year: i32::MAX, month: 12, day: 31 }, 784351576776),
(Day { year: i32::MIN, month: 1, day: 1 }, -784353015833),
] {
assert_eq!(day.to_days(), days, "{day}");
assert_eq!(Day::from_days(days), day, "{days}");
}
assert_eq!(Day { year: 2025, month: 2, day: 30 }.to_days(), 0, "a day that doesn't exist");
}
const DAY_MS_FOR_TESTS: u64 = 86_400_000;
+41 -4
View File
@@ -40,13 +40,32 @@ impl fmt::Display for SecretString {
}
/// Replaces every occurrence of each non-empty secret in `text` with `[redacted]`.
///
/// Matches are found in `text` itself, never in the markers already written, so the output is at
/// most a fixed multiple of the input whatever the secrets are. Overlapping matches of different
/// secrets become one marker.
pub fn redact(text: &str, secrets: &[&SecretString]) -> String {
let mut out = text.to_owned();
for secret in secrets {
if !secret.is_empty() {
out = out.replace(secret.expose(), "[redacted]");
const MARKER: &str = "[redacted]";
let mut values: Vec<&str> = secrets.iter().map(|s| s.expose()).filter(|v| !v.is_empty()).collect();
values.sort_unstable();
values.dedup();
let mut spans: Vec<(usize, usize)> = values.iter().flat_map(|v| text.match_indices(v).map(|(at, m)| (at, at + m.len()))).collect();
spans.sort_unstable();
let mut out = String::with_capacity(text.len());
let mut done = 0;
let mut i = 0;
while i < spans.len() {
let (start, mut end) = spans[i];
i += 1;
while i < spans.len() && spans[i].0 < end {
end = end.max(spans[i].1);
i += 1;
}
out.push_str(&text[done..start]);
out.push_str(MARKER);
done = end;
}
out.push_str(&text[done..]);
out
}
@@ -69,6 +88,24 @@ mod tests {
assert_eq!(out, "bad key [redacted] ([redacted])");
}
#[test]
fn redact_never_searches_its_own_markers() {
// Each secret matches the marker the one before it wrote; replacing in turn doubled the
// text for every secret.
let r = SecretString::new("r");
let marker = SecretString::new("[redacted]");
let secrets: Vec<&SecretString> = std::iter::once(&r).chain(std::iter::repeat_n(&marker, 64)).collect();
assert_eq!(redact("rrrr", &secrets), "[redacted][redacted][redacted][redacted]");
}
#[test]
fn redact_merges_overlapping_secrets() {
let a = SecretString::new("abc");
let b = SecretString::new("bcd");
assert_eq!(redact("xabcdx", &[&a, &b]), "x[redacted]x");
assert_eq!(redact("abcabc", &[&a]), "[redacted][redacted]");
}
#[test]
fn redact_ignores_empty_secret() {
let s = SecretString::new("");
+4 -1
View File
@@ -37,8 +37,11 @@ pub const SPEC_VERSION: u32 = 1;
pub const MAX_SPEC_BYTES: usize = 32 * 1024;
/// Most models one spec may offer.
pub const MAX_MODELS: usize = 16;
/// Largest rendered or stored template (a body, serialized).
/// Largest stored template (a body, serialized).
pub const MAX_TEMPLATE_BYTES: usize = 4 * 1024;
/// Most text one rendered template may hold, summed over its strings with every substitution made.
/// A slot may repeat, so this, not [`MAX_TEMPLATE_BYTES`], bounds what a prompt can grow to.
pub const MAX_RENDERED_BYTES: usize = 4 * 1024 * 1024;
/// Deepest JSON pointer.
pub const MAX_POINTER_DEPTH: usize = 8;
/// Most entries in a status table.
+25 -8
View File
@@ -114,23 +114,40 @@ pub fn value_slots(template: &Value) -> Result<Vec<Slot>, String> {
Ok(out)
}
/// Renders one string. Fails past [`MAX_RENDERED_BYTES`](crate::spec::MAX_RENDERED_BYTES).
pub fn render_text(text: &str, values: &Values<'_>) -> Result<String, String> {
let mut left = crate::spec::MAX_RENDERED_BYTES;
render_text_within(text, values, &mut left)
}
/// Renders `text`, taking its length from `left`; a template that repeats a slot cannot outgrow it.
fn render_text_within(text: &str, values: &Values<'_>, left: &mut usize) -> Result<String, String> {
let mut out = String::new();
for piece in pieces(text)? {
match piece {
Piece::Text(t) => out.push_str(&t),
Piece::Slot(slot, name) => out.push_str(values.get(slot).ok_or_else(|| format!("{{{name}}} is not available in this call"))?),
}
let add = match &piece {
Piece::Text(t) => t.as_str(),
Piece::Slot(slot, name) => values.get(*slot).ok_or_else(|| format!("{{{name}}} is not available in this call"))?,
};
*left = left.checked_sub(add.len()).ok_or_else(|| format!("a rendered template is at most {} bytes", crate::spec::MAX_RENDERED_BYTES))?;
out.push_str(add);
}
Ok(out)
}
/// Renders every string value of `template`. Keys are never substituted.
/// Renders every string value of `template`. Keys are never substituted. All strings together
/// share one [`MAX_RENDERED_BYTES`](crate::spec::MAX_RENDERED_BYTES) budget.
pub fn render_value(template: &Value, values: &Values<'_>) -> Result<Value, String> {
let mut left = crate::spec::MAX_RENDERED_BYTES;
render_value_within(template, values, &mut left)
}
fn render_value_within(template: &Value, values: &Values<'_>, left: &mut usize) -> Result<Value, String> {
Ok(match template {
Value::String(s) => Value::String(render_text(s, values)?),
Value::Array(items) => Value::Array(items.iter().map(|i| render_value(i, values)).collect::<Result<_, _>>()?),
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| Ok((k.clone(), render_value(v, values)?))).collect::<Result<_, String>>()?),
Value::String(s) => Value::String(render_text_within(s, values, left)?),
Value::Array(items) => Value::Array(items.iter().map(|i| render_value_within(i, values, left)).collect::<Result<_, _>>()?),
Value::Object(map) => {
Value::Object(map.iter().map(|(k, v)| Ok((k.clone(), render_value_within(v, values, left)?))).collect::<Result<_, String>>()?)
}
other => other.clone(),
})
}
+12
View File
@@ -65,3 +65,15 @@ fn a_slot_with_no_value_is_an_error_not_an_empty_string() {
let why = render_text("/jobs/{job_id}", &v).unwrap_err();
assert!(why.contains("{job_id} is not available"), "{why}");
}
#[test]
fn repeated_slots_share_one_rendered_budget() {
let prompt = "x".repeat(64 * 1024);
let v = Values { prompt: Some(&prompt), ..values() };
let slots = vec![json!("{prompt}"); 16];
assert!(render_value(&json!({ "a": slots }), &v).is_ok());
let slots = vec![json!("{prompt}{prompt}{prompt}{prompt}"); 32];
let why = render_value(&json!({ "a": slots }), &v).unwrap_err();
assert!(why.contains("at most"), "{why}");
assert!(render_text(&"{prompt}".repeat(128), &v).is_err());
}
+46 -5
View File
@@ -223,15 +223,22 @@ impl Clut {
}
fn multilinear(&self, input: &[f32], out: &mut [f32]) {
let n = self.inputs;
// Only dimensions that sit between two grid points spread over corners. A singleton
// dimension, or an input on a node, takes its lower corner with weight 1, so a table of
// many such dimensions costs no more than the dimensions that actually interpolate.
let mut base = 0usize;
let mut r = [0.0f32; MAX_CHANNELS];
let mut s = [0usize; MAX_CHANNELS];
for d in 0..n {
let (b, rr, ss) = self.locate(d, input[d]);
let mut n = 0;
for (d, &v) in input.iter().enumerate().take(self.inputs) {
let (b, rr, ss) = self.locate(d, v);
base += b;
r[d] = rr;
s[d] = ss;
// `!= 0.0` keeps a NaN fraction, so a NaN input still gives a NaN result.
if ss > 0 && rr != 0.0 {
r[n] = rr;
s[n] = ss;
n += 1;
}
}
let o = self.outputs;
out[..o].iter_mut().for_each(|v| *v = 0.0);
@@ -309,4 +316,38 @@ mod tests {
c.eval(&[0.5, 0.25], &mut o).unwrap();
assert!((o[0] - 0.75).abs() < 1e-6);
}
#[test]
fn a_nan_input_still_gives_nan() {
let c = Clut::sample(vec![2; 5], 1, |p, o| o[0] = p.iter().sum()).unwrap();
let mut o = [0.0];
c.eval_multilinear(&[0.5, f32::NAN, 0.5, 0.5, 0.5], &mut o).unwrap();
assert!(o[0].is_nan(), "{o:?}");
}
#[test]
fn singleton_dimensions_do_not_multiply_corners() {
// Fifteen inputs, all but two on a single grid point: the result interpolates only those
// two, and matches the same table built with two inputs.
let mut grid = vec![1usize; 15];
grid[3] = 2;
grid[9] = 3;
let wide = Clut::sample(grid, 2, |p, o| {
o[0] = p[3] * 0.5 + p[9] * 0.25;
o[1] = p[3] * p[9];
})
.unwrap();
let narrow = Clut::sample(vec![2, 3], 2, |p, o| {
o[0] = p[0] * 0.5 + p[1] * 0.25;
o[1] = p[0] * p[1];
})
.unwrap();
let mut input = [0.4f32; 15];
input[3] = 0.3;
input[9] = 0.7;
let (mut a, mut b) = ([0.0; 2], [0.0; 2]);
wide.eval_multilinear(&input, &mut a).unwrap();
narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap();
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}");
}
}
+38 -2
View File
@@ -85,6 +85,20 @@ impl Curve {
}
}
/// [`eval_inverse64`](Self::eval_inverse64) for many values. A table's flat ends are found
/// once here instead of on every call, so building an output table from a long, flat table
/// costs one scan of it, not one per entry.
pub fn inverse64_many(&self) -> impl Fn(f64) -> f64 + '_ {
let edges = match self {
Curve::Table(t) if t.len() >= 2 => Some((edge_index(t, true), edge_index(t, false))),
_ => None,
};
move |y| match (self, edges) {
(Curve::Table(t), Some((first, last))) => table_inverse_with(t, y as f32, |start| if start { first } else { last }) as f64,
_ => self.eval_inverse64(y),
}
}
#[inline]
pub fn eval_inverse(&self, y: f32) -> f32 {
match self {
@@ -240,6 +254,11 @@ fn table(t: &[f32], x: f32) -> f32 {
/// Exact inverse of a piecewise-linear table (binary search), for increasing or decreasing tables.
fn table_inverse(t: &[f32], y: f32) -> f32 {
table_inverse_with(t, y, |start| edge_index(t, start))
}
/// [`table_inverse`] with the flat-run ends from `edge` (see [`edge_index`]).
fn table_inverse_with(t: &[f32], y: f32, edge: impl Fn(bool) -> f32) -> f32 {
let n = t.len();
if n < 2 {
return y;
@@ -248,10 +267,10 @@ fn table_inverse(t: &[f32], y: f32) -> f32 {
let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) };
if y <= lo_v {
// Flat start: choose the last index still at the lowest value.
return if increasing { edge_index(t, true) } else { edge_index(t, false) };
return edge(increasing);
}
if y >= hi_v {
return if increasing { edge_index(t, false) } else { edge_index(t, true) };
return edge(!increasing);
}
let (mut lo, mut hi) = (0usize, n - 1);
while hi - lo > 1 {
@@ -329,6 +348,23 @@ mod tests {
assert!((dec.eval_inverse(0.25) - 0.75).abs() < 1e-6);
}
#[test]
fn batch_inverse_matches_single_inverse() {
let mut flat = vec![0.25f32; 300];
flat.extend((0..300).map(|i| 0.25 + i as f32 / 600.0));
flat.extend([0.75f32; 300]);
for t in [flat.clone(), flat.iter().rev().copied().collect(), vec![0.5; 1000], Curve::Gamma(2.2).sample(256)] {
let c = Curve::Table(t);
let many = c.inverse64_many();
for i in 0..=200 {
let y = i as f64 / 200.0;
assert_eq!(many(y), c.eval_inverse64(y), "{y}");
}
}
let g = Curve::Gamma(2.2);
assert_eq!(g.inverse64_many()(0.3), g.eval_inverse64(0.3));
}
#[test]
fn hdr_and_negative_extension() {
let c = srgb_trc();
+2 -1
View File
@@ -150,10 +150,11 @@ impl Transform {
let tables = cs
.iter()
.map(|c| {
let inverse = c.inverse64_many();
(0..OUT_TABLE)
.map(|i| {
let u = i as f64 / (OUT_TABLE - 1) as f64;
c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32
inverse(u * u * u * u).clamp(0.0, 1.0) as f32
})
.collect()
})
+1
View File
@@ -10,6 +10,7 @@ repository.workspace = true
[dependencies]
intl_pluralrules = "=7.0.2"
unic-langid = "0.9"
log = "0.4"
sys-locale = "0.3"
unicode-bidi = "0.3"
+8 -6
View File
@@ -20,8 +20,8 @@ include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs")); // pub static CRAFT_I18N:
pub static I18N: LazyLock<craft_i18n::I18n> = LazyLock::new(|| {
let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect(); // the app's own list
let (i18n, problems) = craft_i18n::I18n::new(CRAFT_I18N, &offered);
problems.iter().for_each(|p| log::warn!("translation catalog: {p}; that string shows in English"));
let (i18n, problems) = craft_i18n::I18n::new(CRAFT_I18N, &offered); // parses nothing yet
problems.iter().for_each(|p| log::warn!("translation catalog: {p}"));
i18n
});
@@ -51,15 +51,17 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
placeholders, filled in one pass.
- **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice,
of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case)
are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before
they ship.
are skipped; craft-i18n's CI rejects them before they ship. `I18n::new` parses nothing: it
returns the bad locales, and each locale's rows are parsed (and its bad rows logged with
`log::warn!`) on the first lookup that reaches it, including as a fallback. `I18n::check_all()`
parses every locale and returns all problems (`<locale>/<common|app>: line N: …`), for a test.
- **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories
whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the
last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br`
`pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and
`plural_form_counts` holds the same counts.
- **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its
code; English is "English".
- **Names:** a language's name comes from the app catalog's `@id language.name` row, else the
shared catalog's, else its code; English is "English".
- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its
interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab),
`direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han`
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) fn parse(text: &str, forms: usize) -> (Vec<Entry>, Vec<String>) {
(rows, problems)
}
fn parse_row(text: &str, forms: usize) -> Result<Entry, String> {
pub(crate) fn parse_row(text: &str, forms: usize) -> Result<Entry, String> {
let columns: Vec<&str> = text.split('\t').collect();
let [context, source, translation] = columns.as_slice() else {
return Err(format!("{} columns; expected 3", columns.len()));
+154 -27
View File
@@ -34,7 +34,14 @@ impl Lang {
/// One locale's translations: context → source text → translation, app rows over shared ones.
struct Locale {
code: String,
rows: HashMap<String, HashMap<String, String>>,
/// The shared and the app's catalog text, parsed on first use.
shared: &'static str,
app: &'static str,
/// Plural forms `@plural` rows must list, for [`catalog::parse`].
forms: usize,
parsed: OnceLock<Parsed>,
/// [`I18n::name`], read from the text on first use.
name: OnceLock<Option<String>>,
/// Itself, then its parent without the region if that is embedded too (`pt-br`, `pt`;
/// `zh-hant-hk`, `zh-hant`). Empty for English and the pseudo locale.
chain: Vec<usize>,
@@ -45,7 +52,49 @@ struct Locale {
plural: Option<(PluralRules, Vec<PluralCategory>)>,
}
/// The embedded catalogs, ready for lookups. Build it once, at start-up.
/// A locale's rows, app over shared, and what was wrong with the catalogs it came from.
struct Parsed {
rows: HashMap<String, HashMap<String, String>>,
problems: Vec<String>,
}
impl Locale {
/// The rows; the catalogs are parsed on the first call and any problems logged then.
fn parsed(&self) -> &Parsed {
self.parsed.get_or_init(|| {
let mut problems = Vec::new();
let mut rows: HashMap<String, HashMap<String, String>> = HashMap::new();
for (name, text) in [("common", self.shared), ("app", self.app)] {
let (entries, errors) = catalog::parse(text, self.forms);
problems.extend(errors.into_iter().map(|e| format!("{}/{name}: {e}", self.code)));
for e in entries {
rows.entry(e.context).or_default().insert(e.source, e.translation);
}
}
problems.iter().for_each(|p| log::warn!("translation catalog: {p}; row skipped"));
Parsed { rows, problems }
})
}
/// The `@id language.name` row of the app's catalog, else the shared one, found by scanning the
/// text so the locale needn't be parsed.
fn language_name(&self) -> Option<&str> {
self.name
.get_or_init(|| {
[self.app, self.shared].into_iter().find_map(|text| {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
text.split('\n')
.filter(|line| line.starts_with("@id\tlanguage.name\t"))
.find_map(|line| catalog::parse_row(line.strip_suffix('\r').unwrap_or(line), self.forms).ok())
.map(|row| row.translation)
})
})
.as_deref()
}
}
/// The embedded catalogs, ready for lookups. Build it once, at start-up. Each locale is parsed on
/// its first lookup, which is also when its bad rows are logged.
pub struct I18n {
locales: Vec<Locale>,
/// Marked strings for the pseudo locale by source text, leaked so lookups can hand out `&str`.
@@ -58,10 +107,11 @@ pub struct I18n {
}
impl I18n {
/// Read `embedded`, offering the locales in `offered` (any case; the app's own language list),
/// so an app never offers a language its interface isn't ready for (right to left, say). Rows
/// and locales that can't be used are skipped and come back as problems
/// (`<locale>/<common|app>: line N: …`) for the app to log.
/// Index `embedded` without parsing it, offering the locales in `offered` (any case; the app's
/// own language list), so an app never offers a language its interface isn't ready for (right
/// to left, say). Locales that can't be used are skipped and come back as problems for the app
/// to log. A locale's bad rows are logged (`log::warn!`) when it is first looked up in, and
/// [`I18n::check_all`] lists them all.
pub fn new(embedded: &'static Embedded, offered: &[&str]) -> (I18n, Vec<String>) {
let mut problems = Vec::new();
let mut locales = vec![bare("en"), bare("pseudo")];
@@ -76,16 +126,8 @@ impl I18n {
problems.push(format!("{code}: no plural rules; plurals show in English"));
}
let forms = plural.as_ref().map_or(1, |(_, c)| c.len());
let mut rows: HashMap<String, HashMap<String, String>> = HashMap::new();
for (name, text) in [("common", shared), ("app", app)] {
let (entries, errors) = catalog::parse(text, forms);
problems.extend(errors.into_iter().map(|e| format!("{code}/{name}: {e}")));
for e in entries {
rows.entry(e.context).or_default().insert(e.source, e.translation);
}
}
let offered = offered.iter().any(|o| o.eq_ignore_ascii_case(&code));
locales.push(Locale { code, rows, chain: Vec::new(), plural, offered });
locales.push(Locale { code, shared, app, forms, parsed: OnceLock::new(), name: OnceLock::new(), chain: Vec::new(), plural, offered });
}
for i in 2..locales.len() {
let mut chain = vec![i];
@@ -108,6 +150,12 @@ impl I18n {
(I18n { locales, pseudo: Mutex::default(), system: OnceLock::new() }, problems)
}
/// Parse every locale and list every problem, `<locale>/<common|app>: line N: …`, for a test or
/// an audit; the same ones [`I18n::new`] leaves to lookups to log.
pub fn check_all(&self) -> Vec<String> {
self.locales.iter().flat_map(|l| l.parsed().problems.iter().cloned()).collect()
}
/// The language with this code (any case): `en`, an offered locale, or in debug builds
/// `pseudo`.
pub fn from_code(&self, code: &str) -> Option<Lang> {
@@ -170,7 +218,7 @@ impl I18n {
.iter()
.find_map(|&i| {
let locale = self.locales.get(i)?;
let forms = locale.rows.get("@plural")?.get(&key)?;
let forms = locale.parsed().rows.get("@plural")?.get(&key)?;
let (rules, categories) = locale.plural.as_ref()?;
let category = rules.select(n).ok()?;
// A category whole numbers up to 1000 never reach (French `many`) takes the last form.
@@ -193,13 +241,14 @@ impl I18n {
std::iter::once(Lang::EN).chain(embedded.into_iter().map(Lang)).collect()
}
/// The language's name in itself, for a language picker: its shared catalog's
/// `@id language.name` row, or the code when there is none.
/// The language's name in itself, for a language picker: its `@id language.name` row (the
/// app's catalog over the shared one), or the code when there is none. Doesn't parse the
/// locale.
pub fn name(&self, lang: Lang) -> &str {
if lang == Lang::EN {
return "English";
}
self.locale(lang).and_then(|l| l.rows.get("@id")?.get("language.name")).map_or_else(|| self.code(lang), String::as_str)
self.locale(lang).and_then(Locale::language_name).unwrap_or_else(|| self.code(lang))
}
/// Whether the language is written right to left ([`direction`]). Putting text into display
@@ -256,7 +305,7 @@ impl I18n {
fn find(&self, lang: Lang, keys: &[(&str, &str)]) -> Option<&str> {
let chain = self.locale(lang).map(|l| l.chain.as_slice())?;
chain.iter().find_map(|&i| {
let rows = &self.locales.get(i)?.rows;
let rows = &self.locales.get(i)?.parsed().rows;
keys.iter().find_map(|(context, source)| rows.get(*context)?.get(*source).map(String::as_str))
})
}
@@ -374,7 +423,17 @@ fn is_region(subtag: &str) -> bool {
/// A locale with no catalogs (English, the pseudo locale).
fn bare(code: &str) -> Locale {
Locale { code: code.to_owned(), rows: HashMap::new(), chain: Vec::new(), plural: None, offered: false }
Locale {
code: code.to_owned(),
shared: "",
app: "",
forms: 1,
parsed: OnceLock::new(),
name: OnceLock::new(),
chain: Vec::new(),
plural: None,
offered: false,
}
}
/// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order
@@ -412,6 +471,15 @@ fn rank(c: &PluralCategory) -> u8 {
mod tests {
use super::*;
impl I18n {
/// The embedded locales parsed so far, by code.
fn parsed(&self) -> Vec<&str> {
let mut codes: Vec<&str> = self.locales.iter().skip(2).filter(|l| l.parsed.get().is_some()).map(|l| l.code.as_str()).collect();
codes.sort_unstable();
codes
}
}
static E: &Embedded = &[
("fr", "\tSave\tEnregistrer\n\tOpen\tOuvrir\nmenu\tOpen\tOuvrir le menu\n@id\tfile.save\tEnregistrer le fichier\n", "\tSave\tSauvegarder\n"),
("pt", "\tSave\tGuardar\n\tUndo\tDesfazer\n", ""),
@@ -475,11 +543,9 @@ mod tests {
#[test]
fn bad_rows_skipped_and_reported() {
let (i, problems) = all(E);
assert_eq!(problems.len(), 2, "{problems:?}");
assert!(problems[0].starts_with("de/common: line 1:"), "{problems:?}");
assert!(problems[1].starts_with("de/common: line 2:"), "{problems:?}");
let i = i18n();
assert_eq!(i.t(lang(&i, "de"), "Undo"), "Rückgängig");
assert_eq!(i.check_all().len(), 2);
}
#[test]
@@ -733,8 +799,8 @@ mod tests {
#[test]
fn duplicate_row_keeps_the_first() {
let (i, problems) = all(&[("fr", "\tSave\tEnregistrer\n\tSave\tSauver\n", "")]);
assert_eq!(problems, ["fr/common: line 2: defined twice (same context and source text); the first is kept"]);
let i = all(&[("fr", "\tSave\tEnregistrer\n\tSave\tSauver\n", "")]).0;
assert_eq!(i.check_all(), ["fr/common: line 2: defined twice (same context and source text); the first is kept"]);
assert_eq!(i.t(lang(&i, "fr"), "Save"), "Enregistrer");
}
@@ -770,6 +836,67 @@ mod tests {
assert!(i.languages().into_iter().skip(1).all(|l| i.rtl(l)));
}
#[test]
fn check_all_reports_every_bad_row() {
let (i, problems) = all(E);
assert!(i.parsed().is_empty(), "new parses nothing");
assert!(problems.iter().all(|p| !p.contains("line")), "{problems:?}");
let found = i.check_all();
assert_eq!(found.len(), 2, "{found:?}");
assert!(found[0].starts_with("de/common: line 1:"), "{found:?}");
assert!(found[1].starts_with("de/common: line 2:"), "{found:?}");
assert_eq!(i.check_all(), found);
assert_eq!(i.parsed(), ["de", "fr", "pt", "pt-br"]);
}
#[test]
fn lookup_parses_only_what_it_reaches() {
static LAZY: &Embedded = &[
("pt", "\tUndo\tDesfazer\n", ""),
("pt-br", "\tSave\tSalvar\n", ""),
("de", "\tUndo\tRückgängig\n", ""),
("fr", "\tUndo\tAnnuler\n", ""),
];
let i = all(LAZY).0;
assert!(i.parsed().is_empty());
assert_eq!(i.t(lang(&i, "pt-br"), "Save"), "Salvar");
assert_eq!(i.parsed(), ["pt-br"]);
assert_eq!(i.t(lang(&i, "pt-br"), "Undo"), "Desfazer");
assert_eq!(i.parsed(), ["pt", "pt-br"]);
assert_eq!(i.t(lang(&i, "pt-br"), "Undo"), "Desfazer");
assert_eq!(i.parsed(), ["pt", "pt-br"]);
assert_eq!(i.t(Lang::EN, "Undo"), "Undo");
assert_eq!(i.t(Lang::PSEUDO, "Undo"), "⟦Undo⟧");
assert_eq!(i.parsed(), ["pt", "pt-br"]);
}
#[test]
fn name_reads_escapes_without_parsing() {
let i = all(&[("fr", "@id\tlanguage.name\tFran\\\\ais\\tx\n", ""), ("de", "\tA\tB\n@id\tlanguage.name\tDeutsch\n", "")]).0;
assert_eq!(i.name(lang(&i, "fr")), "Fran\\ais\tx");
assert_eq!(i.name(lang(&i, "de")), "Deutsch");
assert!(i.parsed().is_empty());
}
#[test]
fn app_name_overrides_shared() {
let i = all(&[
("fr", "@id\tlanguage.name\tFrançais\n", "@id\tlanguage.name\tFrançais (app)\n"),
("de", "@id\tlanguage.name\tDeutsch\n", "\tA\tB\n"),
("es", "", ""),
])
.0;
assert_eq!(i.name(lang(&i, "fr")), "Français (app)");
assert_eq!(i.name(lang(&i, "de")), "Deutsch");
assert_eq!(i.name(lang(&i, "es")), "es");
}
#[test]
fn i18n_is_send_and_sync() {
fn check<T: Send + Sync>() {}
check::<I18n>();
}
/// Prints what this machine reports, so CI logs show `system_lang` on each platform.
#[test]
fn system_lang_reads_the_system() {
+59 -12
View File
@@ -39,8 +39,42 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>
The code comes from FilmCraft's `filmcraft-bitstream`, `filmcraft-isobmff` and
`filmcraft-matroska` crates at
[storytold/filmcraft@6c3948a](https://github.com/storytold/filmcraft/commit/6c3948a36c80e4996d924766ba7157eec4fca85e).
Every public item keeps its name, signature and behavior for valid input, so FilmCraft can turn
all three crates into re-exports (`pub use craft_media::isobmff::*;`). The differences:
Public items keep their names and signatures, so FilmCraft can turn all three crates into
re-exports (`pub use craft_media::isobmff::*;`), except for the renames below. Our own names use
US spelling; names taken from a specification keep its spelling.
Renamed:
| FilmCraft | craft-media |
|---|---|
| `matroska::Colour` | `matroska::Color` |
| `matroska::VideoInfo::colour` | `matroska::VideoInfo::color` |
| `matroska::VideoInfo::colour_space` | `matroska::VideoInfo::color_space` |
The element IDs `element_ids::COLOUR` and `COLOUR_SPACE` and the field
`isobmff::VpcConfig::colour_primaries` keep the spellings of the Matroska and VP9 specifications.
Output that differs on valid input:
- Matroska lacing applies only to tracks with a `DefaultDuration`, and a lace ends at a timestamp
gap. Readers space the frames of a lace by `DefaultDuration`, so FilmCraft's laces moved frames
that followed a gap.
- The sample budget covers the whole file, so a multi-track file with more than 2²⁶ samples in
total fails with `TooLarge` (ISO BMFF: all tracks and fragments; Matroska: all tracks, and at
most 2¹⁶ TrackEntries). FilmCraft capped ISO BMFF per track and Matroska not at all.
- MOV and MP4 PCM chunks end at 1 MiB, as ffmpeg writes them; FilmCraft wrote
each contiguous run of one track as one chunk. The reader splits a PCM chunk above 16 MiB into
samples of at most 16 MiB; FilmCraft made it one sample, cut at 4 GiB.
- MOV PCM at 65,536 Hz and above, and unsigned 16-bit PCM, is written as `lpcm` (sound
description v2), and signed 8-bit PCM as `twos`. FilmCraft wrote 0 Hz for the former and
labeled the signedness wrongly for the latter two. `raw ` under a sound handler reads as PCM.
- Matroska `BlockDuration` is rounded to the nearest tick; FilmCraft truncated it.
- A Matroska `TimestampScale` above 2³² is ignored, like a zero scale.
- `matroska::MkvWriter` writes `MuxingApp` = `craft-media`, and `MuxOptions::default()` has
`writing_app` = `craft-media` (FilmCraft: `filmcraft-matroska` and `filmcraft`). Apps set
`writing_app` to their own name.
Other differences:
- `bitstream::BitReader::read_ue_plus` (from DeckCraft's copy) is new.
- `isobmff::boxes` is new: FilmCraft's private box iterator and byte reader, made public (see
@@ -49,15 +83,21 @@ all three crates into re-exports (`pub use craft_media::isobmff::*;`). The diffe
without a debug assertion: `peek` and `read_bits` return the low 32 bits of the field (use
`read_bits_u64` for wide fields), and `write_bits` writes leading zero bits, at most 64 bits in
all.
- `bitstream::length_prefixed_nals` returns `BitError::Eof` for a zero `length_size` on non-empty
data and cannot overflow on a hostile length prefix.
- `bitstream::length_prefixed_nals` returns the new `BitError::InvalidNalLengthSize` for a
`length_size` outside 1 to 4 (as FilmCraft does since filmcraft#2953) and cannot overflow on a
hostile length prefix.
- `isobmff::PcmConfig::bytes_per_frame` saturates at `u32::MAX` for a hostile channel count.
- `matroska`: a cluster timestamp above `i64::MAX` saturates at `i64::MAX` (FilmCraft wraps it to
a negative value), and adding a block's relative timestamp saturates instead of overflowing (a
debug-build panic in FilmCraft).
- `matroska::MkvWriter` writes `MuxingApp` = `craft-media`, and `MuxOptions::default()` has
`writing_app` = `craft-media` (FilmCraft: `filmcraft-matroska` and `filmcraft`). Apps set
`writing_app` to their own name.
- `matroska::Error::TooLarge` is new: indexing a file past the limits above returns it.
- `matroska::MuxOptions::max_lace_frames` is clamped to 1 to 256; FilmCraft wrote a corrupt
frame count above 256.
- `isobmff::Mp4Writer::set_sample_entry` returns an error when the new entry changes the codec,
the media type or the PCM frame size, and `Error::NoSuchTrack` for a bad index. FilmCraft
accepted any entry.
- `isobmff::Mp4Writer::write_sample` returns an error when a track would pass 2³² − 1 samples
(PCM: frames), the most `stsz` can count; FilmCraft clamped the count.
- `matroska::ByteSource` stays a separate trait from `isobmff::ByteSource`, with the same shape.
FilmCraft implements both for one type, which a single shared trait would reject as conflicting
impls.
@@ -109,7 +149,7 @@ let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-
raw for the caller to interpret.
- **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is
merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one
table entry per audio frame.
table entry per audio frame. A chunk above 16 MiB becomes several samples of at most 16 MiB.
#### Parsed boxes
`ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language
@@ -145,7 +185,8 @@ Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `ga
#### Robustness
The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are
checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is
clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples.
clamped to what fits in the file. `trun` counts are checked, and the whole file (all tracks and
fragments) is capped at 2²⁶ samples.
`moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with
`Error::Unsupported`.
@@ -170,7 +211,8 @@ let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end
- Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses
QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box.
- Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is
sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk.
sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk;
a PCM chunk ends at 1 MiB.
- Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed
entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`.
PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as
@@ -224,7 +266,7 @@ for b in BoxIter::new(&bytes) {
- `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a
QuickTime color table are not decoded.
- Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`.
- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`,
- Muxer: one `mdat`, no chunk-size or duration limits outside PCM (the caller controls interleaving), no `sdtp`,
`sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented
writer has no PCM, `sidx` or `mfra`.
@@ -274,6 +316,10 @@ while let Some(p) = d.next_packet()? { // file order, all tracks
- **Damage:** an undecodable or oversized element starts a byte scan for the next plausible level-1
element (a Cluster whose first child is Timestamp, or a sized metadata element); a truncated final
block is dropped. Problems are listed in `MkvFile::warnings`.
- **Limits:** indexing stops with `Error::TooLarge` past 2²⁶ frames in the file, past one frame
per file byte (each frame counts its stored size, and at least one byte), or past 2¹⁶
TrackEntries. Level-1 elements read at most the file's length in all, so SeekHead entries that
point into one another are skipped with a warning.
- **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present, otherwise Cues
(refined by scanning the cued cluster for the exact keyframe), otherwise builds the index by
scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list.
@@ -321,7 +367,8 @@ while let Some(p) = d.next_packet()? { // file order, all tracks
keyframe, data, duration_ns)` per frame, then `finish()`. It writes the EBML header, Segment,
SeekHead, Info (with Duration), Tracks, Clusters of SimpleBlocks (a new cluster at a video keyframe
after `cluster_duration_ns`), BlockGroups for frames whose duration differs from the track's
`default_duration_ns`, optional audio lacing (Xiph, EBML, fixed-size), and Cues (video keyframes,
`default_duration_ns`, optional audio lacing (Xiph, EBML, fixed-size; for tracks with a
`default_duration_ns`, at most 256 frames, ending at a timestamp gap), and Cues (video keyframes,
or the first frame of each track per cluster). `MuxOptions::doc_type` = `"webm"` writes WebM.
CodecPrivate is fixed when the writer is created, so a codec whose configuration arrives with the
first frame (AV1 from `rav1e`) creates the writer after that frame.
+34 -26
View File
@@ -13,6 +13,8 @@ pub enum BitError {
Eof,
/// An Exp-Golomb code longer than 32 leading zeros.
InvalidExpGolomb,
/// An AVCC/HVCC NAL length prefix width outside 1 to 4 bytes.
InvalidNalLengthSize,
}
impl fmt::Display for BitError {
@@ -20,6 +22,7 @@ impl fmt::Display for BitError {
match self {
BitError::Eof => f.write_str("unexpected end of bitstream"),
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
BitError::InvalidNalLengthSize => f.write_str("NAL length prefix must be 1 to 4 bytes"),
}
}
}
@@ -33,11 +36,15 @@ pub struct BitReader<'a> {
data: &'a [u8],
/// Position in bits.
pos: usize,
/// Bit position of the last set bit (the `rbsp_stop_one_bit`), found once so that
/// [`Self::more_rbsp_data`] does not rescan a long zero suffix on every call.
rbsp_stop: Option<usize>,
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
let rbsp_stop = data.iter().rposition(|&b| b != 0).map(|i| i.saturating_mul(8).saturating_add(7 - data[i].trailing_zeros() as usize));
Self { data, pos: 0, rbsp_stop }
}
pub fn data(&self) -> &'a [u8] {
self.data
@@ -167,22 +174,7 @@ impl<'a> BitReader<'a> {
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
pub fn more_rbsp_data(&self) -> bool {
let total = self.data.len().saturating_mul(8);
if self.pos >= total {
return false;
}
// find last set bit (the rbsp_stop_one_bit)
let mut last = None;
for (i, &b) in self.data.iter().enumerate().rev() {
if b != 0 {
last = Some(i.saturating_mul(8).saturating_add(7 - b.trailing_zeros() as usize));
break;
}
}
match last {
Some(stop) => self.pos < stop,
None => false,
}
self.rbsp_stop.is_some_and(|stop| self.pos < stop)
}
}
@@ -335,15 +327,16 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
out
}
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
/// A zero `length_size` cannot split non-empty data and returns [`BitError::Eof`].
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1 to 4 bytes) into NAL units.
/// Any other `length_size` returns [`BitError::InvalidNalLengthSize`], even for empty data.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
if !(1..=4).contains(&length_size) {
return Err(BitError::InvalidNalLengthSize);
}
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
// Compare against the remaining length so a hostile prefix cannot overflow `usize`
// (32-bit on wasm).
if length_size == 0 || length_size > data.len() - i {
if length_size > data.len() - i {
return Err(BitError::Eof);
}
let mut len = 0usize;
@@ -472,6 +465,19 @@ mod tests {
assert!(!r.more_rbsp_data());
}
#[test]
fn more_rbsp_data_finds_the_stop_bit_before_a_zero_suffix() {
let mut d = vec![0b1010_0000];
d.resize(1 << 16, 0);
let mut r = BitReader::new(&d);
assert_eq!(r.rbsp_stop, Some(2));
assert!(r.more_rbsp_data());
r.skip(2).unwrap();
assert!(!r.more_rbsp_data());
assert_eq!(BitReader::new(&[0; 4]).rbsp_stop, None);
assert!(!BitReader::new(&[0; 4]).more_rbsp_data());
}
#[test]
fn crc() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
@@ -490,13 +496,15 @@ mod tests {
}
#[test]
fn length_prefixed_rejects_zero_width_and_oversized_length() {
assert_eq!(length_prefixed_nals(&[], 0).unwrap(), Vec::<&[u8]>::new());
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::Eof));
fn length_prefixed_rejects_invalid_width_and_oversized_length() {
for size in [0, 5, 8, usize::MAX] {
assert_eq!(length_prefixed_nals(&[], size), Err(BitError::InvalidNalLengthSize), "size {size}");
assert_eq!(length_prefixed_nals(&[0; 16], size), Err(BitError::InvalidNalLengthSize), "size {size}");
}
// `(lengthSizeMinusOne & 3) + 1` can be 3; such samples still split.
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
assert_eq!(length_prefixed_nals(&[0, 1], usize::MAX), Err(BitError::Eof));
}
#[test]
+26 -11
View File
@@ -491,7 +491,7 @@ pub struct VpcConfig {
pub bit_depth: u8,
pub chroma_subsampling: u8,
pub full_range: bool,
pub color_primaries: u8,
pub colour_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
pub codec_init: Vec<u8>,
@@ -512,7 +512,7 @@ impl VpcConfig {
let profile = c.u8()?;
let level = c.u8()?;
let a = c.u8()?;
let color_primaries = c.u8()?;
let colour_primaries = c.u8()?;
let transfer_characteristics = c.u8()?;
let matrix_coefficients = c.u8()?;
let n = c.u16()? as usize;
@@ -523,7 +523,7 @@ impl VpcConfig {
bit_depth: a >> 4,
chroma_subsampling: (a >> 1) & 7,
full_range: a & 1 != 0,
color_primaries,
colour_primaries,
transfer_characteristics,
matrix_coefficients,
codec_init,
@@ -535,7 +535,7 @@ impl VpcConfig {
b.u8(self.profile);
b.u8(self.level);
b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8);
b.u8(self.color_primaries);
b.u8(self.colour_primaries);
b.u8(self.transfer_characteristics);
b.u8(self.matrix_coefficients);
b.u16(self.codec_init.len() as u16);
@@ -1009,6 +1009,9 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
let _size_of_struct = c.u32()?;
a.sample_rate = c.f64()?;
a.channels = c.u32()?;
if a.channels > u32::from(u16::MAX) {
return Err(Error::Invalid(format!("QuickTime v2 sound entry with {} channels", a.channels)));
}
let _always = c.u32()?;
a.const_bits_per_channel = c.u32()?;
a.lpcm_flags = c.u32()?;
@@ -1052,7 +1055,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
},
b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() },
b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() },
b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)),
b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)?),
b"ipcm" | b"fpcm" => {
let mut pcm = PcmConfig {
bits: a.sample_size,
@@ -1078,11 +1081,12 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None })
}
fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig {
fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> Result<PcmConfig> {
let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() };
match f {
b"lpcm" => {
p.bits = a.const_bits_per_channel as u16;
p.bits = u16::try_from(a.const_bits_per_channel)
.map_err(|_| Error::Invalid(format!("lpcm with {} bits per channel", a.const_bits_per_channel)))?;
p.float = a.lpcm_flags & 1 != 0;
p.big_endian = a.lpcm_flags & 2 != 0;
p.signed = a.lpcm_flags & 4 != 0 || p.float;
@@ -1113,7 +1117,7 @@ fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig {
}
_ => {}
}
p
Ok(p)
}
fn parse_btrt(p: &[u8]) -> Result<BitRate> {
@@ -1651,7 +1655,7 @@ mod tests {
bit_depth: 10,
chroma_subsampling: 1,
full_range: true,
color_primaries: 9,
colour_primaries: 9,
transfer_characteristics: 16,
matrix_coefficients: 9,
codec_init: vec![],
@@ -1733,8 +1737,19 @@ mod tests {
assert_eq!(PcmConfig { bits: 16, channels: u32::MAX, ..Default::default() }.bytes_per_frame(), u32::MAX);
let ok = parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(2, 16), FourCc(*b"soun"), true).unwrap();
assert!(matches!(ok.codec, CodecConfig::Pcm(p) if p.bytes_per_frame() == 4));
let hostile = parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(u32::MAX, 16), FourCc(*b"soun"), true).unwrap();
assert!(matches!(hostile.codec, CodecConfig::Pcm(p) if p.bytes_per_frame() == u32::MAX));
}
#[test]
fn qt_v2_pcm_rejects_channels_and_bits_past_16_bits() {
let entry = |channels, bits| parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(channels, bits), FourCc(*b"soun"), true);
assert!(matches!(entry(u32::MAX, 16), Err(Error::Invalid(_))));
assert!(matches!(entry(65_536, 16), Err(Error::Invalid(_))));
assert!(entry(65_535, 16).is_ok());
// 0x0001_0010 would read as 16-bit PCM, and 0x0001_0000 would fall back to the sample size.
assert!(matches!(entry(2, 0x0001_0010), Err(Error::Invalid(_))));
assert!(matches!(entry(2, 0x0001_0000), Err(Error::Invalid(_))));
let ok = entry(2, 24).unwrap();
assert!(matches!(ok.codec, CodecConfig::Pcm(p) if p.bits == 24));
}
#[test]
+241 -35
View File
@@ -8,12 +8,33 @@ use std::collections::HashMap;
/// Hard cap on the sample count of the whole file, all tracks and fragments (after PCM chunk merging).
const MAX_SAMPLES: usize = 1 << 26;
/// Bytes of file each sample needs beyond the first `MIN_SAMPLE_ALLOWANCE` samples. A [`Sample`]
/// takes 40 bytes of memory and a constant-size `stsz` or a `trun` without per-sample fields
/// describes one in a byte or none, so without this a file could make the sample tables about
/// 40 times its own size. A table-backed sample has a 4-byte `stsz` or `trun` entry.
const FILE_BYTES_PER_SAMPLE: u64 = 4;
/// Samples any file may hold, however short: 40 MiB of sample tables.
const MIN_SAMPLE_ALLOWANCE: usize = 1 << 20;
/// Hard cap on the size of an in-memory `moov`/`moof` box.
const MAX_HEADER_BOX: u64 = 1 << 30;
/// Most text all metadata entries may hold together; entries past it are dropped. One `keys`
/// name can be named by many `ilst` items, each a copy, so the file's size does not bound this.
const MAX_METADATA_BYTES: usize = 4 << 20;
/// What an entry costs beyond its text, so that empty entries are bounded too.
const METADATA_ENTRY_COST: usize = 64;
/// Largest sample built from a PCM chunk; larger chunks are split. Writers that put a whole
/// recording in one chunk would otherwise give samples past `u32::MAX` bytes.
const PCM_MAX_SAMPLE_BYTES: u64 = 1 << 24;
/// Frames in one piece of a split PCM chunk: whole units, at most `PCM_MAX_SAMPLE_BYTES`.
fn pcm_piece_frames(unit: u64, unit_bytes: u64) -> u64 {
(PCM_MAX_SAMPLE_BYTES / unit_bytes.max(1)).max(1) * unit
}
/// What is left of the file-wide limits while tracks and fragments are parsed.
struct SampleBudget {
/// Samples (`MAX_SAMPLES` minus those already built).
/// Samples (what the file's length allows, at most `MAX_SAMPLES`, minus those already built).
samples: usize,
/// Bytes of file for samples whose count no table entry backs (constant-size `stsz`, `trun`
/// without per-sample fields). Each such sample claims at least one byte, so a file can't
@@ -22,6 +43,11 @@ struct SampleBudget {
}
impl SampleBudget {
fn new(file_len: u64) -> Self {
let by_len = usize::try_from(file_len / FILE_BYTES_PER_SAMPLE).unwrap_or(usize::MAX);
Self { samples: by_len.clamp(MIN_SAMPLE_ALLOWANCE, MAX_SAMPLES), implied_bytes: file_len }
}
fn take(&mut self, n: usize) -> Result<()> {
self.samples = self.samples.checked_sub(n).ok_or(Error::TooLarge("sample count"))?;
Ok(())
@@ -305,6 +331,7 @@ pub fn open<S: ByteSource>(src: S) -> Result<Mp4File> {
}
let mut trex: HashMap<u32, Trex> = HashMap::new();
let mut trak_payloads = Vec::new();
let mut meta_left = MAX_METADATA_BYTES;
for b in boxes(&moov) {
let b = b?;
match &b.kind.0 {
@@ -334,12 +361,12 @@ pub fn open<S: ByteSource>(src: S) -> Result<Mp4File> {
}
}
}
b"udta" => parse_udta(b.payload, &mut file.metadata),
b"meta" => parse_meta(b.payload, &mut file.metadata),
b"udta" => parse_udta(b.payload, &mut file.metadata, &mut meta_left),
b"meta" => parse_meta(b.payload, &mut file.metadata, &mut meta_left),
_ => {}
}
}
let mut budget = SampleBudget { samples: MAX_SAMPLES, implied_bytes: len };
let mut budget = SampleBudget::new(len);
for p in trak_payloads {
let t = parse_trak(p, &file, len, &mut budget)?;
file.tracks.push(t);
@@ -347,8 +374,13 @@ pub fn open<S: ByteSource>(src: S) -> Result<Mp4File> {
// Movie fragments.
let mut next_dts: Vec<i64> =
file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts.saturating_add(i64::from(s.duration))).unwrap_or(0)).collect();
// A duplicate track ID resolves to its first track.
let mut track_index = HashMap::new();
for (i, t) in file.tracks.iter().enumerate() {
track_index.entry(t.id).or_insert(i);
}
for (moof_off, moof) in &moofs {
parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, &mut budget)?;
parse_moof(*moof_off, moof, &mut file.tracks, &track_index, &trex, &mut next_dts, &mut budget)?;
}
for t in &mut file.tracks {
if file.fragmented && !moofs.is_empty() {
@@ -719,15 +751,16 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m
if let Some(p) = pcm {
t.pcm_chunked = true;
let a = t.audio().cloned().unwrap_or_default();
let frame_bytes = |frames: u64| -> u64 {
if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 {
frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64
} else if const_size > 1 {
frames * const_size as u64
} else {
frames * p.bytes_per_frame() as u64
}
// Frames come in units of `unit` frames and `unit_bytes` bytes (QuickTime v1 packets).
let (unit, unit_bytes) = if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 {
(u64::from(a.samples_per_packet), u64::from(a.bytes_per_frame))
} else if const_size > 1 {
(1, u64::from(const_size))
} else {
(1, u64::from(p.bytes_per_frame()))
};
let frame_bytes = |frames: u64| -> u64 { (frames / unit).saturating_mul(unit_bytes) };
let piece_frames = pcm_piece_frames(unit, unit_bytes);
if chunks.len() > budget.samples {
return Err(Error::TooLarge("chunk count"));
}
@@ -740,18 +773,35 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m
if spc == 0 {
continue;
}
let dur = stts_c.take(spc as u64);
let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32;
samples.push(Sample {
offset: off,
size,
dts,
pts: dts,
duration: dur.min(u32::MAX as u64) as u32,
is_sync: true,
description_index: stsc[stsc_i].2,
});
dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64);
// A chunk past PCM_MAX_SAMPLE_BYTES becomes several samples.
let mut left = spc as u64;
let mut pos = off;
while left > 0 {
if samples.len() >= budget.samples {
return Err(Error::TooLarge("sample count"));
}
let split = frame_bytes(left) > PCM_MAX_SAMPLE_BYTES;
let frames = if split { piece_frames } else { left };
let dur = stts_c.take(frames);
let size = frame_bytes(frames);
if split {
// Each piece is at least half of PCM_MAX_SAMPLE_BYTES, so the file's size
// bounds how many a hostile chunk size can make.
budget.implied_bytes = budget.implied_bytes.checked_sub(size).ok_or(Error::TooLarge("PCM chunk size"))?;
}
samples.push(Sample {
offset: pos,
size: size.min(u32::MAX as u64) as u32,
dts,
pts: dts,
duration: dur.min(u32::MAX as u64) as u32,
is_sync: true,
description_index: stsc[stsc_i].2,
});
dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64);
pos = pos.saturating_add(size);
left -= frames;
}
sidx += spc;
if sidx >= sample_count {
break;
@@ -764,7 +814,20 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m
if sample_count > budget.samples {
return Err(Error::TooLarge("sample count"));
}
samples.reserve(sample_count);
// Reserve only what the chunk map can place. A declared count far past it would keep
// capacity the budget below never charges, track after track.
let mut mapped = 0usize;
let mut map_i = 0usize;
for ci in 0..chunks.len() {
while map_i + 1 < stsc.len() && stsc[map_i + 1].0 as usize <= ci + 1 {
map_i += 1;
}
mapped = mapped.saturating_add(stsc.get(map_i).map_or(0, |e| e.1 as usize));
if mapped >= sample_count {
break;
}
}
samples.try_reserve(sample_count.min(mapped)).map_err(|_| Error::TooLarge("sample count"))?;
let mut stss_i = 0usize;
'chunks: for (ci, &off) in chunks.iter().enumerate() {
while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 {
@@ -807,6 +870,7 @@ fn parse_moof(
moof_off: u64,
moof: &[u8],
tracks: &mut [Track],
track_index: &HashMap<u32, usize>,
trex: &HashMap<u32, Trex>,
next_dts: &mut [i64],
budget: &mut SampleBudget,
@@ -834,7 +898,7 @@ fn parse_moof(
let mut c = Cur::labeled(tfhd, "tfhd");
let (_, fl) = c.full_header()?;
let id = c.u32()?;
let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue };
let Some(&ti) = track_index.get(&id) else { continue };
let d = trex.get(&id).copied().unwrap_or_default();
let base = if fl & 1 != 0 {
c.u64()?
@@ -903,20 +967,42 @@ fn parse_moof(
Ok(())
}
fn parse_udta(p: &[u8], md: &mut Metadata) {
/// Takes an entry of `key_len` and `value_len` bytes from `left`; false once it would not fit.
fn charge_metadata(left: &mut usize, key_len: usize, value_len: usize) -> bool {
let cost = key_len.saturating_add(value_len).saturating_add(METADATA_ENTRY_COST);
match left.checked_sub(cost) {
Some(rest) => {
*left = rest;
true
}
None => false,
}
}
/// Length of `String::from_utf8_lossy(bytes)` without building it: each invalid sequence
/// becomes one three-byte U+FFFD.
fn lossy_len(bytes: &[u8]) -> usize {
bytes.utf8_chunks().map(|c| c.valid().len() + if c.invalid().is_empty() { 0 } else { 3 }).sum()
}
fn parse_udta(p: &[u8], md: &mut Metadata, left: &mut usize) {
for b in boxes(p).map_while(|b| b.ok()) {
if b.kind == b"meta" {
parse_meta(b.payload, md);
parse_meta(b.payload, md, left);
} else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 {
let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize;
if let Some(s) = b.payload.get(4..4 + n) {
md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned()));
// Charged at the stored text's length, before the value is converted.
let key = b.kind.to_string_lossy();
if let Some(s) = b.payload.get(4..4 + n)
&& charge_metadata(left, key.len(), lossy_len(s))
{
md.entries.push((key, String::from_utf8_lossy(s).into_owned()));
}
}
}
}
fn parse_meta(p: &[u8], md: &mut Metadata) {
fn parse_meta(p: &[u8], md: &mut Metadata, left: &mut usize) {
// QuickTime `meta` is a plain box; ISO `meta` is a full box.
let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) };
let mut keys: Vec<String> = Vec::new();
@@ -936,13 +1022,20 @@ fn parse_meta(p: &[u8], md: &mut Metadata) {
let Some(ilst) = find(body, b"ilst") else { return };
for item in boxes(ilst).map_while(|b| b.ok()) {
let idx = item.kind.as_u32() as usize;
let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() };
let named = if idx >= 1 { keys.get(idx - 1) } else { None };
if let Some(d) = find(item.payload, b"data")
&& d.len() >= 8
{
let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF;
// Charged before the key is copied (every item naming one long key is another copy)
// and before the value is converted, at the lengths they will be stored at.
if ty == 1 || ty == 0 {
md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned()));
let fallback = if named.is_none() { Some(item.kind.to_string_lossy()) } else { None };
let key_len = named.or(fallback.as_ref()).map_or(0, String::len);
if charge_metadata(left, key_len, lossy_len(&d[8..])) {
let key = named.cloned().or(fallback).unwrap_or_default();
md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned()));
}
}
}
}
@@ -1002,7 +1095,7 @@ mod tests {
b.end(il);
b.end(meta);
let mut md = Metadata::default();
parse_udta(&b.buf, &mut md);
parse_udta(&b.buf, &mut md, &mut { MAX_METADATA_BYTES });
assert_eq!(md.title(), Some("hi"));
assert_eq!(md.get("©ART"), Some("me"));
}
@@ -1219,6 +1312,66 @@ mod tests {
assert_eq!(open(d.as_slice()).unwrap().tracks[0].samples.len(), 4);
}
#[test]
fn one_long_key_named_by_many_items_stays_within_the_metadata_budget() {
let key = vec![b'k'; 64 * 1024];
let mut b = BoxBuf::new();
b.leaf(b"hdlr", &[0; 24]);
let keys = b.start_full(b"keys", 0, 0);
b.u32(1);
b.u32(8 + key.len() as u32);
b.bytes(b"mdta");
b.bytes(&key);
b.end(keys);
let il = b.start(b"ilst");
for _ in 0..1000 {
let it = b.start(&1u32.to_be_bytes());
let d = b.start(b"data");
b.u32(1);
b.u32(0);
b.bytes(b"v");
b.end(d);
b.end(it);
}
b.end(il);
let mut payload = vec![0; 4];
payload.extend_from_slice(&b.buf);
let mut md = Metadata::default();
parse_meta(&payload, &mut md, &mut { MAX_METADATA_BYTES });
assert!(!md.entries.is_empty() && md.entries.len() < 1000, "{}", md.entries.len());
assert!(md.entries.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>() <= MAX_METADATA_BYTES);
assert_eq!(md.entries[0].0.len(), key.len());
}
#[test]
fn metadata_is_charged_at_its_stored_length() {
assert_eq!(lossy_len(b"plain"), 5);
for bytes in [&b"a\xFFb"[..], b"\xFF\xFE\xFD", b"\xE2\x82", "\u{a9}nam".as_bytes()] {
assert_eq!(lossy_len(bytes), String::from_utf8_lossy(bytes).len(), "{bytes:?}");
}
// Invalid UTF-8 triples in size when converted; the budget sees the converted size.
let mut b = BoxBuf::new();
let m = b.start(&[0xA9, b'n', b'a', b'm']);
b.u16(1000);
b.u16(0);
b.bytes(&[0xFF; 1000]);
b.end(m);
let mut md = Metadata::default();
let mut left = 3000 + METADATA_ENTRY_COST + 5;
parse_udta(&b.buf, &mut md, &mut left);
assert_eq!(md.entries.len(), 1);
assert_eq!(md.entries[0].0.len() + md.entries[0].1.len(), 3000 + 5 - left);
}
#[test]
fn a_declared_sample_count_past_the_chunk_map_reserves_only_what_it_maps() {
// stsz claims u32::MAX samples; the two chunks place four.
let d = handmade(Some((1, u32::MAX)));
let f = open(d.as_slice()).unwrap();
assert_eq!(f.tracks[0].samples.len(), 4);
assert!(f.tracks[0].samples.capacity() < 64, "{}", f.tracks[0].samples.capacity());
}
#[test]
fn absurd_counts_are_bounded() {
// Constant-size stsz claiming 4 billion samples is clamped to what fits in the file.
@@ -1236,4 +1389,57 @@ mod tests {
d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes());
assert!(open(d.as_slice()).is_err());
}
/// `data` followed by zeros up to `len` bytes.
struct Padded(Vec<u8>, u64);
impl ByteSource for Padded {
fn len(&self) -> u64 {
self.1
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
buf.fill(0);
let start = (offset as usize).min(self.0.len());
let end = (start + buf.len()).min(self.0.len());
buf[..end - start].copy_from_slice(&self.0[start..end]);
Ok(())
}
}
#[test]
fn samples_scale_with_the_file_length() {
assert_eq!(SampleBudget::new(1000).samples, MIN_SAMPLE_ALLOWANCE);
assert_eq!(SampleBudget::new(64 << 20).samples, 16 << 20);
assert_eq!(SampleBudget::new(u64::MAX).samples, MAX_SAMPLES);
// A constant-size stsz of one-byte samples claims as many as a 64 MiB file has bytes.
let d = handmade(Some((1, u32::MAX)));
assert!(matches!(open(Padded(d, 64 << 20)), Err(Error::TooLarge(_))));
}
#[test]
fn a_pcm_chunk_past_4_gib_reads_as_bounded_samples() {
use crate::isobmff::{Brand, Mp4Writer, PcmConfig, TrackConfig, WriteSample, WriterOptions};
let pcm = PcmConfig { bits: 8, float: false, big_endian: false, signed: false, channels: 1, sample_rate: 48000.0 };
let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap();
w.write_sample(t, WriteSample { data: &[0; 4], duration: 0, composition_offset: 0, is_sync: true }).unwrap();
let mut d = w.finish_faststart().unwrap().into_inner();
// One chunk of u32::MAX one-byte frames, as a writer without chunk limits makes for a
// 4 GiB recording.
let frames = u32::MAX.to_be_bytes();
for (kind, at) in [(b"stts", 12), (b"stsz", 12), (b"stsc", 16)] {
let p = d.windows(4).position(|w| w == kind).unwrap();
d[p + at..p + at + 4].copy_from_slice(&frames);
}
// In a file too small for it, the claim is clamped to the file.
let f = open(d.as_slice()).unwrap();
assert!(f.tracks[0].samples.iter().map(|s| u64::from(s.size)).sum::<u64>() <= d.len() as u64);
let len = d.len() as u64 + u64::from(u32::MAX);
let f = open(Padded(d, len)).unwrap();
let s = &f.tracks[0].samples;
assert_eq!(s.len(), 256);
assert!(s.iter().all(|s| u64::from(s.size) <= PCM_MAX_SAMPLE_BYTES));
assert_eq!(s.iter().map(|s| u64::from(s.size)).sum::<u64>(), u64::from(u32::MAX));
assert_eq!(s.iter().map(|s| u64::from(s.duration)).sum::<u64>(), u64::from(u32::MAX));
assert!(s.windows(2).all(|w| w[1].offset == w[0].offset + u64::from(w[0].size) && w[1].dts == w[0].dts + i64::from(w[0].duration)));
}
}
+74 -17
View File
@@ -85,6 +85,18 @@ fn handler_of(e: &SampleEntry) -> Handler {
}
}
/// The `CodecConfig` variant, and the fourcc of variants that cover several codecs.
fn same_codec(a: &CodecConfig, b: &CodecConfig) -> bool {
use CodecConfig::*;
match (a, b) {
(ProRes { fourcc: x }, ProRes { fourcc: y })
| (Jpeg { fourcc: x }, Jpeg { fourcc: y })
| (Dnx { fourcc: x }, Dnx { fourcc: y })
| (Unknown { fourcc: x, .. }, Unknown { fourcc: y, .. }) => x == y,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
/// Accumulated sample tables for one track.
#[derive(Clone, Debug, Default)]
struct Tables {
@@ -109,16 +121,51 @@ impl Tables {
fn new() -> Self {
Tables { all_sync: true, ..Default::default() }
}
fn push_run<T: PartialEq + Copy>(runs: &mut Vec<(u32, T)>, count: u32, v: T) {
fn push_run<T: PartialEq + Copy>(runs: &mut Vec<(u32, T)>, mut count: u64, v: T) {
if let Some(last) = runs.last_mut()
&& last.1 == v
&& last.0.checked_add(count).is_some()
{
last.0 += count;
return;
let n = count.min(u64::from(u32::MAX - last.0));
last.0 += n as u32;
count -= n;
}
while count > 0 {
let n = count.min(u64::from(u32::MAX));
runs.push((n as u32, v));
count -= n;
}
runs.push((count, v));
}
/// Records `count` samples written at `offset`, `sample_bytes` each, extending the last
/// chunk when the write follows it. A chunk holds at most `max` samples.
fn push_chunks(&mut self, mut offset: u64, mut count: u64, sample_bytes: u64, contiguous: bool, max: u32) {
if contiguous && let Some(c) = self.chunks.last_mut() {
let n = count.min(u64::from(max.saturating_sub(c.1)));
c.1 += n as u32;
count -= n;
offset += n * sample_bytes;
}
while count > 0 {
let n = count.min(u64::from(max));
self.chunks.push((offset, n as u32));
count -= n;
offset += n * sample_bytes;
}
}
}
/// `stsz` counts samples (PCM: frames) in 32 bits.
fn too_many_samples() -> Error {
Error::Mux("more than 2^32 - 1 samples in one track".into())
}
/// PCM chunk size limit, ffmpeg's: readers that take each PCM chunk as one sample (this crate's
/// demuxer among them) get samples of bounded size.
const PCM_CHUNK_BYTES: u64 = 1 << 20;
/// Frames per PCM chunk: at most [`PCM_CHUNK_BYTES`], at least one frame.
fn pcm_chunk_frames(frame_bytes: u32) -> u32 {
(PCM_CHUNK_BYTES / u64::from(frame_bytes.max(1))).max(1) as u32
}
struct WTrack {
@@ -420,11 +467,12 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f
b.u32(1);
}
b.end(m);
let sample_count = u32::try_from(tb.sample_count).map_err(|_| too_many_samples())?;
let m = b.start_full(b"stsz", 0, 0);
match tb.const_size {
Some(s) => {
b.u32(s);
b.u32(tb.sample_count.min(u32::MAX as u64) as u32);
b.u32(sample_count);
}
None => {
let first = tb.sizes.first().copied().unwrap_or(0);
@@ -432,10 +480,10 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f
// all-empty samples must still be written as a table.
if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) {
b.u32(first);
b.u32(tb.sizes.len() as u32);
b.u32(sample_count);
} else {
b.u32(0);
b.u32(tb.sizes.len() as u32);
b.u32(sample_count);
for &s in &tb.sizes {
b.u32(s);
}
@@ -543,11 +591,14 @@ impl<W: Write + Seek> Mp4Writer<W> {
/// Replace a track's sample entry before the file is finished (the `moov` is written last), for
/// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`.
/// The new entry must keep the media type and, for PCM, the frame size.
/// The new entry must keep the codec and, for PCM, the frame size.
pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> {
let t = self.tracks.get_mut(track).ok_or(Error::NoSuchTrack(track))?;
if handler_of(&entry) != t.handler || validate_track(&entry, t.cfg.timescale)? != t.pcm_frame_bytes {
return Err(Error::Mux("replacement sample entry changes the track's media type or PCM layout".into()));
if handler_of(&entry) != t.handler
|| !same_codec(&entry.codec, &t.cfg.entry.codec)
|| validate_track(&entry, t.cfg.timescale)? != t.pcm_frame_bytes
{
return Err(Error::Mux("replacement sample entry changes the track's codec or PCM layout".into()));
}
t.cfg.entry = entry;
Ok(())
@@ -589,7 +640,7 @@ impl<W: Write + Seek> Mp4Writer<W> {
if !s.data.len().is_multiple_of(fb as usize) {
return Err(Error::Mux("PCM data is not a whole number of frames".into()));
}
((s.data.len() / fb as usize) as u32, 1u32)
(s.data.len() as u64 / u64::from(fb), 1u32)
}
None => {
if s.data.len() > u32::MAX as usize {
@@ -601,6 +652,9 @@ impl<W: Write + Seek> Mp4Writer<W> {
if count == 0 {
return Ok(());
}
if tr.t.sample_count + count > u64::from(u32::MAX) {
return Err(too_many_samples());
}
self.w.write_all(s.data)?;
let offset = self.pos;
self.pos += s.data.len() as u64;
@@ -618,13 +672,13 @@ impl<W: Write + Seek> Mp4Writer<W> {
t.all_sync = false;
}
}
t.sample_count += count as u64;
t.sample_count += count;
let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 };
t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64);
t.media_duration += count as u64 * dur as u64;
match t.chunks.last_mut() {
Some(c) if contiguous => c.1 += count,
_ => t.chunks.push((offset, count)),
t.media_duration += count * dur as u64;
match tr.pcm_frame_bytes {
Some(fb) => t.push_chunks(offset, count, u64::from(fb), contiguous, pcm_chunk_frames(fb)),
None => t.push_chunks(offset, count, s.data.len() as u64, contiguous, u32::MAX),
}
Ok(())
}
@@ -960,6 +1014,9 @@ mod tests {
assert!(w.set_sample_entry(a, SampleEntry::pcm(PcmConfig { big_endian: true, ..pcm })).is_ok());
assert!(w.set_sample_entry(a, SampleEntry::pcm(PcmConfig { bits: 24, ..pcm })).is_err());
assert!(w.set_sample_entry(a, avc_entry()).is_err());
let v = w.add_track(TrackConfig::new(avc_entry(), 90_000)).unwrap();
assert!(w.set_sample_entry(v, avc_entry()).is_ok());
assert!(w.set_sample_entry(v, SampleEntry::jpeg(64, 64)).is_err());
assert!(w.set_sample_entry(f, SampleEntry::flac(&[1; 34])).is_ok());
assert!(w.set_sample_entry(f, SampleEntry::pcm(pcm)).is_err());
assert!(matches!(w.set_sample_entry(5, avc_entry()), Err(Error::NoSuchTrack(5))));
+107 -30
View File
@@ -1,6 +1,6 @@
//! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking.
use std::collections::{HashSet, VecDeque};
use std::collections::{HashMap, VecDeque};
use crate::matroska::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size};
use crate::matroska::error::{Error, Result};
@@ -12,6 +12,42 @@ use crate::matroska::track::{Sample, Track, TrackKind, gcd};
const WIN: usize = 64 * 1024;
/// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…).
const MAX_META: u64 = 256 << 20;
/// Cap on the frames indexed from one file, all tracks together.
const MAX_FRAMES: usize = 1 << 26;
/// Bytes of file each indexed frame needs beyond the first `MIN_FRAME_ALLOWANCE` frames. An
/// indexed frame takes about 76 bytes of memory (its [`Sample`] and keyframe entry) and fixed
/// lacing can describe one in no bytes at all, so without this a file could make the index
/// about 76 times its own size. Real frames carry a block header or lace size and their data,
/// well over 8 bytes each.
const FILE_BYTES_PER_FRAME: u64 = 8;
/// Frames any file may index, however short: about 80 MiB of index.
const MIN_FRAME_ALLOWANCE: usize = 1 << 20;
/// Cap on the TrackEntries of a file. Each kept entry needs only 5 bytes of file but becomes a
/// [`Track`] of several hundred bytes.
pub(super) const MAX_TRACKS: usize = 1 << 16;
/// What is left of the file-wide limits while clusters are scanned.
struct FrameBudget {
/// Frames (what the file's length allows, at most `MAX_FRAMES`, minus those already indexed).
frames: usize,
/// Bytes of file not yet claimed by a frame. Each frame claims its stored size and at least
/// one byte, because fixed-size lacing describes up to 256 empty frames in a 4-byte block.
bytes: u64,
}
impl FrameBudget {
fn new(file_len: u64) -> Self {
let by_len = usize::try_from(file_len / FILE_BYTES_PER_FRAME).unwrap_or(usize::MAX);
Self { frames: by_len.clamp(MIN_FRAME_ALLOWANCE, MAX_FRAMES), bytes: file_len }
}
fn take(&mut self, frames: &[(u64, u32)]) -> Result<()> {
self.frames = self.frames.checked_sub(frames.len()).ok_or(Error::TooLarge("frame count"))?;
let bytes = frames.iter().map(|&(_, n)| u64::from(n.max(1))).sum::<u64>();
self.bytes = self.bytes.checked_sub(bytes).ok_or(Error::TooLarge("frame count"))?;
Ok(())
}
}
/// Options for [`open_with`] / [`Demuxer::with_options`].
#[derive(Clone, Debug)]
@@ -324,8 +360,15 @@ struct FrameRef {
addition: Option<(u64, u32)>,
}
fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option<usize> + '_ {
move |n| tracks.iter().position(|t| t.number == n)
/// Track number to track index. A duplicate number resolves to its first track.
type TrackIndex = HashMap<u64, usize>;
fn track_index(tracks: &[Track]) -> TrackIndex {
let mut m = HashMap::with_capacity(tracks.len());
for (i, t) in tracks.iter().enumerate() {
m.entry(t.number).or_insert(i);
}
m
}
/// A parsed block header: (track index, relative ts, flags, frames as (offset, size)).
@@ -333,12 +376,7 @@ type BlockHeader = (usize, i16, u8, Vec<(u64, u32)>);
/// Parse the Block/SimpleBlock header at `data_start` (`len` bytes).
/// Returns `None` for unknown tracks / damage.
fn parse_block<S: ByteSource + ?Sized>(
io: &mut Io<'_, S>,
data_start: u64,
len: u64,
lookup: &dyn Fn(u64) -> Option<usize>,
) -> Result<Option<BlockHeader>> {
fn parse_block<S: ByteSource + ?Sized>(io: &mut Io<'_, S>, data_start: u64, len: u64, lookup: &TrackIndex) -> Result<Option<BlockHeader>> {
let head = io.peek(data_start, 16.min(len as usize))?;
let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) };
if head.len() < tl + 3 {
@@ -346,7 +384,7 @@ fn parse_block<S: ByteSource + ?Sized>(
}
let rel = i16::from_be_bytes([head[tl], head[tl + 1]]);
let flags = head[tl + 2];
let Some(track) = lookup(tn) else { return Ok(None) };
let Some(&track) = lookup.get(&tn) else { return Ok(None) };
let hdr = (tl + 3) as u64;
let lacing = Lacing::from_flags(flags);
let frames = if lacing == Lacing::None {
@@ -402,13 +440,7 @@ fn cluster_timestamp(raw: u64) -> i64 {
}
/// Parse one SimpleBlock or BlockGroup element.
fn parse_block_elem<S: ByteSource + ?Sized>(
io: &mut Io<'_, S>,
h: &Hdr,
end: u64,
cluster_ts: i64,
lookup: &dyn Fn(u64) -> Option<usize>,
) -> Result<Option<BlockRef>> {
fn parse_block_elem<S: ByteSource + ?Sized>(io: &mut Io<'_, S>, h: &Hdr, end: u64, cluster_ts: i64, lookup: &TrackIndex) -> Result<Option<BlockRef>> {
let ds = h.data_start();
match h.id {
SIMPLE_BLOCK => {
@@ -514,10 +546,10 @@ fn scan_cluster<S: ByteSource + ?Sized>(
io: &mut Io<'_, S>,
h: &Hdr,
limit: u64,
tracks: &[Track],
lookup: &TrackIndex,
budget: &mut FrameBudget,
warnings: &mut Vec<String>,
) -> Result<(ClusterInfo, Vec<BlockRef>)> {
let lookup = track_lookup(tracks);
let ds = h.data_start();
let known_end = match h.size {
Size::Known(_) => Some(h.end(limit)),
@@ -549,7 +581,8 @@ fn scan_cluster<S: ByteSource + ?Sized>(
match c.id {
TIMESTAMP => ts = cluster_timestamp(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)),
SIMPLE_BLOCK | BLOCK_GROUP => {
if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? {
if let Some(b) = parse_block_elem(io, &c, ce, ts, lookup)? {
budget.take(&b.frames)?;
blocks.push(b);
}
}
@@ -637,7 +670,7 @@ fn open_impl<S: ByteSource + ?Sized>(src: &S, opts: &OpenOptions, cache: &mut Ca
if seg.overruns(len) {
file.warnings.push("Segment extends past end of file (truncated)".into());
}
let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false };
let mut w = Walker { opts, parsed: HashMap::new(), meta_left: len, blocks_seen: false, lookup: None, budget: FrameBudget::new(len) };
w.walk(&mut io, &mut file)?;
finish_tracks(&mut file);
Ok(file)
@@ -645,8 +678,15 @@ fn open_impl<S: ByteSource + ?Sized>(src: &S, opts: &OpenOptions, cache: &mut Ca
struct Walker<'o> {
opts: &'o OpenOptions,
parsed: HashSet<u64>,
/// Start and end of each level-1 element read.
parsed: HashMap<u64, u64>,
/// Bytes level-1 elements may still read or scan. Distinct elements do not overlap, so a
/// valid file stays within its length; SeekHead entries pointing into one another do not.
meta_left: u64,
blocks_seen: bool,
/// Built at the first Cluster after Tracks is parsed.
lookup: Option<TrackIndex>,
budget: FrameBudget,
}
impl Walker<'_> {
@@ -674,7 +714,8 @@ impl Walker<'_> {
break;
}
}
let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?;
let lookup = self.lookup.get_or_insert_with(|| track_index(&file.tracks));
let (ci, blocks) = scan_cluster(io, &h, seg_end, lookup, &mut self.budget, &mut file.warnings)?;
if self.opts.verify_crc && h.size != Size::Unknown {
let data = io.read_vec(h.data_start(), ci.end - h.data_start())?;
self.check_crc(file, h.pos, &data);
@@ -720,7 +761,7 @@ impl Walker<'_> {
continue;
}
let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue };
if self.parsed.contains(&abs) || abs >= file.segment_end {
if self.parsed.contains_key(&abs) || abs >= file.segment_end {
continue;
}
match io.header(abs, file.segment_end)? {
@@ -745,13 +786,24 @@ impl Walker<'_> {
/// Parse a level-1 metadata element; returns its end.
fn level1<S: ByteSource + ?Sized>(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result<u64> {
let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) };
if let Some(&end) = self.parsed.get(&h.pos) {
return Ok(end);
}
let end = match h.size {
Size::Unknown => io.unknown_end(h.data_start(), limit.min(h.data_start().saturating_add(self.meta_left)))?,
Size::Known(_) => h.end(limit),
};
self.parsed.insert(h.pos, end);
if h.overruns(limit) {
file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos));
}
if !self.parsed.insert(h.pos) {
let n = end - h.data_start();
if n > self.meta_left || (h.size == Size::Unknown && n == self.meta_left && end < limit) {
self.meta_left = 0;
file.warnings.push(format!("element 0x{:X} at {} overlaps elements already read; skipped", h.id, h.pos));
return Ok(end);
}
self.meta_left -= n;
if h.id == ATTACHMENTS {
self.attachments(io, file, h.data_start(), end)?;
return Ok(end);
@@ -769,7 +821,11 @@ impl Walker<'_> {
SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)),
INFO => file.info = parse_info(&data),
TRACKS if file.tracks.is_empty() => {
if Children::new(&data).filter(|e| e.id == TRACK_ENTRY).count() > MAX_TRACKS {
return Err(Error::TooLarge("track count"));
}
file.tracks = parse_tracks(&data, file.info.timestamp_scale);
self.lookup = None;
}
CUES => parse_cues(&data, &mut file.cues),
CHAPTERS => parse_chapters(&data, &mut file.chapters),
@@ -931,6 +987,7 @@ pub struct Demuxer<S> {
file: MkvFile,
opts: OpenOptions,
cache: Cache,
lookup: TrackIndex,
pos: u64,
cluster: Option<InCluster>,
pending: VecDeque<FrameRef>,
@@ -962,7 +1019,8 @@ impl<S: ByteSource> Demuxer<S> {
let mut cache = Cache::default();
let file = open_impl(&src, &opts, &mut cache)?;
let pos = file.first_cluster.unwrap_or(file.segment_end);
Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false })
let lookup = track_index(&file.tracks);
Ok(Self { src, file, opts, cache, lookup, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false })
}
pub fn file(&self) -> &MkvFile {
@@ -989,6 +1047,7 @@ impl<S: ByteSource> Demuxer<S> {
}
let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc };
self.file = open_impl(&self.src, &opts, &mut self.cache)?;
self.lookup = track_index(&self.file.tracks);
Ok(())
}
@@ -1076,7 +1135,8 @@ impl<S: ByteSource> Demuxer<S> {
return Ok(None);
}
let mut warnings = Vec::new();
let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?;
let mut budget = FrameBudget::new(io.len);
let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.lookup, &mut budget, &mut warnings)?;
let t = &self.file.tracks[track];
let mut frames = Vec::new();
for b in blocks.iter().filter(|b| b.track == track && b.keyframe) {
@@ -1139,7 +1199,7 @@ impl<S: ByteSource> Demuxer<S> {
let seg_end = self.file.segment_end;
let scale = self.file.info.timestamp_scale;
let mut io = Io::new(&self.src, &mut self.cache);
let lookup = track_lookup(&self.file.tracks);
let lookup = &self.lookup;
loop {
if let Some(c) = self.cluster {
let lim = c.end.unwrap_or(seg_end);
@@ -1179,7 +1239,7 @@ impl<S: ByteSource> Demuxer<S> {
self.cluster = Some(InCluster { ts: v, end: c.end });
}
SIMPLE_BLOCK | BLOCK_GROUP => {
if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? {
if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, lookup)? {
let skip = match self.skip_laces.take() {
Some((off, n)) if off == h.pos => n,
_ => 0,
@@ -1236,3 +1296,20 @@ impl<S: ByteSource> Iterator for Demuxer<S> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_indexed_scale_with_the_file_length() {
assert_eq!(FrameBudget::new(1000).frames, MIN_FRAME_ALLOWANCE);
// 64 MiB of file is enough bytes for 2^26 empty frames, but not enough index memory.
let mut b = FrameBudget::new(64 << 20);
assert_eq!(b.frames, 8 << 20);
let lace = [(0u64, 0u32); 256];
let taken = (0..1 << 18).take_while(|_| b.take(&lace).is_ok()).count();
assert_eq!(taken * lace.len(), 8 << 20);
assert_eq!(FrameBudget::new(u64::MAX).frames, MAX_FRAMES);
}
}
+3
View File
@@ -16,6 +16,8 @@ pub enum Error {
NoSuchTrack(usize),
/// A sample index was out of range.
NoSuchSample(usize),
/// The file describes more than the demuxer's limits allow (frames, tracks).
TooLarge(&'static str),
}
impl fmt::Display for Error {
@@ -27,6 +29,7 @@ impl fmt::Display for Error {
Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"),
Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"),
Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"),
Error::TooLarge(s) => write!(f, "size limit exceeded: {s}"),
}
}
}
+4 -4
View File
@@ -107,10 +107,10 @@ pub const PIXEL_CROP_RIGHT: u32 = 0x54DD;
pub const DISPLAY_WIDTH: u32 = 0x54B0;
pub const DISPLAY_HEIGHT: u32 = 0x54BA;
pub const DISPLAY_UNIT: u32 = 0x54B2;
/// Matroska `ColourSpace`.
pub const COLOR_SPACE: u32 = 0x2E_B524;
/// Matroska `Colour`.
pub const COLOR: u32 = 0x55B0;
/// `ColourSpace`; read into [`VideoInfo::color_space`](crate::matroska::VideoInfo::color_space).
pub const COLOUR_SPACE: u32 = 0x2E_B524;
/// `Colour`; read into [`Color`](crate::matroska::Color).
pub const COLOUR: u32 = 0x55B0;
pub const PROJECTION: u32 = 0x7670;
pub const PROJECTION_TYPE: u32 = 0x7671;
pub const PROJECTION_POSE_YAW: u32 = 0x7673;
+66 -7
View File
@@ -8,6 +8,11 @@ use crate::matroska::track::{AudioInfo, Color, ContentEncoding, MasteringMetadat
// A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains.
const MAX_META_DEPTH: usize = 64;
/// Longest header-stripping prefix accepted. It is copied in front of every frame, laced
/// zero-length frames included, so its length multiplies what a small file can make a reader
/// produce. Real files strip a few bytes of codec header; a longer one is treated as an
/// unsupported compression and its frames are not reconstructed.
const MAX_STRIPPED_HEADER: usize = 256;
/// Largest accepted `TimestampScale` (about 4.3 s per tick). It keeps tick to nanosecond
/// arithmetic on 64-bit timestamps within `i128`.
const MAX_TIMESTAMP_SCALE: u64 = 1 << 32;
@@ -403,8 +408,8 @@ fn parse_video(d: &[u8]) -> VideoInfo {
FIELD_ORDER => v.field_order = Some(u),
STEREO_MODE => v.stereo_mode = Some(u),
ALPHA_MODE => v.alpha_mode = u,
COLOR_SPACE => v.color_space = e.data.try_into().ok(),
COLOR => v.color = Some(parse_color(e.data)),
COLOUR_SPACE => v.color_space = e.data.try_into().ok(),
COLOUR => v.color = Some(parse_color(e.data)),
PROJECTION => {
let mut p = Projection::default();
for x in Children::new(e.data) {
@@ -466,7 +471,7 @@ fn parse_encodings(d: &[u8]) -> Vec<ContentEncoding> {
ContentEncoding::Encryption { scope }
} else {
let (algo, settings) = comp.unwrap_or((0, Vec::new()));
if algo == 3 {
if algo == 3 && settings.len() <= MAX_STRIPPED_HEADER {
ContentEncoding::HeaderStripping { bytes: settings, scope }
} else {
ContentEncoding::Compression { algo, settings, scope }
@@ -542,15 +547,19 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec<Track> {
t.audio = Some(AudioInfo::default());
}
// CodecPrivate may itself be header-stripped (scope bit 2 = private data)
for enc in &t.encodings {
// Prepends in list order, built in one pass: a hostile entry can list millions.
let mut prefix = Vec::new();
for enc in t.encodings.iter().rev() {
if let ContentEncoding::HeaderStripping { bytes, scope } = enc
&& scope & 2 != 0
{
let mut p = bytes.clone();
p.extend_from_slice(&t.codec_private);
t.codec_private = p;
prefix.extend_from_slice(bytes);
}
}
if !prefix.is_empty() {
prefix.extend_from_slice(&t.codec_private);
t.codec_private = prefix;
}
t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref());
out.push(t);
}
@@ -560,6 +569,56 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec<Track> {
#[cfg(test)]
mod metadata_depth_tests {
use super::*;
use crate::matroska::mux::{el, el_uint};
fn header_stripping(bytes: &[u8]) -> Vec<ContentEncoding> {
let mut comp = Vec::new();
el_uint(&mut comp, CONTENT_COMP_ALGO, 3);
el(&mut comp, CONTENT_COMP_SETTINGS, bytes);
let mut enc = Vec::new();
el(&mut enc, CONTENT_COMPRESSION, &comp);
let mut encs = Vec::new();
el(&mut encs, CONTENT_ENCODING, &enc);
parse_encodings(&encs)
}
#[test]
fn a_long_stripped_header_is_not_put_in_front_of_frames() {
let kept = header_stripping(&[7; MAX_STRIPPED_HEADER]);
assert!(matches!(&kept[..], [ContentEncoding::HeaderStripping { bytes, .. }] if bytes.len() == MAX_STRIPPED_HEADER));
let refused = header_stripping(&[7; MAX_STRIPPED_HEADER + 1]);
assert!(matches!(&refused[..], [ContentEncoding::Compression { algo: 3, .. }]), "{refused:?}");
}
fn track_with_private(private: &[u8], strips: &[(&[u8], u64)]) -> Track {
let mut encs = Vec::new();
for &(bytes, scope) in strips {
let mut comp = Vec::new();
el_uint(&mut comp, CONTENT_COMP_ALGO, 3);
el(&mut comp, CONTENT_COMP_SETTINGS, bytes);
let mut enc = Vec::new();
el_uint(&mut enc, CONTENT_ENCODING_SCOPE, scope);
el(&mut enc, CONTENT_COMPRESSION, &comp);
el(&mut encs, CONTENT_ENCODING, &enc);
}
let mut te = Vec::new();
el_uint(&mut te, TRACK_NUMBER, 1);
el(&mut te, CODEC_ID, b"V_TEST");
el(&mut te, CODEC_PRIVATE, private);
el(&mut te, CONTENT_ENCODINGS, &encs);
let mut tracks = Vec::new();
el(&mut tracks, TRACK_ENTRY, &te);
let mut parsed = parse_tracks(&tracks, 1_000_000);
assert_eq!(parsed.len(), 1);
parsed.remove(0)
}
#[test]
fn codec_private_stripped_headers_are_restored_in_reverse_order() {
let strips: [(&[u8], u64); 4] = [(b"first", 2), (b"", 2), (b"second", 2), (b"frame", 1)];
assert_eq!(track_with_private(b"private", &strips).codec_private, b"secondfirstprivate");
assert_eq!(track_with_private(b"private", &[(b"frame", 1)]).codec_private, b"private");
}
#[test]
fn deeply_nested_chapters_stop_at_depth_limit() {
+64 -8
View File
@@ -64,7 +64,8 @@ pub struct MuxOptions {
/// Start a new cluster at the next video keyframe (or any frame without video) after this long.
pub cluster_duration_ns: u64,
pub lacing: LacingMode,
/// Maximum frames per laced block.
/// Maximum frames per laced block. Matroska allows 1 to 256; [`MkvWriter::new`] clamps
/// other values into that range.
pub max_lace_frames: usize,
pub title: Option<String>,
pub writing_app: String,
@@ -136,16 +137,21 @@ pub struct MkvWriter<W: Write + Seek> {
cluster_start_ns: i64,
pending: Option<Pending>,
cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position)
cued_tracks_in_cluster: Vec<bool>,
/// The cluster number in which each track last had a cue. Starting a cluster bumps
/// `cluster_number` instead of clearing an entry for every declared track.
track_cued_in: Vec<u64>,
cluster_number: u64,
end_ns: i64,
has_video: bool,
}
impl<W: Write + Seek> MkvWriter<W> {
pub fn new(mut w: W, tracks: Vec<TrackSpec>, opts: MuxOptions) -> Result<Self> {
pub fn new(mut w: W, tracks: Vec<TrackSpec>, mut opts: MuxOptions) -> Result<Self> {
if tracks.is_empty() || opts.timestamp_scale == 0 {
return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into()));
}
// A block stores its frame count minus one in a byte.
opts.max_lace_frames = opts.max_lace_frames.clamp(1, 256);
let mut out = Vec::new();
let mut h = Vec::new();
el_uint(&mut h, EBML_VERSION, 1);
@@ -253,7 +259,8 @@ impl<W: Write + Seek> MkvWriter<W> {
cluster_start_ns: 0,
pending: None,
cues: Vec::new(),
cued_tracks_in_cluster: vec![false; n],
track_cued_in: vec![u64::MAX; n],
cluster_number: 0,
end_ns: 0,
has_video,
})
@@ -294,8 +301,8 @@ impl<W: Write + Seek> MkvWriter<W> {
}
let cts = self.cluster_ts.unwrap_or(0);
// cue: video keyframes, or the first frame of each track per cluster when there is no video
if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) {
self.cued_tracks_in_cluster[track] = true;
if (is_video && keyframe) || (!self.has_video && self.track_cued_in[track] != self.cluster_number) {
self.track_cued_in[track] = self.cluster_number;
let pos = self.w.stream_position()? - self.segment_data_start;
if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) {
self.cues.push((ts as u64, track as u64 + 1, pos));
@@ -329,7 +336,7 @@ impl<W: Write + Seek> MkvWriter<W> {
block.extend_from_slice(data);
let mut g = Vec::new();
el(&mut g, BLOCK, &block);
el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64);
el_uint(&mut g, BLOCK_DURATION, ((u128::from(d) + scale as u128 / 2) / scale as u128) as u64);
if !keyframe {
el(&mut g, REFERENCE_BLOCK, &[0xFF]);
}
@@ -399,7 +406,7 @@ impl<W: Write + Seek> MkvWriter<W> {
}
self.cluster.clear();
self.cluster_ts = None;
self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false);
self.cluster_number = self.cluster_number.wrapping_add(1);
Ok(())
}
@@ -568,4 +575,53 @@ mod tests {
let got: Vec<_> = Demuxer::from_slice(&bytes).unwrap().map(|p| p.unwrap()).map(|p| (p.track, p.pts, p.lace)).collect();
assert_eq!(got, [(0, 0, 0), (0, 100, 0), (0, 110, 1), (0, 120, 2), (0, 200, 0), (1, 300, 0), (1, 310, 0)]);
}
#[test]
fn lace_size_is_clamped_to_256_frames() {
let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
a.audio = Some((48000.0, 1, Some(16)));
a.default_duration_ns = Some(1_000_000);
for lacing in [LacingMode::Xiph, LacingMode::Ebml, LacingMode::FixedOrEbml] {
let opts = MuxOptions { lacing, max_lace_frames: 300, ..Default::default() };
let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![a.clone()], opts).unwrap();
for i in 0..300i64 {
m.write_frame(0, i * 1_000_000, true, &[i as u8; 4], None).unwrap();
}
let bytes = m.finish().unwrap().into_inner();
let d = Demuxer::from_slice(&bytes).unwrap();
assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings);
let got: Vec<_> = d.map(|p| p.unwrap()).map(|p| (p.pts, p.lace, p.data)).collect();
let want: Vec<_> = (0..300i64).map(|i| (i, (i % 256) as u16, vec![i as u8; 4])).collect();
assert_eq!(got, want);
}
}
#[test]
fn block_duration_rounds_to_the_nearest_tick() {
let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG");
v.video_size = Some((16, 16));
let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v], MuxOptions::default()).unwrap();
m.write_frame(0, 0, true, &[0; 4], Some(41_700_000)).unwrap();
let bytes = m.finish().unwrap().into_inner();
assert_eq!(Demuxer::from_slice(&bytes).unwrap().next_packet().unwrap().unwrap().duration, 42);
}
#[test]
fn audio_only_files_cue_each_track_once_per_cluster() {
let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
a.audio = Some((48000.0, 1, Some(16)));
let opts = MuxOptions { cluster_duration_ns: 1, lacing: LacingMode::None, ..Default::default() };
let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![a.clone(), a], opts).unwrap();
for i in 0..4i64 {
m.write_frame(0, i * 10, true, &[1; 4], None).unwrap();
m.write_frame(1, i * 10, true, &[2; 4], None).unwrap();
m.write_frame(0, i * 10 + 1, true, &[3; 4], None).unwrap();
}
// Clusters hold [t0, t1] then [t0] at each step: one cue per track per cluster.
let mut seen: Vec<(u64, u64)> = m.cues.iter().map(|c| (c.1, c.2)).collect();
assert_eq!(seen.len(), 12, "{:?}", m.cues);
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), 12, "{:?}", m.cues);
}
}
+96 -2
View File
@@ -2,11 +2,13 @@
//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32.
use crate::matroska::codec::Codec;
use crate::matroska::demux::MAX_TRACKS;
use crate::matroska::ebml::{self, write_id, write_unknown_size};
use crate::matroska::error::Error;
use crate::matroska::ids::*;
use crate::matroska::mux::{el, el_float, el_str, el_uint};
use crate::matroska::track::ContentEncoding;
use crate::matroska::{Demuxer, OpenOptions, Packet, open_with};
use crate::matroska::{ByteSource, Demuxer, OpenOptions, Packet, open_with};
fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
let mut b = vec![0x80 | track];
@@ -64,7 +66,7 @@ fn synthetic() -> Vec<u8> {
el_uint(&mut vid, PIXEL_HEIGHT, 1080);
el_uint(&mut vid, DISPLAY_WIDTH, 1920);
el_uint(&mut vid, DISPLAY_HEIGHT, 1080);
el(&mut vid, COLOR, &col);
el(&mut vid, COLOUR, &col);
// rectangular, a quarter turn clockwise (portrait phone video)
let mut proj = Vec::new();
el_uint(&mut proj, PROJECTION_TYPE, 0);
@@ -349,3 +351,95 @@ fn duration_of_a_block_at_the_largest_timestamp_saturates() {
assert_eq!(f.duration_ns(), Some(u64::MAX));
}
}
#[test]
fn empty_laced_frames_exhaust_the_frame_budget() {
// Each 7-byte SimpleBlock is a fixed-size lace of 256 empty frames.
let mut c = Vec::new();
for _ in 0..100 {
el(&mut c, SIMPLE_BLOCK, &block(1, 0, 0x04, &[0xFF]));
}
let r = open_with(&one_cluster(0, &c)[..], &OpenOptions::default());
assert!(matches!(r, Err(Error::TooLarge(_))), "{r:?}");
// Laces of one-byte frames are paid for by their bytes.
let mut c = Vec::new();
for _ in 0..4 {
let mut data = vec![0xFF];
data.extend_from_slice(&[0; 256]);
el(&mut c, SIMPLE_BLOCK, &block(1, 0, 0x04, &data));
}
let f = open_with(&one_cluster(0, &c)[..], &OpenOptions::default()).unwrap();
assert_eq!(f.tracks[0].samples.len(), 1024);
}
#[test]
fn track_entries_past_the_cap_are_rejected() {
let file = |entries: usize| {
let mut f = Vec::new();
let mut h = Vec::new();
el_str(&mut h, DOC_TYPE, "matroska");
el(&mut f, EBML, &h);
write_id(&mut f, SEGMENT);
write_unknown_size(&mut f, 8);
let mut tr = Vec::new();
for _ in 0..entries {
el(&mut tr, TRACK_ENTRY, &[0xD7, 0x81, 0x02]);
}
el(&mut f, TRACKS, &tr);
f
};
assert_eq!(open_with(&file(3)[..], &OpenOptions::default()).unwrap().tracks.len(), 3);
let r = open_with(&file(MAX_TRACKS + 1)[..], &OpenOptions::default());
assert!(matches!(r, Err(Error::TooLarge(_))), "{r:?}");
}
/// Counts the bytes read from `data`.
struct Counting<'a>(&'a [u8], std::cell::Cell<u64>);
impl ByteSource for Counting<'_> {
fn len(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.1.set(self.1.get() + buf.len() as u64);
self.0.read_at(offset, buf)
}
}
#[test]
fn overlapping_seek_head_targets_are_read_once() {
// SeekHead entries point at nested Tags headers, each running to the end of the file.
let (n, pad) = (64u64, 128usize << 10);
let entry = |pos: u64| {
let mut s = Vec::new();
el(&mut s, SEEK_ID, &TAGS.to_be_bytes());
el(&mut s, SEEK_POSITION, &pos.to_be_bytes());
let mut o = Vec::new();
el(&mut o, SEEK, &s);
o
};
let seek_head = |first: u64| {
let entries: Vec<u8> = (0..n).flat_map(|i| entry(first + i * 12)).collect();
let mut o = Vec::new();
el(&mut o, SEEK_HEAD, &entries);
o
};
let mut f = Vec::new();
let mut h = Vec::new();
el_str(&mut h, DOC_TYPE, "matroska");
el(&mut f, EBML, &h);
write_id(&mut f, SEGMENT);
write_unknown_size(&mut f, 8);
let first = seek_head(0).len() as u64;
f.extend_from_slice(&seek_head(first));
let total = n * 12 + pad as u64;
for i in 0..n {
write_id(&mut f, TAGS);
ebml::write_size(&mut f, total - (i + 1) * 12, 8);
}
f.resize(f.len() + pad, 0xEC);
let src = Counting(&f, std::cell::Cell::new(0));
let file = open_with(&src, &OpenOptions { index: false, verify_crc: false }).unwrap();
assert_eq!(file.seek_head.len(), n as usize);
assert!(src.1.get() <= 4 * f.len() as u64, "read {} bytes of a {}-byte file", src.1.get(), f.len());
}
+7 -1
View File
@@ -265,10 +265,16 @@ impl Track {
p
}
/// Length of [`frame_prefix`](Self::frame_prefix), without building it.
pub fn frame_prefix_len(&self) -> u64 {
let stripped = self.stripped_header().map_or(0, <[u8]>::len) as u64;
if matches!(self.codec, Codec::ProRes { .. }) { stripped + 8 } else { stripped }
}
/// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix).
pub fn sample_size(&self, i: usize) -> Option<u64> {
let s = self.samples.get(i)?;
Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64)
Some(s.size as u64 + self.frame_prefix_len())
}
/// Indices of keyframe samples, ascending by file order.
+5 -3
View File
@@ -93,6 +93,7 @@ fn stream_packets(path: &Path) -> Option<Vec<Vec<Value>>> {
/// Compare ffprobe's view of two files (per-stream packets: pts, dts, size, key flag).
fn assert_same_packets(a: &Path, b: &Path) {
let (Some(pa), Some(pb)) = (stream_packets(a), stream_packets(b)) else { return };
assert_eq!(pa.len(), pb.len(), "stream count {} vs {}", a.display(), b.display());
for (i, (x, y)) in pa.iter().zip(&pb).enumerate() {
let key = |p: &Value| (int(&p["pts"]), int(&p["dts"]), int(&p["size"]), p["flags"].as_str().map(|f| f.starts_with('K')));
let kx: Vec<_> = x.iter().map(key).collect();
@@ -106,10 +107,11 @@ fn assert_same_packets(a: &Path, b: &Path) {
fn assert_same_streams(a: &Path, b: &Path) {
let (Some(sa), Some(sb)) = (streams(a), streams(b)) else { return };
// Timecode (data) streams are compared by the tests that add them.
let media = |s: Vec<Value>| s.into_iter().filter(|x| x["codec_type"] != "data").collect::<Vec<_>>();
let (sa, sb) = (media(sa), media(sb));
assert_eq!(sa.len(), sb.len(), "stream count {} vs {}", a.display(), b.display());
for (x, y) in sa.iter().zip(&sb) {
if x["codec_type"] == "data" {
continue;
}
for k in ["codec_name", "width", "height", "sample_rate", "channels", "nb_frames", "duration_ts", "time_base", "profile", "pix_fmt"] {
assert_eq!(x[k], y[k], "stream field {k}");
}
+97 -4
View File
@@ -2,7 +2,8 @@
use craft_media::isobmff::*;
use proptest::prelude::*;
use std::io::Cursor;
use std::collections::BTreeMap;
use std::io::{Cursor, Seek, SeekFrom, Write};
#[derive(Clone, Debug)]
struct S {
@@ -164,9 +165,9 @@ fn sample_lookup() {
}
#[test]
fn large_offsets_use_co64_and_largesize_mdat() {
// Files > 4 GiB are impractical in tests, so patch a small file into the same layout the
// writer produces for them: co64 chunk offsets and a 16-byte large-size mdat header.
fn patched_co64_and_largesize_mdat_are_read() {
// A small file patched into the layout of a file past 4 GiB: co64 chunk offsets and a
// 16-byte large-size mdat header.
let mut moov = Vec::new();
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap();
@@ -221,3 +222,95 @@ fn all_empty_samples_roundtrip() {
assert!(f.tracks[0].samples.iter().all(|s| s.size == 0));
}
}
/// An output that keeps only writes under 1 MiB and reads everything else as zeros, so tests can
/// write files past 4 GiB from a reused zero buffer.
#[derive(Default)]
struct Sparse {
pos: u64,
len: u64,
kept: BTreeMap<u64, Vec<u8>>,
}
impl Write for Sparse {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
let end = self.pos + b.len() as u64;
self.kept.retain(|&at, _| at < self.pos || at >= end);
if b.len() < 1 << 20 {
self.kept.insert(self.pos, b.to_vec());
}
self.pos += b.len() as u64;
self.len = self.len.max(self.pos);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Seek for Sparse {
fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
self.pos = match to {
SeekFrom::Start(p) => p,
SeekFrom::End(d) => self.len.checked_add_signed(d).unwrap(),
SeekFrom::Current(d) => self.pos.checked_add_signed(d).unwrap(),
};
Ok(self.pos)
}
}
impl ByteSource for Sparse {
fn len(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
buf.fill(0);
let end = offset + buf.len() as u64;
for (&at, data) in &self.kept {
let (lo, hi) = (at.max(offset), (at + data.len() as u64).min(end));
if lo < hi {
buf[(lo - offset) as usize..(hi - offset) as usize].copy_from_slice(&data[(lo - at) as usize..(hi - at) as usize]);
}
}
Ok(())
}
}
const MIB: usize = 1 << 20;
#[test]
fn writer_uses_co64_and_largesize_mdat_past_4_gib() {
let zeros = vec![0u8; MIB];
let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mp4)).unwrap();
let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap();
let n = 4 * 1024 + 1;
for _ in 0..n {
w.write_sample(t, WriteSample { data: &zeros, duration: 1, composition_offset: 0, is_sync: true }).unwrap();
}
w.write_sample(t, WriteSample { data: b"abc", duration: 1, composition_offset: 0, is_sync: true }).unwrap();
let out = w.finish().unwrap();
let f = open(&out).unwrap();
let s = &f.tracks[0].samples;
assert_eq!(s.len(), n + 1);
assert!(s[n].offset > u64::from(u32::MAX));
assert_eq!(f.read_sample(&out, 0, n).unwrap(), b"abc");
}
#[test]
fn pcm_past_4_gib_reads_back_whole() {
let pcm = PcmConfig { bits: 16, float: false, big_endian: true, signed: true, channels: 2, sample_rate: 48000.0 };
let zeros = vec![0u8; MIB];
let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap();
let writes = 4 * 1024 + 1;
for _ in 0..writes {
w.write_sample(t, WriteSample { data: &zeros, duration: 0, composition_offset: 0, is_sync: true }).unwrap();
}
let out = w.finish().unwrap();
let f = open(&out).unwrap();
let s = &f.tracks[0].samples;
let bytes = (writes * MIB) as u64;
assert_eq!(s.iter().map(|s| u64::from(s.size)).sum::<u64>(), bytes);
assert_eq!(s.iter().map(|s| u64::from(s.duration)).sum::<u64>(), bytes / 4);
assert!(s.iter().all(|s| s.size <= 1 << 20));
}
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "craft-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Small platform lifecycle services for the Craft suite"
[lints]
workspace = true
[features]
default = []
dialogs = []
appearance = ["dep:zbus", "dep:futures-lite", "dep:async-channel", "dep:async-io", "dep:async-process"]
browser = ["dep:wasm-bindgen", "dep:js-sys", "dep:web-sys"]
egui = ["dep:egui"]
[dependencies]
egui = { workspace = true, optional = true }
[target.'cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io"], optional = true }
futures-lite = { version = "2", optional = true }
async-channel = { version = "2", optional = true }
async-io = { version = "2", optional = true }
async-process = { version = "2", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2", optional = true }
js-sys = { version = "0.3", optional = true }
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Node", "Element"], optional = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
wasm-bindgen-futures = "0.4"
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the craft-libs contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
Platform lifecycle source provenance
The optional crates/platform services adapt the preserved local platform candidate
9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and browser resource ownership),
and current application implementations:
- PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f.
- CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b.
- ArtCraft Launcher apps/artcraft-launcher/src/system_theme.rs, f1676dfb41c226b5be490721f486ee6427419d80.
- Earlier PhotoCraft, FilmCraft and EffectCraft appearance services at revisions
0c72d95425dece90ef9a1cceb49e3315c96e22d5, 794a25fc9ed2e3f6fab51a254625bc9b99a88015
and 1b4d47f34f2b1c1b4a89f4b7348889def8fd648b respectively.
Sources: https://github.com/storytold/pdfcraft, https://github.com/storytold/cadcraft,
https://github.com/storytold/craft-launcher, https://github.com/storytold/photocraft,
https://github.com/storytold/filmcraft, https://github.com/storytold/effectcraft.
Copyright (c) 2026 ArtCraft Team and the respective application and craft-libs contributors.
All adapted source is licensed MIT OR Apache-2.0.
+81
View File
@@ -0,0 +1,81 @@
# craft-platform
Small platform lifecycle services for Craft applications. Application state, settings, palettes,
document policy and native command routing remain in their current owners. Defaults add no
third-party dependencies. Enable only the services a host uses.
| Feature | Shared responsibility |
| --- | --- |
| default | Typed dialog outcomes; no backend or toolkit. |
| `dialogs` | Reserve waiting workers before eager UI-thread native picker creation; serial requests, failure delivery and completion wakeups. The backend factory belongs to the app. |
| `egui` | Combine with `dialogs` for the deferred-close guard or with `appearance` for native theme mapping. |
| `appearance` | Linux/BSD XDG desktop preference observation, owned cancellable subscriptions, bounded reads/helper capture, legacy compatibility and reconnect. |
| `browser` | Wasm download link/object URL ownership, cleanup on setup failure or abandonment, and cleanup timer registration before clicking. |
## Native dialogs
`dialogs::wait_prepared(create, deliver)` reserves the worker first. Call it on the native UI thread.
`create` must call `pick_file`, `save_file`, or the equivalent backend method **before returning its
future**. Putting that call inside an async block delays native sheet creation until the worker
polls it. Worker reservation failure never opens the sheet. No live picker has a timeout.
`NativeDialogs<Request, Answer>` provides an optional serial queue. Pump it on the event-loop
thread before consuming answers and after code that can request a picker. Replies become visible
only after the pump releases transport ownership, so a continuation can enqueue the next picker.
Dropping a stale reply does not release the actual picker. A host-specific preflight is optional;
it runs off-thread and must be bounded. Unknown capability permits a picker. Proven absence may
request an application-owned fallback. Custom save-format sheets and portal choices remain intact.
`Outcome::Selected`, explicit `Cancelled`, `Failed` and `Indeterminate` have different evidence.
An rfd `None` is `Indeterminate`; it cannot prove user cancellation rather than backend failure.
Disconnected workers are failures. Apps own localization, document identity/revision checks, filters,
actual save receipts, chained continuations and the decision to close.
Retain the actual parent window through queued requests, preflight, the native picker AND any
unconsumed application continuation. `DeferredClose::update` cancels close during pending work and
returns a request to retry the application's normal close policy afterward. It never grants
permission to discard work. Run those guards before `craft_quit::on_close_request` in `App::logic`.
## Desktop appearance
`appearance::Watcher` caches the desktop preference and wakes only on changes. Subscription precedes
the first read. ReadOne/legacy Read, the standard namespace and GNOME fallback are handled together;
malformed values retain the last valid answer, and explicit no-preference clears it. Disconnection
retains the last valid value until a fresh answer. A later service owner triggers a reread; session
reconnection has bounded backoff. Dropping cancels asynchronous work and waits at most 100 ms
for acknowledgement, joining only an already-finished worker. A synchronously blocked OS call or caller wake callback can outlive
the watcher: that exceptional worker is detached and retains its resources until it returns,
possibly until process exit. Wake callbacks must be nonblocking. No per-helper reader threads
are created.
App-selected Light/Dark/System, custom palettes, window decorations and unknown-theme fallback remain
application policy. `set_active(false)` suppresses wake notifications while continuing to cache.
Four existing custom-palette service hooks retain their current signatures and receive change-only
notifications even in manual mode; they do not change the chosen palette. Never infer Auto from the
egui theme preference when an app implements Auto by explicitly applying a Light/Dark palette.
`sync_native_window_theme(ctx, observed)` forwards the cached Linux/BSD desktop answer to native
decorations when egui's window policy is System, falling back to the current toolkit answer.
Its output hook runs after egui's automatic
window synchronization, including child-window output. Concrete Light/Dark preferences retain
precedence, absence of both answers releases the native pin, and `sync_window_theme = false` opts out.
It does not change styles or theme preference. Hosts whose custom palette is manual but whose
native window follows System keep observation active for the native window. Native decoration
rendering still depends on the window manager and requires visible acceptance testing.
## Browser downloads
`PreparedDownload::{bytes,blob}` owns a detached anchor and URL until click or drop. Call `click`
with the application's positive URL lifetime; setup/attachment/timer failure releases resources
without clicking. A successful return means a click was issued, not that the browser saved a file.
Applications own names, MIME types, pacing, capture hooks and user activation. File System Access
and print iframe lifetimes remain separate. This service has no native filesystem semantics.
## Integration
Enable only the services a host uses. Applications retain palettes, settings, document identity,
close policy, filters, names, MIME types and native command routing. Existing craft-settings,
craft-atomic, craft-print, craft-docpath and craft-quit remain canonical. `craft-window` owns
decoration selection and window-button layout; appearance observation and native color hints here
do not choose a frame or draw an application header.
+635
View File
@@ -0,0 +1,635 @@
//! Desktop colour preference through the XDG Settings portal.
//!
//! Derived from PdfCraft's activation-safe subscription and GNOME schema fallback,
//! CADCraft's BSD support, and ArtCraft Launcher's ReadOne/Read compatibility.
//! Palettes, manual choices and the fallback when no preference exists belong to the host.
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
};
/// Desktop color preference observed independently of the application palette.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Theme {
/// The desktop requests a dark appearance.
Dark,
/// The desktop requests a light appearance.
Light,
}
struct Shared {
value: AtomicU8,
active: AtomicBool,
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
wake: Box<dyn Fn() + Send + Sync>,
}
impl Shared {
fn get(&self) -> Option<Theme> {
match self.value.load(Ordering::Acquire) {
1 => Some(Theme::Dark),
2 => Some(Theme::Light),
_ => None,
}
}
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
fn publish(&self, theme: Option<Theme>) {
let code = match theme {
Some(Theme::Dark) => 1,
Some(Theme::Light) => 2,
None => 0,
};
if self.value.swap(code, Ordering::AcqRel) != code && self.active.load(Ordering::Acquire) {
(self.wake)();
}
}
}
/// An owned subscription. Dropping cancels connection setup, reads, backoff and helper capture,
/// and waits at most 100 ms for acknowledgement. Only a finished worker is joined: an OS call
/// or caller wake callback can block synchronously, so an exceptional unfinished worker is
/// detached and retains its resources until it returns (possibly until process exit).
/// Cache reads never block the UI; callers should keep wake callbacks nonblocking.
pub struct Watcher {
shared: Arc<Shared>,
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
stop: async_channel::Sender<()>,
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
worker: Option<std::thread::JoinHandle<()>>,
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
finished: std::sync::mpsc::Receiver<()>,
}
impl Watcher {
/// Start an active subscription, or return `None` on unsupported targets or worker failure.
/// `wake` runs on the worker and must not block.
pub fn start(wake: impl Fn() + Send + Sync + 'static) -> Option<Self> {
Self::start_active(true, wake)
}
/// Start observing with explicit control over wake notifications.
/// An inactive subscription still updates its cache. Unsupported targets and worker
/// startup failure return `None`; browser targets never start a desktop watcher.
pub fn start_active(active: bool, wake: impl Fn() + Send + Sync + 'static) -> Option<Self> {
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
{
Self::spawn(active, wake, portal::watch)
}
#[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))]
{
let _ = (active, wake);
None
}
}
/// Read the cached preference without blocking; `None` means no known preference.
pub fn current(&self) -> Option<Theme> {
self.shared.get()
}
/// Enable or suppress future change notifications without stopping observation.
/// Changing this flag does not itself wake the host or clear the cached preference.
pub fn set_active(&self, active: bool) {
self.shared.active.store(active, Ordering::Release);
}
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
fn spawn<F: std::future::Future<Output = ()> + 'static>(
active: bool,
wake: impl Fn() + Send + Sync + 'static,
source: impl FnOnce(Arc<Shared>) -> F + Send + 'static,
) -> Option<Self> {
let shared = Arc::new(Shared { value: AtomicU8::new(0), active: AtomicBool::new(active), wake: Box::new(wake) });
let worker_shared = Arc::clone(&shared);
let (stop, stopped) = async_channel::bounded::<()>(1);
let (done, finished) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new()
.name("desktop-appearance".into())
.spawn(move || {
futures_lite::future::block_on(futures_lite::future::race(
async {
let _ = stopped.recv().await;
},
source(worker_shared),
));
let _ = done.send(());
})
.ok()?;
Some(Self { shared, stop, worker: Some(worker), finished })
}
}
impl Drop for Watcher {
fn drop(&mut self) {
self.set_active(false);
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
{
self.stop.close();
if let Some(worker) = self.worker.take() {
let _ = self.finished.recv_timeout(std::time::Duration::from_millis(100));
// Acknowledgement precedes thread-local destructors. They can block too, so
// never join solely on the acknowledgement (or channel disconnection).
if worker.is_finished() {
let _ = worker.join();
}
}
}
}
}
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod portal {
use super::{Shared, Theme};
use futures_lite::StreamExt;
use std::{sync::Arc, time::Duration};
use zbus::zvariant::{OwnedValue, Value};
const DEST: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
const IFACE: &str = "org.freedesktop.portal.Settings";
const STANDARD: &str = "org.freedesktop.appearance";
const GNOME: &str = "org.gnome.desktop.interface";
const KEY: &str = "color-scheme";
const TIMEOUT: Duration = Duration::from_secs(2);
// Outer None means unknown/malformed; Some(None) is an explicit no-preference answer.
fn decode(namespace: &str, value: &Value<'_>) -> Option<Option<Theme>> {
match (namespace, value) {
(STANDARD, Value::U32(n)) => Some(match n {
1 => Some(Theme::Dark),
2 => Some(Theme::Light),
_ => None,
}),
(GNOME, Value::Str(s)) => Some(match s.as_str() {
"prefer-dark" => Some(Theme::Dark),
"prefer-light" => Some(Theme::Light),
_ => None,
}),
_ => None,
}
}
fn reply(namespace: &str, value: &Value<'_>, legacy: bool) -> Option<Option<Theme>> {
if legacy {
let Value::Value(inner) = value else { return None };
decode(namespace, inner)
} else {
decode(namespace, value)
}
}
fn combined(standard: Option<Option<Theme>>, gnome: Option<Option<Theme>>) -> Option<Option<Theme>> {
if standard.is_none() && gnome.is_none() { None } else { Some(standard.flatten().or(gnome.flatten())) }
}
async fn bounded<T>(future: impl std::future::Future<Output = T>) -> Option<T> {
futures_lite::future::race(async { Some(future.await) }, async {
async_io::Timer::after(TIMEOUT).await;
None
})
.await
}
async fn read(connection: &zbus::Connection, namespace: &str) -> Option<Option<Theme>> {
for (method, legacy) in [("ReadOne", false), ("Read", true)] {
if let Some(Ok(message)) = bounded(connection.call_method(Some(DEST), PATH, Some(IFACE), method, &(namespace, KEY))).await
&& let Ok(value) = message.body().deserialize::<OwnedValue>()
&& let Some(answer) = reply(namespace, &value, legacy)
{
return Some(answer);
}
}
None
}
fn rule() -> zbus::Result<zbus::MatchRule<'static>> {
Ok(zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DEST)?
.path(PATH)?
.interface(IFACE)?
.member("SettingChanged")?
.arg(1, KEY)?
.build())
}
fn owner_rule() -> zbus::Result<zbus::MatchRule<'static>> {
Ok(zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")?
.interface("org.freedesktop.DBus")?
.member("NameOwnerChanged")?
.arg(0, DEST)?
.build())
}
pub(super) async fn watch(shared: Arc<Shared>) {
let mut retry = Duration::from_secs(5);
let mut fallback_used = false;
loop {
let mut connected = false;
let _ = session(&shared, &mut fallback_used, &mut connected).await;
if connected {
retry = Duration::from_secs(5);
}
// Keep the last valid answer during disconnect; a new explicit no-preference clears it.
async_io::Timer::after(retry).await;
retry = (retry * 2).min(Duration::from_secs(300));
}
}
// Keep Nix/profile PATH entries, but never execute a helper from a relative search folder.
fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
let dirs = path.map(std::env::split_paths).into_iter().flatten().filter(|p| p.is_absolute());
dirs.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|p| p.join("gsettings")).find(|p| p.is_file())
}
async fn fallback(shared: &Shared, used: &mut bool) {
if *used {
return;
}
*used = true;
if let Some(exe) = gsettings_exe(std::env::var_os("PATH").as_deref())
&& let Some(text) = crate::process::capture(&exe, &["get", GNOME, KEY], TIMEOUT).await
{
let text = text.trim().trim_matches('\'');
if let Some(answer) = decode(GNOME, &Value::from(text)) {
shared.publish(answer);
}
}
}
async fn session(shared: &Shared, fallback_used: &mut bool, connected: &mut bool) -> Option<()> {
let connection = match zbus::connection::Builder::session() {
Ok(builder) => bounded(builder.method_timeout(TIMEOUT).build()).await.and_then(Result::ok),
Err(_) => None,
};
let Some(connection) = connection else {
fallback(shared, fallback_used).await;
return None;
};
watch_connection(shared, fallback_used, connected, connection).await
}
async fn watch_connection(shared: &Shared, fallback_used: &mut bool, connected: &mut bool, connection: zbus::Connection) -> Option<()> {
// MatchRule installs before activation: unlike a proxy subscription it needs no current owner.
let mut changes = bounded(zbus::MessageStream::for_match_rule(rule().ok()?, &connection, Some(64))).await?.ok()?;
let mut owners = bounded(zbus::MessageStream::for_match_rule(owner_rule().ok()?, &connection, Some(8))).await?.ok()?;
let mut standard = read(&connection, STANDARD).await;
let mut gnome = read(&connection, GNOME).await;
match combined(standard, gnome) {
Some(answer) => {
*connected = true;
shared.publish(answer);
}
None => {
fallback(shared, fallback_used).await;
return None;
}
}
loop {
let message =
futures_lite::future::race(async { changes.next().await.map(|m| (false, m)) }, async { owners.next().await.map(|m| (true, m)) })
.await?;
let (owner, message) = message;
let message = message.ok()?;
if owner {
// Activation is already covered by the initial read. Loss/replacement reconnects.
if let Ok((_, old, _)) = message.body().deserialize::<(String, String, String)>()
&& !old.is_empty()
{
return None;
}
continue;
}
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, OwnedValue)>() else { continue };
if key != KEY {
continue;
}
let Some(answer) = decode(&namespace, &value) else { continue };
match namespace.as_str() {
STANDARD => standard = Some(answer),
GNOME => gnome = Some(answer),
_ => continue,
}
if let Some(answer) = combined(standard, gnome) {
shared.publish(answer);
}
}
}
#[cfg(test)]
#[path = "appearance_bus_tests.rs"]
mod bus_tests;
#[cfg(test)]
mod tests {
#[test]
fn gsettings_respects_absolute_nix_style_path_without_using_relative_folders() -> Result<(), Box<dyn std::error::Error>> {
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
let directory = std::env::temp_dir().join(format!("craft-platform-gsettings-{}-{nonce}", std::process::id()));
std::fs::create_dir(&directory)?;
let fixture = Fixture(directory);
let helper = fixture.0.join("gsettings");
std::fs::write(&helper, b"fixture")?;
let path = std::env::join_paths([std::path::Path::new("relative-untrusted"), fixture.0.as_path()])?;
assert_eq!(super::gsettings_exe(Some(path.as_os_str())), Some(helper));
assert!(super::gsettings_exe(Some(std::ffi::OsStr::new("relative-untrusted"))).is_none_or(|p| p.is_absolute()));
Ok(())
}
use super::{GNOME, STANDARD, combined, decode, reply};
use crate::appearance::{Theme, Watcher};
use std::{
sync::{Arc, atomic::Ordering},
time::Duration,
};
use zbus::zvariant::Value;
#[test]
fn no_preference_is_an_answer_and_gnome_only_fills_missing_preference() {
assert_eq!(combined(None, None), None);
assert_eq!(combined(Some(None), None), Some(None));
assert_eq!(combined(Some(None), Some(Some(Theme::Dark))), Some(Some(Theme::Dark)));
assert_eq!(combined(Some(Some(Theme::Light)), Some(Some(Theme::Dark))), Some(Some(Theme::Light)));
assert_eq!(combined(None, Some(None)), Some(None));
}
#[test]
fn malformed_is_distinct_from_no_preference_and_legacy_is_exact() {
assert_eq!(decode(STANDARD, &Value::U32(0)), Some(None));
assert_eq!(decode(STANDARD, &Value::U32(77)), Some(None));
assert_eq!(decode(STANDARD, &Value::from("dark")), None);
assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::U32(1))), true), Some(Some(Theme::Dark)));
assert_eq!(reply(STANDARD, &Value::U32(1), true), None);
assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))), true), None);
assert_eq!(decode(STANDARD, &Value::Value(Box::new(Value::U32(1)))), None);
assert_eq!(decode(STANDARD, &Value::I32(1)), None);
assert_eq!(decode(GNOME, &Value::from("default")), Some(None));
assert_eq!(decode(GNOME, &Value::U32(1)), None);
assert_eq!(reply(GNOME, &Value::from("prefer-light"), false), Some(Some(Theme::Light)));
}
#[test]
fn manual_cache_changes_do_not_wake_and_drop_cancels_pending_source() -> Result<(), Box<dyn std::error::Error>> {
let (tx, rx) = std::sync::mpsc::channel();
let (cancelled, cancellation) = std::sync::mpsc::channel();
struct Cancelled(std::sync::mpsc::Sender<()>);
impl Drop for Cancelled {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed = Arc::clone(&wakes);
let watcher = Watcher::spawn(
false,
move || {
observed.fetch_add(1, Ordering::Relaxed);
},
move |shared| async move {
let _cancelled = Cancelled(cancelled);
shared.publish(Some(Theme::Dark));
let _ = tx.send(());
futures_lite::future::pending::<()>().await;
},
)
.ok_or("worker creation failed")?;
rx.recv_timeout(Duration::from_secs(2))?;
assert_eq!(watcher.current(), Some(Theme::Dark));
assert_eq!(wakes.load(Ordering::Relaxed), 0);
watcher.set_active(true);
watcher.shared.publish(Some(Theme::Dark));
assert_eq!(wakes.load(Ordering::Relaxed), 0);
watcher.shared.publish(Some(Theme::Light));
assert_eq!(watcher.current(), Some(Theme::Light));
watcher.shared.publish(Some(Theme::Dark));
assert_eq!(watcher.current(), Some(Theme::Dark));
watcher.shared.publish(None);
assert_eq!(watcher.current(), None);
assert_eq!(wakes.load(Ordering::Relaxed), 3);
drop(watcher);
cancellation.recv_timeout(Duration::from_secs(2))?;
Ok(())
}
#[test]
fn drop_does_not_wait_for_a_synchronously_blocked_source() -> Result<(), Box<dyn std::error::Error>> {
let (started, ready) = std::sync::mpsc::channel();
let (release, blocked) = std::sync::mpsc::channel();
let (exited, exit) = std::sync::mpsc::channel();
struct Release(Option<std::sync::mpsc::Sender<()>>);
impl Drop for Release {
fn drop(&mut self) {
if let Some(release) = self.0.take() {
let _ = release.send(());
}
}
}
// Declared before the watcher: every error/unwind releases the synchronous poll.
let release = Release(Some(release));
let watcher = Watcher::spawn(
false,
|| {},
move |_| async move {
let _ = started.send(());
let _ = blocked.recv();
let _ = exited.send(());
},
)
.ok_or("worker creation failed")?;
ready.recv_timeout(Duration::from_secs(2))?;
let (dropped, drop_result) = std::sync::mpsc::channel();
let dropper = std::thread::spawn(move || {
drop(watcher);
let _ = dropped.send(());
});
let bounded = drop_result.recv_timeout(Duration::from_secs(1)).is_ok();
// Release before asserting, including when an unbounded implementation regresses.
drop(release);
exit.recv_timeout(Duration::from_secs(2))?;
assert!(dropper.join().is_ok(), "watcher drop panicked");
assert!(bounded, "watcher drop waited for a blocked synchronous poll");
Ok(())
}
}
}
/// Owned callback that supplies an observed theme and updates egui native-window hints.
#[cfg(feature = "egui")]
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// Create an egui theme callback that owns the subscription and wakes its first context.
/// Return `None` when no desktop watcher can start; the host retains its fallback policy.
#[cfg(feature = "egui")]
pub fn egui_service() -> Option<SystemThemeFn> {
let context = Arc::new(std::sync::OnceLock::<egui::Context>::new());
let worker_context = Arc::clone(&context);
let watcher = Watcher::start(move || {
if let Some(ctx) = worker_context.get() {
ctx.request_repaint();
}
})?;
Some(Box::new(move |ctx| {
let _ = context.set(ctx.clone());
sync_native_window_theme(ctx, watcher.current());
watcher.current().map(|t| match t {
Theme::Dark => egui::Theme::Dark,
Theme::Light => egui::Theme::Light,
})
}))
}
/// Supply the portal answer for native window decorations without changing egui's theme choice
/// or either palette. A missing portal answer falls back to the current toolkit system theme;
/// when both are absent the native pin is released. Linux/BSD window backends cannot reliably
/// resolve `SystemDefault` themselves.
/// The output hook runs after egui's own window-theme synchronization, so it cannot be overwritten
/// by a later automatic `SetTheme(SystemDefault)` in the same pass.
#[cfg(feature = "egui")]
pub fn sync_native_window_theme(ctx: &egui::Context, observed: Option<Theme>) {
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
{
ctx.add_plugin(NativeWindowTheme::default());
ctx.with_plugin(|plugin: &mut NativeWindowTheme| plugin.observed = observed);
}
#[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))]
let _ = (ctx, observed);
}
#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
#[derive(Default)]
struct NativeWindowTheme {
observed: Option<Theme>,
sent: std::collections::HashMap<egui::ViewportId, egui::SystemTheme>,
}
#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
impl egui::Plugin for NativeWindowTheme {
fn debug_name(&self) -> &'static str {
"desktop window appearance"
}
fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
if !ctx.options(|o| o.sync_window_theme) {
// Another owner may alter the native theme while synchronization is disabled.
self.sent.clear();
return;
}
let desired = match ctx.options(|o| o.theme_preference) {
egui::ThemePreference::Dark => egui::SystemTheme::Dark,
egui::ThemePreference::Light => egui::SystemTheme::Light,
egui::ThemePreference::System => match self.observed.or_else(|| {
ctx.system_theme().map(|theme| match theme {
egui::Theme::Dark => Theme::Dark,
egui::Theme::Light => Theme::Light,
})
}) {
Some(Theme::Dark) => egui::SystemTheme::Dark,
Some(Theme::Light) => egui::SystemTheme::Light,
None => egui::SystemTheme::SystemDefault,
},
};
// FullOutput can carry child-window commands too. Rewrite every viewport so a deferred
// or immediate child cannot reintroduce egui's inherited hint after its own UI pass.
for (id, viewport) in &mut output.viewport_output {
let had_sync = viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::SetTheme(_)));
viewport.commands.retain(|command| !matches!(command, egui::ViewportCommand::SetTheme(_)));
if had_sync || self.sent.get(id) != Some(&desired) {
viewport.commands.push(egui::ViewportCommand::SetTheme(desired));
self.sent.insert(*id, desired);
}
}
// Forget destroyed native windows: a reused viewport identifier must receive its hint.
self.sent.retain(|id, _| output.viewport_output.contains_key(id));
}
}
#[cfg(all(test, feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod native_window_tests {
use super::{Theme, sync_native_window_theme};
fn pass(ctx: &egui::Context, observed: Option<Theme>, preference: egui::ThemePreference) -> Vec<egui::SystemTheme> {
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
sync_native_window_theme(ui.ctx(), observed);
// Deliberately apply the host's choice AFTER the observer, as real palette adapters do.
ui.ctx().set_theme(preference);
});
output.textures_delta.clear(); // Headless policy test; no renderer consumes uploads.
output
.viewport_output
.values()
.flat_map(|v| &v.commands)
.filter_map(|command| match command {
egui::ViewportCommand::SetTheme(theme) => Some(*theme),
_ => None,
})
.collect()
}
#[test]
fn actual_end_pass_maps_system_manual_and_reset_without_overwriting_palettes() {
use egui::{SystemTheme as Native, ThemePreference as Choice};
let ctx = egui::Context::default();
let marker = egui::Color32::from_rgb(12, 34, 56);
ctx.all_styles_mut(|s| s.visuals.panel_fill = marker);
assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]);
assert_eq!(ctx.options(|o| o.theme_preference), Choice::System);
assert!(pass(&ctx, Some(Theme::Light), Choice::System).is_empty());
assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]);
// A portal change cannot override a concrete manual native preference.
assert_eq!(pass(&ctx, Some(Theme::Light), Choice::Dark), vec![Native::Dark]);
assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::Light), vec![Native::Light]);
assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]);
assert_eq!(pass(&ctx, None, Choice::System), vec![Native::SystemDefault]);
assert!(pass(&ctx, None, Choice::System).is_empty());
assert_eq!(ctx.style_of(egui::Theme::Dark).visuals.panel_fill, marker);
assert_eq!(ctx.style_of(egui::Theme::Light).visuals.panel_fill, marker);
}
#[test]
fn native_system_hint_uses_current_toolkit_fallback_without_retaining_stale_input() {
let ctx = egui::Context::default();
for (raw_theme, observed, expected) in [
(Some(egui::Theme::Light), None, egui::SystemTheme::Light),
(Some(egui::Theme::Dark), None, egui::SystemTheme::Dark),
(Some(egui::Theme::Dark), Some(Theme::Light), egui::SystemTheme::Light),
(None, None, egui::SystemTheme::SystemDefault),
] {
let mut output = ctx.run_ui(egui::RawInput { system_theme: raw_theme, ..Default::default() }, |ui| {
sync_native_window_theme(ui.ctx(), observed);
ui.ctx().set_theme(egui::ThemePreference::System);
});
let hints: Vec<_> = output
.viewport_output
.values()
.flat_map(|v| &v.commands)
.filter_map(|command| match command {
egui::ViewportCommand::SetTheme(theme) => Some(*theme),
_ => None,
})
.collect();
output.textures_delta.clear();
assert_eq!(hints, vec![expected]);
}
}
#[test]
fn deferred_windows_receive_the_same_hint_and_reset() {
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let child = egui::ViewportId::from_hash_of("appearance-child");
for observed in [Some(Theme::Light), Some(Theme::Dark), None] {
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
sync_native_window_theme(ui.ctx(), observed);
ui.ctx().set_theme(egui::ThemePreference::System);
ui.ctx().show_viewport_deferred(child, egui::ViewportBuilder::default(), |_, _| {});
});
output.textures_delta.clear();
let expected = match observed {
Some(Theme::Light) => egui::SystemTheme::Light,
Some(Theme::Dark) => egui::SystemTheme::Dark,
None => egui::SystemTheme::SystemDefault,
};
for id in [egui::ViewportId::ROOT, child] {
let commands: Vec<_> = output
.viewport_output
.get(&id)
.into_iter()
.flat_map(|v| &v.commands)
.filter_map(|c| match c {
egui::ViewportCommand::SetTheme(t) => Some(*t),
_ => None,
})
.collect();
assert_eq!(commands, vec![expected]);
}
}
}
#[test]
fn custom_palette_system_policy_and_explicit_sync_opt_out_are_preserved() {
use egui::{SystemTheme as Native, ThemePreference as Choice};
let ctx = egui::Context::default();
// Photo/PDF/Sound keep System native policy even when their custom palette is manual.
ctx.all_styles_mut(|s| s.visuals = egui::Visuals::dark());
assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]);
assert!(ctx.style_of(egui::Theme::Light).visuals.dark_mode);
ctx.options_mut(|o| o.sync_window_theme = false);
assert!(pass(&ctx, Some(Theme::Dark), Choice::System).is_empty());
ctx.options_mut(|o| o.sync_window_theme = true);
assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]);
}
}
@@ -0,0 +1,147 @@
//! Private-bus regression based on PdfCraft's queued-update test (storytold/pdfcraft#881).
use super::{DEST, GNOME, IFACE, KEY, PATH, STANDARD, watch_connection};
use crate::appearance::{Theme, Watcher};
use std::time::Duration;
use zbus::zvariant::{OwnedValue, Value};
#[test]
#[ignore = "requires dbus-daemon; private session bus only"]
fn legacy_read_keeps_queued_signals_and_drop_cancels_idle_subscription() -> Result<(), Box<dyn std::error::Error>> {
use std::io::BufRead;
use std::process::{Command, Stdio};
struct Daemon(std::process::Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct Settings {
captured: std::sync::mpsc::Sender<()>,
released: async_channel::Receiver<()>,
}
#[zbus::interface(name = "org.freedesktop.portal.Settings")]
impl Settings {
async fn read(&self, namespace: &str, _key: &str) -> zbus::fdo::Result<OwnedValue> {
if namespace != STANDARD {
return Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into()));
}
self.captured.send(()).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
self.released.recv().await.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
OwnedValue::try_from(Value::Value(Box::new(Value::U32(2)))).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
}
let mut daemon = Daemon(
Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.arg(format!("--address=unix:abstract=craft-appearance-test-{}", std::process::id()))
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?,
);
let mut address = String::new();
std::io::BufReader::new(daemon.0.stdout.take().ok_or("no daemon stdout")?).read_line(&mut address)?;
let (captured, seen) = std::sync::mpsc::channel();
let (release, released) = async_channel::bounded(1);
let server = futures_lite::future::block_on(
zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, Settings { captured, released })?.build(),
)?;
let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?;
let (woke, wakes) = std::sync::mpsc::channel();
let tracker = Watcher::spawn(
true,
move || {
let _ = woke.send(());
},
move |shared| async move {
let _ = watch_connection(&shared, &mut true, &mut false, client).await;
},
)
.ok_or("could not start observer")?;
seen.recv_timeout(Duration::from_secs(2))?;
futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(1u32))))?;
release.send_blocking(())?;
wakes.recv_timeout(Duration::from_secs(2))?;
wakes.recv_timeout(Duration::from_secs(2))?;
assert_eq!(tracker.current(), Some(Theme::Dark));
// A malformed update must not clear the valid value or cause a wake.
futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(true))))?;
assert!(wakes.recv_timeout(Duration::from_millis(50)).is_err());
assert_eq!(tracker.current(), Some(Theme::Dark));
let before = std::time::Instant::now();
drop(tracker);
assert!(before.elapsed() < Duration::from_secs(1));
// Drop also cancels a backend that has entered Read and never replies.
let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?;
let pending = Watcher::spawn(
true,
|| {},
move |shared| async move {
let _ = watch_connection(&shared, &mut true, &mut false, client).await;
},
)
.ok_or("could not start pending-read observer")?;
seen.recv_timeout(Duration::from_secs(2))?;
let before = std::time::Instant::now();
drop(pending);
assert!(before.elapsed() < Duration::from_secs(1));
// A newly available portal uses ReadOne, and a replacement owner must trigger a reread.
futures_lite::future::block_on(server.release_name(DEST))?;
struct ModernSettings(u32);
#[zbus::interface(name = "org.freedesktop.portal.Settings")]
impl ModernSettings {
fn read_one(&self, namespace: &str, _key: &str) -> zbus::fdo::Result<OwnedValue> {
if namespace == STANDARD { Ok(OwnedValue::from(self.0)) } else { Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into())) }
}
}
let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?;
let (woke, wakes) = std::sync::mpsc::channel();
let (attempted, attempts) = std::sync::mpsc::channel();
let tracker = Watcher::spawn(
true,
move || {
let _ = woke.send(());
},
move |shared| async move {
// The production observer repeats this session after its bounded reconnect backoff.
loop {
let _ = watch_connection(&shared, &mut true, &mut false, client.clone()).await;
let _ = attempted.send(());
async_io::Timer::after(Duration::from_millis(10)).await;
}
},
)
.ok_or("could not start modern observer")?;
attempts.recv_timeout(Duration::from_secs(2))?;
let modern =
futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(2))?.build())?;
wakes.recv_timeout(Duration::from_secs(2))?;
assert_eq!(tracker.current(), Some(Theme::Light));
futures_lite::future::block_on(modern.release_name(DEST))?;
let replacement =
futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(1))?.build())?;
wakes.recv_timeout(Duration::from_secs(2))?;
assert_eq!(tracker.current(), Some(Theme::Dark));
futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(0u32))))?;
wakes.recv_timeout(Duration::from_secs(2))?;
assert_eq!(tracker.current(), None);
// GNOME's string schema fills the empty standard preference. Manual mode still caches it,
// and opting back into System sees that latest cache without a redundant wake.
tracker.set_active(false);
futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("prefer-dark"))))?;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while tracker.current() != Some(Theme::Dark) {
assert!(std::time::Instant::now() < deadline, "GNOME change was not cached");
std::thread::sleep(Duration::from_millis(5));
}
assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)));
tracker.set_active(true);
assert_eq!(tracker.current(), Some(Theme::Dark));
assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)));
futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("default"))))?;
wakes.recv_timeout(Duration::from_secs(2))?;
assert_eq!(tracker.current(), None);
drop(tracker);
Ok(())
}
+89
View File
@@ -0,0 +1,89 @@
//! Browser downloads. Caller owns MIME/name decisions and pacing; this module owns resource cleanup.
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url, Window};
/// Object-URL lifetime for a requested download; the default is 60 seconds.
#[derive(Clone, Copy, Debug)]
pub struct DownloadOptions {
/// Positive delay in milliseconds before cleanup; nonpositive values reject the click.
pub revoke_after_ms: i32,
}
impl Default for DownloadOptions {
fn default() -> Self {
Self { revoke_after_ms: 60_000 }
}
}
/// Prepared DOM link and object URL. Dropping without clicking cleans both up.
/// Preparation can occur before an app's download pacer yields.
pub struct PreparedDownload {
window: Window,
anchor: HtmlAnchorElement,
url: Option<String>,
}
impl PreparedDownload {
/// Prepare an owned Blob and hidden link using the caller's bytes, MIME and filename.
/// No download is requested until [`Self::click`]; browser setup failures return an error.
pub fn bytes(name: &str, mime: &str, bytes: &[u8]) -> Result<Self, JsValue> {
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let options = BlobPropertyBag::new();
options.set_type(mime);
Self::blob(name, &Blob::new_with_u8_array_sequence_and_options(&parts, &options)?)
}
/// Prepare a hidden link and owned object URL for an existing Blob.
/// Blank filenames, missing browser DOM and setup failures return an error.
pub fn blob(name: &str, blob: &Blob) -> Result<Self, JsValue> {
if name.trim().is_empty() {
return Err(JsValue::from_str("a file name is required"));
}
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no browser window"))?;
let document = window.document().ok_or_else(|| JsValue::from_str("no browser document"))?;
document.body().ok_or_else(|| JsValue::from_str("the page has no body"))?;
let anchor = document.create_element("a")?.dyn_into::<HtmlAnchorElement>()?;
anchor.set_download(name);
anchor.set_attribute("style", "display:none")?;
let download = Self { window, anchor, url: Some(Url::create_object_url_with_blob(blob)?) };
if let Some(url) = &download.url {
download.anchor.set_href(url);
}
Ok(download)
}
/// Register cleanup before clicking: a timer failure returns an error without triggering
/// a partial download. Success only means a click was issued; browser acceptance is unobservable.
pub fn click(mut self, options: DownloadOptions) -> Result<(), JsValue> {
if options.revoke_after_ms <= 0 {
return Err(JsValue::from_str("download lifetime must be positive"));
}
let Some(url) = self.url.as_ref().cloned() else {
return Err(JsValue::from_str("download has no object URL"));
};
let body = self.window.document().and_then(|d| d.body()).ok_or_else(|| JsValue::from_str("the page has no body"))?;
body.append_child(&self.anchor)?;
let revoke = Closure::once(move || {
let _ = Url::revoke_object_url(&url);
});
self.window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.as_ref().unchecked_ref(), options.revoke_after_ms)?;
revoke.forget(); // Ownership transfers only after successful timer registration.
self.url = None;
self.anchor.click();
Ok(())
}
}
impl Drop for PreparedDownload {
fn drop(&mut self) {
self.anchor.remove();
if let Some(url) = self.url.take() {
let _ = Url::revoke_object_url(&url);
}
}
}
/// Prepare bytes and request a download with the specified URL lifetime.
/// Success means the click was issued, not that the browser accepted or saved the file.
pub fn download_bytes(name: &str, mime: &str, bytes: &[u8], options: DownloadOptions) -> Result<(), JsValue> {
PreparedDownload::bytes(name, mime, bytes)?.click(options)
}
/// Prepare a Blob and request a download with the specified URL lifetime.
/// Setup or scheduling failures clean up owned resources and return an error.
pub fn download_blob(name: &str, blob: &Blob, options: DownloadOptions) -> Result<(), JsValue> {
PreparedDownload::blob(name, blob)?.click(options)
}
+97
View File
@@ -0,0 +1,97 @@
//! Dialog results with evidence: an empty rfd answer does not establish user cancellation.
/// Host assessment of whether a native backend is available; unknown is not proven absence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Capability {
/// The host has confirmed that a backend is available.
Available,
/// Proven backend absence, with a diagnostic suitable for the host.
Unavailable(String),
/// Inconclusive capability evidence; the host should still permit a picker attempt.
Unknown(String),
}
/// The layer that prevented a dialog request from completing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ErrorKind {
/// The host proved that no backend is available.
Unavailable,
/// Request transport or background execution failed.
Worker,
/// The picker backend reported or encountered a failure.
Backend,
}
/// A classified dialog failure with a host-facing diagnostic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DialogError {
/// The failing layer.
pub kind: ErrorKind,
/// Diagnostic text for the host to present or log.
pub message: String,
}
impl DialogError {
/// Construct a failure without inferring cancellation from the diagnostic.
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
}
}
impl std::fmt::Display for DialogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for DialogError {}
/// Selection, explicit cancellation, failure, or an answer whose cause is unknown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome<T> {
/// A value returned by the picker.
Selected(T),
/// Explicit app/backend cancellation; never inferred from rfd's empty answer.
Cancelled,
/// A transport, capability or backend failure.
Failed(DialogError),
/// No selection; rfd cannot say whether the user cancelled or its backend failed.
Indeterminate,
}
impl<T> Outcome<T> {
/// Preserve a selection; an empty rfd answer is indeterminate, never proven cancellation.
pub fn from_rfd(value: Option<T>) -> Self {
value.map_or(Self::Indeterminate, Self::Selected)
}
/// Transform only a selected value, preserving all other outcomes unchanged.
pub fn map<U>(self, map: impl FnOnce(T) -> U) -> Outcome<U> {
match self {
Self::Selected(value) => Outcome::Selected(map(value)),
Self::Cancelled => Outcome::Cancelled,
Self::Failed(error) => Outcome::Failed(error),
Self::Indeterminate => Outcome::Indeterminate,
}
}
/// Construct a backend failure with the supplied diagnostic.
pub fn failed(message: impl Into<String>) -> Self {
Self::Failed(DialogError::new(ErrorKind::Backend, message))
}
}
impl<T> Outcome<Vec<T>> {
/// Treat missing and empty multi-selection answers as indeterminate.
pub fn from_rfd_files(value: Option<Vec<T>>) -> Self {
Self::from_rfd(value.filter(|paths| !paths.is_empty()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_and_empty_rfd_answers_never_establish_cancellation() {
assert_eq!(Outcome::<String>::from_rfd(None), Outcome::Indeterminate);
assert_eq!(Outcome::<Vec<String>>::from_rfd_files(Some(Vec::new())), Outcome::Indeterminate);
assert_eq!(Outcome::from_rfd_files(Some(vec!["a"])), Outcome::Selected(vec!["a"]));
for _capability in [Capability::Available, Capability::Unavailable("missing".into()), Capability::Unknown("timeout".into())] {
assert_eq!(Outcome::<String>::from_rfd(None), Outcome::Indeterminate);
}
}
#[test]
fn mapping_preserves_explicit_cancellation_and_failure() {
assert_eq!(Outcome::<u8>::Cancelled.map(u32::from), Outcome::Cancelled);
assert_eq!(Outcome::<u8>::failed("worker stopped").map(u32::from), Outcome::failed("worker stopped"));
assert_eq!(Outcome::Selected(7_u8).map(u32::from), Outcome::Selected(7));
}
}
+258
View File
@@ -0,0 +1,258 @@
//! UI-thread picker creation with reserved waiting workers. No backend or application policy.
//!
//! `create` must call the native picker method eagerly on the event-loop thread, returning its
//! already-created future. Hosts retain the parent through transport AND their continuation.
//! A missing backend answer is indeterminate; only explicit backend evidence proves cancellation.
use crate::dialog_outcome::{DialogError, ErrorKind, Outcome};
use std::{
future::Future,
pin::Pin,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, Sender, SyncSender, TryRecvError},
},
thread::JoinHandle,
};
/// An already-created native picker future that a reserved worker can wait on.
pub type DialogFuture<T> = Pin<Box<dyn Future<Output = Outcome<T>> + Send>>;
/// Wait for a future without polling an idle native dialog.
pub fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut context = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
std::thread::park();
}
}
// Notification callbacks must not unwind a reserved worker after the UI observes readiness.
fn notify(wake: &dyn Fn()) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(wake));
}
fn worker_error(message: impl Into<String>) -> DialogError {
DialogError::new(ErrorKind::Worker, message)
}
fn guarded<T>(f: impl FnOnce() -> Outcome<T>) -> Outcome<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Outcome::Failed(worker_error("The file-dialog backend failed")))
}
/// Reserve the worker before opening a native sheet. A failed reservation never calls `create`.
/// No timeout is imposed on a live picker. Backend constructor/poll panics become explicit failures.
pub fn wait_prepared<T: Send + 'static, F: Future<Output = Outcome<T>> + Send + 'static>(
create: impl FnOnce() -> F,
deliver: impl FnOnce(Outcome<T>) + Send + 'static,
) -> std::io::Result<JoinHandle<()>> {
let (send, receive) = mpsc::channel::<F>();
let worker = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
deliver(guarded(|| match receive.recv() {
Ok(future) => block_on(future),
Err(_) => Outcome::Failed(worker_error("The file-dialog factory failed")),
}));
})?;
if let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)) {
send.send(future).map_err(|_| std::io::Error::other("The reserved file-dialog worker stopped"))?;
}
Ok(worker)
}
type Job<R, T> = (R, Sender<Outcome<T>>);
/// Clonable request endpoint. At most one queued, preparing or live request is accepted.
pub struct DialogClient<R, T> {
sender: SyncSender<Job<R, T>>,
busy: Arc<AtomicBool>,
wake: Arc<dyn Fn() + Send + Sync>,
}
impl<R, T> Clone for DialogClient<R, T> {
fn clone(&self) -> Self {
Self { sender: self.sender.clone(), busy: self.busy.clone(), wake: self.wake.clone() }
}
}
impl<R, T> DialogClient<R, T> {
/// Queue a request without blocking and wake the host to pump it.
/// A busy or unavailable host answers with a worker failure; no second picker is opened.
pub fn request(&self, request: R) -> Receiver<Outcome<T>> {
let (answer, receive) = mpsc::channel();
if self.busy.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
let _ = answer.send(Outcome::Failed(worker_error("A file dialog is already pending")));
} else if let Err(error) = self.sender.try_send((request, answer)) {
self.busy.store(false, Ordering::SeqCst);
let (_, answer) = match error {
mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job) => job,
};
let _ = answer.send(Outcome::Failed(worker_error("The file-dialog host is unavailable")));
}
notify(self.wake.as_ref());
receive
}
}
struct Preparing<R, T> {
request: R,
ready: Receiver<()>,
send: Sender<DialogFuture<T>>,
completion: Completion<T>,
}
struct Completion<T> {
done: Receiver<Outcome<T>>,
answer: Sender<Outcome<T>>,
}
impl<T> Completion<T> {
fn poll(&self) -> Option<Outcome<T>> {
match self.done.try_recv() {
Ok(outcome) => Some(outcome),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Outcome::Failed(worker_error("The file-dialog worker stopped without an answer"))),
}
}
}
enum State<R, T> {
Preparing(Preparing<R, T>),
Waiting(Completion<T>),
}
/// Event-loop-owned transport. Dropping a reply does not release a live picker or its parent.
/// `pump` must run before consuming replies, and after app code that may enqueue a request.
/// The host must retain this service/window while `pending()` or its own continuation is pending.
pub struct NativeDialogs<R, T> {
queue: Receiver<Job<R, T>>,
busy: Arc<AtomicBool>,
state: Option<State<R, T>>,
wake: Arc<dyn Fn() + Send + Sync>,
check: Arc<dyn Fn() -> Result<(), DialogError> + Send + Sync>,
}
impl<R, T: Send + 'static> NativeDialogs<R, T> {
/// Create a client and its event-loop-owned service without a backend preflight check.
pub fn new(wake: impl Fn() + Send + Sync + 'static) -> (DialogClient<R, T>, Self) {
Self::with_preflight(wake, || Ok(()))
}
/// Optional host-owned advisory check, run on the reserved worker before any picker exists.
/// Hosts must bound this check. Unknown capability must permit the actual picker; return an
/// Unavailable error only when absence is proven. This service performs no filesystem probes.
pub fn with_preflight(
wake: impl Fn() + Send + Sync + 'static,
check: impl Fn() -> Result<(), DialogError> + Send + Sync + 'static,
) -> (DialogClient<R, T>, Self) {
let (sender, queue) = mpsc::sync_channel(1);
let busy = Arc::new(AtomicBool::new(false));
let wake: Arc<dyn Fn() + Send + Sync> = Arc::new(wake);
(DialogClient { sender, busy: busy.clone(), wake: wake.clone() }, Self { queue, busy, state: None, wake, check: Arc::new(check) })
}
/// Whether a queued, preparing or live picker still owns the transport reservation.
pub fn pending(&self) -> bool {
self.busy.load(Ordering::SeqCst)
}
/// Advance requests on the event-loop thread and publish completed replies.
/// Call before consuming replies and after enqueuing work. `create` must eagerly open
/// the native picker on this thread and return its already-created future.
pub fn pump(&mut self, mut create: impl FnMut(R) -> DialogFuture<T>) {
if let Some(state) = self.state.take() {
match state {
State::Waiting(completion) => {
if let Some(outcome) = completion.poll() {
// Only the UI pump publishes replies, after releasing the transport.
// A continuation can therefore immediately request its next picker.
self.busy.store(false, Ordering::SeqCst);
let _ = completion.answer.send(outcome);
} else {
self.state = Some(State::Waiting(completion));
}
}
State::Preparing(preparing) => {
if let Some(outcome) = preparing.completion.poll() {
self.busy.store(false, Ordering::SeqCst);
let _ = preparing.completion.answer.send(outcome);
} else if preparing.ready.try_recv().is_ok() {
let future = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| create(preparing.request)))
.unwrap_or_else(|_| Box::pin(async { Outcome::Failed(worker_error("The file-dialog factory failed")) }));
let _ = preparing.send.send(future);
self.state = Some(State::Waiting(preparing.completion));
} else {
self.state = Some(State::Preparing(preparing));
}
}
}
}
if self.state.is_none()
&& let Ok((request, answer)) = self.queue.try_recv()
{
let (ready_send, ready) = mpsc::channel();
let (send, receive) = mpsc::channel::<DialogFuture<T>>();
let (done_send, done) = mpsc::channel();
let check = self.check.clone();
let wake = self.wake.clone();
let started = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
let output = guarded(|| {
if let Err(error) = check() {
return Outcome::Failed(error);
}
if ready_send.send(()).is_err() {
return Outcome::Failed(worker_error("The file-dialog host stopped"));
}
notify(wake.as_ref());
match receive.recv() {
Ok(future) => block_on(future),
Err(_) => Outcome::Failed(worker_error("The file-dialog host stopped")),
}
});
let _ = done_send.send(output);
notify(wake.as_ref());
});
match started {
Ok(_) => self.state = Some(State::Preparing(Preparing { request, ready, send, completion: Completion { done, answer } })),
Err(error) => {
self.busy.store(false, Ordering::SeqCst);
let _ = answer.send(Outcome::Failed(worker_error(error.to_string())));
notify(self.wake.as_ref());
}
}
}
}
}
/// Holds close intent while a picker/continuation owns the parent. A retry never authorizes
/// discarding documents: the caller must run its ordinary unsaved-work policy again.
#[cfg(feature = "egui")]
#[derive(Default, Debug)]
pub struct DeferredClose {
held: bool,
}
#[cfg(feature = "egui")]
impl DeferredClose {
/// Hold actual close requests while a picker or app continuation is pending.
/// Return `true` once after settlement so the host retries its unsaved-work policy;
/// this never grants permission to discard changes or closes the viewport itself.
pub fn update(&mut self, ctx: &egui::Context, pending: bool) -> bool {
let outgoing = ctx.viewport(|v| {
v.commands
.iter()
.rev()
.find_map(|c| match c {
egui::ViewportCommand::Close => Some(true),
egui::ViewportCommand::CancelClose => Some(false),
_ => None,
})
.unwrap_or(false)
});
if pending {
let requested = outgoing || ctx.input(|i| i.viewport().close_requested());
self.held |= requested;
// Every viewport command requests a repaint. An idle native picker must sleep.
if requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
false
} else if std::mem::take(&mut self.held) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.request_repaint();
true
} else {
false
}
}
}
+11
View File
@@ -0,0 +1,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts.
#[cfg(feature = "appearance")]
pub mod appearance;
#[cfg(all(feature = "browser", target_arch = "wasm32"))]
pub mod browser;
pub mod dialog_outcome;
#[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))]
pub mod dialogs;
#[cfg(all(feature = "appearance", unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod process;
+53
View File
@@ -0,0 +1,53 @@
//! Bounded, cancellable output capture for the desktop's one-shot settings fallback.
/// Reads at most 8193 bytes from a pipe, without a detached reader or disk spool.
/// Cancellation/timeout drops the reader and kills the owned child; async-process reaps it.
pub(crate) async fn capture(program: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
use futures_lite::io::AsyncReadExt;
use std::process::Stdio;
let mut child = async_process::Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.reap_on_drop(true)
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
futures_lite::future::race(
async {
let mut bytes = Vec::new();
stdout.take(8193).read_to_end(&mut bytes).await.ok()?;
if bytes.len() > 8192 {
return None;
}
if !child.status().await.ok()?.success() {
return None;
}
String::from_utf8(bytes).ok()
},
async {
async_io::Timer::after(timeout).await;
None
},
)
.await
}
#[cfg(all(test, feature = "appearance", unix, not(target_os = "macos")))]
mod tests {
use super::capture;
#[test]
fn capture_checks_exit_status_size_and_deadline() {
use std::time::{Duration, Instant};
futures_lite::future::block_on(async {
let shell = std::path::Path::new("/bin/sh");
assert_eq!(capture(shell, &["-c", "printf hello"], Duration::from_secs(1)).await.as_deref(), Some("hello"));
assert_eq!(capture(shell, &["-c", "printf failure; exit 7"], Duration::from_secs(1)).await, None);
let before = Instant::now();
assert_eq!(capture(shell, &["-c", "exec sleep 5"], Duration::from_millis(30)).await, None);
assert!(before.elapsed() < Duration::from_secs(1));
assert_eq!(capture(shell, &["-c", "head -c 10000 /dev/zero"], Duration::from_secs(1)).await, None);
});
}
}
@@ -0,0 +1,98 @@
#![cfg(all(target_arch = "wasm32", feature = "browser"))]
use craft_platform::browser::{DownloadOptions, PreparedDownload, download_bytes};
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen(module = "/tests/browser_harness.js")]
extern "C" {
fn setup(mode: &str);
fn restore();
fn created_count() -> u32;
fn revoked_count() -> u32;
fn clicked_count() -> u32;
fn anchor_count() -> u32;
fn last_name() -> String;
fn last_mime() -> String;
fn timer_before_click() -> bool;
fn wait_for_cleanup() -> js_sys::Promise;
}
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
restore();
}
}
#[wasm_bindgen_test]
fn abandoning_a_prepared_download_releases_its_url() -> Result<(), JsValue> {
setup("");
let _restore = Restore;
let prepared = PreparedDownload::bytes("export.txt", "text/plain", b"content")?;
assert_eq!(created_count(), 1);
drop(prepared);
assert_eq!(revoked_count(), 1);
assert_eq!(clicked_count(), 0);
assert_eq!(anchor_count(), 0);
Ok(())
}
#[wasm_bindgen_test]
fn failed_attachment_and_timer_do_not_click_or_leak() {
for mode in ["attach-error", "timer-error"] {
setup(mode);
let _restore = Restore;
assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err());
assert_eq!(created_count(), 1);
assert_eq!(revoked_count(), 1);
assert_eq!(clicked_count(), 0);
assert_eq!(anchor_count(), 0);
}
}
#[wasm_bindgen_test]
async fn successful_download_releases_its_url_after_the_click() -> Result<(), JsValue> {
setup("");
let _restore = Restore;
download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 5 })?;
assert_eq!(clicked_count(), 1);
assert_eq!(last_name(), "export.txt");
assert_eq!(last_mime(), "text/plain");
assert!(timer_before_click());
assert_eq!(revoked_count(), 0);
assert_eq!(anchor_count(), 0);
wasm_bindgen_futures::JsFuture::from(wait_for_cleanup()).await?;
assert_eq!(revoked_count(), 1);
Ok(())
}
#[wasm_bindgen_test]
fn invalid_lifetime_cleans_a_prepared_download() {
setup("");
let _restore = Restore;
assert!(download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 0 }).is_err());
assert_eq!(revoked_count(), 1);
assert_eq!(clicked_count(), 0);
}
#[wasm_bindgen_test]
fn failed_setup_does_not_allocate_a_url_or_click() {
for mode in ["element-error", "style-error"] {
setup(mode);
let _restore = Restore;
assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err());
assert_eq!(created_count(), 0);
assert_eq!(revoked_count(), 0);
assert_eq!(clicked_count(), 0);
assert_eq!(anchor_count(), 0);
}
}
#[wasm_bindgen_test]
fn empty_name_is_rejected_before_allocating_resources() {
setup("");
let _restore = Restore;
assert!(download_bytes(" ", "text/plain", b"content", Default::default()).is_err());
assert_eq!(created_count(), 0);
assert_eq!(clicked_count(), 0);
}
+42
View File
@@ -0,0 +1,42 @@
// Test-only browser fault injection; no app runtime depends on this module.
let originals;
let created, revoked, clicked, scheduled, lastName, lastMime, timerBeforeClick;
export function setup(mode) {
created = 0; revoked = 0; clicked = 0; scheduled = 0;
lastName = ''; lastMime = ''; timerBeforeClick = false;
originals = {
create: URL.createObjectURL, revoke: URL.revokeObjectURL,
click: HTMLAnchorElement.prototype.click,
append: Node.prototype.appendChild, timer: window.setTimeout,
element: Document.prototype.createElement,
attribute: Element.prototype.setAttribute,
};
URL.createObjectURL = function(blob) { created++; lastMime = blob.type; return originals.create.call(URL, blob); };
URL.revokeObjectURL = function(url) { revoked++; return originals.revoke.call(URL, url); };
HTMLAnchorElement.prototype.click = function() {
clicked++; lastName = this.download; timerBeforeClick = scheduled > 0;
};
window.setTimeout = function(...args) {
if (mode === 'timer-error') throw new Error('timer failed');
scheduled++;
return originals.timer.apply(window, args);
};
if (mode === 'attach-error') Node.prototype.appendChild = function() { throw new Error('attachment failed'); };
if (mode === 'element-error') Document.prototype.createElement = function() { throw new Error('element failed'); };
if (mode === 'style-error') Element.prototype.setAttribute = function() { throw new Error('style failed'); };
}
export function restore() {
URL.createObjectURL = originals.create; URL.revokeObjectURL = originals.revoke;
HTMLAnchorElement.prototype.click = originals.click;
Node.prototype.appendChild = originals.append; window.setTimeout = originals.timer;
Document.prototype.createElement = originals.element;
Element.prototype.setAttribute = originals.attribute;
}
export function created_count() { return created; }
export function revoked_count() { return revoked; }
export function clicked_count() { return clicked; }
export function anchor_count() { return document.querySelectorAll('a[download]').length; }
export function last_name() { return lastName; }
export function last_mime() { return lastMime; }
export function timer_before_click() { return timerBeforeClick; }
export function wait_for_cleanup() { return new Promise(resolve => originals.timer.call(window, resolve, 50)); }
+248
View File
@@ -0,0 +1,248 @@
#![cfg(all(feature = "dialogs", not(target_arch = "wasm32")))]
use craft_platform::{
dialog_outcome::{DialogError, ErrorKind, Outcome},
dialogs::{NativeDialogs, wait_prepared},
};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc,
};
use std::time::{Duration, Instant};
fn pump_until<R, T: Send + 'static>(
service: &mut NativeDialogs<R, T>,
mut create: impl FnMut(R) -> craft_platform::dialogs::DialogFuture<T>,
until: impl Fn() -> bool,
) {
let deadline = Instant::now() + Duration::from_secs(3);
while !until() {
assert!(Instant::now() < deadline, "worker did not settle");
service.pump(&mut create);
std::thread::yield_now();
}
}
#[test]
fn factory_is_eager_on_caller_and_completion_on_worker() -> Result<(), Box<dyn std::error::Error>> {
let caller = std::thread::current().id();
let (tx, rx) = mpsc::channel();
let worker = wait_prepared(
|| {
assert_eq!(caller, std::thread::current().id());
async { Outcome::Selected(42) }
},
move |outcome| {
let _ = tx.send((outcome, std::thread::current().id()));
},
)?;
let (outcome, thread) = rx.recv_timeout(Duration::from_secs(3))?;
assert_eq!(outcome, Outcome::Selected(42));
assert_ne!(caller, thread);
assert!(worker.join().is_ok());
Ok(())
}
#[test]
fn constructor_and_poll_failures_are_not_cancellation() -> Result<(), Box<dyn std::error::Error>> {
for construct in [false, true] {
let (tx, rx) = mpsc::channel();
let worker = wait_prepared(
|| {
if construct {
std::panic::resume_unwind(Box::new("constructor"));
}
async {
std::panic::resume_unwind(Box::new("poll"));
#[allow(unreachable_code)]
Outcome::<u8>::Cancelled
}
},
move |outcome| {
let _ = tx.send(outcome);
},
)?;
assert!(matches!(rx.recv_timeout(Duration::from_secs(3))?, Outcome::Failed(DialogError { kind: ErrorKind::Worker, .. })));
assert!(worker.join().is_ok());
}
Ok(())
}
#[test]
fn queued_and_dropped_replies_keep_live_picker_busy() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::new(|| {});
let reply = c.request(());
assert!(service.pending());
drop(reply);
let (release, wait) = mpsc::channel();
let mut wait = Some(wait);
let created = Arc::new(AtomicUsize::new(0));
let count = created.clone();
pump_until(
&mut service,
move |()| {
count.fetch_add(1, Ordering::SeqCst);
let wait = wait.take();
Box::pin(async move {
let _ = wait.map(|w| w.recv());
Outcome::Selected(1)
})
},
|| created.load(Ordering::SeqCst) == 1,
);
assert!(service.pending());
assert!(matches!(c.request(()).recv_timeout(Duration::from_secs(3))?, Outcome::Failed(_)));
release.send(())?;
let deadline = Instant::now() + Duration::from_secs(3);
while service.pending() {
assert!(Instant::now() < deadline);
service.pump(|()| Box::pin(async { Outcome::Indeterminate }));
std::thread::yield_now();
}
Ok(())
}
#[test]
fn proven_absence_skips_native_factory() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::with_preflight(|| {}, || Err(DialogError::new(ErrorKind::Unavailable, "test backend missing")));
let reply = c.request(());
let called = Arc::new(AtomicUsize::new(0));
let count = called.clone();
let mut result = None;
let deadline = Instant::now() + Duration::from_secs(3);
while result.is_none() {
assert!(Instant::now() < deadline);
service.pump(|()| {
count.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Outcome::Selected(1) })
});
result = reply.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(called.load(Ordering::SeqCst), 0);
assert!(matches!(result, Some(Outcome::Failed(DialogError { kind: ErrorKind::Unavailable, .. }))));
Ok(())
}
#[test]
fn missing_backend_answer_is_ambiguous() {
assert_eq!(Outcome::<u8>::from_rfd(None), Outcome::Indeterminate);
}
#[cfg(feature = "egui")]
#[test]
fn held_close_retries_policy_without_granting_discard() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let mut output = ctx.run_ui(input, |ui| assert!(!close.update(ui.ctx(), true)));
assert!(output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::CancelClose));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false)));
assert!(!output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Close));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}
#[test]
fn notification_panic_cannot_kill_reserved_picker_worker() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::new(|| std::panic::resume_unwind(Box::new("wake failure")));
let reply = c.request(());
let mut result = None;
let deadline = Instant::now() + Duration::from_secs(3);
while result.is_none() {
assert!(Instant::now() < deadline);
service.pump(|()| Box::pin(async { Outcome::Selected(7) }));
result = reply.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(result, Some(Outcome::Selected(7)));
Ok(())
}
#[test]
fn completion_between_pumps_cannot_reject_a_chained_request() -> Result<(), Box<dyn std::error::Error>> {
let wakes = Arc::new(AtomicUsize::new(0));
let count = wakes.clone();
let (client, mut service) = NativeDialogs::<u8, u8>::new(move || {
count.fetch_add(1, Ordering::SeqCst);
});
let first = client.request(1);
let (release, wait) = mpsc::channel();
let mut wait = Some(wait);
let created = Arc::new(AtomicUsize::new(0));
let count = created.clone();
pump_until(
&mut service,
move |value| {
count.fetch_add(1, Ordering::SeqCst);
let wait = wait.take();
Box::pin(async move {
let _ = wait.map(|wait| wait.recv());
Outcome::Selected(value)
})
},
|| created.load(Ordering::SeqCst) == 1,
);
// Finish after the host's pump, before its app continuation polls the reply.
release.send(())?;
let deadline = Instant::now() + Duration::from_secs(3);
while wakes.load(Ordering::SeqCst) < 3 {
assert!(Instant::now() < deadline, "worker did not publish completion");
std::thread::yield_now();
}
assert!(matches!(first.try_recv(), Err(mpsc::TryRecvError::Empty)));
assert!(service.pending(), "an unpublished completion still retains the parent");
service.pump(|value| Box::pin(async move { Outcome::Selected(value) }));
assert_eq!(first.try_recv()?, Outcome::Selected(1));
assert!(!service.pending());
// The app can chain the next dialog immediately upon observing the first answer.
let second = client.request(2);
assert!(matches!(second.try_recv(), Err(mpsc::TryRecvError::Empty)));
let mut result = None;
while result.is_none() {
assert!(Instant::now() < deadline, "chained picker did not settle");
service.pump(|value| Box::pin(async move { Outcome::Selected(value) }));
result = second.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(result, Some(Outcome::Selected(2)));
assert!(!service.pending());
Ok(())
}
#[cfg(feature = "egui")]
#[test]
fn pending_dialog_without_close_intent_does_not_schedule_viewport_work() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
// Let egui settle its own initial font/style passes before testing the idle host.
for _ in 0..4 {
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
}
for _ in 0..3 {
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(!close.update(ui.ctx(), true));
});
output.textures_delta.clear();
let viewport = output.viewport_output.get(&egui::ViewportId::ROOT).unwrap();
assert!(viewport.commands.is_empty(), "an idle picker must not enqueue commands: {:?}", viewport.commands);
assert_ne!(viewport.repaint_delay, std::time::Duration::ZERO, "an idle picker must not request another frame");
}
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}
#[cfg(feature = "egui")]
#[test]
fn programmatic_close_is_held_until_the_pending_dialog_settles() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
assert!(!close.update(ui.ctx(), true));
});
output.textures_delta.clear();
let commands = &output.viewport_output.get(&egui::ViewportId::ROOT).unwrap().commands;
let last_close = commands.iter().rev().find(|command| matches!(command, egui::ViewportCommand::Close | egui::ViewportCommand::CancelClose));
assert_eq!(last_close, Some(&egui::ViewportCommand::CancelClose));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false)));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}
+12 -4
View File
@@ -195,6 +195,13 @@ pub const HANDLED_OPTIONS: &[&str] = &["PageSize", "PageRegion", "Duplex", "Coll
/// Caps on what a PPD may make the dialog hold (PPDs are text files from the printer vendor).
const MAX_OPTIONS: usize = 400;
const MAX_CHOICES: usize = 400;
/// Longest group, option or choice label kept, in characters; every option copies its group's.
const MAX_PPD_LABEL: usize = 256;
/// `label`, cut to [`MAX_PPD_LABEL`] characters.
fn ppd_label(label: &str) -> String {
label.chars().take(MAX_PPD_LABEL).collect()
}
/// A PPD keyword: printable ASCII without spaces, `/`, `:` or `=`.
fn is_ppd_keyword(s: &str) -> bool {
@@ -220,7 +227,7 @@ pub fn parse_ppd(ppd: &str) -> Vec<PrinterOption> {
let line = line.trim_end_matches('\r');
if let Some(rest) = line.strip_prefix("*OpenGroup:") {
let (k, l) = name_label(rest);
(group_key, group) = (k.to_string(), l.to_string());
(group_key, group) = (k.to_string(), ppd_label(l));
} else if line.starts_with("*CloseGroup") {
(group_key, group) = (String::new(), String::new());
} else if let Some(rest) = line.strip_prefix("*OpenUI") {
@@ -228,7 +235,7 @@ pub fn parse_ppd(ppd: &str) -> Vec<PrinterOption> {
let (key, label) = name_label(head.trim().trim_start_matches('*'));
open = is_ppd_keyword(key).then(|| PrinterOption {
key: key.to_string(),
label: label.to_string(),
label: ppd_label(label),
group: group.clone(),
choices: Vec::new(),
default: String::new(),
@@ -251,7 +258,7 @@ pub fn parse_ppd(ppd: &str) -> Vec<PrinterOption> {
let head = rest.split_once(':').map_or(rest, |(h, _)| h);
let (choice, label) = name_label(head);
if is_ppd_keyword(choice) && o.choices.len() < MAX_CHOICES && o.choices.iter().all(|(c, _)| c != choice) {
o.choices.push((choice.to_string(), label.to_string()));
o.choices.push((choice.to_string(), ppd_label(label)));
}
}
}
@@ -413,8 +420,9 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec<Printer> {
let mut printers = parse_lpstat(queues);
let default = default_destination(queues);
if let Some(names) = available {
let mut seen: std::collections::HashSet<String> = printers.iter().map(|p| p.name.clone()).collect();
for name in parse_lpstat_e(names) {
if !printers.iter().any(|p| p.name == name) {
if seen.insert(name.clone()) {
printers.push(Printer { default: default == Some(name.as_str()), name, description: String::new(), two_sided: None });
}
}
+19 -7
View File
@@ -11,6 +11,7 @@
//! Every length, count and depth is bounded, and anything unreadable is an [`Error`], never a
//! panic: the caller's PDF is input like any other.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -781,16 +782,27 @@ impl Reader<'_> {
None
}
fn rect(&mut self, obj: Option<&Obj>) -> Option<[f64; 4]> {
let obj = self.resolve(obj?)?;
let items = obj.array()?;
let mut v = [0.0; 4];
for (slot, item) in v.iter_mut().zip(items) {
*slot = self.resolve(item)?.num()?;
/// Like [`resolve`](Self::resolve), but a direct object is borrowed, not copied. Inherited
/// page attributes are read once per page, so copying a large inline value would repeat that
/// work for every page outside the budget.
fn resolve_borrowed<'o>(&mut self, obj: &'o Obj) -> Option<Cow<'o, Obj>> {
match obj {
Obj::Ref(..) => self.resolve(obj).map(Cow::Owned),
direct => Some(Cow::Borrowed(direct)),
}
}
fn rect(&mut self, obj: Option<&Obj>) -> Option<[f64; 4]> {
let obj = self.resolve_borrowed(obj?)?;
let items = obj.array()?;
// The shape is checked before reading any element.
if items.len() != 4 {
return None;
}
let mut v = [0.0; 4];
for (slot, item) in v.iter_mut().zip(items) {
*slot = self.resolve_borrowed(item)?.num()?;
}
let [x0, y0, x1, y1] = v;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
@@ -938,7 +950,7 @@ pub(crate) fn read(data: &[u8]) -> Result<Sheets<'_>, Error> {
.map(|c| [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])])
.filter(|c| c[2] > c[0] && c[3] > c[1])
.unwrap_or(media);
let rotate = here.rotate.as_ref().and_then(|r| reader.resolve(r)).and_then(|r| r.int()).unwrap_or(0).rem_euclid(360);
let rotate = here.rotate.as_ref().and_then(|r| reader.resolve_borrowed(r).and_then(|r| r.int())).unwrap_or(0).rem_euclid(360);
// Not a multiple of 90 is invalid; such a page is shown unrotated.
let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 };
// Windows renders viewer visibility, not annotation print visibility. Only a plain
+23
View File
@@ -256,6 +256,29 @@ fn printer_options_come_from_the_ppd() {
assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped");
}
#[test]
fn ppd_labels_are_capped_before_every_option_copies_its_group() {
let long = "G".repeat(100_000);
let mut ppd = format!("*OpenGroup: Big/{long}\n");
for i in 0..300 {
ppd += &format!("*OpenUI *Opt{i}/{long}: PickOne\n*Opt{i} A/{long}: \"\"\n*CloseUI: *Opt{i}\n");
}
let options = spool::parse_ppd(&ppd);
assert_eq!(options.len(), 300);
assert!(options.iter().all(|o| o.group.len() <= 256 && o.label.len() <= 256 && o.choices[0].1.len() <= 256));
assert_eq!(options[0].group, "G".repeat(256));
}
#[test]
fn many_driverless_destinations_merge_in_linear_time() {
let names: String = (0..200_000).map(|i| format!("dest_{i}\n")).collect();
let started = std::time::Instant::now();
let printers = spool::printers_parsed("printer Office is idle.\n", Some(&format!("Office\n{names}dest_7\n")));
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
assert_eq!(printers.len(), 200_001, "each destination once, queues first");
assert_eq!(printers[0].name, "Office");
}
#[test]
fn printer_option_defaults_and_job_arguments() {
let current =
+29
View File
@@ -254,3 +254,32 @@ fn an_inflate_limit_stops_later_stream_and_object_work() {
assert_eq!(reader.budget.nodes, 0);
assert_eq!(reader.budget.tokens, 0);
}
#[test]
fn an_oversized_inherited_media_box_is_not_copied_for_every_page() {
// One inline MediaBox far longer than four numbers, inherited by many small pages.
let pages = 4_000;
let media = vec!["0"; 25_000].join(" ");
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect();
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [{media}] >>", kids.join(" ")),
];
objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string()));
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
for offset in offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
let started = std::time::Instant::now();
let sheets = read(&pdf).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
assert_eq!(sheets.sheets.len(), pages);
}
+8 -4
View File
@@ -36,18 +36,22 @@ pub fn new_document(version: u32) -> DocumentMut {
/// is kept. This is what keeps a renamed enum variant from resetting the whole file.
pub fn deserialize_sections<T: Settings>(doc: &DocumentMut) -> (T, Vec<SectionError>) {
let defaults = T::default();
let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else {
let Ok(base) = toml_edit::ser::to_document(&defaults) else {
return (defaults, Vec::new());
};
let mut merged = base.clone();
let mut errors = Vec::new();
for (key, item) in doc.iter() {
if key == SCHEMA_VERSION_KEY {
continue;
}
let mut candidate = merged.clone();
// Each section is tried against the defaults alone, never against the sections accepted
// before it, so a file of many keys costs the defaults plus that key per key instead of
// the whole document so far.
let mut candidate = base.clone();
candidate[key] = item.clone();
match toml_edit::de::from_document::<T>(candidate.clone()) {
Ok(_) => merged = candidate,
match toml_edit::de::from_document::<T>(candidate) {
Ok(_) => merged[key] = item.clone(),
Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }),
}
}
+17
View File
@@ -47,6 +47,23 @@ fn bad_value_only_resets_its_section() {
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
}
#[test]
fn many_unknown_keys_load_in_linear_time() {
// Each key was once tried against every key accepted before it.
let tmp = tempfile::tempdir().unwrap();
let mut text = String::from("schema_version = 2\n");
for i in 0..20_000 {
text.push_str(&format!("unknown_{i} = \"{}\"\n", "x".repeat(64)));
}
text.push_str("[general]\nlanguage = \"de\"\n");
write_config(tmp.path(), &text);
let started = std::time::Instant::now();
let (store, report) = open_in(tmp.path());
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().general.language, "de");
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
#[test]
fn newer_schema_opens_read_only_and_never_overwrites() {
let tmp = tempfile::tempdir().unwrap();
+7 -1
View File
@@ -389,7 +389,13 @@ impl<P: Clone + Eq + Hash> Layout<P> {
}
Action::Float { panel, .. } if !permissions(panel).floatable || !permissions(panel).movable => return Err(DockError::NotAllowed),
Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed),
Action::MoveFloating { panel, .. } if !permissions(panel).movable => return Err(DockError::NotAllowed),
// The rectangle belongs to the whole floating group, so every member must allow it.
Action::MoveFloating { panel, .. }
if !permissions(panel).movable
|| self.floating.iter().find(|g| g.panels.contains(panel)).is_some_and(|g| g.panels.iter().any(|p| !permissions(p).movable)) =>
{
return Err(DockError::NotAllowed);
}
Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed),
_ => {}
}
+3 -1
View File
@@ -793,7 +793,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
});
}
if response.enabled() && delta != Vec2::ZERO {
// Resizing changes the group's shared rectangle, which the model allows only when
// every member is movable.
if response.enabled() && delta != Vec2::ZERO && group.panels.iter().all(|p| (self.permissions)(p).movable) {
let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size());
self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] });
}
+57
View File
@@ -129,6 +129,21 @@ fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() {
assert!(next.contains(&1) && next.contains(&7));
}
#[test]
fn moving_a_floating_group_needs_every_member_movable() {
// sample() floats 5 and 6 together; 6 may not move, so naming 5 cannot move it either.
let permissions = |panel: &u32| Permissions { movable: *panel != 6, ..Permissions::default() };
let layout = sample();
let mut next = layout.clone();
assert_eq!(
next.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, permissions),
Err(DockError::NotAllowed)
);
assert_eq!(next, layout);
next.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, |_: &u32| Permissions::default()).unwrap();
assert_eq!(next.floating[0].rect, [0.0, 0.0, 300.0, 220.0]);
}
#[test]
fn close_and_restore_preserve_fixed_side_and_floating_geometry() {
let mut layout = Layout {
@@ -852,6 +867,48 @@ fn protected_group_member_blocks_whole_group_gesture_and_host_drop() {
}
}
/// Drags the floating group's resize grip in `sample()` and returns every action emitted.
fn resize_floating_group(permissions: impl Fn(&u32) -> Permissions + Copy) -> Vec<Action<u32>> {
let ctx = Context::default();
let layout = sample();
// sample() floats 5 and 6 at [80, 70, 300, 220]; the grip is its bottom-right corner.
let (start, end) = (pos2(374.0, 284.0), pos2(398.0, 308.0));
let mut actions = Vec::new();
for events in [
vec![],
vec![],
vec![Event::PointerMoved(start), pointer_button(start, true)],
vec![Event::PointerMoved(end)],
vec![pointer_button(end, false)],
] {
let mut out = DockOutput::default();
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| {
let style = DockStyle::from_ui(ui);
out = DockArea::new(Id::new("resize-area")).show(
ui,
&layout,
&style,
|p| format!("Panel {p}"),
permissions,
|ui, _| {
ui.label("Body");
},
);
})
.textures_delta
.clear();
actions.extend(out.actions);
}
actions
}
#[test]
fn protected_group_member_blocks_floating_group_resize() {
let moved = |actions: &[Action<u32>]| actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }));
assert!(moved(&resize_floating_group(|_| Permissions::default())), "the grip resizes a movable group");
assert!(!moved(&resize_floating_group(|p| if *p == 6 { Permissions::PROTECTED } else { Permissions::default() })));
}
#[test]
fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid_layout() {
let ctx = Context::default();
+28 -12
View File
@@ -1,6 +1,7 @@
//! Printer selection and asynchronous job feedback, independent of any spooler or document.
//! Hosts provide workers; applications own PDF generation, commands and translated wording.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, TryRecvError};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -19,26 +20,41 @@ pub struct Printer {
/// printers show their names, which the spooler keeps distinct.
pub fn shown_names(printers: &[Printer]) -> Vec<String> {
let base: Vec<&str> = printers.iter().map(|p| if p.label.trim().is_empty() { p.name.as_str() } else { p.label.as_str() }).collect();
let mut base_count: HashMap<&str, usize> = HashMap::with_capacity(base.len());
for text in &base {
*base_count.entry(text).or_default() += 1;
}
let mut shown: Vec<String> = printers
.iter()
.zip(&base)
.map(|(printer, text)| {
let shared = base.iter().filter(|other| *other == text).count() > 1;
let shared = base_count.get(text).is_some_and(|n| *n > 1);
if shared && *text != printer.name { format!("{text} ({})", printer.name) } else { (*text).to_owned() }
})
.collect();
// Each pass turns at least one shared text into its name, or stops: at most one per printer.
for _ in 0..printers.len() {
let shared: Vec<bool> = shown.iter().map(|text| shown.iter().filter(|other| *other == text).count() > 1).collect();
let mut changed = false;
for ((text, printer), shared) in shown.iter_mut().zip(printers).zip(shared) {
if shared && *text != printer.name {
text.clone_from(&printer.name);
changed = true;
}
// Which printers show each text. A shared text turns every printer showing it into its name;
// a printer that shows its name never changes again, so each moves at most once and the
// whole cascade stays linear in the number of printers.
let mut by_text: HashMap<String, Vec<usize>> = HashMap::with_capacity(shown.len());
for (i, text) in shown.iter().enumerate() {
by_text.entry(text.clone()).or_default().push(i);
}
let mut pending: Vec<String> = by_text.iter().filter(|(_, ids)| ids.len() > 1).map(|(text, _)| text.clone()).collect();
while let Some(text) = pending.pop() {
let Some(ids) = by_text.get_mut(&text) else { continue };
if ids.len() < 2 {
continue;
}
if !changed {
break;
let (stay, moving): (Vec<usize>, Vec<usize>) = ids.iter().partition(|&&i| printers.get(i).is_some_and(|p| p.name == text));
*ids = stay;
for i in moving {
let (Some(printer), Some(slot)) = (printers.get(i), shown.get_mut(i)) else { continue };
slot.clone_from(&printer.name);
let ids = by_text.entry(printer.name.clone()).or_default();
ids.push(i);
if ids.len() == 2 {
pending.push(printer.name.clone());
}
}
}
shown
+62
View File
@@ -8,6 +8,68 @@ fn labelled(name: &str, label: &str) -> Printer {
Printer { name: name.into(), label: label.into(), default: false }
}
/// The pass-by-pass resolution `shown_names` replaced, to check they agree.
fn shown_names_by_passes(printers: &[Printer]) -> Vec<String> {
let base: Vec<&str> = printers.iter().map(|p| if p.label.trim().is_empty() { p.name.as_str() } else { p.label.as_str() }).collect();
let mut shown: Vec<String> = printers
.iter()
.zip(&base)
.map(|(printer, text)| {
let shared = base.iter().filter(|other| *other == text).count() > 1;
if shared && *text != printer.name { format!("{text} ({})", printer.name) } else { (*text).to_owned() }
})
.collect();
loop {
let shared: Vec<bool> = shown.iter().map(|text| shown.iter().filter(|other| *other == text).count() > 1).collect();
let mut changed = false;
for ((text, printer), shared) in shown.iter_mut().zip(printers).zip(shared) {
if shared && *text != printer.name {
text.clone_from(&printer.name);
changed = true;
}
}
if !changed {
return shown;
}
}
}
/// Each printer's label is the name the one before it falls back to, so every pass of the
/// old resolution freed exactly one more printer.
fn cascade(n: usize) -> Vec<Printer> {
let mut printers = vec![labelled("n0", "x"), labelled("n1", "x"), labelled("n2", "x (n0)")];
printers.extend((3..n).map(|i| labelled(&format!("n{i}"), &format!("n{}", i - 1))));
printers
}
#[test]
fn collisions_resolve_like_the_pass_by_pass_rule() {
let names = ["a", "b", "c", "Office", "Office_1", "x"];
let labels = ["", "a", "b", "Office", "Office (Office_1)", "x (a)", "a (b)", "c"];
let mut seed = 7u32;
for _ in 0..2000 {
let printers: Vec<Printer> = (0..5)
.map(|_| {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
labelled(names[(seed >> 8) as usize % names.len()], labels[(seed >> 16) as usize % labels.len()])
})
.collect();
assert_eq!(shown_names(&printers), shown_names_by_passes(&printers), "{printers:?}");
}
assert_eq!(shown_names(&cascade(40)), shown_names_by_passes(&cascade(40)));
}
#[test]
fn a_long_collision_cascade_stays_fast() {
let printers = cascade(20_000);
let started = std::time::Instant::now();
let shown = shown_names(&printers);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
assert_eq!(shown[0], "n0");
assert_eq!(shown[1], "x (n1)");
assert!(shown[2..].iter().zip(&printers[2..]).all(|(s, p)| *s == p.name));
}
#[test]
fn the_dialog_shows_a_printers_label_and_keeps_its_queue_name() {
// CUPS can't put dots in a queue name: the printer added as 192.0.2.15 is `_192_0_2_15`.
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "craft-window"
description = "Decide who draws the window frame on Linux (the compositor or the app) and apply it, so every Craft app gets the same native titlebar and borders per desktop."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[features]
# `apply`/`decorations`: decide decorations and set them on an `egui::ViewportBuilder`.
egui = ["dep:egui"]
[dependencies]
egui = { workspace = true, optional = true }
# Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
wayland-client = "0.31"
[lints]
workspace = true
+70
View File
@@ -0,0 +1,70 @@
craft-window
============
Decide who draws the window frame — the desktop or the app — so every Craft app shows the same
titlebar and borders on a given desktop.
## Why
egui apps either keep the desktop's window frame (titlebar, borders, window buttons) or turn it off
and draw their own. Across the suite this had drifted:
- some apps always drew their own frame on Linux (so KDE's titlebar was replaced by the app's),
- some always kept the desktop's,
- some probed the compositor and only drew their own on GNOME.
So two Craft apps open side by side on KDE could wear different titlebars. This crate makes the
decision in one place, matching each desktop.
## The rule
| Platform | Frame |
| --- | --- |
| macOS, Windows | desktop (the OS) |
| Linux/BSD on X11 | desktop (the window manager) |
| Linux/BSD on Wayland **with** `zxdg_decoration_manager_v1` (KDE, Sway, Hyprland, COSMIC) | desktop (the compositor) |
| Linux/BSD on Wayland **without** it (GNOME's Mutter) | the app |
When the app draws the frame, the decision carries the window-button `Layout` the desktop names
(GNOME's `button-layout`), so the app's header bar puts minimize/maximize/close where that desktop
puts them. This crate decides *whether* and *where*; drawing the buttons stays in each app, whose
title bars differ.
### Override
`<PREFIX>_TITLEBAR` forces the choice. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` forces
the app frame, `=system` (or `=desktop`) forces the desktop frame. Apps pass their own prefix
(`Prefix::new("PDFCRAFT")` → `PDFCRAFT_TITLEBAR`) so each keeps its own variable name.
## Use
```rust,ignore
use craft_window::{Choice, Prefix, decorate};
// Once at startup, before the window is created:
let choice = Choice::probe(Prefix::new("PHOTOCRAFT"));
let viewport = decorate(egui::ViewportBuilder::default().with_title("PhotoCraft"), choice.frame());
// If the app draws the frame, lay the header-bar buttons out by the desktop's order:
if let Some(layout) = choice.frame().layout() {
// layout.left / layout.right -> draw Minimize/Maximize/Close there
}
```
An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting,
and can override the probe by setting the frame to `Frame::Desktop`.
The `egui` feature adds [`decorate`]. Without it, the crate is pure decision logic (`Choice`,
`Frame`, `Layout`) with no egui dependency.
## Notes
- The Wayland probe opens a short-lived connection and reads the global registry once, on its own
thread with a 500 ms timeout; on failure or timeout it keeps the desktop's frame. Call it once at
startup.
- It never touches the connection winit inherits: when `WAYLAND_SOCKET` is set (a compositor handed
the app an open descriptor), the probe skips connecting and keeps the desktop frame, so it can't
consume the socket winit needs.
- The GNOME `button-layout` lookup (`gsettings`) runs behind the same bounded worker, so a stalled
`gsettings` can't block startup either.
- `#![forbid(unsafe_code)]`, and the never-crash lints the workspace requires.
+439
View File
@@ -0,0 +1,439 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
//! Decide who draws the window frame, so every Craft app shows the same titlebar and borders on a
//! given desktop.
//!
//! egui apps either let the desktop draw the window frame (the title bar, borders and window
//! buttons) or turn it off and draw their own. Across the suite this decision had drifted: some
//! apps always drew their own frame on Linux, some always kept the desktop's, and some probed the
//! compositor — so on KDE, say, one app wore KWin's title bar while another wore its own. This
//! crate makes the decision one place.
//!
//! The rule, by platform:
//!
//! - **macOS and Windows:** the OS always draws the frame ([`Frame::Desktop`]). (An app that insets
//! its content under the traffic lights, or draws its own caption buttons on Windows, still asks
//! the OS for a decorated window and overdraws it; that is the app's choice and not this crate's.)
//! - **Linux and the BSDs, X11:** the window manager draws the frame ([`Frame::Desktop`]).
//! - **Linux and the BSDs, Wayland:** a compositor that offers server-side decorations
//! (`zxdg_decoration_manager_v1` — KDE, Sway, Hyprland, COSMIC) draws the frame
//! ([`Frame::Desktop`]). One that does not (GNOME's Mutter) leaves the frame to the app
//! ([`Frame::App`]): without this, winit draws an imitation frame that matches no desktop.
//!
//! An environment variable overrides the choice, for users whose compositor the probe can't read
//! or who simply prefer the other frame. With the default prefix `CRAFT`, `CRAFT_TITLEBAR=app`
//! forces [`Frame::App`] and `=system` (or `=desktop`) forces [`Frame::Desktop`]; apps may pass
//! their own prefix (`PDFCRAFT`, `FIGCRAFT`, …) so each keeps its own variable name.
//!
//! When the app draws the frame, [`Frame::App`] carries the [`Layout`] of window buttons the
//! desktop names (GNOME's `button-layout`), so the app's header bar puts minimize, maximize and
//! close where that desktop puts them. This crate decides *whether* and *where*; drawing the
//! buttons stays with each app (their title bars differ).
//!
//! # With egui
//!
//! The `egui` feature adds [`decorate`], which applies the decision to an
//! [`egui::ViewportBuilder`] before the window is created:
//!
//! ```ignore
//! let viewport = egui::ViewportBuilder::default().with_title("PhotoCraft");
//! // The app may still prefer its own frame when the user asked for it in preferences:
//! let choice = craft_window::Choice::probe(craft_window::Prefix::default());
//! let viewport = craft_window::decorate(viewport, choice.frame());
//! ```
//!
//! The probe opens a short-lived Wayland connection and reads the global registry once; it never
//! touches the connection winit inherits. Call it once at startup.
/// The environment-variable prefix an app overrides the frame with: `<PREFIX>_TITLEBAR`.
///
/// `Prefix::default()` is `CRAFT`, so `CRAFT_TITLEBAR`. An app that wants its own name passes
/// [`Prefix::new`] (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`). The name is used as
/// given; it should be ASCII letters, digits and underscores, as environment names are.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Prefix(&'static str);
impl Default for Prefix {
fn default() -> Self {
Self("CRAFT")
}
}
impl Prefix {
/// An app-specific prefix, so the override variable is `<prefix>_TITLEBAR`.
pub const fn new(prefix: &'static str) -> Self {
Self(prefix)
}
/// The environment variable this prefix reads, e.g. `CRAFT_TITLEBAR`.
pub fn var_name(self) -> String {
format!("{}_TITLEBAR", self.0)
}
}
/// Who draws the window frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Frame {
/// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the
/// compositor on a Wayland session with server-side decorations. The app leaves decorations on.
Desktop,
/// The app draws it (GNOME's Wayland session, or an override). The app turns decorations off
/// and draws a header bar with the window buttons in this [`Layout`].
App(Layout),
}
impl Frame {
/// Whether the app should keep the OS/compositor decorations on (`true` for [`Frame::Desktop`]).
/// This is the value to pass to `egui::ViewportBuilder::with_decorations`.
pub fn decorations(&self) -> bool {
matches!(self, Frame::Desktop)
}
/// The window-button layout when the app draws the frame, else `None`.
pub fn layout(&self) -> Option<&Layout> {
match self {
Frame::App(layout) => Some(layout),
Frame::Desktop => None,
}
}
}
/// How the frame was decided, so an app can log it or offer a "System title bar" preference that
/// knows the current state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
frame: Frame,
/// `Some(true)` when `<PREFIX>_TITLEBAR` forced the app frame, `Some(false)` when it forced the
/// desktop frame, `None` when the platform decided.
overridden: Option<bool>,
}
impl Choice {
/// Probe the running platform and desktop and decide who draws the frame.
///
/// `prefix` names the override variable (`<prefix>_TITLEBAR`). On macOS, Windows and X11 this
/// does no I/O; on a Wayland session it opens a short-lived connection to read the registry.
pub fn probe(prefix: Prefix) -> Self {
match override_frame(&prefix.var_name()) {
Some(true) => Self { frame: Frame::App(Layout::from_desktop()), overridden: Some(true) },
Some(false) => Self { frame: Frame::Desktop, overridden: Some(false) },
None if app_draws_frame() => Self { frame: Frame::App(Layout::from_desktop()), overridden: None },
None => Self { frame: Frame::Desktop, overridden: None },
}
}
/// The decided frame.
pub fn frame(&self) -> &Frame {
&self.frame
}
/// The decided frame, consumed.
pub fn into_frame(self) -> Frame {
self.frame
}
/// Whether `<PREFIX>_TITLEBAR` set the frame: `Some(true)` it forced the app's, `Some(false)`
/// the desktop's, `None` the platform decided.
pub fn overridden(&self) -> Option<bool> {
self.overridden
}
}
/// Read `<var>`: `app` → force the app frame, `system`/`desktop` → force the desktop frame,
/// anything else (or unset) → let the platform decide.
fn override_frame(var: &str) -> Option<bool> {
parse_override(std::env::var(var).ok().as_deref())
}
/// The override a `<PREFIX>_TITLEBAR` value asks for: `Some(true)` the app frame, `Some(false)` the
/// desktop frame, `None` to let the platform decide. Pure, so it is tested without the environment.
fn parse_override(value: Option<&str>) -> Option<bool> {
match value.map(str::trim) {
Some("app") => Some(true),
Some("system" | "desktop") => Some(false),
_ => None,
}
}
/// A window button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Button {
Minimize,
Maximize,
Close,
}
/// Which window buttons go on which end of the app-drawn header bar, outermost last.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub left: Vec<Button>,
pub right: Vec<Button>,
}
impl Default for Layout {
/// What a desktop that names no layout gets: all three, on the right.
fn default() -> Self {
Self { left: Vec::new(), right: vec![Button::Minimize, Button::Maximize, Button::Close] }
}
}
impl Layout {
/// The desktop's window-button layout, read from GNOME's `button-layout` where it is set, else
/// the default (all three on the right). Only meaningful when the app draws the frame.
pub fn from_desktop() -> Self {
desktop_button_layout()
}
/// Parse GNOME's `org.gnome.desktop.wm.preferences button-layout`: `left:right`, each a comma
/// list such as `appmenu:minimize,maximize,close` (Fedora's default is `appmenu:close`).
/// Without a colon everything is on the left, as in GTK. Names this crate has no button for
/// (`appmenu`, `icon`, `menu`, `spacer`) are skipped, and a button named twice appears once.
/// `gsettings` prints the value quoted; the quotes are dropped.
pub fn parse(value: &str) -> Self {
let value = value.trim().trim_matches(|c| c == '\'' || c == '"');
let (left, right) = value.split_once(':').unwrap_or((value, ""));
let mut seen: Vec<Button> = Vec::new();
let mut side = |list: &str| {
let mut out = Vec::new();
for name in list.split(',') {
let button = match name.trim() {
"minimize" => Button::Minimize,
"maximize" => Button::Maximize,
"close" => Button::Close,
_ => continue,
};
if !seen.contains(&button) {
seen.push(button);
out.push(button);
}
}
out
};
let left = side(left);
let right = side(right);
Self { left, right }
}
/// Whether the layout has at least one button, so the window can be closed by mouse. A layout
/// with none (a desktop that named only buttons this crate skips) falls back to the default.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))]
fn is_usable(&self) -> bool {
!self.left.is_empty() || !self.right.is_empty()
}
}
/// GNOME's button layout where `gsettings` can read it, else the default. Returns the default on
/// any platform without `gsettings` or when the value names no usable button.
fn desktop_button_layout() -> Layout {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(value) = platform::gsettings_button_layout() {
let layout = Layout::parse(&value);
if layout.is_usable() {
return layout;
}
}
Layout::default()
}
/// Whether the app should draw the frame itself: a Wayland compositor with no server-side
/// decorations. `false` on macOS, Windows, X11 and any Wayland compositor that decorates.
fn app_draws_frame() -> bool {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
{
platform::wayland_leaves_frame_to_app()
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))]
{
false
}
}
/// Apply a [`Frame`] to an [`egui::ViewportBuilder`]: decorations on for the desktop frame, off for
/// the app frame. Call before the window is created. The app still draws its header bar and, for
/// [`Frame::App`], lays its window buttons out by [`Frame::layout`].
#[cfg(feature = "egui")]
pub fn decorate(builder: egui::ViewportBuilder, frame: &Frame) -> egui::ViewportBuilder {
builder.with_decorations(frame.decorations())
}
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
mod platform {
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Whether this is a Wayland session whose compositor offers no server-side decorations
/// (`zxdg_decoration_manager_v1`), so the frame is left to the app (GNOME). `false` on X11
/// (no Wayland connection) and on any compositor that offers them (KDE, Sway, Hyprland,
/// COSMIC).
///
/// Called once at startup, before the window is created. It runs the registry read on a
/// worker thread with a 500 ms deadline so a stalled compositor can't block startup; on
/// failure or timeout it returns `false` and the window keeps the desktop's frame. (A
/// compositor that never answers would leave that one worker parked for the process's life;
/// acceptable because this is a single, one-shot call, not a repeated one.)
pub(super) fn wayland_leaves_frame_to_app() -> bool {
bounded(registry_leaves_frame_to_app).flatten().unwrap_or(false)
}
/// Run `f` on a short-lived worker thread with a 500 ms deadline, so a stalled dependency can't
/// block window startup. `None` when the thread can't be spawned or `f` doesn't finish in time;
/// otherwise `f`'s return value. For a child process, prefer [`gsettings_button_layout`], which
/// also kills and reaps the child on timeout; this helper only bounds the caller's wait.
fn bounded<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
let (send, receive) = mpsc::sync_channel(1);
let spawned = std::thread::Builder::new().name("craft-window-probe".into()).spawn(move || {
let _ = send.send(f());
});
if spawned.is_err() {
return None;
}
receive.recv_timeout(Duration::from_millis(500)).ok()
}
/// A fresh connection (never winit's inherited one): `None` when there is no Wayland
/// compositor, else whether it offers no decoration manager.
fn registry_leaves_frame_to_app() -> Option<bool> {
// `connect_to_env` would take ownership of an inherited `WAYLAND_SOCKET` fd and unset the
// variable; dropping this short-lived connection would then close the descriptor winit
// needs. So when the compositor handed us an open socket (rare: normal launches get
// `WAYLAND_DISPLAY`, a name we could reopen, not `WAYLAND_SOCKET`), skip the probe and keep
// the desktop frame. On GNOME this means winit's fallback frame rather than an app-drawn
// one, the same as an app that doesn't probe at all \u2014 a safe default, not a regression,
// taken only to protect winit's connection.
if std::env::var_os("WAYLAND_SOCKET").is_some() {
return None;
}
let conn = Connection::connect_to_env().ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&conn).ok()?;
let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"));
Some(!offered)
}
/// GNOME's `button-layout`, read with `gsettings`. `None` when `gsettings` is missing, fails,
/// times out, or prints nothing usable. The child is spawned with piped output and read on a
/// worker thread; if it doesn't finish within 500 ms the child is killed and reaped, so a
/// stalled `gsettings` leaves neither a blocked process nor a parked worker.
pub(super) fn gsettings_button_layout() -> Option<String> {
let mut child = Command::new("gsettings")
.args(["get", "org.gnome.desktop.wm.preferences", "button-layout"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
let (send, receive) = mpsc::sync_channel(1);
let reader = std::thread::Builder::new().name("craft-window-gsettings".into()).spawn(move || {
let mut stdout = stdout;
let mut buf = String::new();
let read = std::io::Read::read_to_string(&mut stdout, &mut buf);
let _ = send.send(read.ok().map(|_| buf));
});
if reader.is_err() {
let _ = child.kill();
let _ = child.wait();
return None;
}
let output = receive.recv_timeout(Duration::from_millis(500));
// Kill-and-reap whether it finished or timed out: a finished child still needs reaping, and
// a timed-out one must not linger. `kill` on an exited child is a harmless error.
let _ = child.kill();
let _ = child.wait();
let value = output.ok().flatten()?;
let value = value.trim();
if value.is_empty() { None } else { Some(value.to_string()) }
}
/// The registry roundtrip needs a `Dispatch`, but no event carries state here.
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(_: &mut Self, _: &wl_registry::WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
}
#[cfg(test)]
mod tests {
use super::{Button, Frame, Layout, Prefix, parse_override};
#[test]
fn prefix_names_the_override_variable() {
assert_eq!(Prefix::default().var_name(), "CRAFT_TITLEBAR");
assert_eq!(Prefix::new("PDFCRAFT").var_name(), "PDFCRAFT_TITLEBAR");
}
#[test]
fn the_override_reads_app_and_system() {
assert_eq!(parse_override(Some("app")), Some(true));
assert_eq!(parse_override(Some(" system ")), Some(false));
assert_eq!(parse_override(Some("desktop")), Some(false));
assert_eq!(parse_override(Some("nonsense")), None);
assert_eq!(parse_override(None), None);
}
#[test]
fn frame_maps_to_decorations_and_layout() {
assert!(Frame::Desktop.decorations());
assert!(Frame::Desktop.layout().is_none());
let app = Frame::App(Layout::default());
assert!(!app.decorations());
assert!(app.layout().is_some());
}
#[test]
fn the_default_layout_is_three_buttons_on_the_right() {
let layout = Layout::default();
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Maximize, Button::Close]);
}
#[test]
fn parse_reads_gnomes_two_sided_layout() {
let layout = Layout::parse("appmenu:minimize,maximize,close");
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Maximize, Button::Close]);
let fedora = Layout::parse("appmenu:close");
assert_eq!(fedora.right, vec![Button::Close]);
let left = Layout::parse("close,minimize,maximize:");
assert_eq!(left.left, vec![Button::Close, Button::Minimize, Button::Maximize]);
assert!(left.right.is_empty());
}
#[test]
fn parse_drops_quotes_unknown_names_and_duplicates() {
// gsettings prints the value single-quoted.
let layout = Layout::parse("'appmenu:minimize,close,close,spacer'");
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Close]);
}
#[test]
fn parse_without_a_colon_puts_everything_on_the_left() {
let layout = Layout::parse("minimize,maximize,close");
assert_eq!(layout.left, vec![Button::Minimize, Button::Maximize, Button::Close]);
assert!(layout.right.is_empty());
}
#[test]
fn a_layout_naming_no_known_button_is_not_usable() {
assert!(!Layout::parse("appmenu:spacer").is_usable());
assert!(Layout::parse("appmenu:close").is_usable());
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn the_os_always_draws_the_frame_on_macos_and_windows() {
// A prefix whose variable no test or environment sets, so the platform decides.
let choice = super::Choice::probe(Prefix::new("CRAFT_WINDOW_UNSET_FOR_TEST"));
assert_eq!(choice.frame(), &Frame::Desktop);
assert_eq!(choice.overridden(), None);
}
}
+29 -2
View File
@@ -4,13 +4,28 @@
import argparse
import hashlib
import json
import os
from pathlib import Path
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlsplit(newurl).scheme != "https":
raise ValueError("refusing to follow a non-HTTPS redirect")
return super().redirect_request(req, fp, code, msg, headers, newurl)
urllib.request.install_opener(urllib.request.build_opener(_HttpsOnlyRedirectHandler))
def fetch(url):
if urllib.parse.urlsplit(url).scheme != "https":
raise ValueError("refusing to fetch a non-HTTPS URL")
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=60) as response:
@@ -42,14 +57,26 @@ def main():
name, url, digest = fixture["name"], fixture["source"], fixture["sha256"]
if Path(name).name != name or name in ("", ".", "..") or not url.startswith("https://"):
raise ValueError("invalid fixture name or source")
path = destination / name
path = (destination / name).resolve()
if path.parent != destination:
raise ValueError(f"fixture path escapes destination: {name}")
if path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
print(f"Verified {name}")
continue
data = fetch(url)
if len(data) > 16 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != digest:
raise ValueError(f"fixture size or SHA-256 mismatch: {name}")
path.write_bytes(data)
fd, tmp = tempfile.mkstemp(dir=destination, prefix=f".{name}.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
os.replace(tmp, destination / name)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
print(f"Fetched and verified {name}")
+6
View File
@@ -0,0 +1,6 @@
sonar.projectKey=storytold_craft-libs
sonar.organization=storytold
# Browser fault-injection fixtures belong to the test analysis scope.
sonar.tests=crates/platform/tests
sonar.test.inclusions=crates/platform/tests/**
+23
View File
@@ -36,6 +36,29 @@ Byte transfer-function samples use rounded 8-bit values; failed or non-finite ev
leave the sample unchanged. Historical source hashes remain in each `SOURCE.json`,
alongside local hashes and the compatibility reference.
The JBIG2 segment parser caps the long-form referred-to-segment count at `u16::MAX`
and rejects a larger claim as an invalid count before any retention-flag or
referred-to-number bytes are consumed, so a crafted header cannot turn
`Vec::with_capacity` into a multi-gigabyte reservation that aborts on allocation
failure.
Hostile-input bounds (CodeRabbit findings):
- An xref-stream subsection must be backed by its decoded bytes: all-zero `/W` widths
are refused, and a declared count longer than the data is refused before any entry is
inserted, so a tiny stream cannot declare billions of entries.
- Each object stream's `/N` header pairs are parsed once per document and cached beside its
decoded data, instead of on every compressed-object lookup.
- An embedded JBIG2 page over 2^28 pixels is refused before the output buffer is allocated,
and that buffer is reserved fallibly (`Bitmap::reinitialize` allocates before touching the
existing bitmap, so a failed reservation leaves it intact). A `JBIG2Globals` stream with an image filter is
ignored, so globals cannot recurse through further globals.
- At most 4096 graphics states, holding at most 64 MiB together, are saved; a `q` past
either limit is counted but not copied, and its `Q` still removes the clips added since.
- An xref-stream `/W` type field wider than one byte is read at its full width (big-endian)
instead of only its first byte, and `/W[0]` over 8 bytes is refused like `/W[1]`.
A dash array over 256 entries strokes solid.
This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion,
floating component precision and codec versions, rather than claiming full 0.8
equivalence. The independent color oracles, backend tests and facade contracts cover
+4 -4
View File
@@ -188,7 +188,7 @@
"path": "src/context.rs",
"source_sha256": "e6898005282196c77bf44357237c79f8c839e74f666e8157756230fd9e2f93ed",
"source_git_blob": "3e83229c5f58cfbbefd15bf3b5e7b71380291c01",
"sha256": "86e838ca19dfc47f5bb0d7a2ffff80709f2fd51bc6b62d65151841bcba212e08"
"sha256": "c6f3ca9d12a53042a28dbb8088ee97ba8b2fc6b03ec35867b58fca99dd9b9807"
},
{
"path": "src/convert.rs",
@@ -362,7 +362,7 @@
"path": "src/interpret/mod.rs",
"source_sha256": "2b286df0c34b8194e1bdd1b63c1e08354d24f8389958fd782f42e2cabf364673",
"source_git_blob": "931b3388cef188cee0a5a1d495b69a6ff04f5e51",
"sha256": "6ae30906c8e6d0e869e0fac8ece4d97e0ac08d8abd7042c0242a069d4e609351"
"sha256": "72650303e920e855cf4c78c82bee2c4b72a4f251c58e16d27bd5ae3860c9983b"
},
{
"path": "src/interpret/path.rs",
@@ -374,7 +374,7 @@
"path": "src/interpret/state.rs",
"source_sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814",
"source_git_blob": "95b773fb5cc434ab8db362d00e4e5180a8b740c9",
"sha256": "dce778291b13f6df83c8c13aee22f60edd033ec9f7d89309ef148d46d3ba9814"
"sha256": "a0d726f48645374f5cbf4ecb10906c13cabc747e3de878b9140c73b6d1303819"
},
{
"path": "src/interpret/text.rs",
@@ -422,7 +422,7 @@
"path": "src/types.rs",
"source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46",
"source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd",
"sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46"
"sha256": "542fafbfcf8a472466377cacd580eda2fc2d40b834a8d5ecd1ad35d75445f532"
},
{
"path": "src/util.rs",
+115 -2
View File
@@ -166,6 +166,23 @@ pub struct Context<'a> {
pub(crate) xref: &'a XRef,
pub(crate) ocg_state: OcgState,
nesting_depth: u32,
/// PdfCraft patch: estimated bytes held by saved states, and for each `q` not saved because
/// a limit was reached, the clip count to return to at its `Q`.
saved_bytes: usize,
skipped_saves: Vec<usize>,
}
/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash
/// array and clip list included, so without a limit short operators multiply one large state.
const MAX_SAVED_STATES: usize = 4096;
/// PdfCraft patch: most bytes all saved states may hold together.
const MAX_SAVED_STATE_BYTES: usize = 64 << 20;
/// Roughly what a saved copy of `state` holds.
fn state_bytes(state: &State<'_>) -> usize {
size_of::<State<'_>>()
+ state.graphics_state.stroke_props.dash_array.len() * size_of::<f32>()
+ state.clips.len() * size_of::<ClipType>()
}
impl<'a> Context<'a> {
@@ -220,15 +237,33 @@ impl<'a> Context<'a> {
interpreter_cache: cache.clone(),
ocg_state,
nesting_depth,
saved_bytes: 0,
skipped_saves: Vec::new(),
}
}
pub(crate) fn save_state(&mut self) {
let Some(cur) = self.states.last().cloned() else {
let Some(cur) = self.states.last() else {
warn!("attempted to save state without existing state");
return;
};
// PdfCraft patch: past either limit the `q` is recorded but nothing is copied. Its `Q`
// still removes the clips added since; other changes made in between are kept.
let bytes = state_bytes(cur);
if !self.skipped_saves.is_empty()
|| self.states.len() >= MAX_SAVED_STATES
|| self.saved_bytes.saturating_add(bytes) > MAX_SAVED_STATE_BYTES
{
if self.skipped_saves.is_empty() {
warn!("too many saved graphics states; further saves are not kept");
}
self.skipped_saves.push(cur.clips.len());
return;
}
let cur = cur.clone();
self.saved_bytes += bytes;
self.states.push(cur);
}
@@ -305,6 +340,14 @@ impl<'a> Context<'a> {
}
pub(crate) fn restore_state(&mut self, device: &mut impl Device<'a>) {
// PdfCraft patch: a save that was not kept is the most recent one.
if let Some(target_clips) = self.skipped_saves.pop() {
while self.get().clips.len() > target_clips {
self.pop_clip_path(device);
}
return;
}
let Some(target_clips) = self
.states
.get(self.states.len().saturating_sub(2))
@@ -321,6 +364,10 @@ impl<'a> Context<'a> {
// The first state should never be popped.
if self.states.len() > 1 {
self.states.pop();
// PdfCraft patch: the state now on top is the copy that was charged when saved.
if let Some(top) = self.states.last() {
self.saved_bytes = self.saved_bytes.saturating_sub(state_bytes(top));
}
}
device.set_soft_mask(
@@ -392,7 +439,8 @@ impl<'a> Context<'a> {
}
pub(crate) fn num_states(&self) -> usize {
self.states.len()
// PdfCraft patch: saves not kept still count, so callers restore back past them.
self.states.len() + self.skipped_saves.len()
}
pub(crate) fn nesting_depth(&self) -> u32 {
@@ -484,3 +532,68 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option<Rect> {
None
}
}
#[cfg(test)]
mod saved_state_tests {
use super::*;
use crate::device::DummyDevice;
use hayro_syntax::Pdf;
fn pdf() -> Pdf {
let objects = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Count 0 /Kids [] >>"];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(b"xref\n0 3\n0000000000 65535 f \n");
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Pdf::new(out).expect("synthetic pdf")
}
#[test]
fn repeated_saves_of_a_large_state_stay_bounded_and_balanced() {
let pdf = pdf();
let cache = InterpreterCache::new();
let mut ctx = Context::new(Affine::IDENTITY, Rect::new(0.0, 0.0, 100.0, 100.0), &cache, pdf.xref(), InterpreterSettings::default());
ctx.get_mut().graphics_state.stroke_props.dash_array = crate::types::dash_array((0..crate::types::MAX_DASH_ENTRIES).map(|i| i as f32 + 1.0));
for _ in 0..20_000 {
ctx.save_state();
}
assert!(ctx.states.len() <= MAX_SAVED_STATES);
assert!(ctx.saved_bytes <= MAX_SAVED_STATE_BYTES);
assert_eq!(ctx.num_states(), 20_001, "every q still counts");
// A clip added after a save that was not kept is removed by its Q.
let mut device = DummyDevice;
let mut triangle = BezPath::new();
triangle.move_to((10.0, 10.0));
triangle.line_to((90.0, 10.0));
triangle.line_to((50.0, 90.0));
triangle.close_path();
let clips = ctx.get().clips.len();
ctx.push_clip_path(triangle, FillRule::NonZero, &mut device);
assert_eq!(ctx.get().clips.len(), clips + 1);
ctx.restore_state(&mut device);
assert_eq!(ctx.get().clips.len(), clips);
while ctx.num_states() > 1 {
ctx.restore_state(&mut device);
}
assert_eq!(ctx.states.len(), 1);
assert_eq!(ctx.saved_bytes, 0);
}
#[test]
fn an_overlong_dash_array_strokes_solid() {
let max = crate::types::MAX_DASH_ENTRIES;
assert_eq!(crate::types::dash_array((0..max).map(|_| 2.0)).len(), max);
assert!(crate::types::dash_array((0..max + 1).map(|_| 2.0)).is_empty());
assert_eq!(crate::types::dash_array([0.0, 3.0].into_iter()).as_slice(), &[0.01, 3.0]);
}
}
+2 -4
View File
@@ -555,11 +555,9 @@ fn interpret_window<'a>(
}
TypedInstruction::DashPattern(p) => {
context.get_mut().graphics_state.stroke_props.dash_offset = p.1.as_f32();
// kurbo apparently cannot properly deal with offsets that are exactly 0.
// PdfCraft patch: bounded, see `types::dash_array`.
context.get_mut().graphics_state.stroke_props.dash_array =
p.0.iter::<f32>()
.map(|n| if n == 0.0 { 0.01 } else { n })
.collect();
crate::types::dash_array(p.0.iter::<f32>());
}
TypedInstruction::RenderingIntent(_) => {
// Ignore for now.
+3 -5
View File
@@ -402,11 +402,9 @@ pub(crate) fn handle_gs_single<'a>(
let dash_phase = iter.next()?.into_number()?.as_f32();
context.get_mut().graphics_state.stroke_props.dash_offset = dash_phase;
context.get_mut().graphics_state.stroke_props.dash_array = dash_array
.iter::<f32>()
// kurbo apparently cannot properly deal with offsets that are exactly 0.
.map(|n| if n == 0.0 { 0.01 } else { n })
.collect();
// PdfCraft patch: bounded, see `types::dash_array`.
context.get_mut().graphics_state.stroke_props.dash_array =
crate::types::dash_array(dash_array.iter::<f32>());
}
"Type" => {}
_ => {}
+18
View File
@@ -305,6 +305,24 @@ pub struct StrokeProps {
pub dash_offset: f32,
}
/// PdfCraft patch: longest dash array kept. A longer one strokes solid; it is copied by every
/// saved graphics state, and real dash patterns have a handful of entries.
pub(crate) const MAX_DASH_ENTRIES: usize = 256;
/// PdfCraft patch: the dash array a `d` operator or `/D` entry sets, from its numbers.
pub(crate) fn dash_array(values: impl Iterator<Item = f32>) -> SmallVec<[f32; 4]> {
// kurbo apparently cannot properly deal with offsets that are exactly 0.
let dashes: SmallVec<[f32; 4]> = values
.take(MAX_DASH_ENTRIES + 1)
.map(|n| if n == 0.0 { 0.01 } else { n })
.collect();
if dashes.len() > MAX_DASH_ENTRIES {
warn!("dash array is too long; stroking solid");
return smallvec![];
}
dashes
}
impl Default for StrokeProps {
fn default() -> Self {
Self {
+2 -2
View File
@@ -62,7 +62,7 @@
"path": "src/bitmap.rs",
"source_sha256": "59ed83d8bac6c39e40270a9856ca3faee628de7d9efae1b01f714e140e2b6b7e",
"source_git_blob": "35ed97561597d30a70fe819cf8cfbd3780538f75",
"sha256": "59ed83d8bac6c39e40270a9856ca3faee628de7d9efae1b01f714e140e2b6b7e"
"sha256": "99dddbef5f7b36c55d0e914d07664326598919eb6d54cdfa93f300076a8ae69d"
},
{
"path": "src/decode/generic.rs",
@@ -176,7 +176,7 @@
"path": "src/segment.rs",
"source_sha256": "58d9fd05a15358b4ca945ffa3b8c1872f4ea57f8bbe50e8db3c6fce5bcac2914",
"source_git_blob": "35c17c38af62b1fece4e280cda923cdcb23a8661",
"sha256": "58d9fd05a15358b4ca945ffa3b8c1872f4ea57f8bbe50e8db3c6fce5bcac2914"
"sha256": "c64e2845dbdd26e33b5461acde399ba4820f9942e9090a32782f44ff0fd5e61f"
},
{
"path": "src/simd.rs",
+26 -5
View File
@@ -7,7 +7,6 @@
use crate::decode::CombinationOperator;
use crate::error::{OverflowError, Result, bail};
use alloc::vec;
use alloc::vec::Vec;
// To guard better against malicious files, we limit bitmap dimensions
@@ -50,6 +49,14 @@ pub(crate) struct Bitmap {
pub(crate) y_location: u32,
}
/// PdfCraft patch: fallible replacement for `vec![word; len]` / `resize`.
fn fill_words(data: &mut Vec<Word>, len: usize, word: Word) -> Result<()> {
data.try_reserve_exact(len)
.map_err(|_| OverflowError::BitmapDimension)?;
data.resize(len, word);
Ok(())
}
impl Bitmap {
/// Create a new bitmap filled with white pixels (false).
///
@@ -76,7 +83,9 @@ impl Bitmap {
let stride = width.div_ceil(WORD_BITS);
let default_word: Word = if default_pixel { !0 } else { 0 };
let data = vec![default_word; stride as usize * height as usize];
// PdfCraft patch: reserve fallibly so hostile dimensions error instead of aborting.
let mut data = Vec::new();
fill_words(&mut data, stride as usize * height as usize, default_word)?;
Ok(Self {
width,
height,
@@ -103,14 +112,15 @@ impl Bitmap {
let stride = width.div_ceil(WORD_BITS);
let default_word: Word = if default_pixel { !0 } else { 0 };
// Allocate first so a failed reservation leaves the bitmap untouched.
let mut data = Vec::new();
fill_words(&mut data, stride as usize * height as usize, default_word)?;
self.width = width;
self.height = height;
self.stride = stride;
self.x_location = 0;
self.y_location = 0;
self.data.clear();
self.data
.resize(stride as usize * height as usize, default_word);
self.data = data;
Ok(())
}
@@ -225,3 +235,14 @@ impl Bitmap {
self.data[(row * self.stride + word_idx) as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_reservation_is_an_error() {
let mut data = Vec::new();
assert!(fill_words(&mut data, usize::MAX / 2, 0).is_err());
}
}
+37
View File
@@ -8,6 +8,14 @@ use alloc::vec::Vec;
use crate::error::{ParseError, Result, SegmentError, bail, err};
use crate::reader::Reader;
/// PdfCraft patch: the most segments one segment may refer to. A crafted long-form
/// header can claim a 29-bit count, which `Vec::with_capacity` below would turn into
/// a multi-gigabyte reservation before a single referred-to number is read — an
/// allocation failure then aborts the process in a way `catch_unwind` cannot contain.
/// Real files refer to a handful of segments, so `u16::MAX` is far beyond any
/// legitimate need.
pub(crate) const MAX_REFERRED_TO_SEGMENTS: u32 = u16::MAX as u32;
/// "The segment type is a number between 0 and 63, inclusive. Not all values
/// are allowed." (7.3)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -183,6 +191,12 @@ pub(crate) fn parse_segment_header(reader: &mut Reader<'_>) -> Result<SegmentHea
u32::from_be_bytes([count_and_retention & 0x1F, rest[0], rest[1], rest[2]])
};
// Reject an absurd count before reserving memory for it, and before
// skipping the retention-flag bytes it claims to own.
if referred_to_count > MAX_REFERRED_TO_SEGMENTS {
bail!(SegmentError::InvalidReferredCount);
}
// Skip retention flag bytes in long form.
// "The first one-byte field following the initial four-byte field is formatted
// as follows: Bit 0: Retain bit for this segment. Bit 1-7: Retain bits for
@@ -416,4 +430,27 @@ mod tests {
assert_eq!(header.data_length, Some(32));
}
#[test]
fn test_referred_to_count_is_capped() {
use crate::error::DecodeError;
// A long-form header claiming the 29-bit maximum of 536,870,911
// referred-to segments would otherwise make `Vec::with_capacity`
// reserve ~2 GiB before a single referred-to number is read.
// The first byte is 0xFF: long form, count bits 0x1F; the count is
// 0x1FFFFFFF.
let data = [
0x00, 0x01, 0x00, 0x00, // Segment number = 65536
0x86, // Flags: type 6, page assoc 1 byte, deferred non-retain
0xFF, 0xFF, 0xFF, 0xFF, // Long form, count = 536,870,911
];
let mut reader = Reader::new(&data);
let error = parse_segment_header(&mut reader).unwrap_err();
assert!(matches!(
error,
DecodeError::Segment(SegmentError::InvalidReferredCount)
));
}
}
+39
View File
@@ -168,6 +168,7 @@ dependencies = [
"rustc-hash",
"sha2",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -244,12 +245,50 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "version_check"
version = "0.9.5"
+3 -3
View File
@@ -128,7 +128,7 @@
"path": "src/data.rs",
"source_sha256": "c8dc5a157358e024d73e405333ecac01e94ba6880488d5a10b952bd3fc47fd41",
"source_git_blob": "9d5e2030f8d6dab7167cd2c5d8da36a7e5688c34",
"sha256": "c8dc5a157358e024d73e405333ecac01e94ba6880488d5a10b952bd3fc47fd41"
"sha256": "83c4417cfe9b301ef0fce8dc2f6a7f694d025e1fd82a68db6acded31155d3bee"
},
{
"path": "src/filter/ascii_85.rs",
@@ -158,7 +158,7 @@
"path": "src/filter/jbig2.rs",
"source_sha256": "0ac3c16e02cb13f2d5fe401edd35949320662906c3906cb39fe743acd5e32b09",
"source_git_blob": "d87253f536e6b8ac18323f1f06164b516ad775df",
"sha256": "0ac3c16e02cb13f2d5fe401edd35949320662906c3906cb39fe743acd5e32b09"
"sha256": "4ce7d4c4f33528f5922395c99ddf2da67d15f166bfa593c43df5e5ebf75b3669"
},
{
"path": "src/filter/jpx.rs",
@@ -338,7 +338,7 @@
"path": "src/xref.rs",
"source_sha256": "51ffeeb7d4ac035bae2cd5ba9288c180088ae06cdcefe2b29dfeda560a793aa0",
"source_git_blob": "20e33a410489d42e438a3f2b87d70c7bb8d66eae",
"sha256": "2bd19a3df5f81c4c0babc80a473877714fe13d2542eafb5c8b5843f81af36d80"
"sha256": "411ce7e3c0d385b1735f945b92dba164825456ef3b70ef464279ba0bac0879d4"
},
{
"path": "src/decode_limits_tests.rs",
+19
View File
@@ -68,6 +68,10 @@ pub(crate) struct Data {
decoded: SegmentList<Option<Vec<u8>>, 32>,
map: Mutex<HashMap<ObjectIdentifier, usize>>,
loading: LoadGate,
// PdfCraft patch: each object stream's header pairs, parsed once. Looking up every object of
// a stream otherwise reparsed all /N pairs per lookup, quadratic in /N. An entry is at most
// one pair per four decoded bytes, and decoded streams are kept for the document anyway.
object_offsets: Mutex<HashMap<ObjectIdentifier, Arc<Vec<(u32, usize)>>>>,
}
impl Debug for Data {
@@ -84,6 +88,7 @@ impl Data {
decoded: SegmentList::new(),
map: Mutex::new(HashMap::new()),
loading: LoadGate::default(),
object_offsets: Mutex::new(HashMap::new()),
}
}
@@ -92,6 +97,20 @@ impl Data {
&self.data
}
/// PdfCraft patch: the header pairs of object stream `id`, from `parse` on first use.
pub(crate) fn object_offsets(
&self,
id: ObjectIdentifier,
parse: impl FnOnce() -> Option<Vec<(u32, usize)>>,
) -> Option<Arc<Vec<(u32, usize)>>> {
if let Some(offsets) = self.object_offsets.get().get(&id) {
return Some(offsets.clone());
}
// Parsed without the lock held; a racing parse of the same stream gives the same table.
let parsed = Arc::new(parse()?);
Some(self.object_offsets.get().entry(id).or_insert(parsed).clone())
}
/// Get access to the data of a decoded object stream.
pub(crate) fn get_with(&self, id: ObjectIdentifier, ctx: &ReaderContext<'_>) -> Option<&[u8]> {
// PdfCraft patch: (#307, see vendor/README.md hayro-syntax (10)) register exactly once:
+63 -3
View File
@@ -1,13 +1,19 @@
use crate::bit_reader::BitWriter;
use crate::filter::Filter;
use crate::object::Dict;
use crate::object::Stream;
use crate::object::dict::keys::JBIG2_GLOBALS;
use crate::object::stream::{FilterResult, ImageColorSpace, ImageData, ImageDecodeParams};
use alloc::borrow::Cow;
use alloc::vec;
use alloc::vec::Vec;
use core::iter;
/// PdfCraft patch: most pixels an embedded JBIG2 page may decode to, matching the renderer's
/// `MAX_IMAGE_PIXELS` and `hayro_jbig2`'s region limit. The page header's own dimensions are
/// checked here, before any output is allocated: the image dictionary's smaller ones say nothing
/// about them.
const MAX_PIXELS: u64 = 1 << 28;
/// Decode JBIG2 data from a PDF stream.
///
/// The `params` dictionary may contain a `JBIG2Globals` entry pointing to
@@ -17,12 +23,28 @@ pub(crate) fn decode(
params: &Dict<'_>,
image_params: &ImageDecodeParams,
) -> Option<FilterResult<'static>> {
// PdfCraft patch: globals are plain segment data. A globals stream filtered as an image
// could name further globals of its own, recursing once per stream in the chain.
let globals = params
.get::<Stream<'_>>(JBIG2_GLOBALS)
.filter(|g| {
!g.filters().iter().any(|f| {
matches!(
f,
Filter::Jbig2Decode | Filter::DctDecode | Filter::JpxDecode | Filter::CcittFaxDecode
)
})
})
.and_then(|g| g.decoded().ok());
let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?;
if u64::from(image.width()) * u64::from(image.height()) > MAX_PIXELS {
warn!("embedded JBIG2 page is too large");
return None;
}
// Whenever possible (if we don't have an indexed color space), we convert
// the data as 8-bit instead of 1-bit, so that it can be easier converted
// into an RGBA8 image.
@@ -31,7 +53,11 @@ pub(crate) fn decode(
// white = 0, but PDF uses the opposite.
let (decoded, bpc) = if image_params.is_indexed {
let row_bytes = (image.width() as usize).div_ceil(8);
let mut packed = vec![0_u8; row_bytes * image.height() as usize];
// PdfCraft patch: checked and fallible, so a failed allocation is a decode error.
let len = row_bytes.checked_mul(image.height() as usize)?;
let mut packed = Vec::new();
packed.try_reserve_exact(len).ok()?;
packed.resize(len, 0_u8);
struct BitWriterDecoder<'a> {
writer: BitWriter<'a>,
@@ -77,7 +103,11 @@ pub(crate) fn decode(
fn next_line(&mut self) {}
}
let mut decoder = Luma8Decoder { output: Vec::new() };
let mut output = Vec::new();
output
.try_reserve_exact((image.width() as usize).checked_mul(image.height() as usize)?)
.ok()?;
let mut decoder = Luma8Decoder { output };
image.decode(&mut decoder).ok()?;
(decoder.output, 8)
@@ -94,3 +124,33 @@ pub(crate) fn decode(
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A page information segment declaring `width` x `height`, and nothing else.
fn page_information(width: u32, height: u32) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0u32.to_be_bytes()); // segment number
out.push(48); // page information
out.push(0); // no referred-to segments
out.push(1); // page 1
out.extend_from_slice(&19u32.to_be_bytes());
out.extend_from_slice(&width.to_be_bytes());
out.extend_from_slice(&height.to_be_bytes());
out.extend_from_slice(&[0; 8]); // resolution
out.push(0); // flags
out.extend_from_slice(&[0; 2]); // striping
out
}
#[test]
fn an_oversized_embedded_page_is_refused_before_allocating() {
let data = page_information(65_535, 65_535);
for is_indexed in [false, true] {
let params = ImageDecodeParams { is_indexed, bpc: Some(1), num_components: Some(1), target_dimension: None, width: 1, height: 1 };
assert!(decode(&data, &Dict::empty(), &params).is_none());
}
}
}
+107 -17
View File
@@ -618,9 +618,13 @@ impl XRef {
return None;
}
let stream = self.get_with::<Stream<'_>>(obj_stream_id, &ctx)?;
let data = repr.data.get_with(obj_stream_id, &ctx)?;
let object_stream = ObjectStream::new(stream, data, &ctx)?;
// PdfCraft patch: the offset table is parsed once per stream (see Data).
let offsets = repr.data.object_offsets(obj_stream_id, || {
let stream = self.get_with::<Stream<'_>>(obj_stream_id, &ctx)?;
ObjectStream::offsets(stream, data)
})?;
let object_stream = ObjectStream::with_offsets(offsets, data, &ctx);
object_stream.get(index)
}
}
@@ -883,17 +887,13 @@ fn populate_from_xref_stream<'a>(
let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?;
if f2_len > size_of::<u64>() as u8 {
error!("xref offset length is larger than the allowed limit");
// PdfCraft patch: the type field may be wider than one byte (upstream behaviour); bound it.
if f1_len > size_of::<u64>() as u8 || f2_len > size_of::<u64>() as u8 {
error!("xref field length is larger than the allowed limit");
return None;
}
// Do such files exist?
if f1_len != 1 {
warn!("first field in xref stream was longer than 1");
}
let xref_data = stream.decoded().ok()?;
let mut xref_reader = Reader::new(xref_data.as_ref());
@@ -959,12 +959,29 @@ fn xref_stream_subsection<'a>(
f3_len: u8,
insert_map: &mut XrefMap,
) -> Option<()> {
// PdfCraft patch: every entry must be backed by stream bytes. With all widths zero an entry
// reads nothing, so a tiny stream could declare billions of entries; and a count past the
// decoded data is refused before any of it is inserted.
let width = f1_len as usize + f2_len as usize + f3_len as usize;
if width == 0 {
warn!("xref stream entries have no width");
return None;
}
let available = xref_reader.len().saturating_sub(xref_reader.offset());
if (num_elements as usize).checked_mul(width).is_none_or(|need| need > available) {
warn!("xref stream subsection declares more entries than its data holds");
return None;
}
for i in 0..num_elements {
// A zero-width type field defaults to 1 (PDF 32000-1, 7.5.8.2).
let f_type = if f1_len == 0 {
1
} else {
// We assume a length of 1.
xref_reader.read_bytes(1)?[0]
let bytes = xref_reader.read_bytes(f1_len as usize)?;
bytes.iter().fold(0u64, |acc, b| (acc << 8) | *b as u64)
};
let obj_number = start + i;
@@ -1064,11 +1081,23 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result<Decryptor,
struct ObjectStream<'a> {
data: &'a [u8],
ctx: ReaderContext<'a>,
offsets: Vec<(u32, usize)>,
offsets: Arc<Vec<(u32, usize)>>,
}
impl<'a> ObjectStream<'a> {
fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option<Self> {
Some(Self::with_offsets(Arc::new(Self::offsets(inner, data)?), data, ctx))
}
fn with_offsets(offsets: Arc<Vec<(u32, usize)>>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Self {
let mut ctx = ctx.clone();
ctx.set_in_object_stream(true);
Self { data, ctx, offsets }
}
/// The `/N` pairs of object number and absolute offset at the start of `data`.
fn offsets(inner: Stream<'_>, data: &[u8]) -> Option<Vec<(u32, usize)>> {
let num_objects = inner.dict().get::<usize>(N)?;
let first_offset = inner.dict().get::<usize>(FIRST)?;
@@ -1082,13 +1111,10 @@ impl<'a> ObjectStream<'a> {
let obj_num = r.read_without_context::<u32>()?;
r.skip_white_spaces_and_comments();
let relative_offset = r.read_without_context::<usize>()?;
offsets.push((obj_num, first_offset + relative_offset));
offsets.push((obj_num, first_offset.checked_add(relative_offset)?));
}
let mut ctx = ctx.clone();
ctx.set_in_object_stream(true);
Some(Self { data, ctx, offsets })
Some(offsets)
}
fn get<T>(&self, index: u32) -> Option<T>
@@ -1137,6 +1163,32 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata {
mod tests {
use super::*;
#[test]
fn xref_stream_entries_need_backing_bytes() {
let mut map = XrefMap::default();
// All widths zero: an entry reads nothing, so none may be declared.
let mut empty = Reader::new(&[]);
assert!(xref_stream_subsection(&mut empty, 0, u32::MAX, 0, 0, 0, &mut map).is_none());
// Three entries of width 3 need nine bytes; eight are refused before any insert.
let data = [1, 0, 9, 1, 0, 9, 1, 0];
let mut short = Reader::new(&data);
assert!(xref_stream_subsection(&mut short, 0, 3, 1, 1, 1, &mut map).is_none());
assert!(map.is_empty());
let mut whole = Reader::new(&data[..6]);
assert!(xref_stream_subsection(&mut whole, 0, 2, 1, 1, 1, &mut map).is_some());
assert_eq!(map.len(), 2);
}
#[test]
fn object_stream_offsets_are_parsed_once() {
let data = Data::new(PdfData::from(Vec::new()));
let id = ObjectIdentifier::new(7, 0);
let first = data.object_offsets(id, || Some(vec![(1, 10), (2, 20)])).unwrap();
let again = data.object_offsets(id, || panic!("reparsed a cached offset table")).unwrap();
assert!(Arc::ptr_eq(&first, &again));
assert!(data.object_offsets(ObjectIdentifier::new(8, 0), || None).is_none());
}
// Contributor-original, MIT OR Apache-2.0. Matches the bounded, uncompressed
// synthetic regression used to reproduce the cold-publication race. A second
// stream optionally checks that racing registration never aliases two ids.
@@ -1221,6 +1273,44 @@ mod tests {
out
}
#[test]
fn xref_stream_type_field_uses_the_full_w0_width() {
// /W [2 1 1]: two-byte type 0x0001 (normal), offset 7, gen 0.
let mut map = XrefMap::default();
let mut reader = Reader::new(&[0u8, 1, 7, 0]);
assert!(xref_stream_subsection(&mut reader, 5, 1, 2, 1, 1, &mut map).is_some());
assert!(matches!(
map.get(&ObjectIdentifier::new(5, 0)),
Some(EntryType::Normal(7))
));
}
#[test]
fn xref_stream_loader_rejects_oversized_w0() {
// Same one-entry stream; only /W[0] differs, so the 9 must be what is refused.
let load = |w: &str| {
let data = format!(
"1 0 obj\n<< /Type /XRef /Size 6 /Index [5 1] /W [{w}] /Length 4 >>\nstream\n"
)
.into_bytes()
.into_iter()
.chain([0u8, 1, 7, 0])
.chain(*b"\nendstream\nendobj\n")
.collect::<Vec<u8>>();
let mut map = XrefMap::default();
let got = populate_from_xref_stream(
&data,
&mut Reader::new(&data),
&mut map,
&mut BTreeSet::new(),
DecodeLimits::default(),
);
got.is_some()
};
assert!(load("2 1 1"));
assert!(!load("9 1 1"));
}
#[cfg(feature = "std")]
#[test]
fn concurrent_cold_object_stream_reads_all_succeed() {
+6
View File
@@ -26,3 +26,9 @@ The shared writer's native subprocess regression covers the previously aborting
cleanup and a successful fresh export afterward. Native and WASM export tests
cover the unchanged successful path. WASM aborts, aborting panic configurations
and process-wide allocation failures remain outside unwind recovery.
A PNG whose header implies more than 1 GiB of decoded output is refused by
`Image::from_png` and by the deferred decoder, and the decoded buffer is reserved
fallibly, so a small file (or a bitmap glyph in a font) cannot request an allocation that
ends the process. The colour and alpha buffers that PNG, GIF and WebP decoding split an
image into are reserved fallibly too, and a failure there is an image error.
+1 -1
View File
@@ -172,7 +172,7 @@
{
"path": "src/graphics/image.rs",
"upstream_sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d",
"sha256": "61324d6426ac79aa5322d0d33a95532934cd1e41797a9dad6302210a2b799d5d"
"sha256": "25fb30fc591e180f8ab4a6445cbda0d14f1cfc7860c4699b444c3fbaab97bd69"
},
{
"path": "src/graphics/mask.rs",
+96 -16
View File
@@ -330,7 +330,7 @@ impl Image {
Self(Arc::new(ImageRepr {
inner: Deferred::new(move || {
let (color_channel, alpha_channel, bits_per_component) =
handle_u8_image(&data, ColorSpace::RGBA);
handle_u8_image(&data, ColorSpace::RGBA)?;
Ok(Repr::Sampled(SampledRepr {
color_channel,
@@ -522,6 +522,13 @@ fn png_metadata(data: &[u8]) -> Result<ImageMetadata, String> {
let reader = decoder
.read_info()
.map_err(|e| e.to_string().to_ascii_lowercase())?;
// Craft patch: refused here too, so `Image::from_png` fails instead of the deferred decode.
if reader
.output_buffer_size()
.is_none_or(|n| n > MAX_DECODED_PNG_BYTES)
{
return Err("image is too large".to_string());
}
let info = reader.info();
let size = (info.width, info.height);
@@ -555,6 +562,11 @@ fn png_metadata(data: &[u8]) -> Result<ImageMetadata, String> {
})
}
/// Craft patch: most bytes a PNG may decode to (16384 x 16384 RGBA8). The header alone sets the
/// output size, so it is checked before any pixel data, and reserved fallibly, so that a bad
/// image is an error instead of an allocation failure that ends the process.
const MAX_DECODED_PNG_BYTES: usize = 1 << 30;
fn decode_png(data: &[u8]) -> Result<Repr, String> {
let cursor = Cursor::new(data);
let mut decoder = png::Decoder::new(cursor);
@@ -562,7 +574,15 @@ fn decode_png(data: &[u8]) -> Result<Repr, String> {
let mut reader = decoder
.read_info()
.map_err(|e| e.to_string().to_ascii_lowercase())?;
let mut img_data = vec![0; reader.output_buffer_size().ok_or("image is too large")?];
let size = reader
.output_buffer_size()
.filter(|&n| n <= MAX_DECODED_PNG_BYTES)
.ok_or("image is too large")?;
let mut img_data = Vec::new();
img_data
.try_reserve_exact(size)
.map_err(|_| "image is too large".to_string())?;
img_data.resize(size, 0);
let _ = reader
.next_frame(&mut img_data)
.map_err(|e| e.to_string())?;
@@ -577,8 +597,8 @@ fn decode_png(data: &[u8]) -> Result<Repr, String> {
};
let (color_channel, alpha_channel, bits_per_component) = match bit_depth {
BitDepth::Eight => handle_u8_image(&img_data, color_space),
BitDepth::Sixteen => handle_u16_image(&img_data, color_space),
BitDepth::Eight => handle_u8_image(&img_data, color_space)?,
BitDepth::Sixteen => handle_u16_image(&img_data, color_space)?,
_ => return Err("image has an unsupported bit-depth".to_string()),
};
@@ -663,7 +683,7 @@ fn decode_gif(data: Data) -> Result<Repr, String> {
.ok_or("GIF image seems to be empty".to_string())?;
let (color_channel, alpha_channel, bits_per_component) =
handle_u8_image(first_frame.buffer.as_ref(), ColorSpace::RGBA);
handle_u8_image(first_frame.buffer.as_ref(), ColorSpace::RGBA)?;
Ok(Repr::Sampled(SampledRepr {
color_channel,
@@ -719,7 +739,7 @@ fn decode_webp(data: Data) -> Result<Repr, String> {
};
let (color_channel, alpha_channel, bits_per_component) =
handle_u8_image(&first_frame, color_space);
handle_u8_image(&first_frame, color_space)?;
Ok(Repr::Sampled(SampledRepr {
color_channel,
@@ -728,9 +748,21 @@ fn decode_webp(data: Data) -> Result<Repr, String> {
}))
}
fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, BitsPerComponent) {
/// Craft patch: an empty vector with room for `capacity` bytes, or an error instead of the
/// allocation failure that would end the process. Splitting a decoded image into colour and alpha
/// briefly needs about twice its size beside the decoded pixels.
fn try_buffer(capacity: usize) -> Result<Vec<u8>, String> {
let mut v = Vec::new();
v.try_reserve_exact(capacity)
.map_err(|_| "image is too large".to_string())?;
Ok(v)
}
type SplitImage = (Vec<u8>, Option<Vec<u8>>, BitsPerComponent);
fn handle_u8_image(data: &[u8], cs: ColorSpace) -> Result<SplitImage, String> {
let mut alphas = if cs.has_alpha() {
Vec::with_capacity(data.len() / cs.num_components())
try_buffer(data.len() / cs.num_components())?
} else {
Vec::new()
};
@@ -738,7 +770,7 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, Bi
let color_channel = match cs {
ColorSpace::RGB => deflate_encode(data),
ColorSpace::RGBA => {
let mut buf = Vec::with_capacity(data.len() * 3 / 4);
let mut buf = try_buffer(data.len() * 3 / 4)?;
data.chunks_exact(4).for_each(|data| {
buf.extend_from_slice(&data[0..3]);
alphas.push(data[3]);
@@ -747,7 +779,7 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, Bi
}
ColorSpace::Luma => deflate_encode(data),
ColorSpace::LumaA => {
let mut buf = Vec::with_capacity(data.len() / 2);
let mut buf = try_buffer(data.len() / 2)?;
data.chunks_exact(2).for_each(|data| {
buf.push(data[0]);
alphas.push(data[1]);
@@ -764,12 +796,12 @@ fn handle_u8_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, Bi
None
};
(color_channel, alpha_channel, BitsPerComponent::Eight)
Ok((color_channel, alpha_channel, BitsPerComponent::Eight))
}
fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, BitsPerComponent) {
fn handle_u16_image(data: &[u8], cs: ColorSpace) -> Result<SplitImage, String> {
let mut alphas = if cs.has_alpha() {
Vec::with_capacity(data.len() / cs.num_components())
try_buffer(data.len() / cs.num_components())?
} else {
Vec::new()
};
@@ -777,7 +809,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, B
let encoded_image = match cs {
ColorSpace::RGB => deflate_encode(data),
ColorSpace::RGBA => {
let mut buf = Vec::with_capacity(data.len() * 3 / 4);
let mut buf = try_buffer(data.len() * 3 / 4)?;
data.chunks_exact(8).for_each(|data| {
buf.extend_from_slice(&data[0..6]);
alphas.extend_from_slice(&data[6..]);
@@ -786,7 +818,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, B
}
ColorSpace::Luma => deflate_encode(data),
ColorSpace::LumaA => {
let mut buf = Vec::with_capacity(data.len() / 2);
let mut buf = try_buffer(data.len() / 2)?;
data.chunks_exact(4).for_each(|data| {
buf.extend_from_slice(&data[0..2]);
alphas.extend_from_slice(&data[2..]);
@@ -803,7 +835,7 @@ fn handle_u16_image(data: &[u8], cs: ColorSpace) -> (Vec<u8>, Option<Vec<u8>>, B
None
};
(encoded_image, encoded_mask, BitsPerComponent::Sixteen)
Ok((encoded_image, encoded_mask, BitsPerComponent::Sixteen))
}
fn get_icc_profile_type(data: &[u8], color_space: ImageColorspace) -> Option<GenericICCProfile> {
@@ -827,6 +859,54 @@ mod tests {
assert!(e.is_err());
}
/// A PNG whose header alone claims `width` x `height` RGBA16, with an empty IDAT.
fn png_header(width: u32, height: u32) -> Vec<u8> {
fn crc(bytes: &[u8]) -> u32 {
let mut c = !0u32;
for &b in bytes {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
let start = out.len();
out.extend_from_slice(kind);
out.extend_from_slice(data);
let sum = crc(&out[start..]);
out.extend_from_slice(&sum.to_be_bytes());
}
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]);
chunk(&mut out, b"IEND", &[]);
out
}
#[test]
fn a_png_header_claiming_a_huge_image_is_refused_before_allocating() {
// 65535 x 65535 RGBA16 would be 34 GB of output.
let huge = png_header(65_535, 65_535);
assert!(Image::from_png(Arc::new(huge.clone()).into(), false).is_err());
assert!(super::decode_png(&huge).is_err());
}
#[test]
fn rgba_split_buffers_are_reserved_fallibly() {
assert!(super::try_buffer(usize::MAX).is_err());
let (color, alpha, _) = super::handle_u8_image(&[1, 2, 3, 128, 4, 5, 6, 255], super::ColorSpace::RGBA).unwrap();
assert!(!color.is_empty() && alpha.is_some());
let (_, alpha, _) = super::handle_u16_image(&[0; 16], super::ColorSpace::RGBA).unwrap();
assert!(alpha.is_some());
}
#[test]
fn invalid_jpeg_image() {
let e = Image::from_jpeg(Arc::new(b"dfngiudfg".to_vec()).into(), false);