diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 592b560..d1f5487 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Check formatting run: | - for manifest in Cargo.toml asset-io-c/Cargo.toml fuzz/Cargo.toml; do + for manifest in Cargo.toml asset-io-c/Cargo.toml asset-io-wasm/Cargo.toml fuzz/Cargo.toml; do cargo fmt --manifest-path "$manifest" -- --check done @@ -102,3 +102,30 @@ jobs: c++ -x c++ -std=c++17 -Wall -Wextra -Werror -Iasset-io-c/include asset-io-c/examples/smoke.c \ -Lasset-io-c/target/debug -lasset_io_c -Wl,-rpath,"$PWD/asset-io-c/target/debug" -o asset-io-c/target/cpp-smoke asset-io-c/target/cpp-smoke + + wasm-bindings: + name: Wasm / JavaScript async proof + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + targets: wasm32-unknown-unknown + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Native wrapper checks + run: | + cargo test --locked --manifest-path asset-io-wasm/Cargo.toml --no-default-features + cargo test --locked --manifest-path asset-io-wasm/Cargo.toml --all-features + cargo clippy --locked --manifest-path asset-io-wasm/Cargo.toml --all-features --all-targets -- -D warnings + - name: Install Wasm packaging tool + run: cargo install wasm-pack --version 0.14.0 --locked + - name: Build Wasm and native comparison oracle + run: | + wasm-pack build asset-io-wasm --target nodejs --dev --no-opt -- --locked --target-dir "$PWD/asset-io-wasm/target" + wasm-pack build asset-io-wasm --target web --out-dir pkg-web --dev --no-opt -- --locked --target-dir "$PWD/asset-io-wasm/target" + cargo build --locked --manifest-path asset-io-wasm/Cargo.toml --example inspect --target-dir asset-io-wasm/target + - name: Test JavaScript-owned async reads + run: node --test asset-io-wasm/js/async.test.mjs diff --git a/Cargo.lock b/Cargo.lock index a8242d1..2c5e460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,12 +44,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "asset-io" version = "0.2.0" @@ -75,9 +69,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -118,9 +112,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "ciborium" @@ -191,9 +185,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -291,12 +285,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -309,9 +297,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "flate2" @@ -323,12 +311,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "futures-core" version = "0.3.34" @@ -355,15 +337,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] @@ -377,27 +357,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" version = "0.5.3" @@ -413,24 +372,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -459,26 +400,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.183" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "linux-raw-sys" @@ -486,12 +421,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - [[package]] name = "md5" version = "0.7.0" @@ -500,15 +429,15 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -578,21 +507,11 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -608,9 +527,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -672,9 +591,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -698,17 +617,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -716,29 +629,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -772,9 +685,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -822,7 +735,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -865,15 +778,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "walkdir" @@ -885,32 +792,13 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -918,9 +806,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -928,9 +816,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", @@ -941,52 +829,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -1016,94 +870,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zerocopy" version = "0.8.63" @@ -1121,11 +887,11 @@ checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 2beb762..b5c7553 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ description = "Streaming, container-agnostic access to media file structure and repository = "https://github.com/contentauth/asset-io" keywords = ["asset", "c2pa", "xmp", "jumbf", "metadata"] categories = ["multimedia", "parsing"] -exclude = ["tests/", "fuzz/", "fuzz.sh", "FUZZING_SETUP.md", "Makefile", "asset-io-c/", ".github/", ".vscode/"] +exclude = ["tests/", "fuzz/", "fuzz.sh", "FUZZING_SETUP.md", "Makefile", "asset-io-c/", "asset-io-wasm/", ".github/", ".vscode/"] rust-version = "1.88" [package.metadata.docs.rs] diff --git a/Makefile b/Makefile index d8b3596..dc7b4af 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Formatting follows c2pa-rs: rustfmt.toml uses unstable options, so it needs # nightly rustfmt (rustup toolchain add nightly). -FMT_MANIFESTS := Cargo.toml asset-io-c/Cargo.toml fuzz/Cargo.toml +FMT_MANIFESTS := Cargo.toml asset-io-c/Cargo.toml asset-io-wasm/Cargo.toml fuzz/Cargo.toml # Run the full CI preflight locally (mirrors .github/workflows/ci.yml). # Run this before pushing a PR. diff --git a/README.md b/README.md index a74221f..acbb219 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,11 @@ output.patch_exact(item, &signed_store)?; `Structure` remains the I/O-free core: async and caller-driven code composes its `*_async` and `*_task` methods directly. +The [Wasm/JavaScript proof](asset-io-wasm/README.md) exposes caller-driven parsing +to JavaScript. Rust returns a read request, JavaScript awaits its own file or +Blob I/O, then supplies bytes and resumes. No Rust executor or WASI filesystem +is required. This proof currently covers parsing, not metadata reads or writes. + ### Tokio Enable `tokio` alongside the container features to use `asset_io::tokio` with diff --git a/asset-io-wasm/.gitignore b/asset-io-wasm/.gitignore new file mode 100644 index 0000000..327c81e --- /dev/null +++ b/asset-io-wasm/.gitignore @@ -0,0 +1,3 @@ +/target/ +/pkg/ +/pkg-web/ diff --git a/asset-io-wasm/Cargo.lock b/asset-io-wasm/Cargo.lock new file mode 100644 index 0000000..6bd2841 --- /dev/null +++ b/asset-io-wasm/Cargo.lock @@ -0,0 +1,267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "asset-io" +version = "0.2.0" +dependencies = [ + "flate2", + "md5", + "memchr", + "quick-xml", + "thiserror", +] + +[[package]] +name = "asset-io-wasm" +version = "0.2.0" +dependencies = [ + "asset-io", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/asset-io-wasm/Cargo.toml b/asset-io-wasm/Cargo.toml new file mode 100644 index 0000000..214c3b1 --- /dev/null +++ b/asset-io-wasm/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] + +[package] +name = "asset-io-wasm" +version = "0.2.0" +edition = "2021" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +description = "Sans-I/O parsing proof for host-native JavaScript async I/O" +repository = "https://github.com/contentauth/asset-io" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +asset-io = { path = "..", version = "0.2.0", default-features = false } +serde_json = "1" +wasm-bindgen = "0.2" + +[features] +default = ["jpeg", "png"] +jpeg = ["asset-io/jpeg"] +png = ["asset-io/png"] +bmff = ["asset-io/bmff"] +riff = ["asset-io/riff"] +svg = ["asset-io/svg"] +pdf = ["asset-io/pdf"] +tiff = ["asset-io/tiff"] +c2pa = ["asset-io/c2pa"] +all-formats = ["jpeg", "png", "bmff", "riff", "svg", "pdf", "tiff", "c2pa"] diff --git a/asset-io-wasm/README.md b/asset-io-wasm/README.md new file mode 100644 index 0000000..4efa50b --- /dev/null +++ b/asset-io-wasm/README.md @@ -0,0 +1,128 @@ +# Wasm / JavaScript sans-I/O parsing proof + +This standalone Cargo package tests asset-io's goal of letting the host language +own its native async scheduling. It defaults to JPEG and PNG; other container +features and `all-formats` forward to the core crate. + +**Rust runs computation; JavaScript performs I/O.** The Wasm module uses +`wasm32-unknown-unknown`, not WASI. It contains no filesystem access, JS read +callback, Tokio runtime, or Rust-to-JS async bridge. Each `resume()` returns before +JavaScript awaits its Promise. Independent parsers can interleave on the JS event +loop; each parser has only one outstanding read. + +## Build and run (Node.js) + +Requirements: Rust 1.88+, Node.js 18.16+ and wasm-pack 0.14.0. +From the repository root: + +```sh +rustup target add wasm32-unknown-unknown +# Only if wasm-pack is not already installed: +cargo install wasm-pack --version 0.14.0 --locked + +wasm-pack build asset-io-wasm --target nodejs --dev --no-opt -- \ + --locked --target-dir "$PWD/asset-io-wasm/target" +cargo build --locked --manifest-path asset-io-wasm/Cargo.toml \ + --example inspect --target-dir asset-io-wasm/target +node --test asset-io-wasm/js/async.test.mjs +node asset-io-wasm/js/inspect.mjs tests/fixtures/Designer.jpeg +node asset-io-wasm/js/inspect.mjs tests/fixtures/GreenCat.png png +``` + +wasm-pack generates JS glue, TypeScript declarations and the Wasm binary under +ignored `pkg/`. It installs a matching wasm-bindgen CLI if missing. The standalone +Cargo lockfile pins the binding dependencies. No npm packages are needed. + +The test suite drives actual Wasm with `node:fs/promises`, and compares its +inspection summary with a native blocking Rust executable. It explicitly awaits +a macrotask between read requests, verifying event-loop progress while Rust is +suspended, not merely immediate Promise resolution. Tests also cover interleaved +tasks, the Blob adapter, EOF, read failures, invalid response bounds/types, +copied response ownership, deferred format errors, and cancellation. + +## Async interface + +```js +import bindings from "./asset-io-wasm/pkg/asset_io_wasm.js"; +import { parseAsync } from "./asset-io-wasm/js/driver.mjs"; + +const summary = await parseAsync( + bindings.ParseTask, + sourceSize, // bigint, in 0..=2^64-1 + async (offset, windowLen) => { + // offset: bigint; windowLen: number. + // Use the host's native async I/O and return a Uint8Array. + return await source.readAt(offset, windowLen); + }, + { format: "jpg", signal: abortController.signal }, +); +``` + +`format` is an optional extension or MIME type. Omit it to detect the format. +The source size and bytes must remain unchanged throughout parsing. +The driver validates sizes before calling Wasm; use it rather than relying on +low-level wasm-bindgen integer conversion to reject out-of-range JS values. + +`readAt` must gather short positive reads itself until it has at least the +requested minimum (reading the whole window is simplest) or reaches EOF. +Responses larger than the window or not `Uint8Array` are errors. Too few bytes +cause unexpected EOF on resume. The Node adapter retries partial reads and +rejects positions outside JavaScript's safe-integer range; request offsets +themselves remain lossless bigint. + +The low-level API is synchronous: + +- `new ParseTask(size, format)` owns its registry and format. +- `resume()` returns a `ReadRequest`, or `undefined` when finished; failures throw + and remain terminal. Repeated calls while waiting return the same descriptor. +- `supply(bytes)` copies the response to the pending request. It does not poll. + An oversized response throws without consuming the request. +- `fail(message)` terminates a pending read with a host error. +- `summaryJson()` returns a source-free summary only after successful completion. +- `free()` releases the parser. Each returned read descriptor also requires + `free()`, after copying its fields. The provided driver handles both lifetimes. + +`parseAsync` rejects with the original host error and always frees its parser. +With `AbortSignal`, cancellation is checked before construction, between resumes, +and after a host read settles. It does **not** interrupt the host's outstanding +read or abandon its Promise; it waits for completion before freeing the task. +A never-settling read therefore cannot be cancelled by this driver alone. +If needed, make `readAt` use the same signal with a cancellation-aware host API. + +## Browser Blob/File adapter + +Generate ES module bindings separately: + +```sh +wasm-pack build asset-io-wasm --target web --out-dir pkg-web --dev --no-opt -- \ + --locked --target-dir "$PWD/asset-io-wasm/target" +``` + +Then, in a browser module served with the generated Wasm: + +```js +import init, { ParseTask } from "./asset-io-wasm/pkg-web/asset_io_wasm.js"; +import { parseBlob } from "./asset-io-wasm/js/driver.mjs"; + +await init(); +const summary = await parseBlob(ParseTask, fileInput.files[0]); +``` + +`parseBlob` uses `Blob.slice(...).arrayBuffer()` Promises. The Blob adapter is +tested using Node's Blob implementation; browser execution is not yet part of +this proof's automated checks. Ignore generated `pkg-web/` output as well. + +## Scope and summary schema + +This is **parsing only**. It does not yet expose metadata reads, writes, retained +source assets, Web Workers, shared-memory Wasm threads, or production packaging. +It proves native JavaScript async I/O without replacing Rust's parser. + +The proof-specific summary is **not the complete C ABI JSON schema**. Schema 1 +includes container ID, media type, source size, trailing range, segment kinds, +ranges/roles/subdivision flags, item kinds/encodings/sizes/extents, and subasset +geometry. All byte offsets, lengths and source/item sizes are decimal strings, +preserving u64 precision after `JSON.parse`; segment indices are numbers. +It intentionally omits native names, transfer details and item relations. +The native oracle and Wasm share the same summary helper, so comparison tests +exercise parsing and geometry parity, not two independently implemented serializers. diff --git a/asset-io-wasm/examples/inspect.rs b/asset-io-wasm/examples/inspect.rs new file mode 100644 index 0000000..9043976 --- /dev/null +++ b/asset-io-wasm/examples/inspect.rs @@ -0,0 +1,16 @@ +use std::{env, fs::File}; + +use asset_io::{HandlerRegistry, Structure}; + +fn main() -> Result<(), Box> { + let mut args = env::args().skip(1); + let path = args.next().ok_or("usage: inspect FILE [FORMAT]")?; + let format = args.next(); + let structure = Structure::parse( + &HandlerRegistry::builtin(), + &mut File::open(path)?, + format.as_deref(), + )?; + println!("{}", asset_io_wasm::inspection_summary(&structure)); + Ok(()) +} diff --git a/asset-io-wasm/js/async.test.mjs b/asset-io-wasm/js/async.test.mjs new file mode 100644 index 0000000..bbffb12 --- /dev/null +++ b/asset-io-wasm/js/async.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { open, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import test from "node:test"; + +import bindings from "../pkg/asset_io_wasm.js"; +import { parseAsync, parseBlob } from "./driver.mjs"; +import { readWindow } from "./node-source.mjs"; + +const { ParseTask } = bindings; +const root = fileURLToPath(new URL("../../", import.meta.url)); +const oracle = fileURLToPath(new URL("../target/debug/examples/inspect", import.meta.url)); +const jpeg = Uint8Array.of(0xff, 0xd8, 0xff, 0xd9); + +for (const [name, format] of [["Designer.jpeg", undefined], ["GreenCat.png", "png"]]) { + test(`native JS async file reads match blocking Rust: ${name}`, async () => { + const path = `${root}tests/fixtures/${name}`; + const expected = JSON.parse(execFileSync(oracle, format ? [path, format] : [path], { + encoding: "utf8", + })); + const file = await open(path, "r"); + try { + const { size } = await file.stat({ bigint: true }); + let reads = 0; + let eventLoopTurns = 0; + let active = false; + const actual = await parseAsync(ParseTask, size, async (offset, windowLen) => { + assert.equal(active, false, "one outstanding read per parser"); + active = true; + assert.equal(typeof offset, "bigint"); + assert.ok(offset >= 0n && offset + BigInt(windowLen) <= size); + reads++; + // A macrotask must run while the parser is suspended, not just a + // Promise.resolve microtask masquerading as async I/O. + await nextTurn(); + eventLoopTurns++; + const bytes = await readWindow(file, offset, windowLen); + active = false; + return bytes; + }, { format }); + assert.ok(reads > 1, "exercise multiple Wasm suspensions"); + assert.equal(eventLoopTurns, reads); + assert.deepEqual(actual, expected); + console.log(`PASS: ${name}: ${reads} async reads / event-loop turns; native summary matches`); + } finally { + await file.close(); + } + }); +} + +test("Blob adapter uses async arrayBuffer and matches the file adapter", async () => { + const data = await readFile(`${root}tests/fixtures/GreenCat.png`); + const expected = await parseAsync(ParseTask, BigInt(data.length), async (offset, len) => { + await nextTurn(); + return data.subarray(Number(offset), Number(offset) + len); + }); + assert.deepEqual(await parseBlob(ParseTask, new Blob([data])), expected); +}); + +test("separate parsers interleave on JavaScript's event loop", async () => { + const seen = []; + async function drive(id) { + return parseAsync(ParseTask, 4n, async (offset, len) => { + seen.push(id); + await nextTurn(); + return jpeg.slice(Number(offset), Number(offset) + len); + }); + } + const [a, b] = await Promise.all([drive("a"), drive("b")]); + assert.deepEqual(a, b); + assert.deepEqual(seen.slice(0, 2), ["a", "b"]); + assert.ok(seen.length >= 4, "detection and parsing each suspend"); +}); + +test("pending reads are stable, supplied bytes are owned, and misuse throws", () => { + const task = new ParseTask(4n); + try { + assert.throws(() => task.summaryJson(), /not completed/); + assert.throws(() => task.supply(jpeg), /no outstanding read/); + assert.throws(() => task.fail("early"), /no outstanding read/); + let req = task.resume(); + const descriptor = [req.offset, req.min_len, req.window_len]; + req.free(); + req = task.resume(); + assert.deepEqual([req.offset, req.min_len, req.window_len], descriptor); + req.free(); + assert.throws(() => task.supply(new Uint8Array(5)), /exceeds requested window/); + for (;;) { + req = task.resume(); + if (req === undefined) break; + const response = jpeg.slice(Number(req.offset), Number(req.offset) + req.window_len); + req.free(); + task.supply(response); + response.fill(0); + } + assert.equal(JSON.parse(task.summaryJson()).container, "jpeg"); + assert.equal(task.resume(), undefined); + assert.throws(() => task.supply(jpeg), /no outstanding read/); + } finally { + task.free(); + } +}); + +test("host failures are terminal and preserve their message", () => { + const task = new ParseTask(4n); + try { + task.resume().free(); + task.fail("native JS read rejected"); + assert.throws(() => task.resume(), /native JS read rejected/); + assert.throws(() => task.resume(), /native JS read rejected/); + assert.throws(() => task.summaryJson(), /not completed/); + } finally { + task.free(); + } +}); + +test("read rejection, EOF, malformed data, and oversized/wrong responses reject", async () => { + const failure = new Error("filesystem failure"); + await assert.rejects(parseAsync(ParseTask, 4n, async () => { throw failure; }), + (error) => error === failure); + await assert.rejects(parseAsync(ParseTask, 4n, async () => new Uint8Array()), + /failed to fill whole buffer/); + await assert.rejects(parseAsync(ParseTask, 4n, async () => Uint8Array.of(1, 2, 3, 4))); + await assert.rejects(parseAsync(ParseTask, 4n, async () => new Uint8Array(5)), /more than/); + await assert.rejects(parseAsync(ParseTask, 4n, async () => [1, 2, 3, 4]), /Uint8Array/); + await assert.rejects(parseAsync(ParseTask, 4n, async () => jpeg, { format: "unsupported" })); +}); + +test("invalid sizes are rejected before invoking Wasm or I/O", async () => { + for (const size of [-1n, 1n << 64n, 4, NaN]) { + await assert.rejects(parseAsync(ParseTask, size, () => { + assert.fail("invalid size must not read"); + }), /size must be a bigint/); + } + await assert.rejects(parseAsync(ParseTask, 4n, undefined), /readAt must be/); + await assert.rejects(parseAsync(ParseTask, 4n, async () => jpeg, { format: 42 }), + /format must be/); + await assert.rejects(readWindow(null, BigInt(Number.MAX_SAFE_INTEGER), 2), /safe integer/); +}); + +test("AbortSignal cancels before starting and after host I/O settles", async () => { + const alreadyAborted = new AbortController(); + alreadyAborted.abort(); + await assert.rejects(parseAsync(ParseTask, 4n, () => { + assert.fail("already aborted task must not read"); + }, { signal: alreadyAborted.signal }), { name: "AbortError" }); + + let freed = 0; + class TrackedTask extends ParseTask { + free() { + freed++; + super.free(); + } + } + const controller = new AbortController(); + let settled = false; + await assert.rejects(parseAsync(TrackedTask, 4n, async () => { + controller.abort(); + await nextTurn(); + assert.equal(freed, 0, "wait for host completion before freeing"); + settled = true; + return jpeg; + }, { signal: controller.signal }), { name: "AbortError" }); + assert.equal(settled, true); + assert.equal(freed, 1); +}); + +test("module uses no WASI filesystem or Rust async runtime", async () => { + const wasm = await readFile(new URL("../pkg/asset_io_wasm_bg.wasm", import.meta.url)); + const module = await WebAssembly.compile(wasm); + const imports = WebAssembly.Module.imports(module); + assert.equal(imports.some(({ module }) => module.startsWith("wasi")), false); +}); diff --git a/asset-io-wasm/js/driver.mjs b/asset-io-wasm/js/driver.mjs new file mode 100644 index 0000000..2e05928 --- /dev/null +++ b/asset-io-wasm/js/driver.mjs @@ -0,0 +1,71 @@ +const U64_MAX = (1n << 64n) - 1n; + +function checkSize(size) { + if (typeof size !== "bigint" || size < 0n || size > U64_MAX) { + throw new RangeError("size must be a bigint in the u64 range"); + } +} + +/** + * Drive Wasm with a host-native async readAt(offset: bigint, windowLen: number). + * The source is immutable; readAt returns a Uint8Array. Gather partial reads in + * readAt itself; a response shorter than minLen means unexpected EOF. + */ +export async function parseAsync(ParseTask, size, readAt, { format, signal } = {}) { + checkSize(size); + if (typeof readAt !== "function") { + throw new TypeError("readAt must be a function"); + } + if (format !== undefined && typeof format !== "string") { + throw new TypeError("format must be a string or undefined"); + } + signal?.throwIfAborted(); + const task = new ParseTask(size, format); + try { + for (;;) { + signal?.throwIfAborted(); + const request = task.resume(); + if (request === undefined) { + return JSON.parse(task.summaryJson()); + } + const { offset, min_len: minLen, window_len: windowLen } = request; + request.free(); + if (minLen <= 0 || minLen > windowLen || offset + BigInt(windowLen) > size) { + throw new Error("invalid Wasm read descriptor"); + } + let bytes; + try { + // Rust has returned; no Rust call or I/O callback is active during this await. + bytes = await readAt(offset, windowLen); + signal?.throwIfAborted(); + if (!(bytes instanceof Uint8Array)) { + throw new TypeError("readAt must resolve to a Uint8Array"); + } + if (bytes.length > windowLen) { + throw new RangeError("readAt returned more than the requested window"); + } + } catch (error) { + task.fail(String(error)); + throw error; + } + task.supply(bytes); + } + } finally { + task.free(); + } +} + +/** Browser Blob/File adapter. Await Blob.arrayBuffer using the browser runtime. */ +export function parseBlob(ParseTask, blob, options) { + return parseAsync(ParseTask, BigInt(blob.size), async (offset, windowLen) => { + if (offset > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError("Blob offset exceeds JavaScript's safe integer range"); + } + const start = Number(offset); + const end = start + windowLen; + if (!Number.isSafeInteger(end)) { + throw new RangeError("Blob range exceeds JavaScript's safe integer range"); + } + return new Uint8Array(await blob.slice(start, end).arrayBuffer()); + }, options); +} diff --git a/asset-io-wasm/js/inspect.mjs b/asset-io-wasm/js/inspect.mjs new file mode 100644 index 0000000..b895aa9 --- /dev/null +++ b/asset-io-wasm/js/inspect.mjs @@ -0,0 +1,20 @@ +import { open } from "node:fs/promises"; +import bindings from "../pkg/asset_io_wasm.js"; +import { parseAsync } from "./driver.mjs"; +import { readWindow } from "./node-source.mjs"; + +const [path, format] = process.argv.slice(2); +if (!path) throw new Error("usage: node asset-io-wasm/js/inspect.mjs FILE [FORMAT]"); +const file = await open(path, "r"); +try { + const { size } = await file.stat({ bigint: true }); + let reads = 0; + const summary = await parseAsync(bindings.ParseTask, size, async (offset, len) => { + reads++; + return readWindow(file, offset, len); + }, { format }); + console.error(`${reads} JavaScript async read completions`); + console.log(JSON.stringify(summary, null, 2)); +} finally { + await file.close(); +} diff --git a/asset-io-wasm/js/node-source.mjs b/asset-io-wasm/js/node-source.mjs new file mode 100644 index 0000000..1d8913b --- /dev/null +++ b/asset-io-wasm/js/node-source.mjs @@ -0,0 +1,15 @@ +/** Async positioned reads using Node's filesystem, not WASI or Rust I/O. */ +export async function readWindow(file, offset, windowLen) { + // Node 18 positioned read APIs use Number offsets. + if (offset < 0n || offset + BigInt(windowLen) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError("file range exceeds JavaScript's safe integer range"); + } + const bytes = new Uint8Array(windowLen); + let len = 0; + while (len < windowLen) { + const { bytesRead } = await file.read(bytes, len, windowLen - len, Number(offset) + len); + if (bytesRead === 0) break; + len += bytesRead; + } + return bytes.subarray(0, len); +} diff --git a/asset-io-wasm/src/lib.rs b/asset-io-wasm/src/lib.rs new file mode 100644 index 0000000..40f5237 --- /dev/null +++ b/asset-io-wasm/src/lib.rs @@ -0,0 +1,248 @@ +//! Caller-driven parsing. JavaScript owns I/O and all async scheduling. + +use asset_io::{ + task::{Progress, Task}, + HandlerRegistry, Structure, +}; +use serde_json::{json, Value}; +use wasm_bindgen::prelude::*; + +/// An absolute read descriptor. The u64 offset is a JavaScript bigint. +#[wasm_bindgen] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReadRequest { + pub offset: u64, + pub min_len: usize, + pub window_len: usize, +} + +enum State { + Ready, + Read(ReadRequest), + Done(Structure), + Failed(String), +} + +/// A source-free parser. Calls are synchronous; no JS callback or Rust executor +/// is invoked. Free it to cancel, coordinating outstanding host reads first. +#[wasm_bindgen] +pub struct ParseTask { + task: Task<'static, Structure>, + state: State, +} + +#[wasm_bindgen] +impl ParseTask { + /// size is a bigint. Undefined format detects; otherwise select an extension + /// or MIME type. Input strings and the builtin registry are owned by the task. + #[wasm_bindgen(constructor)] + pub fn new(size: u64, format: Option) -> Self { + let registry = HandlerRegistry::builtin(); + Self { + task: Task::new(size, move |mut io| async move { + Structure::parse_async(®istry, &mut io, format.as_deref()).await + }), + state: State::Ready, + } + } + + /// Return the pending read, or undefined on success. Errors throw and are + /// terminal. Repeated calls while waiting return the same descriptor. + pub fn resume(&mut self) -> Result, JsError> { + self.step().map_err(|message| JsError::new(&message)) + } + + /// Copy a Uint8Array response. Oversized responses are rejected without + /// consuming the request; short responses become EOF errors on resume. + pub fn supply(&mut self, bytes: &[u8]) -> Result<(), JsError> { + self.respond(bytes) + .map_err(|message| JsError::new(&message)) + } + + /// Record a host read failure. Subsequent resume calls throw this message. + pub fn fail(&mut self, message: String) -> Result<(), JsError> { + self.fail_read(message) + .map_err(|message| JsError::new(&message)) + } + + /// Source-free inspection summary, available only after completion. + /// This is a proof-specific schema, not the complete C ABI inspection schema. + #[wasm_bindgen(js_name = summaryJson)] + pub fn summary_json(&self) -> Result { + self.summary().map_err(|message| JsError::new(&message)) + } +} + +impl ParseTask { + fn step(&mut self) -> Result, String> { + match &self.state { + State::Read(request) => return Ok(Some(*request)), + State::Done(_) => return Ok(None), + State::Failed(message) => return Err(message.clone()), + State::Ready => {} + } + let state = match self.task.resume() { + Ok(Progress::Read(req)) => State::Read(ReadRequest { + offset: req.offset(), + min_len: req.min_len(), + window_len: req.window_len(), + }), + Ok(Progress::Done(structure)) => State::Done(structure), + Err(error) => State::Failed(error.to_string()), + Ok(_) => State::Failed("unsupported task progress".into()), + }; + self.state = state; + match &self.state { + State::Read(request) => Ok(Some(*request)), + State::Done(_) => Ok(None), + State::Failed(message) => Err(message.clone()), + State::Ready => unreachable!(), + } + } + + fn respond(&mut self, bytes: &[u8]) -> Result<(), String> { + let State::Read(request) = &self.state else { + return Err("task has no outstanding read".into()); + }; + if bytes.len() > request.window_len { + return Err("response exceeds requested window".into()); + } + { + let mut req = self + .task + .pending_read() + .ok_or("missing core read request")?; + req.buffer().clear(); + req.buffer().extend_from_slice(bytes); + } + self.state = State::Ready; + Ok(()) + } + + fn fail_read(&mut self, message: String) -> Result<(), String> { + if !matches!(self.state, State::Read(_)) { + return Err("task has no outstanding read".into()); + } + self.state = State::Failed(format!("host read failed: {message}")); + Ok(()) + } + + fn summary(&self) -> Result { + let State::Done(structure) = &self.state else { + return Err("task has not completed successfully".into()); + }; + Ok(inspection_summary(structure).to_string()) + } +} + +fn range(r: asset_io::ByteRange) -> Value { + json!({"offset": r.offset().to_string(), "length": r.len().to_string()}) +} + +/// The proof's geometry summary. Decimal strings preserve u64 precision in JS. +/// Shared with the native blocking oracle, not a second parsing implementation. +pub fn inspection_summary(s: &Structure) -> Value { + json!({ + "schema": 1, + "container": s.container_id(), + "media_type": s.media_type(), + "size": s.total_size().to_string(), + "trailing": s.trailing().map(range), + "segments": s.segments().iter().map(|segment| json!({ + "kind": segment.kind().as_str(), + "range": range(segment.range()), + "subdivides": segment.subdivides(), + "regions": segment.regions().map(|(role, bytes)| json!({ + "role": match role { + asset_io::Role::Header => "header", + asset_io::Role::Payload => "payload", + asset_io::Role::Trailer => "trailer", + }, + "range": range(bytes), + })).collect::>(), + })).collect::>(), + "items": s.items().map(|item| json!({ + "kind": item.kind().as_str(), + "encoding": item.encoding().as_str(), + "size": item.len().to_string(), + "extents": item.extents().map(|extent| json!({ + "segment": extent.segment().index(), + "range": range(extent.range()), + })).collect::>(), + })).collect::>(), + "subassets": s.subassets().iter().map(|asset| json!({ + "range": range(asset.range()), + "segment_start": asset.segments().start, + "segment_end": asset.segments().end, + "media_type": asset.media_type(), + "primary": asset.is_primary(), + })).collect::>(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_ranges_preserve_integer_precision() { + let offset = (1u64 << 53) + 1; + let bytes = asset_io::ByteRange::new(offset, 7).unwrap(); + assert_eq!( + range(bytes), + json!({"offset": offset.to_string(), "length": "7"}) + ); + } + + #[test] + fn format_errors_are_deferred_and_terminal() { + let mut task = ParseTask::new(4, Some("not-a-format".into())); + assert!(task.respond(b"TEST").is_err()); + assert!(task.summary().is_err()); + let error = task.step().unwrap_err(); + assert_eq!(task.step().unwrap_err(), error); + } + + #[cfg(feature = "jpeg")] + #[test] + fn request_lifecycle_and_result_match_blocking() { + let bytes = b"\xff\xd8\xff\xd9"; + let mut task = ParseTask::new(4, None); + let first = task.step().unwrap().unwrap(); + assert_eq!(task.step().unwrap(), Some(first)); + assert!(task.respond(b"12345").is_err()); + assert_eq!(task.step().unwrap(), Some(first)); + while let Some(req) = task.step().unwrap() { + let at = req.offset as usize; + task.respond(&bytes[at..at + req.window_len]).unwrap(); + } + let registry = HandlerRegistry::builtin(); + let structure = + Structure::parse(®istry, &mut std::io::Cursor::new(bytes), None).unwrap(); + assert_eq!( + task.summary().unwrap(), + inspection_summary(&structure).to_string() + ); + assert!(task.step().unwrap().is_none()); + assert!(task.respond(bytes).is_err()); + } + + #[cfg(feature = "jpeg")] + #[test] + fn eof_host_failure_and_cancellation() { + let mut short = ParseTask::new(4, None); + short.step().unwrap(); + short.respond(&[]).unwrap(); + let error = short.step().unwrap_err(); + assert_eq!(short.step().unwrap_err(), error); + let mut failed = ParseTask::new(4, None); + assert!(failed.fail_read("disk error".into()).is_err()); + failed.step().unwrap(); + failed.fail_read("disk error".into()).unwrap(); + assert!(failed.step().unwrap_err().contains("disk error")); + assert!(failed.summary().is_err()); + let mut cancelled = ParseTask::new(4, None); + cancelled.step().unwrap(); + drop(cancelled); + } +} diff --git a/src/task.rs b/src/task.rs index 974d03c..9e15061 100644 --- a/src/task.rs +++ b/src/task.rs @@ -121,6 +121,18 @@ impl<'f, T> Task<'f, T> { } } + /// Reacquire a suspended read without polling the computation. + /// + /// A foreign or asynchronous driver may drop the request from [`resume`](Self::resume), + /// retain its descriptor while doing I/O, then reacquire it to supply bytes + /// before resuming. Returns `None` before a read or after completion. + pub fn pending_read(&mut self) -> Option> { + if self.finished { + return None; + } + ReadRequest::take(&self.link) + } + /// Run to completion, reading from `src`, which must be the stream the task was /// made for. pub fn run(mut self, src: &mut R) -> Result { @@ -366,6 +378,27 @@ mod tests { drop(t); } + #[test] + fn suspended_requests_can_be_reacquired_without_polling() { + let mut task = Task::new(4, |mut io| async move { + let mut bytes = [0; 4]; + io.read_at(0, &mut bytes).await?; + Ok(bytes) + }); + assert!(task.pending_read().is_none()); + let Progress::Read(req) = task.resume().unwrap() else { + panic!("expected a read"); + }; + let descriptor = (req.offset(), req.min_len(), req.window_len()); + drop(req); + let mut req = task.pending_read().unwrap(); + assert_eq!((req.offset(), req.min_len(), req.window_len()), descriptor); + req.buffer().extend_from_slice(b"TEST"); + drop(req); + assert!(matches!(task.resume().unwrap(), Progress::Done(bytes) if bytes == *b"TEST")); + assert!(task.pending_read().is_none()); + } + #[test] fn requests_say_where_and_how_much() { let mut task = Task::new(100_000, |mut io| async move {