From d11eb04d12e35dbfb42dd9a3f201d2da15f4af70 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 11:46:40 +0200 Subject: [PATCH 01/18] [Fix] Clean --- Cargo.lock | 154 +++--- deny.toml | 16 +- example/Cargo.lock | 3 +- example/client/src/messages.rs | 2 +- example/client/src/metrics.rs | 1 + example/server/src/handlers.rs | 4 +- example/server/src/main.rs | 8 +- example/server/src/metrics.rs | 6 +- package.json | 4 +- pnpm-lock.yaml | 935 ++------------------------------- wasm/src/frame.rs | 1 + wasm/src/protected.rs | 1 + wasm/src/relay.rs | 1 + 13 files changed, 163 insertions(+), 973 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4adc519..88e9b74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -221,9 +221,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -561,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -596,9 +596,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fnv" @@ -629,9 +629,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -660,9 +660,9 @@ checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -764,9 +764,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -895,9 +895,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -966,9 +966,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -980,9 +980,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -993,9 +993,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1007,16 +1007,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1027,15 +1028,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1153,9 +1154,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1173,9 +1174,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1201,9 +1202,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1234,9 +1235,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minicov" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -1665,9 +1666,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -1700,9 +1701,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1745,9 +1746,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -1779,7 +1780,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1950,7 +1951,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2120,7 +2121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", "sponge-cursor", ] @@ -2345,9 +2346,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2419,13 +2420,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2570,9 +2572,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2585,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2593,9 +2595,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2603,9 +2605,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -2616,18 +2618,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" dependencies = [ "async-trait", "cast", @@ -2647,9 +2649,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" dependencies = [ "proc-macro2", "quote", @@ -2658,9 +2660,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" +checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" [[package]] name = "wasm-tracing" @@ -2698,7 +2700,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2791,9 +2793,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wtransport" @@ -2938,9 +2940,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2949,9 +2951,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -2960,13 +2962,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/deny.toml b/deny.toml index 41c6bb9..30bd4fb 100644 --- a/deny.toml +++ b/deny.toml @@ -8,9 +8,23 @@ ignore = [] [bans] # Flag multiple versions of the same crate so duplicate trees are visible. -multiple-versions = "warn" +multiple-versions = "deny" wildcards = "deny" +# These versions are required by incompatible upstream dependency lines: +# - pem/rcgen/wtransport still use base64 0.22. +# - ring and wasm-bindgen still use getrandom 0.2. +# - current displaydoc/serde/thiserror/tokio and wasm-bindgen trees span syn 2 +# and syn 3. +# - ring still uses windows-sys 0.52 while the Tokio/QUIC tree uses 0.61. +# Keep the duplicate-version policy strict for every other crate/version. +skip = [ + { name = "base64", version = "0.22.1" }, + { name = "getrandom", version = "0.2.17" }, + { name = "syn", version = "2.0.119" }, + { name = "windows-sys", version = "0.52.0" }, +] + [licenses] # Allowlist of licenses acceptable for this project's dependencies. allow = [ diff --git a/example/Cargo.lock b/example/Cargo.lock index 1c15380..8d2fb30 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -1314,6 +1314,7 @@ dependencies = [ name = "mtp-crypto" version = "0.3.0" dependencies = [ + "argon2", "base64 0.22.1", "chacha20poly1305", "ed25519-dalek", @@ -1337,7 +1338,6 @@ dependencies = [ name = "mtp-files" version = "0.3.0" dependencies = [ - "argon2", "mtp-crypto", "rand", "thiserror 2.0.20", @@ -1353,6 +1353,7 @@ dependencies = [ "mtp-crypto", "mtp-transport", "rand", + "thiserror 2.0.20", "tokio", "tracing", "wtransport", diff --git a/example/client/src/messages.rs b/example/client/src/messages.rs index f5da0d1..12af612 100644 --- a/example/client/src/messages.rs +++ b/example/client/src/messages.rs @@ -62,7 +62,7 @@ pub fn build_demo_message( ) .add_typed_default( DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), + DataValue::UnsignedNumber(timestamp), ) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Flags, DataValue::BoolTrue) diff --git a/example/client/src/metrics.rs b/example/client/src/metrics.rs index fcc039a..3bca4d0 100644 --- a/example/client/src/metrics.rs +++ b/example/client/src/metrics.rs @@ -79,6 +79,7 @@ pub struct ClientMetrics { } impl ClientMetrics { + #[cfg(test)] pub fn new() -> Self { Self { sessions: Vec::new(), diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 13a2990..867a95d 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -47,7 +47,7 @@ fn pong(tm: &TypeMap, data: impl Into) -> Result Result<(), Box> { serde_json::to_string_pretty(&*db).ok() }; - if let Some(json) = json { - if let Err(error) = tokio::fs::write("clients.json", json).await { - eprintln!("Failed to persist clients.json: {error}"); - } + if let Some(json) = json + && let Err(error) = tokio::fs::write("clients.json", json).await + { + eprintln!("Failed to persist clients.json: {error}"); } println!("Registered new client with ID: {id}"); diff --git a/example/server/src/metrics.rs b/example/server/src/metrics.rs index 10dcd62..b994922 100644 --- a/example/server/src/metrics.rs +++ b/example/server/src/metrics.rs @@ -108,6 +108,7 @@ pub struct ServerMetrics { } impl ServerMetrics { + #[cfg(test)] pub fn new() -> Self { Self { inner: Mutex::new(Inner { @@ -218,6 +219,7 @@ impl ServerMetrics { } } + #[cfg(test)] pub fn snapshot(&self) -> ServerMetricsFile { let inner = self.inner.lock().unwrap(); self.to_file(&inner) @@ -553,11 +555,11 @@ mod tests { let metrics = ServerMetrics::new(); for i in 0..3 { - let mut session = metrics.start_session(1000 + i as u64, format!("session {i}")); + let mut session = metrics.start_session(1000 + i, format!("session {i}")); for _ in 0..(i + 1) * 2 { session.record_message(Duration::from_millis(1 + i), true); } - session.record_pipe((i as u64 + 1) * 1000); + session.record_pipe((i + 1) * 1000); session.finish(format!("exit {i}")); } diff --git a/package.json b/package.json index e4af66b..dd4e7a1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "pack": "pnpm run release:web", "release:web": "node create-web-release.mjs", "build:all": "nix run .#build-all", - "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips .", + "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips .", "test:e2e": "tsc && node test/e2ee.mjs", "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", "test:wasm-init": "tsc && node --test test/wasm-init.mjs", @@ -72,7 +72,7 @@ }, "devDependencies": { "@types/node": "^26.0.1", - "jscpd": "4.2.5", + "jscpd": "5.0.14", "typescript": "^7.0.0" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72f1233..460b3de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^26.0.1 version: 26.0.1 jscpd: - specifier: 4.2.5 - version: 4.2.5 + specifier: 5.0.14 + version: 5.0.14 typescript: specifier: ^7.0.0 version: 7.0.2 @@ -37,27 +37,6 @@ importers: packages: - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -67,39 +46,12 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@jscpd/badge-reporter@4.2.5': - resolution: {integrity: sha512-ktXrjPeRaRyUDktxTroSA2/w5sshXpQplWkUuq/e6XqEpKBSbGEnwZLIaegSijOrMwIcCXPQ9k4feXIz5eVJNA==} - - '@jscpd/core@4.2.5': - resolution: {integrity: sha512-Esf2deHxaoNEjePwf2jqP6Urzj+BAOsJVPFLbnnSsV+q7rLNMcn0UEEoKBXIOOt4qMkrkhl9DfwpMyPPOr6GkQ==} - - '@jscpd/finder@4.2.5': - resolution: {integrity: sha512-Rw0dtwp/EeLANbujOubuQeJIuXXXkAlT+f5geZhwkB9TxEYP0hqNrdOJUK/TDBKQjRGrOizEtdNy+S4UlbdzOQ==} - - '@jscpd/html-reporter@4.2.5': - resolution: {integrity: sha512-zMMIKbvi43dMgeNeHXlHQy1ovf+KJrzNlUubaBvCAVatqP23ksW8d3fmsevIQG9mMMTH0D1xOz+SxUn1FREOPg==} - - '@jscpd/tokenizer@4.2.5': - resolution: {integrity: sha512-UM8Wx/jwahmflqQExlcKMQTYOAy58N/fn7Pv6NYrkD3EZm/FTk7gW97wkXy5aDE1Ts9oBUpT9tLY2rz7ogCHAQ==} - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -207,9 +159,6 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - '@types/sarif@2.1.7': - resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -330,113 +279,10 @@ packages: cpu: [x64] os: [win32] - acorn@7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - assert-never@1.4.0: - resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} - - babel-walk@3.0.0-canary-5: - resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} - engines: {node: '>= 10.0.0'} - - badgen@3.3.2: - resolution: {integrity: sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==} - - blamer@1.0.7: - resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} - engines: {node: '>=8.9'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - character-parser@2.2.0: - resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - colors@1.4.0: - resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} - engines: {node: '>=0.1.90'} - - commander@15.0.0: - resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} - engines: {node: '>=22.12.0'} - - constantinople@4.0.1: - resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - doctypes@1.1.0: - resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - execa@4.1.0: - resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} - engines: {node: '>=10'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -446,114 +292,49 @@ packages: picomatch: optional: true - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + jscpd-darwin-arm64@5.0.14: + resolution: {integrity: sha512-Ojjl79SBuj9tEW6WbjZ1a/1ZOR89dneH9yLQYQu8WyWaQownttnx7RYFEHU6aGhS4jIvwUEbr+1wxzFTb37cwg==} + cpu: [arm64] + os: [darwin] - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + jscpd-darwin-x64@5.0.14: + resolution: {integrity: sha512-DxFg5XvjMZ81iVeqillnM5apqcGCfNTbroNF+mPLr7RkHLGH6mudLgtO+ILL/hfpZXy1bF9oIY5BSudPmN/k9A==} + cpu: [x64] + os: [darwin] - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + jscpd-linux-arm64-gnu@5.0.14: + resolution: {integrity: sha512-1uw+XBHEt9pONXNICSp5HpaVWPjG6mQ6deDXaq9Yb0xCNJkX4/8gmn0vhzekIyZD2DspRYKPUolbDsqm/HEdYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} + jscpd-linux-x64-gnu@5.0.14: + resolution: {integrity: sha512-dFTbyyrm+Z9pcXIVzJQCw8QAgiNqIiO69sm4AfA7/wFdPoizoVzjhaXsYXcSV4bs0aoPiWbNazg0J0HgslT/5A==} + cpu: [x64] + os: [linux] + libc: [glibc] - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + jscpd-linux-x64-musl@5.0.14: + resolution: {integrity: sha512-SayS7qQJvixyy9eR0+UjepkTsUUwqvlsiuSxfIdHgG2qzqoh/thnkgiu4By8fsiiDpQONsQrRrZDwHRQ3GDrBQ==} + cpu: [x64] + os: [linux] + libc: [musl] - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + jscpd-windows-x64-msvc@5.0.14: + resolution: {integrity: sha512-DqjxlVkUanlahGgY2lY7Zkrau4BUTI+AwWky+bPGK4kSK2AIOaUziY9Q19u8b58idXmJA9FKK98Fuu4ajNXVjQ==} + cpu: [x64] + os: [win32] - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - human-signals@1.1.1: - resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} - engines: {node: '>=8.12.0'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-expression@4.0.0: - resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-promise@2.2.2: - resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-stringify@1.0.2: - resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} - - jscpd-sarif-reporter@4.2.5: - resolution: {integrity: sha512-O8LcM9grAS5yO5x1Q0yegYaYcUX//IEBEyvzGFSYCeo1YzHbMnAI6EK7oTrwD+7Csjvfg9m8B8G7OOxzcSlr9w==} - - jscpd@4.2.5: - resolution: {integrity: sha512-KDpApYw1ChGelfHb7MwYTEx694OnW52pv3McAasidUV4ILcGDQMiVJzB+vI8ox+ZPVfOSvdXQCk8uRa9B0LXnw==} + jscpd@5.0.14: + resolution: {integrity: sha512-zge+FPZZAymt2Do5Z0+QHyIn4/XcUhrO/W7of9HcHZfx2AK8++dYhLA1uWtwXj47ml3Of8PbcUW4wUWvYMCc3w==} + engines: {node: '>=18'} hasBin: true - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - - jstransformer@1.0.0: - resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -628,66 +409,14 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - markdown-table@2.0.0: - resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-sarif-builder@4.1.0: - resolution: {integrity: sha512-IWqZF6u0EI/07HTBm+zZ+MgXgWl09dnSJRGaDCPBSlOqilDcx6pj3Mpb3HvPN8V2Gr+ISw7ZrMsL7STWs1F++w==} - engines: {node: '>=20'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -696,117 +425,19 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - pug-attrs@3.0.0: - resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} - - pug-code-gen@3.0.4: - resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} - - pug-error@2.1.0: - resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} - - pug-filters@4.0.0: - resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} - - pug-lexer@5.0.1: - resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} - - pug-linker@4.0.0: - resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} - - pug-load@3.0.0: - resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} - - pug-parser@6.0.0: - resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} - - pug-runtime@3.0.1: - resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} - - pug-strip-comments@2.0.0: - resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} - - pug-walk@2.0.0: - resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} - - pug@3.0.4: - resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.1.3: resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - spark-md5@3.0.2: - resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - token-stream@1.0.0: - resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -818,10 +449,6 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -865,22 +492,6 @@ packages: yaml: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - with@7.0.2: - resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} - engines: {node: '>= 10.0.0'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -888,22 +499,6 @@ packages: snapshots: - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@colors/colors@1.5.0': - optional: true - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -920,40 +515,6 @@ snapshots: tslib: 2.8.1 optional: true - '@jscpd/badge-reporter@4.2.5': - dependencies: - badgen: 3.3.2 - colors: 1.4.0 - fs-extra: 11.3.5 - - '@jscpd/core@4.2.5': - dependencies: - eventemitter3: 5.0.4 - - '@jscpd/finder@4.2.5': - dependencies: - '@jscpd/core': 4.2.5 - '@jscpd/tokenizer': 4.2.5 - blamer: 1.0.7 - bytes: 3.1.2 - cli-table3: 0.6.5 - colors: 1.4.0 - fast-glob: 3.3.3 - fs-extra: 11.3.5 - markdown-table: 2.0.0 - pug: 3.0.4 - - '@jscpd/html-reporter@4.2.5': - dependencies: - colors: 1.4.0 - fs-extra: 11.3.5 - pug: 3.0.4 - - '@jscpd/tokenizer@4.2.5': - dependencies: - '@jscpd/core': 4.2.5 - spark-md5: 3.0.2 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -961,18 +522,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@oxc-project/types@0.137.0': {} '@rolldown/binding-android-arm64@1.1.3': @@ -1035,8 +584,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/sarif@2.1.7': {} - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1097,239 +644,41 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - acorn@7.4.1: {} - - ansi-regex@5.0.1: {} - - asap@2.0.6: {} - - assert-never@1.4.0: {} - - babel-walk@3.0.0-canary-5: - dependencies: - '@babel/types': 7.29.7 - - badgen@3.3.2: {} - - blamer@1.0.7: - dependencies: - execa: 4.1.0 - which: 2.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - character-parser@2.2.0: - dependencies: - is-regex: 1.2.1 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - colors@1.4.0: {} - - commander@15.0.0: {} - - constantinople@4.0.1: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - detect-libc@2.1.2: {} - doctypes@1.1.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - emoji-regex@8.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - eventemitter3@5.0.4: {} - - execa@4.1.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 5.2.0 - human-signals: 1.1.1 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fsevents@2.3.3: optional: true - function-bind@1.1.2: {} + jscpd-darwin-arm64@5.0.14: + optional: true - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 + jscpd-darwin-x64@5.0.14: + optional: true - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 + jscpd-linux-arm64-gnu@5.0.14: + optional: true - get-stream@5.2.0: - dependencies: - pump: 3.0.4 + jscpd-linux-x64-gnu@5.0.14: + optional: true - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 + jscpd-linux-x64-musl@5.0.14: + optional: true - gopd@1.2.0: {} + jscpd-windows-x64-msvc@5.0.14: + optional: true - graceful-fs@4.2.11: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - human-signals@1.1.1: {} - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - - is-expression@4.0.0: - dependencies: - acorn: 7.4.1 - object-assign: 4.1.1 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-promise@2.2.2: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - - js-stringify@1.0.2: {} - - jscpd-sarif-reporter@4.2.5: - dependencies: - colors: 1.4.0 - fs-extra: 11.3.5 - node-sarif-builder: 4.1.0 - - jscpd@4.2.5: - dependencies: - '@jscpd/badge-reporter': 4.2.5 - '@jscpd/core': 4.2.5 - '@jscpd/finder': 4.2.5 - '@jscpd/html-reporter': 4.2.5 - '@jscpd/tokenizer': 4.2.5 - colors: 1.4.0 - commander: 15.0.0 - fs-extra: 11.3.5 - jscpd-sarif-reporter: 4.2.5 - - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 + jscpd@5.0.14: optionalDependencies: - graceful-fs: 4.2.11 - - jstransformer@1.0.0: - dependencies: - is-promise: 2.2.2 - promise: 7.3.1 + jscpd-darwin-arm64: 5.0.14 + jscpd-darwin-x64: 5.0.14 + jscpd-linux-arm64-gnu: 5.0.14 + jscpd-linux-x64-gnu: 5.0.14 + jscpd-linux-x64-musl: 5.0.14 + jscpd-windows-x64-msvc: 5.0.14 lightningcss-android-arm64@1.32.0: optional: true @@ -1380,52 +729,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - markdown-table@2.0.0: - dependencies: - repeat-string: 1.6.1 - - math-intrinsics@1.1.0: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mimic-fn@2.1.0: {} - nanoid@3.3.15: {} - node-sarif-builder@4.1.0: - dependencies: - '@types/sarif': 2.1.7 - fs-extra: 11.3.5 - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-assign@4.1.1: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.4: {} postcss@8.5.15: @@ -1434,95 +741,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - promise@7.3.1: - dependencies: - asap: 2.0.6 - - pug-attrs@3.0.0: - dependencies: - constantinople: 4.0.1 - js-stringify: 1.0.2 - pug-runtime: 3.0.1 - - pug-code-gen@3.0.4: - dependencies: - constantinople: 4.0.1 - doctypes: 1.1.0 - js-stringify: 1.0.2 - pug-attrs: 3.0.0 - pug-error: 2.1.0 - pug-runtime: 3.0.1 - void-elements: 3.1.0 - with: 7.0.2 - - pug-error@2.1.0: {} - - pug-filters@4.0.0: - dependencies: - constantinople: 4.0.1 - jstransformer: 1.0.0 - pug-error: 2.1.0 - pug-walk: 2.0.0 - resolve: 1.22.12 - - pug-lexer@5.0.1: - dependencies: - character-parser: 2.2.0 - is-expression: 4.0.0 - pug-error: 2.1.0 - - pug-linker@4.0.0: - dependencies: - pug-error: 2.1.0 - pug-walk: 2.0.0 - - pug-load@3.0.0: - dependencies: - object-assign: 4.1.1 - pug-walk: 2.0.0 - - pug-parser@6.0.0: - dependencies: - pug-error: 2.1.0 - token-stream: 1.0.0 - - pug-runtime@3.0.1: {} - - pug-strip-comments@2.0.0: - dependencies: - pug-error: 2.1.0 - - pug-walk@2.0.0: {} - - pug@3.0.4: - dependencies: - pug-code-gen: 3.0.4 - pug-filters: 4.0.0 - pug-lexer: 5.0.1 - pug-linker: 4.0.0 - pug-load: 3.0.0 - pug-parser: 6.0.0 - pug-runtime: 3.0.1 - pug-strip-comments: 2.0.0 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - queue-microtask@1.2.3: {} - - repeat-string@1.6.1: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - rolldown@1.1.3: dependencies: '@oxc-project/types': 0.137.0 @@ -1544,47 +762,13 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.3 '@rolldown/binding-win32-x64-msvc': 1.1.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} - source-map-js@1.2.1: {} - spark-md5@3.0.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-final-newline@2.0.0: {} - - supports-preserve-symlinks-flag@1.0.0: {} - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - token-stream@1.0.0: {} - tslib@2.8.1: optional: true @@ -1613,8 +797,6 @@ snapshots: undici-types@8.3.0: {} - universalify@2.0.1: {} - vite@8.1.0(@types/node@26.0.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -1627,19 +809,4 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 - void-elements@3.1.0: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - with@7.0.2: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - assert-never: 1.4.0 - babel-walk: 3.0.0-canary-5 - - wrappy@1.0.2: {} - yaml@2.9.0: {} diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs index 44db54f..347ffd7 100644 --- a/wasm/src/frame.rs +++ b/wasm/src/frame.rs @@ -163,6 +163,7 @@ impl JsDataValueEncodeContext { } } +#[cfg(test)] pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { js_to_data_value_with_limits(value, tm, EncodeLimits::default()) } diff --git a/wasm/src/protected.rs b/wasm/src/protected.rs index 283b3f4..d0d4da1 100644 --- a/wasm/src/protected.rs +++ b/wasm/src/protected.rs @@ -380,6 +380,7 @@ pub fn build_protected_frame_with_keyring_with_limits( /// Read the claimed, unverified signer ID after decrypting the protected /// payload. The result may only select trusted keys for the same signer ID. #[wasm_bindgen] +#[allow(deprecated)] #[deprecated(note = "use protected_claimed_signer_id_with_limits")] pub fn protected_claimed_signer_id( frame: &[u8], diff --git a/wasm/src/relay.rs b/wasm/src/relay.rs index 3ce2df4..fcf768b 100644 --- a/wasm/src/relay.rs +++ b/wasm/src/relay.rs @@ -296,6 +296,7 @@ impl WasmVerifiedRelayContent { /// versioned relay metadata parser in the JavaScript SDK. The caller must bind /// this value as the expected signer during the subsequent verification call. #[wasm_bindgen] +#[allow(deprecated)] #[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result { relay_metadata_claimed_signer_id_impl(frame, keyrings, JsValue::UNDEFINED) From a6c4e56835229e1a0323ae60afc9f523b127bd6e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 12:37:22 +0200 Subject: [PATCH 02/18] [Upd] Docs --- README.md | 23 ++++++++++++++------ docs/CONNECTIONS.md | 12 +++++++---- docs/CONNECTOR.md | 39 ++++++++++++++++++++-------------- docs/ERRORS.md | 4 +++- docs/NATIVE-CLIENT.md | 14 +++++++----- docs/NATIVE-HOST-WEB-SERVER.md | 12 ++++++++--- docs/NATIVE-HOST.md | 16 ++++++++------ docs/OPERATIONS.md | 2 +- docs/PIPES.md | 5 ++++- docs/PROTOCOL-REFERENCE.md | 7 +++++- docs/SECURITY.md | 3 ++- docs/TROUBLESHOOTING.md | 2 +- docs/TYPE-MAP.md | 33 ++++++++++++++++------------ docs/WASM-CLIENT.md | 15 +++++++++++-- 14 files changed, 125 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 37f90cf..d7abaee 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,29 @@ Feature summary: | Feature | Pulls in | Enables | | --- | --- | --- | -| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing | -| `host` | `mtp::host`, codec registry | QUIC host and version negotiation | -| `client` | `mtp::client` | QUIC client connections | -| `webserver` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions | +| `serde` | Crypto serialization support | Serde implementations for crypto key types | +| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support | +| `host` | `mtp::host` | Native QUIC host and version negotiation | +| `client` | `mtp::client` | Native QUIC client connections | +| `transport` | `mtp-transport` dependency | Low-level transport support; enabled automatically by `host` and `client` | +| `pipes` | Pipe support in transport, host, client, and web server | Raw and encrypted byte streams | +| `files` | `mtp::files` | `.mk` keyrings and `.mpkb` public bundles; also enables `crypto` | +| `raw` | Raw file APIs | Legacy plaintext keyring migration APIs | +| `web-server` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions | +| `full-server` | Native host and web-server surface | `host`, `web-server`, `crypto`, and `pipes` together | +| `tls` | `mtp::crypto::tls` | Development self-signed certificate generation | +| `insecure-tls` | Lower-level transport | Development-only certificate verification bypass, gated by `MTP_INSECURE_TLS=1` | -The core crates are always available: `codec`, `transport`, `common`, and `type_map`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md) +The core modules always available from the facade are `codec`, `common`, and +`type_map`. Native `client` and `host` modules re-export the transport policy +types; the low-level transport crate is not exposed as `mtp::transport`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md) guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries. ## Sub-crates The `mtp` facade re-exports the following modules: -`mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, and `mtp::client`. +`mtp::codec`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, +`mtp::client`, `mtp::files`, and `mtp::webserver` when their features are enabled. ### Codec diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 0ccf127..69eebad 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -1,19 +1,23 @@ # MTP Connections -Native clients and hosts share the same connection shape after the opening handshake. The client creates the connection; the host receives it from `accept()`. +Native clients and server-side hosts expose parallel connection handles after the +opening handshake. The client creates its handle; the host receives one from +`accept()`. | Member | Native client | Native host | Web host (`WebMTPConnection`) | | --- | --- | --- | --- | | `version` | Compiled client version accepted by the host | Version selected by the registry | Version selected by the registry | | `sender` | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | -| `receiver` | Receives application frames | Receives application frames | Receives application frames | +| `receiver` | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | | `description` | Optional label sent during setup | Optional label received from the client | Optional label received from the client | | `client_id` | Confirmed or assigned ID with `crypto` | Authenticated or guest client ID with `crypto` | Authenticated or guest client ID with `crypto` | | `auth_state` | Authentication result with `crypto` | Authentication result with `crypto` | Authentication result with `crypto` | -| `request_path` | / | / | WebTransport CONNECT path (e.g. `/mtp`) | +| `path` | — | Native hosts use `/` | WebTransport CONNECT path (e.g. `/mtp`) | | `remote_addr` | Server `SocketAddr` when available | Peer `SocketAddr` | Peer `SocketAddr` | -`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same members as the native host connection plus `request_path`, which contains the HTTP/3 path used for the WebTransport extended CONNECT request. +`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same +server-side members as the native host connection. Its `path` contains the +HTTP/3 path used for the WebTransport extended CONNECT request. Server-side MTP connections expose `remote_addr`, the peer address observed by QUIC. HTTP route handlers receive the peer address as `HttpRequest::remote_addr`. diff --git a/docs/CONNECTOR.md b/docs/CONNECTOR.md index e560a71..355faa5 100644 --- a/docs/CONNECTOR.md +++ b/docs/CONNECTOR.md @@ -4,23 +4,28 @@ This file documents the connection and version negotiation logic. ## Registry -The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature): +The `registry` module provides a multi-version `Registry` used by the host for +version negotiation. Accessed through the `mtp` facade (requires the `host` +feature). In this repository, `Registry::builtin()` is generated from +[`example/type-maps.yaml`](../example/type-maps.yaml), which currently contains +protocol version 3.0 only. Downstream projects can register additional versions +in their own YAML configuration. ```rust -use mtp::codec::registry::Registry; +use mtp::codec::{Version, registry::Registry}; -let registry = Registry::builtin(); // loads all TypeMaps from config +let registry = Registry::builtin(); // loads all TypeMaps from the build config // Check if a version is supported -assert!(registry.supports(&Version(1, 0))); +assert!(registry.supports(&Version(3, 0))); // Find highest mutual version for a client -let client_versions = &[Version(0, 0), Version(1, 0)]; +let client_versions = &[Version(2, 0), Version(3, 0)]; let negotiated = registry.negotiate(client_versions); -assert_eq!(negotiated, Some(Version(1, 0))); +assert_eq!(negotiated, Some(Version(3, 0))); // Look up a version's TypeMap -let tm = registry.get(&Version(2, 0)).unwrap(); +let tm = registry.get(&Version(3, 0)).unwrap(); ``` The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config. @@ -54,9 +59,9 @@ let mut host = MTPHost::new(config).await?; while let Some(conn) = host.accept().await? { // conn.version is the negotiated version // conn.codec is a VersionedCodec scoped to that version - // conn.sender / conn.receiver for raw CommunicationValue I/O + // conn.sender / conn.receive() for application CommunicationValue I/O - let msg = conn.receiver.receive().await?; + let msg = conn.receive().await?; } ``` @@ -94,29 +99,31 @@ The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-m ## Version Negotiation Flow ``` -Client (v2.0) Host (v0.0, v1.0, v2.0) +Client (v3.0) Host (v3.0) | | | QUIC connect | |----------------------->| | | | CommValue{ Ident. } | - | Version -> "2.0" | + | Version -> "3.0" | | Id -> 8765 | | (unsigned hello; auth | | challenge follows) | |----------------------->| - | | registry.negotiate(&[Version(2,0)]) - | | -> Some(Version(2,0)) + | | registry.negotiate(&[Version(3,0)]) + | | -> Some(Version(3,0)) | | - | Response | selected v2.0 TypeMap + | Response | selected v3.0 TypeMap |<-----------------------| | Status, version | | | | subsequent messages | - | use v2.0 TypeMap | + | use v3.0 TypeMap | ``` -If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed. +If the client sends an unsupported version (for example, v2.0 to the current +repository builtin host), `negotiate` returns `None` and the connection is +closed. ## Protocol Ping and Pong diff --git a/docs/ERRORS.md b/docs/ERRORS.md index fa31dd4..93365dc 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -12,6 +12,8 @@ MTP reports codec failures separately from connection and transport failures. | `ReservedCommunicationType` | An application attempted to use a reserved communication type ID. | | `InvalidEncoding` | Bytes do not match the MTP value or frame format. | | `TooManyEntries` | A serialized value or frame exceeds its representable size. | +| `MissingTypeMap` | A versioned codec was asked to encode a value without a retained negotiated type map. | +| `TypeMapMismatch` | A value was created with a different protocol type map from the codec or peer operation. | | `CryptoFailed` | Signing, verification, encryption, or decryption failed while encoding or decoding. | | `MissingField` | A required typed field is absent. | @@ -43,4 +45,4 @@ Native builds may expose additional variants wrapping QUIC and WebTransport erro ## Authentication Rejections -The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response. The authentication flow and its signed fields are defined in [Security](SECURITY.md). +The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response; a handshake that exceeds the configured limit returns `AcceptError::AuthenticationTimedOut`. The authentication flow and its signed fields are defined in [Security](SECURITY.md). diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index d764727..485e6ed 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -19,7 +19,7 @@ let request = CommunicationValue::new(CommunicationType::Ping).with_id(1); conn.sender.send(&request).await?; let response = conn.receive().await?; println!("received {:?}", response.id()); -conn.sender.close(); +conn.sender.close().await; ``` ## Configuration @@ -230,7 +230,7 @@ let response = conn Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`. -Two send modes (configured via `mtp::transport::Policy`): +Two send modes (configured via `mtp::client::Policy`): - `PersistentStream` (default): reuses one QUIC unidirectional stream - `SingleStreamPerMessage`: opens a new stream per message @@ -248,12 +248,16 @@ Inbound frames are queued internally. The `receive()` method returns the next av ### Close ```rust -conn.sender.close(); +conn.sender.close().await; // or conn.receiver.close(); ``` -Sends a close frame and signals the peer. The `Sender::close()` spawns an async task that sends the frame, waits for `force_close_delay` (default 300ms), then force-closes the QUIC connection if the peer has not already done so. +`Sender::close().await` gracefully finishes the active send stream, sends the +MTP close frame, and waits for `force_close_delay` (default 300ms) before +force-closing the QUIC connection if necessary. `Sender::close_immediate()` is +the fire-and-forget variant. `Receiver::close()` closes the local receive +handle without performing the sender's graceful close sequence. ### Pipes @@ -309,7 +313,7 @@ For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; The `Policy` struct controls transport behaviour: ```rust -use mtp::transport::{Policy, SendMode}; +use mtp::client::{Policy, SendMode}; let policy = Policy { send_mode: SendMode::PersistentStream, diff --git a/docs/NATIVE-HOST-WEB-SERVER.md b/docs/NATIVE-HOST-WEB-SERVER.md index 36cadf6..c92f18b 100644 --- a/docs/NATIVE-HOST-WEB-SERVER.md +++ b/docs/NATIVE-HOST-WEB-SERVER.md @@ -130,7 +130,7 @@ while let Some(connection) = server.accept().await? { ``` > `MTPWebServer::new` consumes a `HostConfig` (not an `MTPHost` instance). It creates its own QUIC endpoint and does not share a port with a running `MTPHost`. -`server.accept()` returns `Option` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, request path, remote address, description, sender, and receiver used by native MTP connections. +`server.accept()` returns `Option` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, `path`, remote address, description, sender, and receiver used by native MTP connections. ## Deployment @@ -138,7 +138,7 @@ For direct browser access, leave `serve_tcp_https(true)` enabled. The server adv When a reverse proxy or another process owns TCP, use `WebServerConfig::new().serve_tcp_https(false)`. This retains the UDP HTTP/3/WebTransport endpoint and its shared router without claiming the TCP port. -With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown()` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close()` and dropping the server stop both listeners immediately. +With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown().await` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close().await` and dropping the server stop both listeners immediately. ### Authentication @@ -164,12 +164,18 @@ On success, the connection has `AuthState::Authenticated`, the assigned `client_ ## Errors -`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, bind failures, and rejected authentication policy. +`MTPWebServer::new` returns `CommunicationError` for certificate parsing, +certificate loading, and bind failures. Authentication policy is evaluated when +WebTransport sessions are accepted, not rejected during construction. `accept()` returns `AcceptError` for a missing or unsupported version, a receive failure, or a send failure during the WebTransport opening handshake. HTTP route failures are reported through `WebServerMetrics::error_occurred` when metrics are configured. See [Errors](ERRORS.md) for shared error variants. `WebServerMetrics` has these callbacks: ```rust +use std::time::Duration; + +fn connection_accepted(&self) +fn connection_closed(&self, duration: Duration, reason: &str) fn request_started(&self, path: &str) fn request_completed(&self, path: &str, status: u16, duration: Duration) fn error_occurred(&self, error: &WebServerError) diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index 33726ec..32d9146 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -83,18 +83,18 @@ network metadata, not an authenticated client identity. ## Version Negotiation -`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in `type-maps.yaml` by `Registry::builtin()`. +`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in [`example/type-maps.yaml`](../example/type-maps.yaml) by `Registry::builtin()` in this repository; downstream builds can provide their own `MTP_TYPE_MAPS` configuration. ### Registry ```rust -use mtp::codec::registry::Registry; +use mtp::codec::Version; let registry = host.registry(); -assert!(registry.supports(&Version(2, 0))); +assert!(registry.supports(&Version(3, 0))); -let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]); -// -> Some(Version(2, 0)) if both versions are registered +let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]); +// -> Some(Version(3, 0)) for this repository's builtin map ``` ## Authentication Flow @@ -105,13 +105,15 @@ After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated` ## Handling Messages -Use `conn.sender` and `conn.receiver` for bidirectional message exchange: +Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The +connection dispatcher owns the underlying receiver, especially when `pipes` is +enabled: ```rust while let Some(conn) = host.accept().await? { tokio::spawn(async move { loop { - match conn.receiver.receive().await { + match conn.receive().await { Ok(msg) => { let response = process_message(&msg, &conn); conn.sender.send(&response).await.ok(); diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 4ada886..383b1d1 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -39,4 +39,4 @@ Back up host keyrings and client keyrings as protected secrets. Test restoring a ### Graceful Shutdown -Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown()`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated. +Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown().await`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated. diff --git a/docs/PIPES.md b/docs/PIPES.md index 51b9e7e..0d276f4 100644 --- a/docs/PIPES.md +++ b/docs/PIPES.md @@ -167,6 +167,9 @@ if let Some(writer) = handle.wait().await? { ```rust // Host use mtp_transport::{PipeSessionParameters, accept_pipe_session}; +use sha2::{Digest, Sha256}; + +// The streaming digest below requires `sha2` as a direct application dependency. while let Ok(request) = conn.receive_pipe().await { if request.description() != "file-upload" { @@ -182,7 +185,7 @@ while let Ok(request) = conn.receive_pipe().await { let mut reader = accept_pipe_session( reader.into_inner(), ¶ms, &own_keyring, &client_public_bundle, ).await?; - let mut hasher = sha2::Sha256::new(); + let mut hasher = Sha256::new(); while let Some(chunk) = reader.read_record().await? { hasher.update(&chunk); process_chunk(&chunk).await?; diff --git a/docs/PROTOCOL-REFERENCE.md b/docs/PROTOCOL-REFERENCE.md index 5c02ad9..133cfb0 100644 --- a/docs/PROTOCOL-REFERENCE.md +++ b/docs/PROTOCOL-REFERENCE.md @@ -112,4 +112,9 @@ identity-specific response for deployments where IDs are public. The client sends one compiled-in protocol version. The host compares it with the versions in its registry and returns the selected version in the opening response. Subsequent frames use that version's type map. An unsupported version closes the connection with `AcceptError::UnsupportedVersion`. -The self-delimiting `DataValue` codec and the three-bit communication header begin at protocol version `3.0`. A peer offering an older codec version is rejected during version negotiation; the new decoder does not attempt legacy flag, ID, or crypto-container fallbacks. +The current self-delimiting `DataValue` codec and three-bit communication header +are used by the repository's protocol 3.0 map. The checked-in builtin registry +contains only 3.0, so its native clients and hosts do not provide legacy map +fallbacks. Type-map versions are configuration-driven; a custom registry may +register another version number, but its map must use the current codec format +and is not a fallback for a different legacy wire format. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 154f8e8..76fe901 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -27,7 +27,7 @@ For rotation, publish the replacement certificate or key before changing the ser ### Development Certificates -The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. `HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper. +The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. The lower-level `mtp_transport::HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper. Self-signed certificates are for development. Production deployments should use a certificate trusted by the client or an explicitly pinned certificate. @@ -212,6 +212,7 @@ The crate's feature groups are: | `serde` | Serialization support for key types | | `wasm` | `getrandom` support for WebAssembly | | `tls` | Development certificate generation | +| `password-kdf` | Argon2id password derivation for protected keyring files | The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `XChaCha20Poly1305` (with the legacy `ChaCha20Poly1305` alias), `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6842c6f..ad83501 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -59,7 +59,7 @@ When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must b **Prevention:** Treat generated type maps as versioned build artifacts. -`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. Select the negotiated type map and do not send an unmapped variant. +`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. `MissingTypeMap` means a versioned value lost its retained negotiated map; `TypeMapMismatch` means it was combined with a value or codec for another version. Select the negotiated type map and do not send an unmapped variant. `ReservedCommunicationType` means application code attempted to use a reserved wire ID. Use generated communication types instead of assigning protocol IDs manually. `MissingField` means a required typed field was not present. diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index 492cf89..28ab748 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -1,8 +1,14 @@ # Type Map -This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml). -A type musn't be the version of MTP, it stays independant. -MTP version defines the codec. The Type-Map version defines the available Types. +This file documents the type-map and registry configuration used by MTP. The +repository workspace uses [`example/type-maps.yaml`](../example/type-maps.yaml) +through [`.cargo/config.toml`](../.cargo/config.toml); that map currently +selects protocol version 3.0. The root [`example-type-maps.yaml`](../example-type-maps.yaml) +is a separate illustrative multi-version configuration used by the manual WASM +build script. Downstream applications should provide their own map. + +The protocol version selects the generated codec/type-map build, while the +type-map entries define the available application types and their IDs. ## Binary Frame Format @@ -131,7 +137,7 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum use mtp::type_map::{CommunicationType, DataType, TypeMap}; let tm = TypeMap::v3_0(); -let id = tm.data_id_enum(DataType::SomeType).unwrap(); +let id = tm.data_id_enum(DataType::ExampleText).unwrap(); ``` For native builds with the `registry` feature, the enums are a **union across @@ -145,30 +151,31 @@ compiled by the Vite plugin. Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: ```rust -use mtp::codec::{encode, decode, DataValue}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::type_map::TypeMap; -let tm = TypeMap::v2_0(); -let value = DataValue::Str("hello".into()); +let tm = TypeMap::v3_0(); +let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm) + .add_typed(DataType::Description, &tm, DataValue::Str("hello".into())); -let bytes = encode(&value, &tm).unwrap(); -let decoded = decode(&bytes, &tm).unwrap(); +let bytes = value.to_bytes().unwrap(); +let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap(); ``` ```rust let tm_v3 = TypeMap::v3_0(); -assert!(tm_v3.data_id_enum(DataType::SomeType).is_some()); +assert!(tm_v3.data_id_enum(DataType::ExampleText).is_some()); ``` -When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. The self-delimiting codec begins at protocol version `3.0`; older versions are not codec fallbacks. +When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. The current repository map uses the self-delimiting codec format for protocol version `3.0`; a custom registry may register other version numbers, but those maps are not legacy wire-format fallbacks. ### Forward/Backward Compatibility Between Versions Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version: ``` -v3.0 client sends DataType::SomeType → host encodes with v3.0 TypeMap → wire ID 32 -v3.0 host receives an unsupported pre-v3.0 peer → version negotiation error +v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43 +v3.0 host receives a version absent from the registry → version negotiation error ``` Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it. diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 8f4c0eb..02be900 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -602,8 +602,19 @@ The SDK logger receives parsed events: ```typescript type MTPLogEvent = - | { hint: "info" | "warning"; type: string; data: unknown } - | { hint: "error"; type: string | "error"; error: string }; + | { + hint: "info" | "warning"; + type: string; + data: unknown; + direction?: "send" | "recv"; + } + | { + hint: "error"; + type: string | "error"; + error: string; + data?: unknown; + direction?: "send" | "recv"; + }; ``` Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`. From a5c8d4f0c898c78351e9d54124886c86e789a22a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 13:17:05 +0200 Subject: [PATCH 03/18] Update ci.yml --- .forgejo/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index ef9d7f8..70e6c39 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: cargo machete pnpm install --frozen-lockfile + pnpm add --save-dev --save-exact --workspace-root jscpd-linux-x64-gnu@5.0.14 pnpm run dup RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm From 2b0bdc32574a01c56c9245054ba8159d18aad8cf Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:11:08 +0200 Subject: [PATCH 04/18] [Fix] Connections --- mtp-webserver/src/h3.rs | 5 ++ mtp-webserver/src/transport.rs | 75 +++++++++++++++++++++++++---- transport/src/generic_connection.rs | 15 ++++++ wasm/src/client/authentication.rs | 41 ++++++++++++++++ 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/mtp-webserver/src/h3.rs b/mtp-webserver/src/h3.rs index c09379c..abb17aa 100644 --- a/mtp-webserver/src/h3.rs +++ b/mtp-webserver/src/h3.rs @@ -143,6 +143,11 @@ pub(crate) async fn run_driver( return; } }; + tracing::debug!( + remote = %remote_addr, + session_id = ?session.session_id(), + "accepted WebTransport MTP session" + ); tokio::spawn(run_session_requests( session.clone(), router.clone(), diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index a484f26..31d8376 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -32,6 +32,8 @@ pub struct H3TransportSender { pub struct H3TransportReceiver { stream: H3RecvStream, + quinn: quinn::Connection, + read_exact_calls: u64, } impl H3TransportConnection { @@ -76,18 +78,42 @@ impl TransportSendStream for H3TransportSender { #[async_trait::async_trait] impl TransportRecvStream for H3TransportReceiver { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { + let first_read = self.read_exact_calls == 0; + self.read_exact_calls += 1; self.stream .read_exact(buf) .await - .map(|_| ()) + .map(|_| { + if first_read { + tracing::debug!( + remote = %self.quinn.remote_address(), + bytes = buf.len(), + "received first bytes from WebTransport MTP stream" + ); + } + }) .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - // Browser control frames are sent on one-frame uni streams. - // Reaching FIN while looking for another frame is normal. + if error.kind() == std::io::ErrorKind::UnexpectedEof + || self.quinn.close_reason().is_some() + { + /* + * Reaching FIN, or losing the enclosing QUIC connection, + * is a normal stream-closure path. Do not turn it into a + * frame-header failure and close the connection again. + */ return CommunicationError::StreamClosed; } - error!("[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", buf.len()); - tracing::warn!(len = buf.len(), %error, "WebTransport receive stream read_exact failed"); + error!( + "[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", + buf.len() + ); + tracing::warn!( + remote = %self.quinn.remote_address(), + first_read, + len = buf.len(), + %error, + "WebTransport receive stream read_exact failed" + ); CommunicationError::StreamError }) } @@ -101,6 +127,9 @@ impl TransportRecvStream for H3TransportReceiver { Ok(Some(buf)) } Err(error) => { + if self.quinn.close_reason().is_some() { + return Err(CommunicationError::StreamClosed); + } error!( "[mtp-webserver] receive stream read failed (max {} bytes): {error}", max @@ -167,10 +196,27 @@ impl TransportConnection for H3TransportConnection { loop { match self.session.accept_uni().await { Ok(Some((id, stream))) if id == self.session.session_id() => { - return Ok(H3TransportReceiver { stream }); + let stream_id = h3::quic::RecvStream::recv_id(&stream); + tracing::debug!( + remote = %self.quinn.remote_address(), + session_id = ?self.session.session_id(), + stream_id = ?stream_id, + "accepted WebTransport MTP receive stream" + ); + return Ok(H3TransportReceiver { + stream, + quinn: self.quinn.clone(), + read_exact_calls: 0, + }); } - Ok(Some(_)) => { + Ok(Some((stream_session_id, _stream))) => { consecutive_errors = 0; + tracing::debug!( + remote = %self.quinn.remote_address(), + session_id = ?self.session.session_id(), + stream_session_id = ?stream_session_id, + "ignored WebTransport receive stream belonging to another session" + ); continue; } Ok(None) => return Err(CommunicationError::StreamClosed), @@ -306,7 +352,18 @@ async fn accept_web_connection_inner( connection_id, }, ) - .await?; + .await; + #[cfg(feature = "crypto")] + if let Err(error) = &result { + tracing::warn!( + remote = %remote_addr, + connection_id, + %error, + "WebTransport MTP handshake failed" + ); + } + #[cfg(feature = "crypto")] + let result = result?; #[cfg(not(feature = "crypto"))] let result = engine.accept(&sender, &receiver).await?; diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 23f3fe9..4fa6433 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -333,6 +333,18 @@ impl GenericReceiver { break 'stream; } Err(_) => { + if frames == 0 { + tracing::warn!( + timeout = ?policy.read_timeout, + "MTP receive stream timed out before its first complete frame" + ); + } else { + tracing::debug!( + frames, + timeout = ?policy.read_timeout, + "MTP receive stream idle timeout" + ); + } break; } } @@ -376,6 +388,9 @@ impl GenericReceiver { ) .await; if !matches!(&body_read, Ok(Ok(()))) { + if matches!(&body_read, Ok(Err(CommunicationError::StreamClosed))) { + break 'stream; + } tracing::warn!( pipe_chunk_len = chunk_len, ?body_read, diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs index 45cec8d..df7989e 100644 --- a/wasm/src/client/authentication.rs +++ b/wasm/src/client/authentication.rs @@ -8,6 +8,14 @@ use crate::config::ConnectionConfig; use crate::error::js_error; use crate::transport::WasmTransport; +fn server_rejection_message(outcome: &CommunicationValue) -> Option<&str> { + (outcome.get_data(DataType::Connected) == Some(&DataValue::BoolFalse)).then(|| { + outcome + .get_str(DataType::ErrorMessage) + .unwrap_or("host rejected the connection") + }) +} + #[wasm_bindgen] #[allow(deprecated)] impl WasmClient { @@ -79,6 +87,18 @@ impl WasmClient { .unwrap_or("host does not support this protocol version"), )); } + + // Generic host rejections are IdentificationResponse frames with + // Connected=false. They intentionally do not carry a negotiated + // Version because negotiation never completed. Check this before + // reading Version, otherwise a useful server error such as an + // authentication timeout is reported as the misleading + // "host omitted a valid negotiated protocol version". + if let Some(message) = server_rejection_message(&outcome) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error(message)); + } + let negotiated_version = match outcome.get_data(DataType::Version) { Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) .ok_or_else(|| js_error("host omitted a valid negotiated protocol version"))?, @@ -628,3 +648,24 @@ impl WasmClient { Ok(server_challenge) } } + +#[cfg(test)] +mod tests { + use super::server_rejection_message; + use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + + #[test] + fn reports_rejection_reason_without_a_negotiated_version() { + let response = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_typed_default(DataType::Connected, DataValue::BoolFalse) + .add_typed_default( + DataType::ErrorMessage, + DataValue::Str("authentication handshake timed out".into()), + ); + + assert_eq!( + server_rejection_message(&response), + Some("authentication handshake timed out") + ); + } +} From bd660b2afbf08bb1aa3fef9cf93a11f3bdafa1d4 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:37:25 +0200 Subject: [PATCH 05/18] [Debug] --- host/src/engine.rs | 22 +++++++++ mtp-webserver/src/transport.rs | 1 + transport/src/generic_connection.rs | 6 +++ wasm/src/transport.rs | 70 +++++++++++++++-------------- 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/host/src/engine.rs b/host/src/engine.rs index 38e8422..d1ebd19 100644 --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -217,6 +217,12 @@ impl HandshakeEngine { _authentication_context: &AuthenticationContext, ) -> Result { let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; + tracing::debug!( + message_type = ?first_msg.get_type(), + version = ?first_msg.get_str(DataType::Version), + client_id = ?first_msg.get_data(DataType::Id), + "received MTP opening message" + ); let version_str = match first_msg.get_data(DataType::Version) { Some(DataValue::Str(s)) => s.clone(), @@ -271,6 +277,11 @@ impl HandshakeEngine { return Err(AcceptError::UnsupportedVersion(client_version)); } }; + tracing::debug!( + client_version = %client_version, + negotiated_version = %negotiated, + "MTP protocol version negotiated" + ); let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) .ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?; @@ -1105,6 +1116,12 @@ async fn send_rejection_generic( .add_typed_default(DataType::Connected, DataValue::BoolFalse) .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())), }; + tracing::debug!( + reason = %reason, + response_type = ?response.get_type(), + has_version = response.get_data(DataType::Version).is_some(), + "sending MTP handshake rejection" + ); let _ = sender.send(&response).await; } @@ -1138,6 +1155,11 @@ async fn send_accepted_generic( if let Some(id) = assigned_id { response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128)); } + tracing::debug!( + version = %version, + assigned_id = ?assigned_id, + "sending accepted MTP handshake response" + ); sender.send(&response).await?; sender.finish_stream().await } diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 31d8376..9b7de76 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -88,6 +88,7 @@ impl TransportRecvStream for H3TransportReceiver { tracing::debug!( remote = %self.quinn.remote_address(), bytes = buf.len(), + header = ?buf, "received first bytes from WebTransport MTP stream" ); } diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 4fa6433..830fb40 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -423,6 +423,12 @@ impl GenericReceiver { break; } }; + tracing::debug!( + frames, + frame_len, + message_type = ?message.get_type(), + "decoded MTP receive frame" + ); let negotiated_type_map = type_map.read().await.clone(); message.set_type_map(&negotiated_type_map); diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index a4deea6..dc0091e 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -128,8 +128,6 @@ pub struct WasmTransport { buffer: Rc>>, /// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed. new_stream_frame: Rc>, - /// A single ordered browser send stream shared by all cloned transports. - outgoing_writer: Rc>>, /// Serializes stream creation and writes across concurrent callers. send_lock: Rc>, type_map: Rc>, @@ -209,7 +207,6 @@ impl WasmTransport { stream_reader: Rc::new(RefCell::new(None)), buffer: Rc::new(RefCell::new(Vec::new())), new_stream_frame: Rc::new(Cell::new(false)), - outgoing_writer: Rc::new(RefCell::new(None)), send_lock: Rc::new(AsyncMutex::new(())), type_map: Rc::new(RefCell::new(TypeMap::latest())), decode_limits: Rc::new(RefCell::new(decode_limits)), @@ -247,30 +244,30 @@ impl WasmTransport { return Err(js_error("message too large")); } - let writer_val = if let Some(writer) = self.outgoing_writer.borrow().clone() { - writer - } else { - let create_stream = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("createUnidirectionalStream"), - )? + // Use one WebTransport uni-stream per MTP frame. Chromium reliably + // publishes a browser-created uni-stream to the peer when it is + // closed; leaving a shared stream open can leave the server waiting + // in accept_uni() until the authentication deadline. The bytes are + // already the canonical MTP self-framed value, so no extra stream + // length prefix is added here. + let create_stream = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("createUnidirectionalStream"), + )? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream not a function"))?; + let stream_promise = create_stream + .call0(&self.inner)? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; + let stream = JsFuture::from(stream_promise).await?; + let writable_or_stream = resolve_stream_writable(&stream)?; + let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream not a function"))?; - let stream_promise = create_stream - .call0(&self.inner)? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; - let stream = JsFuture::from(stream_promise).await?; - let writable_or_stream = resolve_stream_writable(&stream)?; - let writer = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) - .map_err(|_| js_error("missing getWriter"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; - *self.outgoing_writer.borrow_mut() = Some(writer.clone()); - writer - }; + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable_or_stream) + .map_err(|_| js_error("getWriter call failed"))?; let chunk = js_sys::Uint8Array::from(frame); @@ -283,11 +280,24 @@ impl WasmTransport { .map_err(|e| js_error(format!("write failed: {:?}", e)))?; if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { log_stream_error_code(&e, "send_frame write"); - self.outgoing_writer.borrow_mut().take(); release_writer_lock(&writer_val); return Err(e); } + let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close")) + .map_err(|_| js_error("missing close"))? + .dyn_into::() + .map_err(|_| js_error("close not a function"))?; + let close_promise = close_fn + .call0(&writer_val) + .map_err(|e| js_error(format!("close failed: {:?}", e)))?; + if let Err(e) = JsFuture::from(close_promise.unchecked_into::()).await { + // The frame was already written; do not retry it merely because + // FIN failed, as that would duplicate the MTP frame. + log_stream_error_code(&e, "send_frame close"); + } + release_writer_lock(&writer_val); + Ok(()) } @@ -677,12 +687,6 @@ impl WasmTransport { } pub fn close(&self) { - if let Some(writer) = self.outgoing_writer.borrow_mut().take() { - // The WebTransport session close below terminates the stream. The - // lock must be released first so dropping it is not interpreted as - // an application abort. - release_writer_lock(&writer); - } // Release reader locks before closing so they aren't treated as cancels. if let Some(reader) = self.stream_reader.borrow_mut().take() { release_reader_lock(&reader); From 4c10b56a6cccf90c48a9716ab330b7ea57785ad2 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 20 Aug 2026 20:55:43 +0200 Subject: [PATCH 06/18] Update workflows --- .forgejo/workflows/ci.yml | 4 ---- .forgejo/workflows/release.yml | 9 --------- 2 files changed, 13 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 523e360..5fb883b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,16 +7,12 @@ on: env: CARGO_TERM_COLOR: always - NIX_CONFIG: experimental-features = nix-command flakes jobs: checks: name: checks runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Checkout uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index fcda261..64d19ef 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -14,16 +14,10 @@ on: required: true type: string -env: - NIX_CONFIG: experimental-features = nix-command flakes - jobs: release: runs-on: nixos steps: - - name: Install node & bun - run: nix profile add nixpkgs#nodejs_24 nixpkgs#bun - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 with: @@ -32,9 +26,6 @@ jobs: - name: Install dependencies run: bun install - - name: Install cc linker, sed & jq - run: nix profile add nixpkgs#stdenv.cc nixpkgs#gnused nixpkgs#jq - - name: Build all run: bun build:all From 420831cd09428c9612935334fb5de2e49a85d904 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:42:23 +0200 Subject: [PATCH 07/18] [Add] Docs & patches --- host/src/engine.rs | 8 ++++++++ transport/src/generic_connection.rs | 11 ++++++++++- wasm/src/client/authentication.rs | 13 +++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) mode change 100644 => 100755 host/src/engine.rs diff --git a/host/src/engine.rs b/host/src/engine.rs old mode 100644 new mode 100755 index d1ebd19..08ca87f --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -309,6 +309,12 @@ impl HandshakeEngine { ) || registration || first_msg.get_data(DataType::PublicKeys).is_some() || claimed_client_id.is_some_and(|client_id| client_id != 0); + tracing::info!( + claimed_client_id = ?claimed_client_id, + registration, + authentication_requested, + "classified MTP opening authentication mode" + ); if authentication_requested { let attempt = crate::config::AuthenticationAttempt { peer_network_identity: _authentication_context.peer_network_identity.clone(), @@ -566,6 +572,7 @@ impl HandshakeEngine { } // Unknown or zero ID: fall back to guest + tracing::info!("allocating MTP guest identity"); let guest_id_lease = match self.assign_guest_id().await { Ok(lease) => lease, Err(error) => { @@ -574,6 +581,7 @@ impl HandshakeEngine { } }; let guest_id = guest_id_lease.id; + tracing::info!(guest_id, "allocated MTP guest identity"); send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) .await .map_err(AcceptError::Send)?; diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 830fb40..a7a32de 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -9,7 +9,7 @@ use crate::{ connection::{DecodeRejectionCounters, RuntimePolicy, classify_decode_error}, framing::{RetryClassifier, write_frame}, }; -use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; +use mtp_codec::{CommunicationValue, DataType, DecodeLimits, TypeMap}; use mtp_common::CommunicationError; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -67,6 +67,15 @@ impl GenericSender { if self.connection.close_reason().is_some() { return Err(CommunicationError::StreamClosed); } + if let Some(version) = value.get_str(DataType::Version) { + tracing::debug!( + message_type = ?value.get_type(), + version, + connected = ?value.get_data(DataType::Connected), + client_id = ?value.get_data(DataType::Id), + "sending MTP handshake response frame" + ); + } match self.policy.send_mode { crate::SendMode::SingleStreamPerMessage => { let mut stream = self.open().await?; diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs index df7989e..73bfc9f 100644 --- a/wasm/src/client/authentication.rs +++ b/wasm/src/client/authentication.rs @@ -99,10 +99,19 @@ impl WasmClient { return Err(js_error(message)); } + let missing_version = || { + js_error(format!( + "host omitted a valid negotiated protocol version (response_type={:?}, connected={:?}, frame_len={})", + outcome.get_type(), + outcome.get_data(DataType::Connected), + outcome_bytes.len(), + )) + }; + let negotiated_version = match outcome.get_data(DataType::Version) { Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) - .ok_or_else(|| js_error("host omitted a valid negotiated protocol version"))?, - _ => return Err(js_error("host omitted a valid negotiated protocol version")), + .ok_or_else(|| missing_version())?, + _ => return Err(missing_version()), }; if negotiated_version != PROTOCOL_VERSION { return Err(js_error( From 101b8322a1eb76490fee70c4606c7ded18327fe9 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:59:57 +0200 Subject: [PATCH 08/18] [Fix] Policy overwrites --- host/src/config.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/host/src/config.rs b/host/src/config.rs index 0cef52a..99d62a2 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -251,6 +251,8 @@ pub struct HostConfig { #[cfg(feature = "crypto")] pub authentication_policy: AuthenticationPolicy, #[cfg(feature = "crypto")] + authentication_policy_explicit: bool, + #[cfg(feature = "crypto")] pub auth_timeout: Duration, #[cfg(feature = "crypto")] pub require_pq: bool, @@ -288,6 +290,8 @@ impl HostConfig { #[cfg(feature = "crypto")] authentication_policy: AuthenticationPolicy::Unauthenticated, #[cfg(feature = "crypto")] + authentication_policy_explicit: false, + #[cfg(feature = "crypto")] auth_timeout: Duration::from_secs(30), #[cfg(feature = "crypto")] require_pq: true, @@ -341,7 +345,9 @@ impl HostConfig { get_existing_client: GetExistingClient, complete_register: CompleteRegister, ) -> Self { - self.authentication_policy = AuthenticationPolicy::ForceAuthentication; + if !self.authentication_policy_explicit { + self.authentication_policy = AuthenticationPolicy::ForceAuthentication; + } self.host_keyring = host_keyring; self.get_existing_client = Box::new(get_existing_client); self.complete_register = Box::new(complete_register); @@ -351,6 +357,7 @@ impl HostConfig { #[cfg(feature = "crypto")] pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self { self.authentication_policy = policy; + self.authentication_policy_explicit = true; self } @@ -441,4 +448,78 @@ mod tests { .expect("repeated registration decision") ); } + + fn test_keyring() -> mtp_crypto::Keyring { + mtp_crypto::Keyring::new( + mtp_crypto::KemPublicKey::new(Vec::new()), + mtp_crypto::KemPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePqPublicKey::new(Vec::new()), + mtp_crypto::SignaturePqPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePublicKey::new(Vec::new()), + mtp_crypto::SignaturePrivateKey::new(Vec::new()), + ) + } + + fn test_get_existing_client() -> GetExistingClient { + Box::new(|_, _| Box::pin(async { None })) + } + + fn test_complete_register() -> CompleteRegister { + Box::new(|_, _| Box::pin(async { 1 })) + } + + fn test_config() -> HostConfig { + HostConfig::new( + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + 4433, + Vec::new(), + Vec::new(), + ) + } + + #[test] + fn with_authentication_defaults_to_force_authentication() { + let config = test_config().with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::ForceAuthentication + ); + } + + #[test] + fn explicit_authentication_policy_before_with_authentication_is_preserved() { + let config = test_config() + .with_authentication_policy(AuthenticationPolicy::AllowAuthentication) + .with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::AllowAuthentication + ); + } + + #[test] + fn explicit_authentication_policy_after_with_authentication_is_preserved() { + let config = test_config() + .with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ) + .with_authentication_policy(AuthenticationPolicy::AllowAuthentication); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::AllowAuthentication + ); + } } From e83cd132a22b8a230dd35b21cb17d10af27f3c7c Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 15:31:55 +0200 Subject: [PATCH 09/18] feat(wasm, native, h3): make wasm, native and h3 use unified interface --- client/src/pipe.rs | 42 ++- common/src/lib.rs | 36 ++ host/src/connection.rs | 4 +- host/src/pipe.rs | 81 ++++- mtp-webserver/src/transport.rs | 14 +- transport/src/connection.rs | 122 +++++-- transport/src/framing.rs | 4 + transport/src/generic_connection.rs | 112 ++++-- transport/src/transport_traits.rs | 26 +- transport/tests/generic_pipe.rs | 39 +- wasm/src/client/receive.rs | 7 + wasm/src/pipe.rs | 102 +++--- wasm/src/transport.rs | 546 +++++++++++++++------------- 13 files changed, 737 insertions(+), 398 deletions(-) diff --git a/client/src/pipe.rs b/client/src/pipe.rs index 8e839dd..136fe01 100644 --- a/client/src/pipe.rs +++ b/client/src/pipe.rs @@ -78,9 +78,41 @@ pub struct PipeRequest { pub(crate) pipe_id: u32, pub(crate) description: String, pub(crate) sender: Sender, + pub(crate) receiver: Receiver, pub(crate) dispatcher: Arc, } +#[cfg(feature = "pipes")] +struct ExpectedPipeGuard { + receiver: Receiver, + pipe_id: u32, + armed: bool, +} + +#[cfg(feature = "pipes")] +impl ExpectedPipeGuard { + fn new(receiver: Receiver, pipe_id: u32) -> Self { + Self { + receiver, + pipe_id, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(feature = "pipes")] +impl Drop for ExpectedPipeGuard { + fn drop(&mut self) { + if self.armed { + self.receiver.cancel_expected_pipe(self.pipe_id); + } + } +} + #[cfg(feature = "pipes")] impl PipeRequest { pub fn id(&self) -> u32 { @@ -92,6 +124,10 @@ impl PipeRequest { } pub async fn accept(self) -> Result { + self.receiver + .expect_pipe(self.pipe_id) + .map_err(PipeError::from)?; + let mut expected_pipe = ExpectedPipeGuard::new(self.receiver.clone(), self.pipe_id); let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel(); { let mut pending = self.dispatcher.pending_pipes.lock().await; @@ -115,7 +151,10 @@ impl PipeRequest { let timeout = self.dispatcher.policy.read_timeout; match tokio::time::timeout(timeout, pipe_rx).await { - Ok(Ok(reader)) => Ok(reader), + Ok(Ok(reader)) => { + expected_pipe.disarm(); + Ok(reader) + } Ok(Err(_)) => { self.dispatcher .pending_pipes @@ -413,6 +452,7 @@ pub(crate) async fn run_dispatcher( pipe_id, description, sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(req).await; diff --git a/common/src/lib.rs b/common/src/lib.rs index 1a7fe66..71e87f2 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -164,6 +164,9 @@ pub enum CommunicationError { #[error("Stream Error")] StreamError, + #[error("Stream failed after delivery may have started")] + DeliveryUnknown, + #[error("Stream Error: {0}")] #[cfg(not(target_arch = "wasm32"))] StreamWriteError(#[from] wtransport::error::StreamWriteError), @@ -182,6 +185,38 @@ pub enum CommunicationError { Other(String), } +/// How the protocol layer should handle the first frame on a receive stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FirstFrameDisposition { + Message, + Pipe(u32), +} + +/// Classify a first frame without tying the decision to a WebTransport backend. +/// +/// `PipeRequest` is used both as a control message and as the header of the raw +/// stream opened after that request is accepted. Only the protocol layer knows +/// which raw stream IDs are currently expected. +pub fn classify_first_frame( + is_pipe_request: bool, + pipe_id: Option, + pipe_is_expected: bool, +) -> Result { + if !is_pipe_request { + return Ok(FirstFrameDisposition::Message); + } + + let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| { + CommunicationError::Other("PipeRequest frame must contain a non-zero id".into()) + })?; + + if pipe_is_expected { + Ok(FirstFrameDisposition::Pipe(pipe_id)) + } else { + Ok(FirstFrameDisposition::Message) + } +} + // ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ---- impl PartialEq for CommunicationError { @@ -212,6 +247,7 @@ impl PartialEq for CommunicationError { (Self::ReadExactError(_), Self::ReadExactError(_)) => true, (Self::StreamClosed, Self::StreamClosed) => true, (Self::StreamError, Self::StreamError) => true, + (Self::DeliveryUnknown, Self::DeliveryUnknown) => true, #[cfg(not(target_arch = "wasm32"))] (Self::StreamWriteError(_), Self::StreamWriteError(_)) => true, #[cfg(not(target_arch = "wasm32"))] diff --git a/host/src/connection.rs b/host/src/connection.rs index 71c60db..7871d55 100644 --- a/host/src/connection.rs +++ b/host/src/connection.rs @@ -73,7 +73,7 @@ pub struct MTPConnection< #[cfg(feature = "pipes")] pub(crate) app_rx: Mutex>>, #[cfg(feature = "pipes")] - pub(crate) pipe_req_rx: Mutex>>, + pub(crate) pipe_req_rx: Mutex>>, #[cfg(feature = "pipes")] pub(crate) pipe_dispatcher: Arc>, #[cfg(not(feature = "pipes"))] @@ -381,7 +381,7 @@ where }) } - pub async fn receive_pipe(&self) -> Result, CommunicationError> { + pub async fn receive_pipe(&self) -> Result, CommunicationError> { self.pipe_req_rx .lock() .await diff --git a/host/src/pipe.rs b/host/src/pipe.rs index eae1383..192e3d7 100644 --- a/host/src/pipe.rs +++ b/host/src/pipe.rs @@ -27,6 +27,10 @@ pub trait PipeReceiver

: Clone + Send + Sync + 'static where P: tokio::io::AsyncRead + Send + Unpin + 'static, { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError>; + + fn cancel_expected_pipe(&self, pipe_id: u32); + fn receive_pipe_event( &self, ) -> impl std::future::Future, CommunicationError>> + Send; @@ -52,6 +56,14 @@ impl PipeSender for mtp_transport::Sender { } impl PipeReceiver for mtp_transport::Receiver { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + self.expect_pipe(pipe_id) + } + + fn cancel_expected_pipe(&self, pipe_id: u32) { + self.cancel_expected_pipe(pipe_id); + } + async fn receive_pipe_event( &self, ) -> Result, CommunicationError> { @@ -87,6 +99,14 @@ where C: mtp_transport::TransportConnection, C::RecvStream: tokio::io::AsyncRead + Send + Unpin + 'static, { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + self.expect_pipe(pipe_id) + } + + fn cancel_expected_pipe(&self, pipe_id: u32) { + self.cancel_expected_pipe(pipe_id); + } + async fn receive_pipe_event( &self, ) -> Result, CommunicationError> { @@ -152,16 +172,60 @@ where } } -pub struct PipeRequest { +pub struct PipeRequest { pub(crate) pipe_id: u32, pub(crate) description: String, pub(crate) sender: S, + pub(crate) receiver: R, pub(crate) dispatcher: Arc>, } -impl PipeRequest +struct ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + receiver: R, + pipe_id: u32, + armed: bool, + _stream: std::marker::PhantomData

, +} + +impl ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + fn new(receiver: R, pipe_id: u32) -> Self { + Self { + receiver, + pipe_id, + armed: true, + _stream: std::marker::PhantomData, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + fn drop(&mut self) { + if self.armed { + self.receiver.cancel_expected_pipe(self.pipe_id); + } + } +} + +impl PipeRequest where S: PipeSender, + R: PipeReceiver

, P: tokio::io::AsyncRead + Send + Unpin + 'static, { pub fn id(&self) -> u32 { @@ -173,6 +237,10 @@ where } pub async fn accept(self) -> Result, PipeError> { + self.receiver + .expect_pipe(self.pipe_id) + .map_err(PipeError::from)?; + let mut expected_pipe = ExpectedPipeGuard::::new(self.receiver.clone(), self.pipe_id); let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel(); self.dispatcher .pending_pipes @@ -196,7 +264,10 @@ where } match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await { - Ok(Ok(reader)) => Ok(reader), + Ok(Ok(reader)) => { + expected_pipe.disarm(); + Ok(reader) + } Ok(Err(_)) => { self.dispatcher .pending_pipes @@ -361,7 +432,7 @@ pub(crate) async fn run_dispatcher( receiver: R, sender: S, app_tx: mpsc::Sender>, - pipe_req_tx: mpsc::Sender>, + pipe_req_tx: mpsc::Sender>, dispatcher: Arc>, ) where S: PipeSender, @@ -388,6 +459,7 @@ pub(crate) async fn run_dispatcher( .unwrap_or("") .to_owned(), sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(request).await; @@ -446,6 +518,7 @@ pub(crate) async fn run_dispatcher( pipe_id, description: reader.description().to_owned(), sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(request).await; diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 9b7de76..9dcce5f 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -57,14 +57,14 @@ impl TransportSendStream for H3TransportSender { self.stream .write_all(buf) .await - .map_err(|_| CommunicationError::StreamError)?; + .map_err(|_| CommunicationError::DeliveryUnknown)?; // Control/authentication frames use a persistent stream. h3 keeps // those writes buffered until flushed; without this the peer can wait // for the challenge while the server waits for its proof. self.stream .flush() .await - .map_err(|_| CommunicationError::StreamError) + .map_err(|_| CommunicationError::DeliveryUnknown) } async fn finish(&mut self) -> Result<(), CommunicationError> { @@ -73,6 +73,11 @@ impl TransportSendStream for H3TransportSender { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { + h3::quic::SendStream::reset(&mut self.stream, code as u64); + Ok(()) + } } #[async_trait::async_trait] @@ -140,6 +145,11 @@ impl TransportRecvStream for H3TransportReceiver { } } } + + fn stop(mut self, code: u32) -> Result<(), CommunicationError> { + h3::quic::RecvStream::stop_sending(&mut self.stream, code as u64); + Ok(()) + } } impl tokio::io::AsyncWrite for H3TransportSender { diff --git a/transport/src/connection.rs b/transport/src/connection.rs index dece604..7db09e8 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -4,6 +4,10 @@ use crate::framing::RetryClassifier; use crate::pipe::PipeReader; use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap}; use mtp_common::CommunicationError; +#[cfg(feature = "pipes")] +use mtp_common::{FirstFrameDisposition, classify_first_frame}; +#[cfg(feature = "pipes")] +use std::collections::HashSet; use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -288,15 +292,15 @@ impl Sender { Ok(Ok(())) => Ok(()), Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { warn!("[Sender] write failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::StreamClosed) + Err(CommunicationError::DeliveryUnknown) } Ok(Err(other)) => { warn!("[Sender] write failed: {other}"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } Err(_) => { warn!("[Sender] write timed out (len={})", bytes.len()); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } } } @@ -398,15 +402,15 @@ impl Sender { Ok(Ok(())) => Ok(()), Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { warn!("[Sender] finish failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::StreamClosed) + Err(CommunicationError::DeliveryUnknown) } Ok(Err(other)) => { warn!("[Sender] finish failed: {other}"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } Err(_) => { warn!("[Sender] finish timed out"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } } } @@ -745,6 +749,8 @@ struct ReceiverInner { max_message_size: Arc, type_map: Arc>, decode_rejections: Arc, + #[cfg(feature = "pipes")] + expected_pipes: Arc>>, } impl Clone for Receiver { @@ -831,6 +837,10 @@ impl Receiver { let accept_type_map = type_map.clone(); let decode_rejections = Arc::new(DecodeRejectionCounters::default()); let accept_decode_rejections = decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = Arc::new(std::sync::Mutex::new(HashSet::new())); + #[cfg(feature = "pipes")] + let accept_expected_pipes = expected_pipes.clone(); let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); let accept_stream_limit = stream_limit.clone(); debug!( @@ -900,6 +910,8 @@ impl Receiver { let stream_max_message_size = accept_max_message_size.clone(); let stream_type_map = accept_type_map.clone(); let stream_decode_rejections = accept_decode_rejections.clone(); + #[cfg(feature = "pipes")] + let stream_expected_pipes = accept_expected_pipes.clone(); tokio::spawn(async move { let _permit = permit; @@ -935,38 +947,54 @@ impl Receiver { #[cfg(feature = "pipes")] { - if msg.is_type(mtp_codec::CommunicationType::PipeRequest) - && frame_count == 1 - { - let Some(pipe_id) = msg.id().filter(|id| *id != 0) else { - let error = CommunicationError::Other( - "PipeRequest frame must contain a non-zero id".into(), - ); - let _ = msg_tx_stream.send(Err(error.clone())).await; - stream_handle.close(Some(error)); + if frame_count == 1 { + let is_pipe_request = msg.is_type( + mtp_codec::CommunicationType::PipeRequest, + ); + let pipe_id = msg.id().filter(|id| *id != 0); + let pipe_is_expected = is_pipe_request && pipe_id.is_some_and(|pipe_id| { + stream_expected_pipes + .lock() + .is_ok_and(|mut expected| expected.remove(&pipe_id)) + }); + let disposition = match classify_first_frame( + is_pipe_request, + msg.id(), + pipe_is_expected, + ) { + Ok(disposition) => disposition, + Err(error) => { + let _ = msg_tx_stream + .send(Err(error.clone())) + .await; + stream_handle.close(Some(error)); + break; + } + }; + + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = msg + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); + + let pipe_reader = crate::pipe::PipeReader { + stream: s, + description, + pipe_id, + }; + + if pipe_tx_stream + .send(pipe_reader) + .await + .is_err() + { + stream_handle.close(Some( + CommunicationError::StreamClosed, + )); + } break; - }; - let description = msg - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pipe_reader = crate::pipe::PipeReader { - stream: s, - description, - pipe_id, - }; - - if pipe_tx_stream - .send(pipe_reader) - .await - .is_err() - { - stream_handle.close(Some( - CommunicationError::StreamClosed, - )); } - break; } } @@ -1111,6 +1139,8 @@ impl Receiver { max_message_size, type_map, decode_rejections, + #[cfg(feature = "pipes")] + expected_pipes, }), } } @@ -1127,6 +1157,26 @@ impl Receiver { *self.inner.type_map.write().await = type_map.clone(); } + #[cfg(feature = "pipes")] + pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + if pipe_id == 0 { + return Err(CommunicationError::Other("pipe id must be non-zero".into())); + } + self.inner + .expected_pipes + .lock() + .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? + .insert(pipe_id); + Ok(()) + } + + #[cfg(feature = "pipes")] + pub fn cancel_expected_pipe(&self, pipe_id: u32) { + if let Ok(mut expected) = self.inner.expected_pipes.lock() { + expected.remove(&pipe_id); + } + } + /// Return local counts for frames rejected by the structured decoder. /// /// These counters are intentionally local-only; peers continue to receive diff --git a/transport/src/framing.rs b/transport/src/framing.rs index 9fa1d80..ac4e005 100644 --- a/transport/src/framing.rs +++ b/transport/src/framing.rs @@ -82,6 +82,10 @@ mod tests { async fn finish(&mut self) -> Result<(), CommunicationError> { Ok(()) } + + fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } #[tokio::test] diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index a7a32de..4361855 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -10,8 +10,12 @@ use crate::{ framing::{RetryClassifier, write_frame}, }; use mtp_codec::{CommunicationValue, DataType, DecodeLimits, TypeMap}; -use mtp_common::CommunicationError; +use mtp_common::{CommunicationError, FirstFrameDisposition, classify_first_frame}; +#[cfg(feature = "pipes")] +use std::collections::HashSet; use std::sync::Arc; +#[cfg(feature = "pipes")] +use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; use tokio::time::{Instant, timeout, timeout_at}; @@ -85,9 +89,10 @@ impl GenericSender { ) .await .map_err(|_| CommunicationError::StreamError)??; - timeout(self.policy.write_timeout, stream.finish()) - .await - .map_err(|_| CommunicationError::StreamError)? + match timeout(self.policy.write_timeout, stream.finish()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) | Err(_) => Err(CommunicationError::DeliveryUnknown), + } } crate::SendMode::PersistentStream => { let mut stream = self.persistent.lock().await; @@ -204,6 +209,8 @@ pub struct GenericReceiver { type_map: Arc>, queue_notify: Arc, decode_rejections: Arc, + #[cfg(feature = "pipes")] + expected_pipes: Arc>>, _accept_task: Arc>, } @@ -219,6 +226,8 @@ impl Clone for GenericReceiver { type_map: self.type_map.clone(), queue_notify: self.queue_notify.clone(), decode_rejections: self.decode_rejections.clone(), + #[cfg(feature = "pipes")] + expected_pipes: self.expected_pipes.clone(), _accept_task: self._accept_task.clone(), } } @@ -254,6 +263,10 @@ impl GenericReceiver { let task_queue_notify = queue_notify.clone(); let decode_rejections = Arc::new(DecodeRejectionCounters::default()); let task_decode_rejections = decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = Arc::new(StdMutex::new(HashSet::new())); + #[cfg(feature = "pipes")] + let task_expected_pipes = expected_pipes.clone(); let task_accept_task_tx = tx.clone(); #[cfg(feature = "pipes")] let task_accept_task_pipe_tx = pipe_tx.clone(); @@ -312,6 +325,8 @@ impl GenericReceiver { let connection = task_connection.clone(); let type_map = task_type_map.clone(); let decode_rejections = task_decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = task_expected_pipes.clone(); tokio::spawn(async move { let _permit = permit; let mut stream = stream; @@ -443,37 +458,51 @@ impl GenericReceiver { #[cfg(feature = "pipes")] { - if message.is_type(mtp_codec::CommunicationType::PipeRequest) - && frames == 1 - { - let Some(pipe_id) = message.id().filter(|id| *id != 0) else { - let error = CommunicationError::Other( - "PipeRequest frame must contain a non-zero id".into(), - ); - let _ = tx.send(Err(error.clone())).await; - connection.close( - policy.application_close_code, - b"pipe request missing id", - ); - break; - }; - let description = message - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pipe_reader = PipeReader { - stream, - description, - pipe_id, + if frames == 1 { + let is_pipe_request = + message.is_type(mtp_codec::CommunicationType::PipeRequest); + let pipe_id = message.id().filter(|id| *id != 0); + let pipe_is_expected = is_pipe_request + && pipe_id.is_some_and(|pipe_id| { + expected_pipes + .lock() + .is_ok_and(|mut expected| expected.remove(&pipe_id)) + }); + let disposition = match classify_first_frame( + is_pipe_request, + message.id(), + pipe_is_expected, + ) { + Ok(disposition) => disposition, + Err(error) => { + let _ = tx.send(Err(error.clone())).await; + connection.close( + policy.application_close_code, + b"pipe request missing id", + ); + break; + } }; - tracing::debug!(pipe_id, description = %pipe_reader.description, "classified incoming pipe stream"); + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = message + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); - if pipe_tx.send(pipe_reader).await.is_err() { - break; + let pipe_reader = PipeReader { + stream, + description, + pipe_id, + }; + + tracing::debug!(pipe_id, description = %pipe_reader.description, "classified incoming pipe stream"); + + if pipe_tx.send(pipe_reader).await.is_err() { + break; + } + return; } - return; } } @@ -517,6 +546,8 @@ impl GenericReceiver { type_map, queue_notify, decode_rejections, + #[cfg(feature = "pipes")] + expected_pipes, _accept_task: Arc::new(accept_task), } } @@ -524,6 +555,25 @@ impl GenericReceiver { *self.ping_sender.write().await = Some(sender); } + #[cfg(feature = "pipes")] + pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + if pipe_id == 0 { + return Err(CommunicationError::Other("pipe id must be non-zero".into())); + } + self.expected_pipes + .lock() + .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? + .insert(pipe_id); + Ok(()) + } + + #[cfg(feature = "pipes")] + pub fn cancel_expected_pipe(&self, pipe_id: u32) { + if let Ok(mut expected) = self.expected_pipes.lock() { + expected.remove(&pipe_id); + } + } + /// Switch from the handshake frame limit to the application frame limit. pub fn set_max_message_size(&self, max_message_size: u64) { self.max_message_size diff --git a/transport/src/transport_traits.rs b/transport/src/transport_traits.rs index 63c0268..70395af 100644 --- a/transport/src/transport_traits.rs +++ b/transport/src/transport_traits.rs @@ -17,6 +17,7 @@ use mtp_common::CommunicationError; pub trait TransportSendStream: tokio::io::AsyncWrite + Send + Sync { async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError>; async fn finish(&mut self) -> Result<(), CommunicationError>; + fn reset(&mut self, code: u32) -> Result<(), CommunicationError>; } /// A readable unidirectional stream suitable for MTP frames. @@ -28,6 +29,9 @@ pub trait TransportSendStream: tokio::io::AsyncWrite + Send + Sync { pub trait TransportRecvStream: tokio::io::AsyncRead + Send + Sync { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError>; async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError>; + fn stop(self, code: u32) -> Result<(), CommunicationError> + where + Self: Sized; } /// A QUIC/WebTransport connection that provides MTP's unidirectional streams. @@ -47,7 +51,7 @@ impl TransportSendStream for wtransport::SendStream { async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { wtransport::SendStream::write_all(self, buf) .await - .map_err(|_| CommunicationError::StreamError) + .map_err(|_| CommunicationError::DeliveryUnknown) } async fn finish(&mut self) -> Result<(), CommunicationError> { @@ -55,14 +59,23 @@ impl TransportSendStream for wtransport::SendStream { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { + wtransport::SendStream::reset(self, wtransport::VarInt::from_u32(code)) + .map_err(|_| CommunicationError::StreamClosed) + } } #[async_trait] impl TransportRecvStream for wtransport::RecvStream { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { - wtransport::RecvStream::read_exact(self, buf) - .await - .map_err(|_| CommunicationError::StreamError) + match wtransport::RecvStream::read_exact(self, buf).await { + Ok(()) => Ok(()), + Err(wtransport::error::StreamReadExactError::FinishedEarly(0)) => { + Err(CommunicationError::StreamClosed) + } + Err(_) => Err(CommunicationError::StreamError), + } } async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError> { @@ -76,6 +89,11 @@ impl TransportRecvStream for wtransport::RecvStream { Err(_) => Err(CommunicationError::StreamError), } } + + fn stop(self, code: u32) -> Result<(), CommunicationError> { + wtransport::RecvStream::stop(self, wtransport::VarInt::from_u32(code)); + Ok(()) + } } #[async_trait] diff --git a/transport/tests/generic_pipe.rs b/transport/tests/generic_pipe.rs index 81a875f..7dfb538 100644 --- a/transport/tests/generic_pipe.rs +++ b/transport/tests/generic_pipe.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use mtp_codec::CommunicationValue; use mtp_common::CommunicationError; use mtp_transport::{ - GenericReceiver, GenericSender, Policy, TransportConnection, TransportEvent, + GenericReceiver, GenericSender, Policy, SendMode, TransportConnection, TransportEvent, TransportRecvStream, TransportSendStream, }; use std::sync::Arc; @@ -53,6 +53,10 @@ impl TransportSendStream for MockSendStream { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } struct MockRecvStream { @@ -89,6 +93,10 @@ impl TransportRecvStream for MockRecvStream { Err(_) => Err(CommunicationError::StreamError), } } + + fn stop(self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } #[derive(Clone)] @@ -157,6 +165,7 @@ async fn test_open_pipe_and_receive_reader() -> Result<(), Box Result<(), Box let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(1)?; let mut pipe_writer = sender.open_pipe(1, "data-pipe").await?; let data = b"hello through the pipe"; @@ -195,6 +205,7 @@ async fn test_pipe_large_payload() -> Result<(), Box> { let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(7)?; let mut pipe_writer = sender.open_pipe(7, "big-pipe").await?; let data: Vec = (0..256 * 1024).map(|i| (i % 256) as u8).collect(); @@ -223,6 +234,7 @@ async fn test_receive_event_dispatches_pipe() -> Result<(), Box Result<(), Box Result<(), Box> { let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); + let policy = Arc::new(Policy::default().with_send_mode(SendMode::SingleStreamPerMessage)); let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); - let msg = CommunicationValue::new(mtp_codec::CommunicationType::BadRequest); - sender.send(&msg).await?; - - let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?; + let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) + .with_id(1) + .add_typed_default( + mtp_codec::DataType::Description, + mtp_codec::DataValue::Str("mixed-pipe".into()), + ); + sender.send(&request).await?; let received = receiver.receive().await?; - assert_eq!( - received.get_type(), - mtp_codec::CommunicationType::BadRequest - .try_to_id(&mtp_codec::TypeMap::latest()) - .unwrap() - ); + assert!(received.is_type(mtp_codec::CommunicationType::PipeRequest)); + + receiver.expect_pipe(1)?; + let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?; let pipe_reader = receiver.receive_pipe().await?; assert_eq!(pipe_reader.pipe_id(), 1); @@ -291,6 +304,8 @@ async fn test_multiple_pipes() -> Result<(), Box> { let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(10)?; + receiver.expect_pipe(20)?; let mut pw1 = sender.open_pipe(10, "first").await?; let mut pw2 = sender.open_pipe(20, "second").await?; diff --git a/wasm/src/client/receive.rs b/wasm/src/client/receive.rs index 35ceb02..51a48bf 100644 --- a/wasm/src/client/receive.rs +++ b/wasm/src/client/receive.rs @@ -179,6 +179,7 @@ impl WasmClient { let expired_pipe_creations = self.expired_pipe_creations.clone(); let pending_pipes = self.pending_pipes.clone(); let loop_pending_pipes = pending_pipes.clone(); + let expected_pending_pipes = pending_pipes.clone(); let on_pipe_request = self.on_pipe_request.clone(); let loop_pipe_creations = pending_pipe_creations.clone(); let loop_expired_pipe_creations = expired_pipe_creations.clone(); @@ -293,6 +294,12 @@ impl WasmClient { let _ = entry.sender.send(Ok(pipe_reader)); } }, + move |pipe_id| { + expected_pending_pipes + .borrow() + .get(&pipe_id) + .is_some_and(|entry| entry.generation == loop_generation) + }, ) .await; if connection_generation.get() != generation { diff --git a/wasm/src/pipe.rs b/wasm/src/pipe.rs index efa4f07..88783ae 100644 --- a/wasm/src/pipe.rs +++ b/wasm/src/pipe.rs @@ -1,9 +1,6 @@ -use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; -use crate::error::js_error; -use crate::transport::release_writer_lock; +use crate::transport::{BrowserRecvStream, BrowserSendStream, log_stream_error_code}; #[wasm_bindgen(typescript_custom_section)] const PIPE_TS: &str = r#" @@ -23,54 +20,41 @@ export interface PipeReader { #[wasm_bindgen] pub struct PipeWriter { - writer: JsValue, + stream: BrowserSendStream, pipe_id: u32, } impl PipeWriter { - pub fn new(writer: JsValue, pipe_id: u32) -> Self { - Self { writer, pipe_id } + pub(crate) fn new(stream: BrowserSendStream, pipe_id: u32) -> Self { + Self { stream, pipe_id } + } +} + +impl Drop for PipeWriter { + fn drop(&mut self) { + self.stream.release(); } } #[wasm_bindgen] impl PipeWriter { pub async fn write(&mut self, data: &[u8]) -> Result<(), JsValue> { - let chunk = js_sys::Uint8Array::from(data); - let write_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write")) - .map_err(|_| js_error("missing write"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&self.writer, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - JsFuture::from(write_promise.unchecked_into::()).await?; - Ok(()) + self.stream.write_all(data).await } - pub async fn close(self) -> Result<(), JsValue> { - let close_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("close")) - .map_err(|_| js_error("missing close"))? - .dyn_into::() - .map_err(|_| js_error("close not a function"))?; - let close_promise = close_fn - .call0(&self.writer) - .map_err(|e| js_error(format!("close failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(close_promise.unchecked_into::()).await { - crate::transport::log_stream_error_code(&e, "pipe writer close"); + pub async fn close(mut self) -> Result<(), JsValue> { + let result = self.stream.finish().await; + if let Err(error) = &result { + log_stream_error_code(error, "pipe writer close"); } - release_writer_lock(&self.writer); - Ok(()) + self.stream.release(); + result } pub fn abort(&mut self) -> Result<(), JsValue> { - let abort_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort")) - .map_err(|_| js_error("missing abort"))? - .dyn_into::() - .map_err(|_| js_error("abort not a function"))?; - let _ = abort_fn.call0(&self.writer); - release_writer_lock(&self.writer); - Ok(()) + let result = self.stream.reset(0); + self.stream.release(); + result } pub fn pipe_id(&self) -> u32 { @@ -80,19 +64,26 @@ impl PipeWriter { #[wasm_bindgen] pub struct PipeReader { - reader: JsValue, + stream: BrowserRecvStream, description: String, pipe_id: u32, pending: Vec, + finished: bool, } impl PipeReader { - pub fn new(reader: JsValue, pipe_id: u32, description: String, pending: Vec) -> Self { + pub(crate) fn new( + stream: BrowserRecvStream, + pipe_id: u32, + description: String, + pending: Vec, + ) -> Self { Self { - reader, + stream, pipe_id, description, pending, + finished: false, } } } @@ -105,27 +96,18 @@ impl PipeReader { return Ok(js_sys::Uint8Array::from(&data[..]).into()); } - let read_fn = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&self.reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = JsFuture::from(promise).await?; - - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if done { + if self.finished { return Ok(JsValue::NULL); } - let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - Ok(js_sys::Uint8Array::new(&value).into()) + match self.stream.read_chunk().await? { + Some(value) => Ok(js_sys::Uint8Array::from(&value[..]).into()), + None => { + self.stream.release(); + self.finished = true; + Ok(JsValue::NULL) + } + } } pub fn pipe_id(&self) -> u32 { @@ -136,3 +118,9 @@ impl PipeReader { self.description.clone() } } + +impl Drop for PipeReader { + fn drop(&mut self) { + self.stream.release(); + } +} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index dc0091e..b7ffebe 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -9,6 +9,7 @@ use wasm_bindgen_futures::JsFuture; use crate::error::js_error; use crate::frame::parse_frame_value_with_limits; use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap}; +use mtp_common::{FirstFrameDisposition, classify_first_frame}; const CLOSE_FRAME_LEN: u32 = u32::MAX; @@ -25,12 +26,6 @@ pub(crate) fn log_stream_error_code(error: &JsValue, context: &str) { let stream_error_code = js_sys::Reflect::get(error, &JsValue::from_str("streamErrorCode")) .ok() .and_then(|v| v.as_f64()); - if matches!(stream_error_code, Some(0.0)) { - // WebTransport reports peer-driven stream shutdown as code 0 in this - // environment. For one-frame handshake streams, that is expected and - // should not be surfaced as a warning. - return; - } let message = error .as_string() .or_else(|| { @@ -76,6 +71,233 @@ fn resolve_stream_readable(recv_stream: &JsValue) -> Result { } } +#[derive(Clone)] +struct BrowserConnection { + inner: JsValue, + incoming_reader: Rc>>, +} + +pub(crate) struct BrowserSendStream { + writer: JsValue, +} + +pub(crate) struct BrowserRecvStream { + reader: JsValue, +} + +impl BrowserConnection { + async fn connect(url: &str, cert_hashes: Option>) -> Result { + let constructor = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport not available"))?; + let args = js_sys::Array::new(); + args.push(&JsValue::from_str(url)); + + if let Some(hashes) = cert_hashes { + let webtransport_hashes = js_sys::Array::new(); + for hash in hashes { + let (algorithm, value) = hash.split_once(':').unwrap_or(("sha-256", hash.as_str())); + if let Ok(value) = hex::decode(value) { + let entry = js_sys::Object::new(); + js_sys::Reflect::set( + &entry, + &JsValue::from_str("algorithm"), + &JsValue::from_str(algorithm), + )?; + js_sys::Reflect::set( + &entry, + &JsValue::from_str("value"), + &js_sys::Uint8Array::from(&value[..]), + )?; + webtransport_hashes.push(&entry); + } + } + if webtransport_hashes.length() > 0 { + let options = js_sys::Object::new(); + js_sys::Reflect::set( + &options, + &JsValue::from_str("serverCertificateHashes"), + &webtransport_hashes, + )?; + args.push(&options); + } + } + + let inner = js_sys::Reflect::construct(&constructor, &args)?; + let ready = js_sys::Reflect::get(&inner, &JsValue::from_str("ready"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; + JsFuture::from(ready) + .await + .map_err(|error| js_error(format!("WebTransport ready failed: {error:?}")))?; + Ok(Self { + inner, + incoming_reader: Rc::new(RefCell::new(None)), + }) + } + + async fn open_uni(&self) -> Result { + let create_stream = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("createUnidirectionalStream"), + )? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream not a function"))?; + let stream_promise = create_stream + .call0(&self.inner)? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; + let stream = JsFuture::from(stream_promise).await?; + let writable = resolve_stream_writable(&stream)?; + let writer = js_sys::Reflect::get(&writable, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? + .dyn_into::() + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable) + .map_err(|_| js_error("getWriter call failed"))?; + Ok(BrowserSendStream { writer }) + } + + async fn accept_uni(&self) -> Result, JsValue> { + let streams_reader = if let Some(reader) = self.incoming_reader.borrow().clone() { + reader + } else { + let incoming = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("incomingUnidirectionalStreams"), + )?; + let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing getReader"))? + .dyn_into::() + .map_err(|_| js_error("getReader not a function"))? + .call0(&incoming) + .map_err(|_| js_error("getReader call failed"))?; + *self.incoming_reader.borrow_mut() = Some(reader.clone()); + reader + }; + + let read = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let promise = read + .call0(&streams_reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(); + let result = JsFuture::from(promise).await.map_err(|error| { + log_stream_error_code(&error, "accept_uni"); + js_error(format!("accept stream failed: {error:?}")) + })?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(None); + } + + let stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + let readable = resolve_stream_readable(&stream)?; + let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing stream getReader"))? + .dyn_into::() + .map_err(|_| js_error("stream getReader not a function"))? + .call0(&readable) + .map_err(|_| js_error("stream getReader call failed"))?; + Ok(Some(BrowserRecvStream { reader })) + } + + fn close(&self) { + if let Some(reader) = self.incoming_reader.borrow_mut().take() { + release_reader_lock(&reader); + } + if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) + .and_then(|value| value.dyn_into::()) + { + let _ = close.call1(&self.inner, &js_sys::Object::new()); + } + } +} + +impl BrowserSendStream { + pub(crate) async fn write_all(&mut self, bytes: &[u8]) -> Result<(), JsValue> { + let write = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write")) + .map_err(|_| js_error("missing write"))? + .dyn_into::() + .map_err(|_| js_error("write not a function"))?; + let promise = write + .call1(&self.writer, &js_sys::Uint8Array::from(bytes)) + .map_err(|error| js_error(format!("write failed: {error:?}")))? + .unchecked_into::(); + JsFuture::from(promise).await.map(|_| ()) + } + + pub(crate) async fn finish(&mut self) -> Result<(), JsValue> { + let close = js_sys::Reflect::get(&self.writer, &JsValue::from_str("close")) + .map_err(|_| js_error("missing close"))? + .dyn_into::() + .map_err(|_| js_error("close not a function"))?; + let promise = close + .call0(&self.writer) + .map_err(|error| js_error(format!("close failed: {error:?}")))? + .unchecked_into::(); + JsFuture::from(promise).await.map(|_| ()) + } + + pub(crate) fn reset(&mut self, code: u32) -> Result<(), JsValue> { + let abort = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort")) + .map_err(|_| js_error("missing abort"))? + .dyn_into::() + .map_err(|_| js_error("abort not a function"))?; + let _ = abort.call1(&self.writer, &JsValue::from_f64(code as f64))?; + Ok(()) + } + + pub(crate) fn release(&self) { + release_writer_lock(&self.writer); + } +} + +impl BrowserRecvStream { + pub(crate) async fn read_chunk(&mut self) -> Result>, JsValue> { + let read = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let promise = read + .call0(&self.reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(); + let result = JsFuture::from(promise).await?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(true) + { + return Ok(None); + } + let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) + } + + #[allow(dead_code)] + pub(crate) fn stop(self, code: u32) -> Result<(), JsValue> { + let cancel = js_sys::Reflect::get(&self.reader, &JsValue::from_str("cancel")) + .map_err(|_| js_error("missing cancel"))? + .dyn_into::() + .map_err(|_| js_error("cancel not a function"))?; + let _ = cancel.call1(&self.reader, &JsValue::from_f64(code as f64))?; + Ok(()) + } + + pub(crate) fn release(&self) { + release_reader_lock(&self.reader); + } +} + /// Releases a writer's lock so an abandoned writer isn't treated as an abort (which sends STOP_SENDING). pub(crate) fn release_writer_lock(writer: &JsValue) { if let Ok(release) = js_sys::Reflect::get(writer, &JsValue::from_str("releaseLock")) @@ -118,12 +340,10 @@ enum FrameOutcome { */ #[derive(Clone)] pub struct WasmTransport { - inner: JsValue, + connection: BrowserConnection, max_message_size: u32, - /// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams). - streams_reader: Rc>>, - /// Reader over the host's current uni-directional stream, if one is open. - stream_reader: Rc>>, + /// Current incoming unidirectional stream, shared across handshake and receive loops. + stream_reader: Rc>>, /// Bytes already read from the current stream but not yet consumed as a frame. buffer: Rc>>, /// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed. @@ -149,61 +369,14 @@ impl WasmTransport { max_message_size: u32, configured_limits: Option, ) -> Result { - let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport not available"))?; - let args = js_sys::Array::new(); - args.push(&JsValue::from_str(url)); - - if let Some(hashes) = cert_hashes { - let wt_hashes = js_sys::Array::new(); - for h in hashes { - let (algo, hex_val) = match h.split_once(':') { - Some((algo, hex_val)) => (algo, hex_val), - None => ("sha-256", h.as_str()), - }; - - if let Ok(bytes) = hex::decode(hex_val) { - let hash = js_sys::Object::new(); - js_sys::Reflect::set( - &hash, - &JsValue::from_str("algorithm"), - &JsValue::from_str(algo), - )?; - js_sys::Reflect::set( - &hash, - &JsValue::from_str("value"), - &js_sys::Uint8Array::from(&bytes[..]), - )?; - wt_hashes.push(&hash); - } - } - if wt_hashes.length() > 0 { - let opts = js_sys::Object::new(); - js_sys::Reflect::set( - &opts, - &JsValue::from_str("serverCertificateHashes"), - &wt_hashes, - )?; - args.push(&opts); - } - }; - - let transport = js_sys::Reflect::construct(&ctor, &args)?; - let ready = js_sys::Reflect::get(&transport, &JsValue::from_str("ready"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; - JsFuture::from(ready) - .await - .map_err(|e| js_error(format!("WebTransport ready failed: {:?}", e)))?; + let connection = BrowserConnection::connect(url, cert_hashes).await?; let transport_limits = DecodeLimits::for_transport_message_size(max_message_size as u64); let decode_limits = configured_limits .map(|limits| restrict_decode_limits(limits, transport_limits)) .unwrap_or(transport_limits); Ok(Self { - inner: transport, + connection, max_message_size, - streams_reader: Rc::new(RefCell::new(None)), stream_reader: Rc::new(RefCell::new(None)), buffer: Rc::new(RefCell::new(Vec::new())), new_stream_frame: Rc::new(Cell::new(false)), @@ -214,7 +387,7 @@ impl WasmTransport { } pub fn inner(&self) -> &JsValue { - &self.inner + &self.connection.inner } pub fn set_type_map(&self, type_map: &TypeMap) { @@ -250,154 +423,55 @@ impl WasmTransport { // in accept_uni() until the authentication deadline. The bytes are // already the canonical MTP self-framed value, so no extra stream // length prefix is added here. - let create_stream = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("createUnidirectionalStream"), - )? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream not a function"))?; - let stream_promise = create_stream - .call0(&self.inner)? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; - let stream = JsFuture::from(stream_promise).await?; - let writable_or_stream = resolve_stream_writable(&stream)?; - let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) - .map_err(|_| js_error("missing getWriter"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; - - let chunk = js_sys::Uint8Array::from(frame); - - let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) - .map_err(|_| js_error("missing write"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&writer_val, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { + let mut stream = self.connection.open_uni().await?; + if let Err(e) = stream.write_all(frame).await { log_stream_error_code(&e, "send_frame write"); - release_writer_lock(&writer_val); + stream.release(); return Err(e); } - let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close")) - .map_err(|_| js_error("missing close"))? - .dyn_into::() - .map_err(|_| js_error("close not a function"))?; - let close_promise = close_fn - .call0(&writer_val) - .map_err(|e| js_error(format!("close failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(close_promise.unchecked_into::()).await { + if let Err(e) = stream.finish().await { // The frame was already written; do not retry it merely because // FIN failed, as that would duplicate the MTP frame. log_stream_error_code(&e, "send_frame close"); } - release_writer_lock(&writer_val); + stream.release(); Ok(()) } - /// Get (creating once) the reader over `incoming_unidirectional_streams()`. - fn ensure_streams_reader(&self) -> Result { - if let Some(reader) = self.streams_reader.borrow().clone() { - return Ok(reader); - } - let incoming = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("incomingUnidirectionalStreams"), - )?; - let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing getReader"))? - .dyn_into::() - .map_err(|_| js_error("getReader not a function"))? - .call0(&incoming) - .map_err(|_| js_error("getReader call failed"))?; - *self.streams_reader.borrow_mut() = Some(reader.clone()); - Ok(reader) - } - /// Accept the next incoming uni-directional stream and make it current. /// Returns `false` if the incoming-streams readable has ended. async fn open_next_stream(&self) -> Result { - let streams_reader = self.ensure_streams_reader()?; - - let read_fn = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&streams_reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = match JsFuture::from(promise).await { - Ok(r) => r, - Err(e) => { - log_stream_error_code(&e, "open_next_stream accept"); - return Err(js_error(format!("accept stream failed: {:?}", e))); - } + let Some(stream) = self.connection.accept_uni().await? else { + return Ok(false); }; - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if done { - return Ok(false); - } - - let recv_stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - let readable = resolve_stream_readable(&recv_stream)?; - let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing stream getReader"))? - .dyn_into::() - .map_err(|_| js_error("stream getReader not a function"))? - .call0(&readable) - .map_err(|_| js_error("stream getReader call failed"))?; - - *self.stream_reader.borrow_mut() = Some(reader); + *self.stream_reader.borrow_mut() = Some(stream); self.new_stream_frame.set(true); Ok(true) } /// Read one chunk from the current stream. `Ok(None)` means the stream ended. async fn read_chunk(&self) -> Result>, JsValue> { - let reader = match self.stream_reader.borrow().clone() { - Some(r) => r, + let mut stream = match self.stream_reader.borrow_mut().take() { + Some(stream) => stream, None => return Ok(None), }; - - let read_fn = js_sys::Reflect::get(&reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = match JsFuture::from(promise).await { - Ok(r) => r, + let result = match stream.read_chunk().await { + Ok(result) => result, Err(e) => { log_stream_error_code(&e, "read_chunk"); + stream.release(); return Err(js_error(format!("read failed: {:?}", e))); } }; - - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if done { - return Ok(None); + if result.is_some() { + *self.stream_reader.borrow_mut() = Some(stream); + } else { + stream.release(); } - - let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) + Ok(result) } /// Try to pull one complete frame out of the buffer without reading more. @@ -459,9 +533,7 @@ impl WasmTransport { } None => { // Stream finished; release the reader's lock to avoid a spurious cancel. - if let Some(reader) = self.stream_reader.borrow_mut().take() { - release_reader_lock(&reader); - } + // `read_chunk` releases the raw stream lock on clean FIN. // A frame is never allowed to span stream boundaries. The // native persistent-stream sender packs frames on one // stream, while the WASM sender uses one stream per frame; @@ -520,15 +592,17 @@ impl WasmTransport { /// Pipe-aware receive loop. Identical to `receive_loop` but detects /// `PipeRequest` as the first frame on a new incoming stream and routes /// the stream to `on_pipe` instead of `on_message`. - pub async fn receive_loop_with_pipes( + pub async fn receive_loop_with_pipes( &self, mut on_message: F, mut on_error: H, mut on_pipe: G, + mut pipe_is_expected: I, ) where F: FnMut(JsValue), G: FnMut(crate::pipe::PipeReader), H: FnMut(JsValue), + I: FnMut(u32) -> bool, { loop { match self.next_frame(self.max_message_size).await { @@ -550,36 +624,44 @@ impl WasmTransport { if is_first { self.new_stream_frame.set(false); - if let Some(comm) = comm.as_ref() - && Some(comm.get_type()) == pipe_request_type - { - let Some(pipe_id) = comm.id().filter(|id| *id != 0) else { - on_error(JsValue::from_str( - "PipeRequest frame must contain a non-zero id", - )); - self.close(); - break; - }; - let description = comm - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); + if let Some(comm) = comm.as_ref() { + let is_pipe_request = Some(comm.get_type()) == pipe_request_type; + let pipe_id = comm.id().filter(|id| *id != 0); + let is_expected = + is_pipe_request && pipe_id.is_some_and(&mut pipe_is_expected); + let disposition = + match classify_first_frame(is_pipe_request, comm.id(), is_expected) + { + Ok(disposition) => disposition, + Err(error) => { + on_error(JsValue::from_str(&error.to_string())); + self.close(); + break; + } + }; - let pending = { - let mut buf = self.buffer.borrow_mut(); - std::mem::take(&mut *buf) - }; + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = comm + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); - if let Some(reader) = self.stream_reader.borrow_mut().take() { - let pipe_reader = crate::pipe::PipeReader::new( - reader, - pipe_id, - description, - pending, - ); - on_pipe(pipe_reader); + let pending = { + let mut buf = self.buffer.borrow_mut(); + std::mem::take(&mut *buf) + }; + + if let Some(reader) = self.stream_reader.borrow_mut().take() { + let pipe_reader = crate::pipe::PipeReader::new( + reader, + pipe_id, + description, + pending, + ); + on_pipe(pipe_reader); + } + continue; } - continue; } } @@ -635,25 +717,7 @@ impl WasmTransport { description: &str, ) -> Result { let _send_guard = self.send_lock.lock().await; - let create_stream = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("createUnidirectionalStream"), - )? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream not a function"))?; - let stream_promise = create_stream - .call0(&self.inner)? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; - let stream = JsFuture::from(stream_promise).await?; - - let writable_or_stream = resolve_stream_writable(&stream)?; - let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) - .map_err(|_| js_error("missing getWriter"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; + let mut stream = self.connection.open_uni().await?; let type_map = self.type_map(); let request = mtp_codec::CommunicationValue::new_with_type_map( @@ -669,37 +733,21 @@ impl WasmTransport { .to_bytes() .map_err(|e| js_error(format!("encode failed: {}", e)))?; - let chunk = js_sys::Uint8Array::from(&frame_bytes[..]); - let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) - .map_err(|_| js_error("missing write"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&writer_val, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { + if let Err(e) = stream.write_all(&frame_bytes).await { log_stream_error_code(&e, "open_pipe write"); - release_writer_lock(&writer_val); + stream.release(); return Err(e); } - Ok(crate::pipe::PipeWriter::new(writer_val, pipe_id)) + Ok(crate::pipe::PipeWriter::new(stream, pipe_id)) } pub fn close(&self) { // Release reader locks before closing so they aren't treated as cancels. if let Some(reader) = self.stream_reader.borrow_mut().take() { - release_reader_lock(&reader); - } - if let Some(reader) = self.streams_reader.borrow_mut().take() { - release_reader_lock(&reader); - } - - if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) - .and_then(|value| value.dyn_into::()) - { - let _ = close.call1(&self.inner, &js_sys::Object::new()); + reader.release(); } + self.connection.close(); } } From c30315af944bda05ecdb9c4e9cc350cb99cf7b2e Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:55:52 +0200 Subject: [PATCH 10/18] Clean --- Cargo.lock | 4 ---- Cargo.toml | 5 ----- common/Cargo.toml | 1 - example/Cargo.lock | 1 - type-map/Cargo.toml | 4 ++++ 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88e9b74..cc6b180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1326,9 +1326,6 @@ dependencies = [ "mtp-transport", "mtp-type-map", "mtp-webserver", - "rand", - "rcgen", - "tokio", ] [[package]] @@ -1363,7 +1360,6 @@ name = "mtp-common" version = "0.3.0" dependencies = [ "quinn", - "rustls", "thiserror 2.0.20", "wtransport", ] diff --git a/Cargo.toml b/Cargo.toml index ee1c93a..2c1b1ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,10 +113,5 @@ tls = ["crypto", "mtp-crypto?/tls"] # Requires MTP_INSECURE_TLS=1 at runtime. insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"] -[dev-dependencies] -tokio = { version = "1", features = ["full"] } -rcgen = "0.14" -rand = "0.10.1" - [package.metadata.cargo-machete] ignored = ["mtp-transport"] diff --git a/common/Cargo.toml b/common/Cargo.toml index 24d4777..5fae68e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -15,7 +15,6 @@ wtransport = { version = "0.7.1", default-features = false, features = [ "quinn", "self-signed", ] } -rustls = { version = "0.23.41" } quinn = { version = "0.11.11", default-features = false, features = [ "rustls-aws-lc-rs", "rustls", diff --git a/example/Cargo.lock b/example/Cargo.lock index 8d2fb30..ba5bf49 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -1305,7 +1305,6 @@ name = "mtp-common" version = "0.3.0" dependencies = [ "quinn", - "rustls", "thiserror 2.0.20", "wtransport", ] diff --git a/type-map/Cargo.toml b/type-map/Cargo.toml index ad811ae..1cacaa0 100644 --- a/type-map/Cargo.toml +++ b/type-map/Cargo.toml @@ -16,3 +16,7 @@ pipes = [] [build-dependencies] serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" + +[package.metadata.cargo-machete] +# cargo-machete does not inspect build.rs, where both build dependencies are used. +ignored = ["serde", "serde_yaml"] From bd5547ae6f631502ed38082428002ff0a5b006c3 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 19:29:53 +0200 Subject: [PATCH 11/18] feat(ts-sdk): add schemas --- docs/WASM-CLIENT.md | 57 +++++++++++ src/sdk/client.ts | 157 +++++++++++++++++++++++++---- src/sdk/index.ts | 1 + src/sdk/schema.ts | 234 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 src/sdk/schema.ts diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 02be900..8a56bbd 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -104,6 +104,9 @@ if (!MTPClient.isSupported()) { | `requestTimeoutMs` | 30 seconds | Default `request()` timeout. | | `pings` | `false` | Protocol pings, or an object with `intervalMs`. | | `logger` | No-op | Receives SDK state and error events. | +| `schemas` | None | Client-wide request and response schema registry. | +| `throwProtocolErrors` | `false` | Reject requests whose correlated response is an `Error*` frame. | +| `onValidationError` | No-op | Receives subscription validation failures. | | `sessionStorage` | In-memory | E2EE session state storage. | | `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. | | `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. | @@ -471,6 +474,60 @@ const unsubscribe = client.subscribe("SomeType", (message) => { unsubscribe(); ``` +### Zod request and response schemas + +Applications can provide their request and response schemas once when creating +the client. MTP uses `parseAsync`, so synchronous schemas, async refinements, +defaults, coercions, and transforms all work. MTP has no runtime dependency on +Zod; the application supplies its preferred Zod version. + +```typescript +import { z } from "zod"; +import { MTPClient, MTPValidationError } from "mtp"; + +const schemas = { + GetUser: { + request: z.object({ UserId: z.number().int().positive() }), + response: z.object({ + UserId: z.number().int().positive(), + Display: z.string(), + }), + }, +}; + +const client = await MTPClient.create({ + url, + schemas, + throwProtocolErrors: true, + onValidationError(error) { + console.error(error.messageType, error.cause); + }, +}); + +const response = await client.request("GetUser", { UserId: 42 }); +console.log(response.data.Display); +``` + +Request schemas run before frame encoding and transmission. Their transformed +output is sent. Response schemas run after request correlation, and their +transformed output replaces `frame.data`; `frame.raw`, when present, remains the +original wire frame. Invalid requests and responses reject with +`MTPValidationError`. Invalid subscription messages do not reach the handler +and are reported through `onValidationError`. + +`throwProtocolErrors: true` converts correlated `Error*` frames into +`MTPProtocolError`. It defaults to `false` for compatibility. + +`MTPProxyConnection` applies the same schema registry to another TypeScript +request/subscription transport, such as a Tauri command and event proxy: + +```typescript +const connection = new MTPProxyConnection(adapter, { + schemas, + throwProtocolErrors: true, +}); +``` + Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is: ```typescript diff --git a/src/sdk/client.ts b/src/sdk/client.ts index d3c31be..499c44f 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -10,6 +10,15 @@ import * as bindings from "mtp/raw"; import { unixTimeMillis, utf8Encode } from "./utils.js"; import type * as RawBindings from "../raw/index"; import type { MTPCommunicationType } from "../type-map/index"; +import { MTPProtocol } from "./schema.js"; +import type { + MTPMessageType, + MTPFrame, + MTPNoSchemas, + MTPRequestData, + MTPResponseFrame, + MTPSchemaRegistry, +} from "./schema.js"; import type { MTPSessionStorage, MTPSessionState } from "./session"; import { MTPSessionManager } from "./session.js"; import { @@ -249,7 +258,9 @@ export interface MTPPublicKeyBundleKeys { sigClPublicKey: Uint8Array; } -export interface MTPClientOptions { +export interface MTPClientOptions< + Registry extends MTPSchemaRegistry = MTPNoSchemas, +> { url: string; descriptor?: string; hostPublicKey?: MTPKeyMaterialInput; @@ -282,6 +293,12 @@ export interface MTPClientOptions { securityProfile?: MTPSecurityProfile; /** One receive resource policy shared by frame and protected-value opening. */ receiveLimits?: MTPReceiveLimits; + /** Application request and response schemas, keyed by communication type. */ + schemas?: Registry; + /** Reject `request()` when the correlated response is an `Error*` frame. */ + throwProtocolErrors?: boolean; + /** Receives subscription validation failures. Request failures reject normally. */ + onValidationError?: (error: import("./schema.js").MTPValidationError) => void; } export interface MTPSecurityProfile { @@ -562,8 +579,8 @@ export interface MTPAcceptEncryptedPipeOptions { signaturePolicy?: MTPSignatureVerificationPolicy; } -type NormalizedMTPClientOptions = Omit< - MTPClientOptions, +type NormalizedMTPClientOptions = Omit< + MTPClientOptions, "hostPublicKey" | "receiveLimits" > & { hostPublicKey?: Uint8Array; @@ -914,14 +931,34 @@ function validateOptions(options) { ) { throw new TypeError("requestTimeoutMs must be a positive safe integer"); } + if (options.schemas != null) { + if (typeof options.schemas !== "object" || Array.isArray(options.schemas)) { + throw new TypeError("schemas must be an object"); + } + for (const [type, pair] of Object.entries(options.schemas)) { + if ( + !pair || + typeof pair !== "object" || + typeof (pair as { request?: { parseAsync?: unknown } }).request + ?.parseAsync !== "function" || + typeof (pair as { response?: { parseAsync?: unknown } }).response + ?.parseAsync !== "function" + ) { + throw new TypeError( + `schemas.${type} must contain request and response schemas with parseAsync()`, + ); + } + } + } } -export class MTPClient { +export class MTPClient { static readonly crypto = crypto; static readonly codec = codec; #credentials: InternalCredentials | null; - #options: NormalizedMTPClientOptions; + #options: NormalizedMTPClientOptions; + readonly #protocol: MTPProtocol | undefined; readonly #protectedReplayGuard = new InMemoryReplayGuard(); readonly #relayReplayGuard = new InMemoryReplayGuard(); readonly raw: MTPRaw; @@ -934,10 +971,17 @@ export class MTPClient { readonly encryptedSecretProvider: MTPEncryptedSecretProvider; private constructor( - options: NormalizedMTPClientOptions, + options: NormalizedMTPClientOptions, client: RawBindings.WasmClient, ) { this.#options = options; + this.#protocol = options.schemas + ? new MTPProtocol({ + schemas: options.schemas, + throwProtocolErrors: options.throwProtocolErrors, + onValidationError: options.onValidationError, + }) + : undefined; this.#credentials = deserializeCredentials(options.credentials); this.raw = { client, bindings }; this.encryptedSecretProvider = @@ -947,7 +991,11 @@ export class MTPClient { ); } - static async create(options: MTPClientOptions): Promise { + static async create< + const Registry extends MTPSchemaRegistry = MTPNoSchemas, + >( + options: MTPClientOptions, + ): Promise> { validateOptions(options); await MTPClient.init(options.wasm); @@ -968,7 +1016,7 @@ export class MTPClient { securityProfile: resolveSecurityProfile(options), }; - let sdk: MTPClient | undefined; + let sdk: MTPClient | undefined; const client = new WasmClient( (state) => emit(normalizedOptions.logger, { @@ -1004,7 +1052,7 @@ export class MTPClient { setReceiveLimits.call(rawClient, normalizedOptions.receiveLimits); } - sdk = new MTPClient(normalizedOptions, client); + sdk = new MTPClient(normalizedOptions, client); await sdk.#loadStoredCredentials(); if (!sdk.#credentials) { sdk.#credentials = { @@ -1238,6 +1286,35 @@ export class MTPClient { }; } + async #parseRequestData( + type: MTPCommunicationType, + data: unknown, + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[type]) { + return (data ?? {}) as Record; + } + const parsed = await this.#protocol.parseRequest( + type as MTPMessageType, + data as never, + ); + return (parsed ?? {}) as Record; + } + + async #parseResponseData( + requestedType: MTPCommunicationType, + frame: ParsedFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[requestedType]) { + return frame; + } + return await this.#protocol.parseResponse( + requestedType as MTPMessageType, + frame, + phase, + ); + } + #buildFrame(typeOrFrame, data, options) { if (typeOrFrame instanceof Uint8Array) { if ( @@ -1279,6 +1356,11 @@ export class MTPClient { } async send(message: Uint8Array): Promise; + async send>( + type: Type, + data?: MTPRequestData, + options?: MTPSendOptions, + ): Promise; async send( type: MTPCommunicationType, data: Record, @@ -1286,10 +1368,14 @@ export class MTPClient { ): Promise; async send( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options?: MTPSendOptions, ): Promise { - const message = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const message = this.#buildFrame(typeOrFrame, parsedData, options); try { const frame = this.raw.bindings.parse_frame(message); @@ -1345,6 +1431,11 @@ export class MTPClient { data?: never, options?: MTPRequestOptions, ): Promise; + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise>; async request( type: MTPCommunicationType, data: Record, @@ -1352,15 +1443,19 @@ export class MTPClient { ): Promise; async request( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options: MTPRequestOptions = {}, - ): Promise { + ): Promise> { const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { throw new TypeError("request timeoutMs must be a positive safe integer"); } - const frame = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const frame = this.#buildFrame(typeOrFrame, parsedData, options); try { const parsed = this.raw.bindings.parse_frame(frame); emit( @@ -1391,16 +1486,25 @@ export class MTPClient { // The WASM client owns request expiry and its late-response tombstones. // Keeping a second Promise timer here can reject the SDK call while the // protocol request is still allowed to complete successfully. - return await this.raw.client.request( + const response = await this.raw.client.request( frame, options.responseType ?? null, timeoutMs, ); + return typeof typeOrFrame === "string" + ? await this.#parseResponseData(typeOrFrame, response) + : response; } + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe; subscribe( type: MTPCommunicationType, - handler: (message: ParsedFrame) => void, + handler: (message: MTPFrame) => void | Promise, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); @@ -1408,8 +1512,25 @@ export class MTPClient { if (typeof handler !== "function") { throw new TypeError("subscription handler must be a function"); } - const id = this.raw.client.subscribe(type, handler); - return () => this.raw.client.unsubscribe(id); + let active = true; + const id = this.raw.client.subscribe(type, (message) => { + if (!this.#protocol || !this.#protocol.schemas[type]) { + void handler(message); + return; + } + void this.#parseResponseData(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol?.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + this.raw.client.unsubscribe(id); + }; } #handleFrame(frame) { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e93ca7e..5e3a1e9 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -5,3 +5,4 @@ * keeps the package's historical exports stable. */ export * from "./client.js"; +export * from "./schema.js"; diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts new file mode 100644 index 0000000..9b9aaf6 --- /dev/null +++ b/src/sdk/schema.ts @@ -0,0 +1,234 @@ +import type { MTPRequestOptions, ParsedFrame, Unsubscribe } from "./client.js"; +import type { MTPCommunicationType } from "../type-map/index.js"; + +export interface MTPSchema { + readonly _input: Input; + readonly _output: Output; + parseAsync(value: unknown): Promise; +} + +export interface MTPSchemaPair< + Request extends MTPSchema = MTPSchema, + Response extends MTPSchema = MTPSchema, +> { + request: Request; + response: Response; +} + +export type MTPSchemaRegistry = Record; +export type MTPNoSchemas = Record; + +export type MTPSchemaInput = Schema["_input"]; +export type MTPSchemaOutput = Schema["_output"]; +export type MTPMessageType = + keyof Registry & string; + +export type MTPFrame = { + id?: number; + type: string; + data: Data; + sender?: ParsedFrame["sender"]; + receiver?: ParsedFrame["receiver"]; + raw?: ParsedFrame["raw"]; +}; + +export type MTPTypedFrame = MTPFrame; + +export type MTPResponseFrame< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPTypedFrame>; + +export type MTPRequestData< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPSchemaInput; + +export type MTPRequestFunction = < + Type extends MTPMessageType, +>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, +) => Promise>; + +export type MTPSubscriptionFunction = < + Type extends MTPMessageType, +>( + type: Type, + handler: (message: MTPResponseFrame) => void | Promise, +) => Unsubscribe; + +export class MTPValidationError extends Error { + readonly phase: "request" | "response" | "subscription"; + readonly messageType: string; + readonly frame?: MTPFrame; + + constructor( + phase: MTPValidationError["phase"], + messageType: string, + cause: unknown, + frame?: MTPFrame, + ) { + super(`${phase} validation failed for ${messageType}`, { cause }); + this.name = "MTPValidationError"; + this.phase = phase; + this.messageType = messageType; + this.frame = frame; + } +} + +export class MTPProtocolError extends Error { + readonly type: string; + readonly id: number | undefined; + readonly communicationType: string; + readonly requestId: number | undefined; + readonly errorType: string | undefined; + readonly frame: MTPFrame; + + constructor(frame: MTPFrame) { + const errorType = + frame.data && + typeof frame.data === "object" && + !Array.isArray(frame.data) && + typeof (frame.data as Record).ErrorType === "string" + ? ((frame.data as Record).ErrorType as string) + : undefined; + super(errorType ? `${frame.type}: ${errorType}` : frame.type); + this.name = "MTPProtocolError"; + this.type = frame.type; + this.id = frame.id; + this.communicationType = frame.type; + this.requestId = frame.id; + this.errorType = errorType; + this.frame = frame; + } +} + +export interface MTPProtocolOptions { + schemas: Registry; + throwProtocolErrors?: boolean; + onValidationError?: (error: MTPValidationError) => void; +} + +function isErrorFrame(frame: MTPFrame): boolean { + return frame.type.startsWith("Error"); +} + +export class MTPProtocol { + readonly schemas: Registry; + readonly #throwProtocolErrors: boolean; + readonly #onValidationError: + | ((error: MTPValidationError) => void) + | undefined; + + constructor(options: MTPProtocolOptions) { + this.schemas = options.schemas; + this.#throwProtocolErrors = options.throwProtocolErrors ?? false; + this.#onValidationError = options.onValidationError; + } + + async parseRequest>( + type: Type, + data: MTPRequestData | undefined, + ): Promise> { + try { + return await this.schemas[type].request.parseAsync(data); + } catch (error) { + throw new MTPValidationError("request", type, error); + } + } + + async parseResponse>( + requestedType: Type, + frame: MTPFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (isErrorFrame(frame)) { + if (phase === "response" && this.#throwProtocolErrors) { + throw new MTPProtocolError(frame); + } + return frame as MTPResponseFrame; + } + + const schema = + this.schemas[frame.type]?.response ?? + this.schemas[requestedType].response; + try { + const data = await schema.parseAsync(frame.data); + return { ...frame, data } as MTPResponseFrame; + } catch (error) { + throw new MTPValidationError( + phase, + frame.type || requestedType, + error, + frame, + ); + } + } + + reportValidationError(error: unknown): void { + if (error instanceof MTPValidationError) { + this.#onValidationError?.(error); + } + } +} + +export interface MTPProxyAdapter { + request( + type: MTPCommunicationType, + data: Record, + options?: MTPRequestOptions, + ): Promise; + subscribe( + type: MTPCommunicationType, + handler: (message: MTPFrame) => void, + ): Unsubscribe; +} + +export class MTPProxyConnection { + readonly #adapter: MTPProxyAdapter; + readonly #protocol: MTPProtocol; + + constructor(adapter: MTPProxyAdapter, options: MTPProtocolOptions) { + this.#adapter = adapter; + this.#protocol = new MTPProtocol(options); + } + + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise> { + const parsed = await this.#protocol.parseRequest(type, data); + const response = await this.#adapter.request( + type, + (parsed ?? {}) as Record, + options, + ); + return await this.#protocol.parseResponse(type, response); + } + + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe { + let active = true; + const unsubscribe = this.#adapter.subscribe(type, (message) => { + void this.#protocol.parseResponse(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + unsubscribe(); + }; + } +} From 6348d7884f9a8ef8251fd42c4f28e443bedf262b Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 20:17:11 +0200 Subject: [PATCH 12/18] Fix thing --- example/server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 17b4164..08969f4 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -38,6 +38,7 @@ async fn handle_pipe_loopback( conn: &mtp::webserver::WebMTPConnection, request: mtp::host::PipeRequest< mtp::webserver::WebMtpSender, + mtp::webserver::WebMtpReceiver, mtp::webserver::H3TransportReceiver, >, ) -> Result> { From df75fd2830b55ac77614a1d0c5e618432bd6d991 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 20:28:26 +0200 Subject: [PATCH 13/18] feat(qol): remove dup --- .forgejo/workflows/ci.yml | 2 -- package.json | 1 - 2 files changed, 3 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 2d870bc..970eece 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: cargo machete pnpm install --frozen-lockfile - pnpm add --save-dev --save-exact --workspace-root jscpd-linux-x64-gnu@5.0.14 - pnpm run dup RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web diff --git a/package.json b/package.json index dd4e7a1..72e0b12 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "pack": "pnpm run release:web", "release:web": "node create-web-release.mjs", "build:all": "nix run .#build-all", - "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips .", "test:e2e": "tsc && node test/e2ee.mjs", "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", "test:wasm-init": "tsc && node --test test/wasm-init.mjs", From 22d13742aea2f37c13716da1dbc575fd426075c9 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 21:10:37 +0200 Subject: [PATCH 14/18] feat(qol): remove dup --- flake.nix | 67 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/flake.nix b/flake.nix index 7951305..0a40973 100644 --- a/flake.nix +++ b/flake.nix @@ -5,26 +5,30 @@ rust-overlay.url = "github:oxalica/rust-overlay"; }; - outputs = { - self, - nixpkgs, - rust-overlay, - }: let - systems = [ - "aarch64-darwin" - "aarch64-linux" - "x86_64-darwin" - "x86_64-linux" - ]; - eachSystem = f: - nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate {} ( - map (system: nixpkgs.lib.mapAttrs (_: value: {${system} = value;}) (f system)) systems - ); - in + outputs = + { + self, + nixpkgs, + rust-overlay, + }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + eachSystem = + f: + nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } ( + map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems + ); + in eachSystem ( - system: let - overlays = [rust-overlay.overlays.default]; - pkgs = import nixpkgs {inherit system overlays;}; + system: + let + overlays = [ rust-overlay.overlays.default ]; + pkgs = import nixpkgs { inherit system overlays; }; rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ @@ -32,12 +36,12 @@ "clippy" "rustfmt" ]; - targets = ["wasm32-unknown-unknown"]; + targets = [ "wasm32-unknown-unknown" ]; }; clippyCheck = pkgs.writeShellApplication { name = "mtp-clippy"; - runtimeInputs = [rustToolchain]; + runtimeInputs = [ rustToolchain ]; text = '' export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub @@ -46,7 +50,7 @@ macheteCheck = pkgs.writeShellApplication { name = "mtp-machete"; - runtimeInputs = [pkgs.cargo-machete]; + runtimeInputs = [ pkgs.cargo-machete ]; text = '' cargo machete "$@" ''; @@ -54,7 +58,15 @@ buildAll = pkgs.writeShellApplication { name = "mtp-build-all"; - runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; + runtimeInputs = [ + rustToolchain + pkgs.cargo-deny + pkgs.wasm-pack + pkgs.pnpm + pkgs.coreutils + clippyCheck + macheteCheck + ]; text = '' export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" @@ -66,7 +78,6 @@ cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features mtp-clippy mtp-machete - pnpm run dup pnpm run build RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm pnpm run test:e2e @@ -79,13 +90,17 @@ healthCheck = pkgs.writeShellApplication { name = "mtp-health"; - runtimeInputs = [clippyCheck macheteCheck]; + runtimeInputs = [ + clippyCheck + macheteCheck + ]; text = '' mtp-clippy mtp-machete ''; }; - in { + in + { devShells = { default = pkgs.mkShell { name = "mtp-dev"; From 8d94bc54987640996f676b6a496c4f57502e6aaa Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 21:55:36 +0200 Subject: [PATCH 15/18] feat(qol): add direnv --- .envrc | 1 + .gitignore | 1 + 2 files changed, 2 insertions(+) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 6dff05e..f600624 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.tgz wasm/pkg/ web_client/ +.direnv From c7c7afe5780a45c23725dedf54bbc342bb8cd6fd Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 22:16:16 +0200 Subject: [PATCH 16/18] fix(example): web-client type --- src/sdk/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 499c44f..e0307cd 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -1504,7 +1504,11 @@ export class MTPClient { ): Unsubscribe; subscribe( type: MTPCommunicationType, - handler: (message: MTPFrame) => void | Promise, + handler: (message: ParsedFrame) => void | Promise, + ): Unsubscribe; + subscribe( + type: MTPCommunicationType, + handler: (message: any) => void | Promise, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); From 4493ed32cf8c267bc27d94ce504303014a1215db Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 22:33:20 +0200 Subject: [PATCH 17/18] chore(deps): update chacha20 from 0.10.1 to 0.10.2 --- Cargo.lock | 6 +++--- codec/Cargo.lock | 6 +++--- common/Cargo.lock | 4 ++-- crypto/Cargo.lock | 6 +++--- example/Cargo.lock | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc6b180..8e8f6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1800,7 +1800,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/codec/Cargo.lock b/codec/Cargo.lock index 5ed6f53..a898cf4 100644 --- a/codec/Cargo.lock +++ b/codec/Cargo.lock @@ -187,9 +187,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1151,7 +1151,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/common/Cargo.lock b/common/Cargo.lock index 338d35d..8d0f18c 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -139,9 +139,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", diff --git a/crypto/Cargo.lock b/crypto/Cargo.lock index e536096..5b86012 100644 --- a/crypto/Cargo.lock +++ b/crypto/Cargo.lock @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -853,7 +853,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/example/Cargo.lock b/example/Cargo.lock index ba5bf49..f81b014 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1701,7 +1701,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] From 9cea795d1cec8b9492f3f4e3a0da5db0218c7b0c Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:42:32 +0200 Subject: [PATCH 18/18] ChaCha20 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc6b180..8e8f6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1800,7 +1800,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ]