From 6b6c0ae939995a5297b373a272602b77bb890e04 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:23:48 +0800 Subject: [PATCH 01/23] Add gateway webview and pending connection admission --- .gitignore | 3 + Cargo.lock | 176 ++- crates/core/src/dht/chord.rs | 18 +- crates/core/src/dht/chord_tests.rs | 4 +- crates/core/src/dht/stabilization.rs | 7 +- crates/core/src/dht/storage/mod.rs | 2 +- crates/core/src/dht/storage/repair.rs | 4 +- crates/core/src/dht/storage/sync.rs | 6 +- crates/core/src/dht/storage/tests.rs | 4 +- crates/core/src/dht/types.rs | 24 +- crates/core/src/error.rs | 55 +- crates/core/src/measure.rs | 24 +- crates/core/src/message/effects.rs | 6 + .../core/src/message/handlers/connection.rs | 92 +- crates/core/src/message/handlers/custom.rs | 4 +- crates/core/src/message/handlers/e2e.rs | 12 +- crates/core/src/message/handlers/mod.rs | 41 +- .../src/message/handlers/stabilization.rs | 10 +- crates/core/src/message/handlers/storage.rs | 50 +- crates/core/src/message/handlers/subring.rs | 8 +- crates/core/src/message/payload.rs | 4 +- crates/core/src/storage/memory.rs | 6 +- crates/core/src/storage/mod.rs | 11 +- crates/core/src/swarm/callback.rs | 91 +- crates/core/src/swarm/mod.rs | 24 +- crates/core/src/swarm/transport.rs | 536 +++++++-- crates/core/src/swarm/transport/tests.rs | 95 ++ crates/core/src/tests/default/mod.rs | 19 +- .../core/src/tests/default/test_chunk_e2e.rs | 14 + .../core/src/tests/default/test_connection.rs | 37 +- .../src/tests/default/test_message_handler.rs | 47 +- crates/core/src/tests/mod.rs | 7 +- crates/core/src/utils.rs | 6 +- crates/derive/src/lib.rs | 12 +- crates/node/src/processor/builder.rs | 4 +- crates/node/src/processor/config.rs | 4 +- crates/node/src/processor/mod.rs | 27 + .../node/src/processor/tests/test_config.rs | 8 +- .../node/src/processor/tests/test_network.rs | 33 +- crates/node/src/provider/browser/provider.rs | 4 +- crates/transport/src/connection_ref.rs | 11 +- crates/transport/src/connections/mod.rs | 20 +- crates/transport/src/core/callback.rs | 11 +- crates/transport/src/core/pool.rs | 7 +- crates/transport/src/core/transport.rs | 18 +- crates/transport/src/delivery.rs | 4 +- crates/transport/src/error.rs | 4 +- crates/transport/src/lib.rs | 8 - crates/transport/src/notifier.rs | 25 +- crates/transport/src/pool.rs | 4 +- crates/webview/Cargo.toml | 35 + crates/webview/src/browser.rs | 1006 ++++++++++++++++ crates/webview/src/cookie.rs | 225 ++++ crates/webview/src/cors.rs | 277 +++++ crates/webview/src/error.rs | 46 + crates/webview/src/header.rs | 218 ++++ crates/webview/src/lib.rs | 59 + crates/webview/src/render.rs | 169 +++ crates/webview/src/rewrite.rs | 677 +++++++++++ crates/webview/src/route.rs | 346 ++++++ crates/webview/src/transport.rs | 678 +++++++++++ crates/webview/src/types.rs | 216 ++++ crates/webview/src/url.rs | 156 +++ crates/webview/tests/browser_gateway.rs | 1056 +++++++++++++++++ crates/webview/tests/webview_flow.rs | 281 +++++ docker/cluster/Dockerfile | 1 + frontend/Cargo.lock | 196 ++- frontend/Cargo.toml | 4 + frontend/Trunk.toml | 1 + frontend/assets/webview-host.js | 182 +++ frontend/index.html | 5 +- frontend/rings-webview-service-worker.js | 422 +++++++ frontend/src/app.rs | 25 + frontend/src/app/actions.rs | 18 + frontend/src/browser_api.rs | 30 + frontend/src/controls.rs | 36 + frontend/src/controls/sidebar.rs | 18 +- frontend/src/lib.rs | 5 + frontend/src/node.rs | 18 + frontend/src/onion.rs | 40 +- frontend/src/styles/components.css | 334 ++++++ frontend/src/styles/dialogs.css | 24 +- frontend/src/styles/features.css | 62 +- frontend/src/styles/responsive.css | 51 + frontend/src/styles/theme/console.rs | 9 +- frontend/src/styles/theme/dialogs.rs | 10 +- frontend/src/styles/theme/topology.rs | 27 +- frontend/src/topology.rs | 147 ++- frontend/src/webview.rs | 932 +++++++++++++++ frontend/src/webview_ui.rs | 111 ++ frontend/src/workbench.rs | 10 +- 91 files changed, 9275 insertions(+), 539 deletions(-) create mode 100644 crates/webview/Cargo.toml create mode 100644 crates/webview/src/browser.rs create mode 100644 crates/webview/src/cookie.rs create mode 100644 crates/webview/src/cors.rs create mode 100644 crates/webview/src/error.rs create mode 100644 crates/webview/src/header.rs create mode 100644 crates/webview/src/lib.rs create mode 100644 crates/webview/src/render.rs create mode 100644 crates/webview/src/rewrite.rs create mode 100644 crates/webview/src/route.rs create mode 100644 crates/webview/src/transport.rs create mode 100644 crates/webview/src/types.rs create mode 100644 crates/webview/src/url.rs create mode 100644 crates/webview/tests/browser_gateway.rs create mode 100644 crates/webview/tests/webview_flow.rs create mode 100644 frontend/assets/webview-host.js create mode 100644 frontend/rings-webview-service-worker.js create mode 100644 frontend/src/webview.rs create mode 100644 frontend/src/webview_ui.rs diff --git a/.gitignore b/.gitignore index 5c78a733b..6892af0c0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ config*.yaml **/*.ipynb **/*.js **/*.mjs +!frontend/assets/webview-host.js +!frontend/rings-webview-service-worker.js +frontend/dist-e2e/ .ipynb_checkpoints # Local environment configuration .envrc diff --git a/Cargo.lock b/Cargo.lock index 21ed39eca..b5e0e33bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -861,9 +861,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -1368,6 +1368,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.104", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1660,6 +1683,21 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -1761,6 +1799,15 @@ dependencies = [ "log", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -2288,6 +2335,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -3083,6 +3132,25 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lol_html" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae574a677ef0443a0bd3c291f0cab9d1e61a3ad85a1515e3cfc0bc720d5a48e" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown 0.17.1", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.18", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3142,9 +3210,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -3655,6 +3723,50 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -3664,6 +3776,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -4529,6 +4650,23 @@ dependencies = [ "webrtc", ] +[[package]] +name = "rings-webview" +version = "0.14.0" +dependencies = [ + "async-trait", + "futures", + "js-sys", + "lol_html", + "percent-encoding", + "serde", + "serde-wasm-bindgen", + "thiserror 1.0.69", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rkyv" version = "0.8.16" @@ -4754,6 +4892,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.0" @@ -4892,6 +5049,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5129,7 +5295,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot 0.12.4", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] diff --git a/crates/core/src/dht/chord.rs b/crates/core/src/dht/chord.rs index eb16f1151..31fb41b0b 100644 --- a/crates/core/src/dht/chord.rs +++ b/crates/core/src/dht/chord.rs @@ -42,12 +42,12 @@ use crate::storage::MemStorage; /// `EntryStorage` is the type accepted by `PeerRing::new_with_storage`. /// It's used to store [Entry]s in a storage media provided by user. -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub type EntryStorage = Box>; /// `EntryStorage` is the type accepted by `PeerRing::new_with_storage`. /// It's used to store [Entry]s in a storage media provided by user. -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] pub type EntryStorage = Box + Send + Sync>; /// PeerRing is used to help a node interact with other nodes. @@ -584,8 +584,8 @@ impl PeerRing { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl ChordStorage for PeerRing { /// Look up an [`Entry`] by its ring key. /// Always finds resource by finger table, ignoring the local cache. @@ -644,8 +644,8 @@ impl ChordStorage for PeerRing } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl ChordStorageCache for PeerRing { /// Cache fetched `entry` locally. async fn local_cache_put(&self, entry: Entry) -> Result<()> { @@ -658,8 +658,8 @@ impl ChordStorageCache for PeerRing { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl CorrectChord for PeerRing { /// When Chord have a new successor, ask the new successor for successor list async fn update_successor(&self, did: impl LiveDid) -> Result { @@ -773,6 +773,6 @@ impl CorrectChord for PeerRing { } } -#[cfg(all(not(feature = "wasm"), test))] +#[cfg(all(not(all(feature = "wasm", target_family = "wasm")), test))] #[path = "chord_tests.rs"] mod tests; diff --git a/crates/core/src/dht/chord_tests.rs b/crates/core/src/dht/chord_tests.rs index a75598231..f239f01da 100644 --- a/crates/core/src/dht/chord_tests.rs +++ b/crates/core/src/dht/chord_tests.rs @@ -304,8 +304,8 @@ async fn test_correct_chord_impl() -> Result<()> { assert!(!assert_successor(n1, &n5.did)); #[allow(non_local_definitions)] - #[cfg_attr(feature = "wasm", async_trait(?Send))] - #[cfg_attr(not(feature = "wasm"), async_trait)] + #[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] + #[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl LiveDid for Did { async fn live(&self) -> bool { true diff --git a/crates/core/src/dht/stabilization.rs b/crates/core/src/dht/stabilization.rs index 0ffe34740..916593c04 100644 --- a/crates/core/src/dht/stabilization.rs +++ b/crates/core/src/dht/stabilization.rs @@ -92,7 +92,8 @@ impl Stabilizer { /// Clean unavailable connections in transport. pub async fn clean_unavailable_connections(&self) -> Result<()> { - let conns = self.transport.get_connections(); + self.transport.expire_pending_connections().await?; + let conns = self.transport.admitted_connections(); for (did, conn) in conns.into_iter() { // Only terminal states are cleaned. `Disconnected` is transient: ICE @@ -199,7 +200,7 @@ impl Stabilizer { } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] mod stabilizer { use std::sync::Arc; use std::time::Duration; @@ -228,7 +229,7 @@ mod stabilizer { } } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] mod stabilizer { use std::sync::Arc; use std::time::Duration; diff --git a/crates/core/src/dht/storage/mod.rs b/crates/core/src/dht/storage/mod.rs index 13002660f..1e0a86dd0 100644 --- a/crates/core/src/dht/storage/mod.rs +++ b/crates/core/src/dht/storage/mod.rs @@ -327,5 +327,5 @@ impl PeerRing { } } -#[cfg(all(not(feature = "wasm"), test))] +#[cfg(all(not(all(feature = "wasm", target_family = "wasm")), test))] mod tests; diff --git a/crates/core/src/dht/storage/repair.rs b/crates/core/src/dht/storage/repair.rs index 76ec85a6d..0dca04b39 100644 --- a/crates/core/src/dht/storage/repair.rs +++ b/crates/core/src/dht/storage/repair.rs @@ -222,8 +222,8 @@ impl PeerRing { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl ChordStorageRepair for PeerRing { async fn republish_local_entries(&self, redundancy: u16) -> Result { if redundancy <= 1 { diff --git a/crates/core/src/dht/storage/sync.rs b/crates/core/src/dht/storage/sync.rs index 451fcfd2d..41ecfd2a0 100644 --- a/crates/core/src/dht/storage/sync.rs +++ b/crates/core/src/dht/storage/sync.rs @@ -58,7 +58,7 @@ fn placed_entry_wire_cost(placed: &PlacedEntry) -> Result { serialized_wire_size(placed) } -#[cfg(all(test, not(feature = "wasm")))] +#[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] pub(super) fn sync_entries_batch_wire_cost(data: &[PlacedEntry]) -> Result { let mut cost = sync_entries_fixed_wire_cost()?; for placed in data { @@ -111,8 +111,8 @@ pub(super) fn sync_entries_batches( Ok(batches) } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl ChordStorageSync for PeerRing { /// When the successor of a node is updated, it needs to check if there are /// `Entry`s that are no longer between current node and `new_successor`, diff --git a/crates/core/src/dht/storage/tests.rs b/crates/core/src/dht/storage/tests.rs index c222b78ec..24531bb69 100644 --- a/crates/core/src/dht/storage/tests.rs +++ b/crates/core/src/dht/storage/tests.rs @@ -160,8 +160,8 @@ fn placed_entries_by_key(entries: impl IntoIterator) -> BTre struct FailingGetStorageFixture; -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl KvStorageInterface for FailingGetStorageFixture { // Test-only fixture for the read-error boundary. Browser/localStorage // adapters are production storage implementations and are cfg-excluded here. diff --git a/crates/core/src/dht/types.rs b/crates/core/src/dht/types.rs index 361521159..52f0d6f89 100644 --- a/crates/core/src/dht/types.rs +++ b/crates/core/src/dht/types.rs @@ -62,8 +62,8 @@ pub trait Chord { /// Some methods return an `Action`. It's because the real storing node may not be this /// node. The outer should take the action to forward the request to the real storing /// node. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorage: Chord { /// Look up an [`Entry`] by its ring key. /// Always finds resource by DHT, ignoring the local cache. @@ -74,8 +74,8 @@ pub trait ChordStorage: Chord { } /// ChordStorageSync defines storage hand-off triggered by ownership changes. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorageSync: Chord { /// When the successor of a node is updated, it needs to check if there are /// `Entry`s that are no longer between current node and `new_successor`, @@ -104,8 +104,8 @@ pub trait ChordStorageSync: Chord { /// Repair never deletes local copies. It only republishes a known [`Entry`] as /// a join delivery to the current affine placement set so missing owners can /// regain a copy. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorageRepair: Chord { /// Republish every locally stored entry to its current affine owners. /// @@ -127,8 +127,8 @@ pub trait ChordStorageRepair: Chord { } /// ChordStorageCache defines the basic API for getting and setting DHT cache storage. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorageCache: Chord { /// Cache fetched resource locally. async fn local_cache_put(&self, entry: Entry) -> Result<()>; @@ -166,8 +166,8 @@ pub trait ChordStorageCache: Chord { /// - `topo_info` is a helper function to get the topological info of the chord. /// /// Some methods return an `Action`. The reason is the same as [Chord]. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait CorrectChord: Chord { /// Join Operation in the paper. /// @@ -222,7 +222,7 @@ pub trait CorrectChord: Chord { /// /// Implementors of this trait must also be convertible into a `Did` type using the `Into` trait, and /// must satisfy some additional constraints (see below). -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] #[async_trait(?Send)] pub trait LiveDid: Into + Clone { /// Necessary method, should return true if a wrapped did is live. @@ -233,7 +233,7 @@ pub trait LiveDid: Into + Clone { /// /// Implementors of this trait must also be convertible into a `Did` type using the `Into` trait, and /// must satisfy some additional constraints (see below). -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[async_trait] pub trait LiveDid: Into + Clone + Send + Sync { /// Necessary method, should return true if a wrapped did is live. diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 2418a08f1..c8b0c2571 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -268,6 +268,17 @@ pub enum Error { #[error("Found existing transport when answer offer from remote node")] AlreadyConnected, + /// Pending WebRTC connection capacity {capacity} is exhausted + #[error("Pending WebRTC connection capacity {capacity} is exhausted")] + PendingConnectionCapacityExceeded { + /// Maximum number of concurrent pending peers. + capacity: usize, + }, + + /// Failed to access the swarm connection lifecycle state + #[error("Failed to access the swarm connection lifecycle state")] + SwarmConnectionLifecycleLock, + /// You should not connect to yourself #[error("You should not connect to yourself")] ShouldNotConnectSelf, @@ -369,7 +380,7 @@ pub enum Error { #[error("Invalid entry kind")] InvalidEntryKind, - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC new peer connection failed #[error("RTC new peer connection failed")] RTCPeerConnectionCreateFailed(#[source] webrtc::Error), @@ -378,22 +389,22 @@ pub enum Error { #[error("RTC peer_connection not establish")] RTCPeerConnectionNotEstablish, - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection fail to create offer #[error("RTC peer_connection fail to create offer")] RTCPeerConnectionCreateOfferFailed(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// RTC peer_connection fail to create offer #[error("RTC peer_connection fail to create offer")] RTCPeerConnectionCreateOfferFailed(String), - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection fail to create answer #[error("RTC peer_connection fail to create answer")] RTCPeerConnectionCreateAnswerFailed(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// RTC peer_connection fail to create answer #[error("RTC peer_connection fail to create answer")] RTCPeerConnectionCreateAnswerFailed(String), @@ -402,12 +413,12 @@ pub enum Error { #[error("DataChannel message size not match, {0} < {1}")] RTCDataChannelMessageIncomplete(usize, usize), - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// DataChannel send text message failed #[error("DataChannel send text message failed")] RTCDataChannelSendTextFailed(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// DataChannel send text message failed, {0} #[error("DataChannel send text message failed, {0}")] RTCDataChannelSendTextFailed(String), @@ -420,37 +431,37 @@ pub enum Error { #[error("DataChannel state not open")] RTCDataChannelStateNotOpen, - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection add ice candidate error #[error("RTC peer_connection add ice candidate error")] RTCPeerConnectionAddIceCandidateError(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// RTC peer_connection add ice candidate error #[error("RTC peer_connection add ice candidate error")] RTCPeerConnectionAddIceCandidateError(String), - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection set local description failed #[error("RTC peer_connection set local description failed")] RTCPeerConnectionSetLocalDescFailed(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// RTC peer_connection set local description failed #[error("RTC peer_connection set local description failed")] RTCPeerConnectionSetLocalDescFailed(String), - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection set remote description failed #[error("RTC peer_connection set remote description failed")] RTCPeerConnectionSetRemoteDescFailed(#[source] webrtc::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// RTC peer_connection set remote description failed #[error("RTC peer_connection set remote description failed")] RTCPeerConnectionSetRemoteDescFailed(String), - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// RTC peer_connection failed to close it #[error("RTC peer_connection failed to close it")] RTCPeerConnectionCloseFailed(#[source] webrtc::Error), @@ -507,7 +518,7 @@ pub enum Error { #[error("Only SEND message can reset destination")] ResetDestinationNeedSend, - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// IndexedDB error, {0} #[error("IndexedDB error, {0}")] IDBError(rexie::Error), @@ -516,7 +527,7 @@ pub enum Error { #[error("Invalid capacity value")] InvalidCapacity, - #[cfg(not(feature = "wasm"))] + #[cfg(not(all(feature = "wasm", target_family = "wasm")))] /// Sled error, {0} #[error("Sled error, {0}")] SledError(sled::Error), @@ -561,22 +572,22 @@ pub enum Error { #[error("Peer's negotiated max_message_size {0} is too small to carry even one chunk")] PeerMaxMessageSizeTooSmall(usize), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// Cannot get property {0} from JsValue #[error("Cannot get property {0} from JsValue")] FailedOnGetProperty(String), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// Cannot set property {0} from JsValue #[error("Cannot set property {0} from JsValue")] FailedOnSetProperty(String), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// Error on ser/der JsValue #[error("Error on ser/der JsValue")] SerdeWasmBindgenError(#[from] serde_wasm_bindgen::Error), - #[cfg(feature = "wasm")] + #[cfg(all(feature = "wasm", target_family = "wasm"))] /// Error create RTC connection: {0} #[error("Error create RTC connection: {0}")] CreateConnectionError(String), @@ -600,14 +611,14 @@ impl Error { } } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] impl From for wasm_bindgen::JsValue { fn from(err: Error) -> Self { wasm_bindgen::JsValue::from_str(&err.to_string()) } } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] impl From for Error { fn from(err: js_sys::Error) -> Self { Error::JsError(err.to_string().into()) diff --git a/crates/core/src/measure.rs b/crates/core/src/measure.rs index dedd7f079..2eb440d81 100644 --- a/crates/core/src/measure.rs +++ b/crates/core/src/measure.rs @@ -8,11 +8,11 @@ use async_trait::async_trait; use crate::dht::Did; /// Type of Measure, see [Measure]. -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] pub type MeasureImpl = Arc; /// Type of Measure, see [crate::measure::Measure]. -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub type MeasureImpl = Arc; /// The tag of counters in measure. @@ -216,8 +216,8 @@ pub fn order_peers_by_quality( /// `Measure` is used to assess the reliability of peers by counting their behaviour. /// It currently count the number of sent and received messages in a given period (1 hour). /// The method [Measure::incr] should be called in the proper places. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait Measure { /// `incr` increments the counter of the given peer. async fn incr(&self, did: Did, counter: MeasureCounter); @@ -226,8 +226,8 @@ pub trait Measure { } /// `BehaviourJudgement` classifies local evidence about a peer. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait BehaviourJudgement: Measure { /// Classify local peer quality for DHT connection scheduling. /// @@ -246,8 +246,8 @@ pub trait BehaviourJudgement: Measure { /// `ConnectBehaviour` trait offers a default implementation for the `good` method, providing a judgement /// based on a node's behavior in establishing connections. /// The "goodness" of a node is measured by comparing disconnection counts against a given threshold. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ConnectBehaviour: Measure { /// This asynchronous method returns a boolean indicating whether the node identified by `did` has a satisfactory connection behavior. async fn good(&self, did: Did) -> bool { @@ -266,8 +266,8 @@ pub trait ConnectBehaviour: Measure { /// `MessageSendBehaviour` trait provides a default implementation for the `good` method, judging a node's /// behavior based on its message sending capabilities. /// The "goodness" of a node is measured by comparing the sent and failed-to-send counts against a given threshold. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait MessageSendBehaviour: Measure { /// This asynchronous method returns a boolean indicating whether the node identified by `did` has a satisfactory message sending behavior. async fn good(&self, did: Did) -> bool { @@ -279,8 +279,8 @@ pub trait MessageSendBehaviour: Measure { /// `MessageRecvBehaviour` trait provides a default implementation for the `good` method, assessing a node's /// behavior based on its message receiving capabilities. /// The "goodness" of a node is measured by comparing the received and failed-to-receive counts against a given threshold. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait MessageRecvBehaviour: Measure { /// This asynchronous method returns a boolean indicating whether the node identified by `did` has a satisfactory message receiving behavior. async fn good(&self, did: Did) -> bool { diff --git a/crates/core/src/message/effects.rs b/crates/core/src/message/effects.rs index 98a5ea952..7c4bef5e6 100644 --- a/crates/core/src/message/effects.rs +++ b/crates/core/src/message/effects.rs @@ -451,6 +451,12 @@ impl<'handler> CoreEffectInterpreter<'handler> { ); match self.transport.connect(peer, callback).await { Ok(()) | Err(Error::AlreadyConnected) => Ok(()), + Err(Error::PendingConnectionCapacityExceeded { capacity }) => { + tracing::debug!( + "pending connection pool is full ({capacity}); skipping DHT candidate {peer}" + ); + Ok(()) + } Err(e) => Err(e), } } diff --git a/crates/core/src/message/handlers/connection.rs b/crates/core/src/message/handlers/connection.rs index e38acf5ce..8fb3b66f5 100644 --- a/crates/core/src/message/handlers/connection.rs +++ b/crates/core/src/message/handlers/connection.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::dht::types::Chord; use crate::dht::types::CorrectChord; +use crate::dht::Did; use crate::dht::PeerRingAction; use crate::dht::TopoInfo; use crate::error::Error; @@ -21,9 +22,25 @@ use crate::message::HandleMsg; use crate::message::MessageHandler; use crate::message::MessagePayload; +fn confirmed_topology(info: &TopoInfo, is_active: impl Fn(Did) -> bool) -> TopoInfo { + TopoInfo { + successors: info + .successors + .iter() + .copied() + .filter(|peer| is_active(*peer)) + .collect(), + predecessor: info.predecessor.filter(|peer| is_active(*peer)), + } +} + +fn topology_has_confirmed_peer(info: &TopoInfo) -> bool { + info.predecessor.is_some() || !info.successors.is_empty() +} + /// QueryForTopoInfoSend is direct message -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &QueryForTopoInfoSend) -> Result<()> { let info: TopoInfo = TopoInfo::try_from(self.dht.as_ref())?; @@ -40,8 +57,8 @@ impl HandleMsg for MessageHandler { } /// Try join received node into DHT after received from TopoInfo. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, _ctx: &MessagePayload, msg: &QueryForTopoInfoReport) -> Result<()> { match msg.then { @@ -53,24 +70,30 @@ impl HandleMsg for MessageHandler { } } ::Then::Stabilization => { - // Establish stabilization-learned candidates first so the - // resulting Notify/Query actions can usually send immediately. + // Candidates begin as non-routable pending handshakes. Only + // peers whose data channel has opened may enter the DHT view. let candidates = msg .info .predecessor .into_iter() .chain(msg.info.successors.iter().copied()); self.connect_dht_peers(candidates).await?; - let ev = self.dht.stabilize(msg.info.clone())?; - self.handle_dht_events(&ev).await?; + + let confirmed = confirmed_topology(&msg.info, |peer| { + self.transport.get_connection(peer).is_some() + }); + if topology_has_confirmed_peer(&confirmed) { + let ev = self.dht.stabilize(confirmed)?; + self.handle_dht_events(&ev).await?; + } } } Ok(()) } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &ConnectNodeSend) -> Result<()> { if !self.transport.accepts_connection_offer(msg) { @@ -99,8 +122,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &ConnectNodeReport) -> Result<()> { if ctx.should_forward_from(self.dht.did) { @@ -114,8 +137,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &FindSuccessorSend) -> Result<()> { match self.dht.find_successor(msg.did)? { @@ -148,8 +171,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &FindSuccessorReport) -> Result<()> { if ctx.should_forward_from(self.dht.did) { @@ -160,8 +183,9 @@ impl HandleMsg for MessageHandler { match &msg.handler { FindSuccessorReportHandler::FixFingerTable { index } => { - self.dht.apply_fixed_finger(*index, msg.did)?; - if msg.reports_remote_successor(self.dht.did) { + if self.transport.get_connection(msg.did).is_some() { + self.dht.apply_fixed_finger(*index, msg.did)?; + } else if msg.reports_remote_successor(self.dht.did) { self.connect_dht_peer(msg.did).await?; } } @@ -175,7 +199,7 @@ impl HandleMsg for MessageHandler { } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[cfg(test)] pub mod tests { //! tests @@ -192,6 +216,24 @@ pub mod tests { use crate::tests::default::Node; use crate::tests::manually_establish_connection; + #[test] + fn topology_report_keeps_only_confirmed_peers() { + let active = SecretKey::random().address().into(); + let pending_successor = SecretKey::random().address().into(); + let pending_predecessor = SecretKey::random().address().into(); + let confirmed = confirmed_topology( + &TopoInfo { + successors: vec![active, pending_successor], + predecessor: Some(pending_predecessor), + }, + |peer| peer == active, + ); + + assert_eq!(confirmed.successors, vec![active]); + assert_eq!(confirmed.predecessor, None); + assert!(topology_has_confirmed_peer(&confirmed)); + } + // node1.key < node2.key < node3.key // // Firstly, we connect node1 to node2, node2 to node3. @@ -631,12 +673,16 @@ pub mod tests { node1.assert_transports(vec![]); node2.assert_transports(vec![]); - { + + wait_until("both sides to remove each other from DHT fingers", || { let finger1 = node1.dht().lock_finger()?.clone().clone_finger(); let finger2 = node2.dht().lock_finger()?.clone().clone_finger(); - assert!(finger1.into_iter().all(|x| x.is_none())); - assert!(finger2.into_iter().all(|x| x.is_none())); - } + Ok( + finger1.into_iter().all(|x| x.is_none()) + && finger2.into_iter().all(|x| x.is_none()), + ) + }) + .await?; Ok(()) } diff --git a/crates/core/src/message/handlers/custom.rs b/crates/core/src/message/handlers/custom.rs index f6dae8719..926ba4eda 100644 --- a/crates/core/src/message/handlers/custom.rs +++ b/crates/core/src/message/handlers/custom.rs @@ -20,8 +20,8 @@ pub(crate) fn custom_message_effects<'payload>( } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, _: &CustomMessage) -> Result<()> { self.run_effects(custom_message_effects(self.dht.did, ctx)) diff --git a/crates/core/src/message/handlers/e2e.rs b/crates/core/src/message/handlers/e2e.rs index e87533ac0..e3c191c67 100644 --- a/crates/core/src/message/handlers/e2e.rs +++ b/crates/core/src/message/handlers/e2e.rs @@ -50,8 +50,8 @@ fn e2e_handshake_response_effect<'payload>( .into()) } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &E2eHandshakeRequest) -> Result<()> { run_e2e_local_or_forward(self, ctx, || { @@ -66,8 +66,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &E2eHandshakeResponse) -> Result<()> { run_e2e_local_or_forward(self, ctx, || { @@ -78,8 +78,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &E2eStreamFrame) -> Result<()> { run_e2e_local_or_forward(self, ctx, || { diff --git a/crates/core/src/message/handlers/mod.rs b/crates/core/src/message/handlers/mod.rs index 5a6b37efd..ebefc06b2 100644 --- a/crates/core/src/message/handlers/mod.rs +++ b/crates/core/src/message/handlers/mod.rs @@ -47,8 +47,8 @@ pub struct MessageHandler { } /// Generic trait for handle message ,inspired by Actor-Model. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait HandleMsg { /// Message handler. async fn handle(&self, ctx: &MessagePayload, msg: &T) -> Result<()>; @@ -117,26 +117,23 @@ impl MessageHandler { } pub(crate) async fn leave_dht(&self, peer: Did) -> Result<()> { - if self - .transport - .get_and_check_connection(peer) - .await - .is_none() - { - let should_repair = self + let should_repair = self + .dht + .peer_may_share_storage_responsibility(peer, self.transport.storage_redundancy()) + .await?; + if self.transport.is_admitted_connection(peer) { + self.transport.disconnect(peer).await?; + } else { + self.dht.remove(peer)?; + } + if should_repair { + let repair = self .dht - .peer_may_share_storage_responsibility(peer, self.transport.storage_redundancy()) + .republish_local_entries(self.transport.storage_redundancy()) .await?; - self.dht.remove(peer)?; - if should_repair { - let repair = self - .dht - .republish_local_entries(self.transport.storage_redundancy()) - .await?; - self.run_effects(storage::storage_sync_effects(repair)?) - .await?; - } - }; + self.run_effects(storage::storage_sync_effects(repair)?) + .await?; + } Ok(()) } @@ -194,8 +191,8 @@ impl MessageHandler { Ok(()) } - #[cfg_attr(feature = "wasm", async_recursion(?Send))] - #[cfg_attr(not(feature = "wasm"), async_recursion)] + #[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_recursion(?Send))] + #[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_recursion)] pub(crate) async fn handle_dht_events(&self, act: &PeerRingAction) -> Result<()> { if matches!(act, PeerRingAction::MultiActions(_)) { let mut effects = Vec::new(); diff --git a/crates/core/src/message/handlers/stabilization.rs b/crates/core/src/message/handlers/stabilization.rs index 0aa9c0b03..e7b52424c 100644 --- a/crates/core/src/message/handlers/stabilization.rs +++ b/crates/core/src/message/handlers/stabilization.rs @@ -14,8 +14,8 @@ use crate::message::HandleMsg; use crate::message::MessageHandler; use crate::message::MessagePayload; -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &NotifyPredecessorSend) -> Result<()> { let predecessor = self.dht.notify(msg.did)?; @@ -34,8 +34,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, _ctx: &MessagePayload, msg: &NotifyPredecessorReport) -> Result<()> { self.run_effects([ConnectionFunctor::connect_dht_peer(msg.did).into()]) @@ -56,7 +56,7 @@ impl HandleMsg for MessageHandler { } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[cfg(test)] mod test { use std::sync::Arc; diff --git a/crates/core/src/message/handlers/storage.rs b/crates/core/src/message/handlers/storage.rs index 33fcfb8df..df06174ea 100644 --- a/crates/core/src/message/handlers/storage.rs +++ b/crates/core/src/message/handlers/storage.rs @@ -39,8 +39,8 @@ use crate::swarm::transport::SwarmTransport; use crate::swarm::Swarm; /// ChordStorageInterface should imply necessary method for DHT storage -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorageInterface { /// Fetch an entry from DHT storage. async fn storage_fetch(&self, entry_key: Did) -> Result<()>; @@ -55,8 +55,8 @@ pub trait ChordStorageInterface { } /// ChordStorageInterfaceCacheChecker defines the interface for checking the local cache of the DHT. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait ChordStorageInterfaceCacheChecker { /// Check the local cache of the DHT for a specific entry key. /// @@ -95,8 +95,8 @@ async fn repair_observed_storage_misses( } /// Execute storage fetch actions for the Swarm-facing storage API. -#[cfg_attr(feature = "wasm", async_recursion(?Send))] -#[cfg_attr(not(feature = "wasm"), async_recursion)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_recursion(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_recursion)] async fn handle_storage_fetch_act( transport: Arc, resource: Did, @@ -148,8 +148,8 @@ async fn handle_storage_fetch_act( } /// Execute storage store actions for the Swarm-facing storage API. -#[cfg_attr(feature = "wasm", async_recursion(?Send))] -#[cfg_attr(not(feature = "wasm"), async_recursion)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_recursion(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_recursion)] pub(super) async fn handle_storage_store_act( transport: Arc, act: PeerRingAction, @@ -231,8 +231,8 @@ pub(super) fn storage_sync_effects(act: PeerRingAction) -> Result Option { @@ -411,8 +411,8 @@ impl ChordStorageInterfaceCacheChecker for Swarm { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl ChordStorageInterface for Swarm { /// Fetch an entry. If it exists in local storage, copy it to the cache; /// otherwise query the responsible remote node. @@ -465,8 +465,8 @@ impl ChordStorageInterface for Swarm { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { /// Search Entry via successor /// If a Entry is storead local, it will response immediately.(See Chordstorageinterface::storage_fetch) @@ -481,8 +481,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &FoundEntry) -> Result<()> { if ctx.should_forward_from(self.dht.did) { @@ -515,16 +515,16 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle(&self, ctx: &MessagePayload, msg: &PlacedEntryOperation) -> Result<()> { handle_placed_entry_operation(self, ctx, msg).await } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { // received remote sync entry request async fn handle(&self, ctx: &MessagePayload, msg: &SyncEntriesWithSuccessor) -> Result<()> { @@ -546,8 +546,8 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl HandleMsg for MessageHandler { async fn handle( &self, @@ -575,6 +575,6 @@ impl HandleMsg for MessageHandler { } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[cfg(test)] mod tests; diff --git a/crates/core/src/message/handlers/subring.rs b/crates/core/src/message/handlers/subring.rs index e9dd161a1..b64c7d517 100644 --- a/crates/core/src/message/handlers/subring.rs +++ b/crates/core/src/message/handlers/subring.rs @@ -9,15 +9,15 @@ use crate::prelude::entry::EntryOperation; use crate::swarm::Swarm; /// SubringInterface should imply necessary operator for DHT Subring -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait SubringInterface { /// join a subring async fn subring_join(&self, name: &str) -> Result<()>; } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl SubringInterface for Swarm { /// add did into current chord subring. /// send direct message with `JoinSubring` type, which will handled by `next` node. diff --git a/crates/core/src/message/payload.rs b/crates/core/src/message/payload.rs index 7421d250e..179a016df 100644 --- a/crates/core/src/message/payload.rs +++ b/crates/core/src/message/payload.rs @@ -285,8 +285,8 @@ impl Decoder for MessagePayload { } /// Trait of PayloadSender -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait PayloadSender { /// Get the session sk fn session_sk(&self) -> &SessionSk; diff --git a/crates/core/src/storage/memory.rs b/crates/core/src/storage/memory.rs index c10d30321..e8fe78605 100644 --- a/crates/core/src/storage/memory.rs +++ b/crates/core/src/storage/memory.rs @@ -23,8 +23,8 @@ where V: Clone } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl KvStorageInterface for MemStorage where V: Clone + Send + Sync { @@ -59,7 +59,7 @@ where V: Clone + Send + Sync } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index 019221056..fa5cec428 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -1,11 +1,14 @@ //! Module of MemStorage and PersistenceStorage -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] /// IndexedDB-backed storage for browser runtimes. pub mod idb; /// In-memory key value storage. pub mod memory; -#[cfg(all(not(feature = "wasm"), not(feature = "dummy")))] +#[cfg(all( + not(all(feature = "wasm", target_family = "wasm")), + not(feature = "dummy") +))] /// Sled-backed persistent storage for native runtimes. pub mod sled; @@ -15,8 +18,8 @@ use crate::error::Result; pub use crate::storage::memory::MemStorage; /// Key value storage interface -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait KvStorageInterface { /// Get a cache entry by `key`. async fn get(&self, key: &str) -> Result>; diff --git a/crates/core/src/swarm/callback.rs b/crates/core/src/swarm/callback.rs index 2ec32dd31..5d850c852 100644 --- a/crates/core/src/swarm/callback.rs +++ b/crates/core/src/swarm/callback.rs @@ -13,16 +13,17 @@ use crate::message::Message; use crate::message::MessageHandler; use crate::message::MessagePayload; use crate::message::MessageVerificationExt; +use crate::swarm::transport::PendingConnectionAttempt; use crate::swarm::transport::SwarmTransport; type CallbackError = Box; /// The [InnerSwarmCallback] will accept shared [SwarmCallback] trait object. -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub type SharedSwarmCallback = Arc; /// The [InnerSwarmCallback] will accept shared [SwarmCallback] trait object. -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] pub type SharedSwarmCallback = Arc; /// Used to notify the application of events that occur in the swarm. @@ -39,8 +40,8 @@ pub enum SwarmEvent { } /// Any object that implements this trait can be used as a callback for the swarm. -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] pub trait SwarmCallback { /// This method is invoked when a new message is received and before handling. async fn on_validate(&self, _payload: &MessagePayload) -> Result<(), CallbackError> { @@ -65,6 +66,7 @@ pub struct InnerSwarmCallback { message_handler: MessageHandler, callback: SharedSwarmCallback, reassembler: FuturesMutex, + pending_attempt: Option, } impl InnerSwarmCallback { @@ -77,9 +79,38 @@ impl InnerSwarmCallback { message_handler, callback, reassembler: FuturesMutex::new(reassembler), + pending_attempt: None, } } + /// Bind this callback to the pending handshake that created its transport. + pub(crate) fn with_pending_connection_attempt( + mut self, + pending_attempt: PendingConnectionAttempt, + ) -> Self { + self.pending_attempt = Some(pending_attempt); + self + } + + async fn admit_pending_connection(&self, did: Did) -> Result { + let Some(attempt) = self.pending_attempt else { + return Ok(false); + }; + if !self.transport.promote_pending_connection(attempt)? { + return Ok(false); + } + + self.transport.record_peer_connected(did).await; + self.message_handler.join_dht(did).await?; + self.callback + .on_event(&SwarmEvent::ConnectionStateChange { + peer: did, + state: WebrtcConnectionState::Connected, + }) + .await?; + Ok(true) + } + async fn handle_payload( &self, cid: &str, @@ -150,8 +181,8 @@ impl InnerSwarmCallback { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl TransportCallback for InnerSwarmCallback { async fn on_message(&self, cid: &str, msg: &[u8]) -> Result<(), CallbackError> { let peer = Did::from_str(cid).ok(); @@ -193,9 +224,22 @@ impl TransportCallback for InnerSwarmCallback { }; match s { - // `Failed` and `Closed` are terminal states, so we remove the peer - // from the DHT here. + // ICE `Connected` is not enough for routing: admission happens only + // after the data channel opens, because that is the first point at + // which payload transport is actually usable. + WebrtcConnectionState::Connected => {} + // `Failed` and `Closed` are terminal states. Pending handshakes are + // discarded without touching the DHT; active peers leave it. WebrtcConnectionState::Failed | WebrtcConnectionState::Closed => { + if let Some(attempt) = self.pending_attempt { + if self.transport.cancel_pending_connection(attempt).await? { + self.transport.record_peer_disconnected(did).await; + return Ok(()); + } + } + if !self.transport.is_admitted_connection(did) { + return Ok(()); + } self.transport.record_peer_disconnected(did).await; self.message_handler.leave_dht(did).await?; } @@ -212,9 +256,8 @@ impl TransportCallback for InnerSwarmCallback { _ => {} }; - // Should use the `on_data_channel_open` function to notify the Connected state. - // It prevents users from blocking the channel creation while - // waiting for data channel opening in send_message. + // Data-channel admission emits the application-level Connected event. + // Other state changes are passed through directly. if s != WebrtcConnectionState::Connected { self.callback .on_event(&SwarmEvent::ConnectionStateChange { @@ -233,18 +276,11 @@ impl TransportCallback for InnerSwarmCallback { return Ok(()); }; - self.transport.record_peer_connected(did).await; - self.message_handler.join_dht(did).await?; - - // Notify Connected state here instead of on_peer_connection_state_change. - // It prevents users from blocking the channel creation while - // waiting for data channel opening in send_message. - self.callback - .on_event(&SwarmEvent::ConnectionStateChange { - peer: did, - state: WebrtcConnectionState::Connected, - }) - .await + if !self.admit_pending_connection(did).await? && !self.transport.is_admitted_connection(did) + { + tracing::debug!("ignoring late data-channel open for {did}"); + } + Ok(()) } async fn on_data_channel_close(&self, cid: &str) -> Result<(), CallbackError> { @@ -258,6 +294,15 @@ impl TransportCallback for InnerSwarmCallback { // instead of waiting for the ICE state to reach `Failed`. This is the // graceful counterpart to a local `disconnect()`: the remote learns of // it promptly without relying on the transient `Disconnected` state. + if let Some(attempt) = self.pending_attempt { + if self.transport.cancel_pending_connection(attempt).await? { + self.transport.record_peer_disconnected(did).await; + return Ok(()); + } + } + if !self.transport.is_admitted_connection(did) { + return Ok(()); + } self.transport.record_peer_disconnected(did).await; self.message_handler.leave_dht(did).await?; Ok(()) diff --git a/crates/core/src/swarm/mod.rs b/crates/core/src/swarm/mod.rs index 0d5c8b897..a6b104597 100644 --- a/crates/core/src/swarm/mod.rs +++ b/crates/core/src/swarm/mod.rs @@ -128,9 +128,10 @@ impl Swarm { self.transport.disconnect(peer).await } - /// Connect a given Did. If the did is already connected, return directly, - /// else try prepare offer and establish connection by dht. - /// This function may returns a pending connection or connected connection. + /// Start a non-routable handshake with a peer. + /// + /// The peer becomes visible through the connection inspection APIs only + /// after its data channel opens and the swarm admits it to the DHT. pub async fn connect(&self, peer: Did) -> Result<()> { if peer == self.did() { return Err(Error::ShouldNotConnectSelf); @@ -143,7 +144,15 @@ impl Swarm { self.transport.send_message(msg, destination).await } - /// List peers and their connection status. + /// Send a message directly to an already connected peer, without a Chord lookup. + /// + /// This preserves an application protocol's explicit next-hop selection. Callers must + /// ensure the destination has an active direct transport connection. + pub async fn send_direct_message(&self, msg: Message, destination: Did) -> Result { + self.transport.send_direct_message(msg, destination).await + } + + /// List active, routable peers and their connection status. pub fn peers(&self) -> Vec { self.transport .get_connections() @@ -155,11 +164,16 @@ impl Swarm { .collect() } - /// List peer DIDs known to the transport. + /// List DIDs with active, routable transport connections. pub fn peer_dids(&self) -> Vec { self.transport.get_connection_ids() } + /// List DIDs whose direct WebRTC transport connection is active. + pub fn connected_peer_dids(&self) -> Vec { + self.transport.get_connection_ids() + } + /// Return local measurement counters for `peer`, if observed. pub async fn peer_measurement(&self, peer: Did) -> Option { self.transport.peer_measurement(peer).await diff --git a/crates/core/src/swarm/transport.rs b/crates/core/src/swarm/transport.rs index 7b1f36498..a69aeca07 100644 --- a/crates/core/src/swarm/transport.rs +++ b/crates/core/src/swarm/transport.rs @@ -1,24 +1,31 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::str::FromStr; use std::sync::Arc; use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; use async_trait::async_trait; use bytes::Bytes; use chrono::Utc; use rings_transport::connection_ref::ConnectionRef; -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] pub use rings_transport::connections::DummyConnection as ConnectionOwner; -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] pub use rings_transport::connections::DummyTransport as Transport; -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub use rings_transport::connections::WebSysWebrtcConnection as ConnectionOwner; -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub use rings_transport::connections::WebSysWebrtcTransport as Transport; -#[cfg(all(not(feature = "wasm"), not(feature = "dummy")))] +#[cfg(all( + not(all(feature = "wasm", target_family = "wasm")), + not(feature = "dummy") +))] use rings_transport::connections::WebrtcConnection as ConnectionOwner; -#[cfg(all(not(feature = "wasm"), not(feature = "dummy")))] +#[cfg(all( + not(all(feature = "wasm", target_family = "wasm")), + not(feature = "dummy") +))] use rings_transport::connections::WebrtcTransport as Transport; use rings_transport::core::transport::ConnectionInterface; use rings_transport::core::transport::TransportInterface; @@ -61,6 +68,106 @@ const STORAGE_LOOKUP_OBSERVATION_TTL_MS: i64 = 30_000; /// Maximum number of read-repair miss observation buckets retained per transport. pub(crate) const STORAGE_LOOKUP_OBSERVATION_CAPACITY: usize = 1024; +/// Maximum number of peers that may be handshaking before a data channel opens. +pub(crate) const DEFAULT_PENDING_CONNECTION_CAPACITY: usize = 32; + +const PENDING_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); + +/// Identifies one pending handshake for a peer. +/// +/// A peer can have a replacement handshake after a timeout. Callbacks carry +/// this token so a late callback from the replaced connection cannot promote +/// the newer handshake into the active routing set. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct PendingConnectionAttempt { + peer: Did, + generation: u64, +} + +#[derive(Debug)] +struct PendingPeer { + generation: u64, + admitted_at: Instant, +} + +/// Bounded, non-routable handshakes owned by the swarm lifecycle. +/// +/// The pool deliberately has no DHT reference: a peer is visible to Chord +/// only after its data channel opens and the matching attempt is promoted. +struct PendingPeerPool { + next_generation: u64, + peers: BTreeMap, +} + +impl PendingPeerPool { + fn new() -> Self { + Self { + next_generation: 0, + peers: BTreeMap::new(), + } + } + + fn reserve(&mut self, peer: Did, now: Instant) -> Result { + if self.peers.contains_key(&peer) { + return Err(Error::AlreadyConnected); + } + if self.peers.len() >= MAX_PENDING { + return Err(Error::PendingConnectionCapacityExceeded { + capacity: MAX_PENDING, + }); + } + + self.next_generation = self.next_generation.wrapping_add(1); + let attempt = PendingConnectionAttempt { + peer, + generation: self.next_generation, + }; + self.peers.insert(peer, PendingPeer { + generation: attempt.generation, + admitted_at: now, + }); + Ok(attempt) + } + + fn contains(&self, peer: Did) -> bool { + self.peers.contains_key(&peer) + } + + fn remove(&mut self, attempt: PendingConnectionAttempt) -> bool { + let Some(peer) = self.peers.get(&attempt.peer) else { + return false; + }; + if peer.generation != attempt.generation { + return false; + } + self.peers.remove(&attempt.peer); + true + } + + fn expire(&mut self, now: Instant) -> Vec { + let expired = + self.peers + .iter() + .filter_map(|(peer, pending)| { + (now.duration_since(pending.admitted_at) >= PENDING_CONNECTION_TIMEOUT) + .then_some(PendingConnectionAttempt { + peer: *peer, + generation: pending.generation, + }) + }) + .collect::>(); + for attempt in &expired { + self.peers.remove(&attempt.peer); + } + expired + } + + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] + fn len(&self) -> usize { + self.peers.len() + } +} + // Invariant: after every successful observation-buffer mutation, // observations.len() <= STORAGE_LOOKUP_OBSERVATION_CAPACITY. // Invariant: after evict_storage_lookup_observations(observations, now), every @@ -77,6 +184,8 @@ pub struct SwarmTransport { storage_redundancy: u16, dht_virtual_nodes: u16, reassembly_limits: ReassemblyLimits, + pending_peers: Mutex>, + active_peers: Mutex>, storage_lookup_observations: Mutex, pending_storage_sync_acks: Mutex, measured_disconnects: Mutex>, @@ -199,7 +308,7 @@ async fn record_measurement(measure: Option, did: Did, counter: Mea /// the eventual peer-quality observation. This keeps delivery tracking confined /// to the send site: the status never propagates up through the swarm/node /// layers. -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { wasm_bindgen_futures::spawn_local(async move { match fut.await { @@ -214,7 +323,7 @@ fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { /// Drive a message's [DeliveryFuture] to completion on the runtime, recording /// the eventual peer-quality observation. -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { tokio::spawn(async move { match fut.await { @@ -236,9 +345,9 @@ fn frame_chunk(session_sk: &SessionSk, did: Did, chunk: Chunk) -> Result /// The *tail* of a chunked message — every chunk after the first — yielded lazily. Boxed so the /// background task owns a concrete, nameable type (`Send` off the browser, where spawned tasks must /// be `Send`; single-threaded on it). -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] type ChunkTail = Box + Send>; -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] type ChunkTail = Box>; /// Drive the *tail* of a chunked send: the first chunk has already been accepted by the caller @@ -288,7 +397,7 @@ async fn run_chunked_send( /// Drive the tail of a chunked send on the runtime (one bounded task per large message). See /// [`run_chunked_send`]. -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] fn spawn_chunked_send( conn: SwarmConnection, tail: ChunkTail, @@ -309,7 +418,7 @@ fn spawn_chunked_send( /// Drive the tail of a chunked send on the runtime (one bounded task per large message). See /// [`run_chunked_send`]. -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] fn spawn_chunked_send( conn: SwarmConnection, tail: ChunkTail, @@ -349,6 +458,8 @@ impl SwarmTransport { storage_redundancy: settings.storage_redundancy, dht_virtual_nodes: settings.dht_virtual_nodes, reassembly_limits: settings.reassembly_limits, + pending_peers: Mutex::new(PendingPeerPool::new()), + active_peers: Mutex::new(BTreeSet::new()), storage_lookup_observations: Mutex::new(BTreeMap::new()), pending_storage_sync_acks: Mutex::new(BTreeMap::new()), measured_disconnects: Mutex::new(BTreeSet::new()), @@ -619,7 +730,7 @@ impl SwarmTransport { .collect()) } - #[cfg(all(test, not(feature = "wasm")))] + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] /// Test hook: make one observation bucket older than the freshness TTL. pub(crate) fn expire_storage_lookup_observation( &self, @@ -638,7 +749,7 @@ impl SwarmTransport { Ok(()) } - #[cfg(all(test, not(feature = "wasm")))] + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] /// Test hook: count retained observation buckets. pub(crate) fn storage_lookup_observation_count(&self) -> Result { let observations = self @@ -648,21 +759,22 @@ impl SwarmTransport { Ok(observations.len()) } - /// Create new connection that will be handled by swarm. - pub async fn new_connection(&self, peer: Did, callback: InnerSwarmCallback) -> Result<()> { - if peer == self.dht.did { - return Ok(()); - } + fn pending_peers( + &self, + ) -> Result>> + { + self.pending_peers + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } - let cid = peer.to_string(); - self.transport - .new_connection(&cid, Box::new(callback)) - .await - .map_err(Error::Transport) + fn active_peers(&self) -> Result>> { + self.active_peers + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) } - /// Get connection by did. - pub fn get_connection(&self, peer: Did) -> Option { + fn get_raw_connection(&self, peer: Did) -> Option { self.transport .connection(&peer.to_string()) .map(|conn| SwarmConnection { @@ -672,36 +784,212 @@ impl SwarmTransport { .ok() } - /// Get all connections in transport. - pub fn get_connections(&self) -> Vec<(Did, SwarmConnection)> { + fn is_pending_connection(&self, peer: Did) -> Result { + Ok(self.pending_peers()?.contains(peer)) + } + + /// Return whether `peer` completed a handshake and still owns a logical slot. + /// + /// Unlike [`Self::is_active_connection`], this remains true while a terminal + /// callback removes the peer, so lifecycle cleanup can evict it from the DHT + /// even after WebRTC reports `Closed`. + pub(crate) fn is_admitted_connection(&self, peer: Did) -> bool { + self.active_peers() + .map(|active| active.contains(&peer)) + .unwrap_or(false) + } + + /// Return whether `peer` completed its pending handshake and can route traffic. + /// + /// Data-channel open is the admission boundary. WebRTC may still report a + /// transient `Disconnected` state after admission, so only terminal states + /// make an admitted peer non-routable before its callback removes the slot. + pub(crate) fn is_active_connection(&self, peer: Did) -> bool { + self.is_admitted_connection(peer) + && self.get_raw_connection(peer).is_some_and(|connection| { + !matches!( + connection.webrtc_connection_state(), + WebrtcConnectionState::Failed | WebrtcConnectionState::Closed + ) + }) + } + + async fn reserve_pending_connection(&self, peer: Did) -> Result { + self.expire_pending_connections().await?; + if peer == self.dht.did { + return Err(Error::ShouldNotConnectSelf); + } + // A peer keeps its active slot through transient WebRTC state changes + // until its terminal callback removes it from the DHT. Do not admit a + // second pending handshake for that DID during this interval. + if self.is_admitted_connection(peer) { + return Err(Error::AlreadyConnected); + } + self.pending_peers()?.reserve(peer, Instant::now()) + } + + fn pending_attempt(&self, peer: Did) -> Result> { + let pending = self.pending_peers()?; + Ok(pending + .peers + .get(&peer) + .map(|pending| PendingConnectionAttempt { + peer, + generation: pending.generation, + })) + } + + pub(crate) fn promote_pending_connection( + &self, + attempt: PendingConnectionAttempt, + ) -> Result { + let mut pending = self.pending_peers()?; + if !pending.remove(attempt) { + return Ok(false); + } + drop(pending); + self.active_peers()?.insert(attempt.peer); + Ok(true) + } + + fn retire_pending_connection(&self, attempt: PendingConnectionAttempt) -> Result { + Ok(self.pending_peers()?.remove(attempt)) + } + + fn retire_active_connection(&self, peer: Did) -> Result { + Ok(self.active_peers()?.remove(&peer)) + } + + /// Cancel a current pending handshake and release its non-routable transport object. + pub(crate) async fn cancel_pending_connection( + &self, + attempt: PendingConnectionAttempt, + ) -> Result { + if !self.retire_pending_connection(attempt)? { + return Ok(false); + } self.transport - .connections() + .close_connection(&attempt.peer.to_string()) + .await + .map_err(Error::Transport)?; + Ok(true) + } + + async fn abandon_pending_connection(&self, attempt: PendingConnectionAttempt, operation: &str) { + if let Err(error) = self.cancel_pending_connection(attempt).await { + tracing::warn!( + "failed to cancel pending connection to {} after {operation}: {error}", + attempt.peer + ); + } + } + + /// Close pending handshakes whose data channel did not open before the deadline. + /// + /// These peers have never entered the DHT, so expiry only releases the + /// transport object; it deliberately performs no topology mutation. + pub(crate) async fn expire_pending_connections(&self) -> Result<()> { + let expired = self.pending_peers()?.expire(Instant::now()); + for attempt in expired { + tracing::warn!("pending connection to {} timed out", attempt.peer); + self.transport + .close_connection(&attempt.peer.to_string()) + .await + .map_err(Error::Transport)?; + } + Ok(()) + } + + /// Create a new non-routable transport connection and register its pending attempt. + async fn new_pending_connection( + &self, + attempt: PendingConnectionAttempt, + callback: InnerSwarmCallback, + ) -> Result<()> { + let cid = attempt.peer.to_string(); + if let Err(error) = self + .transport + .new_connection(&cid, Box::new(callback)) + .await + { + let _ = self.retire_pending_connection(attempt); + return Err(Error::Transport(error)); + } + Ok(()) + } + + /// Get an active, routable connection by DID. + /// + /// Pending and terminal physical transports are intentionally invisible here. + pub fn get_connection(&self, peer: Did) -> Option { + self.is_active_connection(peer) + .then(|| self.get_raw_connection(peer)) + .flatten() + } + + /// Get all active, routable transport connections. + pub fn get_connections(&self) -> Vec<(Did, SwarmConnection)> { + self.active_peer_ids() .into_iter() - .filter_map(|(k, v)| { - Did::from_str(&k).ok().map(|did| { - (did, SwarmConnection { - peer: did, - connection: v, - }) - }) + .filter_map(|peer| { + self.get_connection(peer) + .map(|connection| (peer, connection)) + }) + .collect() + } + + fn active_peer_ids(&self) -> Vec { + self.active_peers() + .map(|active| active.iter().copied().collect()) + .unwrap_or_default() + } + + /// Return admitted transports, including a terminal connection that still + /// needs lifecycle cleanup. This is deliberately internal: callers outside + /// the swarm only observe routable connections through [`Self::get_connections`]. + pub(crate) fn admitted_connections(&self) -> Vec<(Did, SwarmConnection)> { + self.active_peer_ids() + .into_iter() + .filter_map(|peer| { + self.get_raw_connection(peer) + .map(|connection| (peer, connection)) }) .collect() } - /// Get dids of all connections in transport. + /// Get DIDs of active, routable connections. pub fn get_connection_ids(&self) -> Vec { - self.transport - .connection_ids() + self.get_connections() .into_iter() - .filter_map(|k| Did::from_str(&k).ok()) + .map(|(peer, _)| peer) .collect() } - /// Disconnect a connection. There are three steps: - /// 1) remove from DHT; - /// 2) remove from Transport; - /// 3) close the connection; + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] + pub(crate) fn pending_connection_count(&self) -> Result { + Ok(self.pending_peers()?.len()) + } + + /// Disconnect a connection. + /// + /// Pending connections are never represented in the DHT, so cancelling one + /// only closes its transport object. Active connections leave the DHT before + /// the underlying WebRTC object is released. pub async fn disconnect(&self, peer: Did) -> Result<()> { + if let Some(attempt) = self.pending_attempt(peer)? { + self.cancel_pending_connection(attempt).await?; + return Ok(()); + } + + let was_active = self.retire_active_connection(peer)?; + if !was_active { + self.transport + .close_connection(&peer.to_string()) + .await + .map_err(Error::Transport)?; + return Ok(()); + } + tracing::info!("removing {peer} from DHT"); self.dht.remove(peer)?; self.transport @@ -713,24 +1001,32 @@ impl SwarmTransport { /// Connect a given Did. If the did is already connected, return Err, /// else try prepare offer and establish connection by dht. pub async fn connect(&self, peer: Did, callback: InnerSwarmCallback) -> Result<()> { - let offer_msg = match self.prepare_connection_offer(peer, callback).await { - Ok(offer_msg) => offer_msg, + let (attempt, offer_msg) = match self + .prepare_connection_offer_with_attempt(peer, callback) + .await + { + Ok(offer) => offer, Err(Error::AlreadyConnected) => return Err(Error::AlreadyConnected), Err(e) => { self.record_peer_message_send_failed(peer).await; return Err(e); } }; - self.send_message(Message::ConnectNodeSend(offer_msg), peer) - .await?; + if let Err(error) = self + .send_message(Message::ConnectNodeSend(offer_msg), peer) + .await + { + self.abandon_pending_connection(attempt, "sending connection offer") + .await; + return Err(error); + } Ok(()) } - /// Get connection by did and check if data channel is open. - /// This method will return None if the connection is not found. - /// This method will wait_for_data_channel_open. - /// If it's not ready in 8 seconds this method will close it and return None. - /// If it's ready in 8 seconds this method will return the connection. + /// Get an active connection by DID and verify that its data channel remains open. + /// This method will return None if the connection is not active. + /// It can wait for a transiently disconnected active connection to recover, + /// but pending handshakes never reach this path. /// See more information about [rings_transport::core::transport::WebrtcConnectionState]. /// See also method webrtc_wait_for_data_channel_open [rings_transport::core::transport::ConnectionInterface]. pub async fn get_and_check_connection(&self, peer: Did) -> Option { @@ -757,18 +1053,41 @@ impl SwarmTransport { peer: Did, callback: InnerSwarmCallback, ) -> Result { - if self.get_and_check_connection(peer).await.is_some() { - return Err(Error::AlreadyConnected); - }; + self.prepare_connection_offer_with_attempt(peer, callback) + .await + .map(|(_, offer)| offer) + } - self.new_connection(peer, callback).await?; - let conn = self - .transport - .connection(&peer.to_string()) - .map_err(Error::Transport)?; + async fn prepare_connection_offer_with_attempt( + &self, + peer: Did, + callback: InnerSwarmCallback, + ) -> Result<(PendingConnectionAttempt, ConnectNodeSend)> { + let attempt = self.reserve_pending_connection(peer).await?; + let callback = callback.with_pending_connection_attempt(attempt); + self.new_pending_connection(attempt, callback).await?; + let Some(conn) = self.get_raw_connection(peer) else { + self.abandon_pending_connection(attempt, "looking up the offer transport") + .await; + return Err(Error::SwarmMissTransport(peer)); + }; - let offer = conn.webrtc_create_offer().await.map_err(Error::Transport)?; - let offer_str = serde_json::to_string(&offer).map_err(|_| Error::SerializeToString)?; + let offer = match conn.connection.webrtc_create_offer().await { + Ok(offer) => offer, + Err(error) => { + self.abandon_pending_connection(attempt, "creating connection offer") + .await; + return Err(Error::Transport(error)); + } + }; + let offer_str = match serde_json::to_string(&offer) { + Ok(offer) => offer, + Err(_) => { + self.abandon_pending_connection(attempt, "serializing connection offer") + .await; + return Err(Error::SerializeToString); + } + }; let offer_msg = ConnectNodeSend { sdp: offer_str, network_id: self.network_id, @@ -776,7 +1095,7 @@ impl SwarmTransport { dht_virtual_nodes: self.dht_virtual_nodes, }; - Ok(offer_msg) + Ok((attempt, offer_msg)) } /// Answer the offer of remote connection. @@ -794,7 +1113,12 @@ impl SwarmTransport { let offer = serde_json::from_str(&offer_msg.sdp).map_err(Error::Deserialize)?; - if let Some(swarm_conn) = self.get_connection(peer) { + self.expire_pending_connections().await?; + if self.is_active_connection(peer) { + return Err(Error::AlreadyConnected); + } + + if let Some(swarm_conn) = self.get_raw_connection(peer) { // Solve the scenario of creating offers simultaneously. // // When both sides create_offer at the same time and trigger answer_offer of the other side, @@ -807,27 +1131,49 @@ impl SwarmTransport { // drop local offer and continue answer remote offer if self.dht.did > peer { // this connection will replaced by new connection created bellow - self.disconnect(peer).await?; + let pending = self.pending_attempt(peer)?; + if let Some(attempt) = pending { + self.cancel_pending_connection(attempt).await?; + } else { + self.transport + .close_connection(&peer.to_string()) + .await + .map_err(Error::Transport)?; + } } else { // ignore remote offer, and refuse to answer remote offer return Err(Error::AlreadyConnected); } - } else if self.get_and_check_connection(peer).await.is_some() { + } else { return Err(Error::AlreadyConnected); - }; - }; + } + } - self.new_connection(peer, callback).await?; - let conn = self - .transport - .connection(&peer.to_string()) - .map_err(Error::Transport)?; + let attempt = self.reserve_pending_connection(peer).await?; + let callback = callback.with_pending_connection_attempt(attempt); + self.new_pending_connection(attempt, callback).await?; + let Some(conn) = self.get_raw_connection(peer) else { + self.abandon_pending_connection(attempt, "looking up the answer transport") + .await; + return Err(Error::SwarmMissTransport(peer)); + }; - let answer = conn - .webrtc_answer_offer(offer) - .await - .map_err(Error::Transport)?; - let answer_str = serde_json::to_string(&answer).map_err(|_| Error::SerializeToString)?; + let answer = match conn.connection.webrtc_answer_offer(offer).await { + Ok(answer) => answer, + Err(error) => { + self.abandon_pending_connection(attempt, "creating connection answer") + .await; + return Err(Error::Transport(error)); + } + }; + let answer_str = match serde_json::to_string(&answer) { + Ok(answer) => answer, + Err(_) => { + self.abandon_pending_connection(attempt, "serializing connection answer") + .await; + return Err(Error::SerializeToString); + } + }; let answer_msg = ConnectNodeReport { sdp: answer_str, network_id: self.network_id, @@ -852,13 +1198,21 @@ impl SwarmTransport { let answer = serde_json::from_str(&answer_msg.sdp).map_err(Error::Deserialize)?; + if !self.is_pending_connection(peer)? { + return Err(Error::SwarmMissTransport(peer)); + } + let conn = self - .transport - .connection(&peer.to_string()) - .map_err(Error::Transport)?; - conn.webrtc_accept_answer(answer) - .await - .map_err(Error::Transport)?; + .get_raw_connection(peer) + .ok_or(Error::SwarmMissTransport(peer))?; + if let Err(error) = conn.connection.webrtc_accept_answer(answer).await { + let attempt = self.pending_attempt(peer)?; + if let Some(attempt) = attempt { + self.abandon_pending_connection(attempt, "accepting connection answer") + .await; + } + return Err(Error::Transport(error)); + } Ok(()) } @@ -883,8 +1237,8 @@ impl SwarmConnection { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl PayloadSender for SwarmTransport { fn session_sk(&self) -> &SessionSk { &self.session_sk @@ -979,8 +1333,8 @@ impl PayloadSender for SwarmTransport { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)] impl LiveDid for SwarmConnection { async fn live(&self) -> bool { self.webrtc_connection_state() == WebrtcConnectionState::Connected @@ -993,5 +1347,5 @@ impl From for Did { } } -#[cfg(all(test, not(feature = "wasm")))] +#[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] mod tests; diff --git a/crates/core/src/swarm/transport/tests.rs b/crates/core/src/swarm/transport/tests.rs index c186c8754..9fe1d86fc 100644 --- a/crates/core/src/swarm/transport/tests.rs +++ b/crates/core/src/swarm/transport/tests.rs @@ -5,6 +5,7 @@ use std::sync::Mutex; use async_trait::async_trait; use super::*; +use crate::dht::successor::SuccessorReader; use crate::dht::VirtualNodeConfig; use crate::dht::DEFAULT_FINGER_TABLE_SIZE; use crate::dht::DEFAULT_STORAGE_VIRTUAL_POSITIONS_PER_OWNER; @@ -13,6 +14,8 @@ use crate::ecc::SecretKey; use crate::measure::BehaviourJudgement; use crate::measure::Measure; use crate::storage::MemStorage; +use crate::swarm::callback::InnerSwarmCallback; +use crate::swarm::callback::SwarmCallback; use crate::swarm::SwarmBuilder; #[derive(Default)] @@ -70,6 +73,11 @@ impl BehaviourJudgement for RecordingMeasure { } } +struct NoopSwarmCallback; + +#[async_trait] +impl SwarmCallback for NoopSwarmCallback {} + fn transport_with_measure(measure: MeasureImpl) -> Result { let key = SecretKey::random(); let session_sk = SessionSk::new_with_seckey(&key)?; @@ -132,6 +140,93 @@ fn swarm_builder_normalizes_virtual_nodes_before_protocol_advertisement() -> Res Ok(()) } +#[test] +fn pending_peer_pool_is_bounded_and_rejects_duplicate_peers() -> Result<()> { + let mut pool = PendingPeerPool::<2>::new(); + let now = Instant::now(); + let peer_a = SecretKey::random().address().into(); + let peer_b = SecretKey::random().address().into(); + let peer_c = SecretKey::random().address().into(); + + let attempt_a = pool.reserve(peer_a, now)?; + assert!(matches!( + pool.reserve(peer_a, now), + Err(Error::AlreadyConnected) + )); + let _attempt_b = pool.reserve(peer_b, now)?; + assert!(matches!( + pool.reserve(peer_c, now), + Err(Error::PendingConnectionCapacityExceeded { capacity: 2 }) + )); + assert_eq!(pool.len(), 2); + + assert!(pool.remove(attempt_a)); + assert_eq!(pool.len(), 1); + assert!(pool.reserve(peer_c, now).is_ok()); + Ok(()) +} + +#[test] +fn stale_pending_callback_cannot_remove_a_replacement_attempt() -> Result<()> { + let mut pool = PendingPeerPool::<1>::new(); + let now = Instant::now(); + let peer = SecretKey::random().address().into(); + + let old_attempt = pool.reserve(peer, now)?; + assert!(pool.remove(old_attempt)); + let current_attempt = pool.reserve(peer, now)?; + + assert!(!pool.remove(old_attempt)); + assert!(pool.contains(peer)); + assert!(pool.remove(current_attempt)); + Ok(()) +} + +#[test] +fn pending_peer_pool_expires_unopened_handshakes() -> Result<()> { + let mut pool = PendingPeerPool::<1>::new(); + let now = Instant::now(); + let peer = SecretKey::random().address().into(); + let attempt = pool.reserve(peer, now)?; + + let expired = pool.expire(now + PENDING_CONNECTION_TIMEOUT); + assert_eq!(expired, vec![attempt]); + assert_eq!(pool.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn admitted_peer_cannot_be_replaced_by_a_pending_handshake() -> Result<()> { + let transport = transport_with_measure(Arc::new(RecordingMeasure::default()))?; + let peer = SecretKey::random().address().into(); + let attempt = transport.reserve_pending_connection(peer).await?; + + assert!(transport.promote_pending_connection(attempt)?); + assert!(matches!( + transport.reserve_pending_connection(peer).await, + Err(Error::AlreadyConnected) + )); + Ok(()) +} + +#[tokio::test] +async fn pending_offer_is_not_routable_or_visible_to_dht() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let peer = SecretKey::random().address().into(); + let callback = InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)); + + let _offer = transport.prepare_connection_offer(peer, callback).await?; + + assert!(transport.get_connection(peer).is_none()); + assert_eq!(transport.pending_connection_count()?, 1); + assert!(!transport.dht.successors().contains(&peer)?); + + transport.disconnect(peer).await?; + Ok(()) +} + #[test] fn connection_offer_protocol_mode_includes_storage_redundancy() -> Result<()> { let transport = transport_with_measure(Arc::new(RecordingMeasure::default()))?; diff --git a/crates/core/src/tests/default/mod.rs b/crates/core/src/tests/default/mod.rs index 636a279af..a76d15b04 100644 --- a/crates/core/src/tests/default/mod.rs +++ b/crates/core/src/tests/default/mod.rs @@ -71,15 +71,14 @@ impl Node { self.message_rx.lock().await.try_recv().ok() } - /// Whether any connection is still mid-handshake (`New`/`Connecting`) — i.e. its offer/answer - /// SDP exchange has not finished. Used to detect true quiescence without a wall clock. + /// Whether any connection is still mid-handshake. Used to detect true + /// quiescence without a wall clock. pub fn has_handshaking_connection(&self) -> bool { - self.swarm.transport.get_connections().iter().any(|(_, c)| { - matches!( - c.webrtc_connection_state(), - WebrtcConnectionState::New | WebrtcConnectionState::Connecting - ) - }) + self.swarm + .transport + .pending_connection_count() + .unwrap_or_default() + > 0 } pub fn did(&self) -> Did { @@ -325,8 +324,8 @@ pub async fn wait_for_msgs(nodes: impl IntoIterator) { drained }; let handshaking = || nodes.iter().any(|n| n.has_handshaking_connection()); - // A snapshot of every node's DHT. Reaching `Connected` fires `on_data_channel_open -> join_dht`, - // which mutates the DHT and emits more messages *after* the handshake finished — so true + // A snapshot of every node's DHT. Opening the data channel fires `join_dht`, which mutates the + // DHT and emits more messages *after* the ICE connection state reached `Connected` — so true // quiescence also requires the DHT to have stopped changing, not just the handshakes to be done. let snapshot = || { nodes diff --git a/crates/core/src/tests/default/test_chunk_e2e.rs b/crates/core/src/tests/default/test_chunk_e2e.rs index cb1ca9099..96acccf2e 100644 --- a/crates/core/src/tests/default/test_chunk_e2e.rs +++ b/crates/core/src/tests/default/test_chunk_e2e.rs @@ -4,10 +4,12 @@ //! just the pure `WireReserves::plan` decision. use rings_transport::connections::dummy_controlled; +use rings_transport::core::transport::WebrtcConnectionState; use crate::ecc::SecretKey; use crate::message::Message; use crate::tests::default::prepare_node; +use crate::tests::default::wait_for_connection_state; use crate::tests::manually_establish_connection; /// Read inbound messages on `node` until a `CustomMessage` arrives (skipping DHT bookkeeping), or @@ -29,6 +31,12 @@ async fn large_message_is_chunked_and_reassembled() { let node1 = prepare_node(key1).await; let node2 = prepare_node(key2).await; manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_connection_state(&node1, node2.did(), WebrtcConnectionState::Connected) + .await + .unwrap(); + wait_for_connection_state(&node2, node1.did(), WebrtcConnectionState::Connected) + .await + .unwrap(); // Force a small negotiated limit so the payload below must be chunked. Set it *after* the // handshake so the connect offer/answer themselves are unaffected. @@ -60,6 +68,12 @@ async fn negotiated_size_too_small_errors_without_partial_send() { let node1 = prepare_node(key1).await; let node2 = prepare_node(key2).await; manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_connection_state(&node1, node2.did(), WebrtcConnectionState::Connected) + .await + .unwrap(); + wait_for_connection_state(&node2, node1.did(), WebrtcConnectionState::Connected) + .await + .unwrap(); // Below `chunk_overhead + MIN_CHUNK_DATA`: no usable chunk size exists, so framing must reject // *before* any chunk is sent (the `None` is returned ahead of the send loop). diff --git a/crates/core/src/tests/default/test_connection.rs b/crates/core/src/tests/default/test_connection.rs index 80726feba..d204a0a4d 100644 --- a/crates/core/src/tests/default/test_connection.rs +++ b/crates/core/src/tests/default/test_connection.rs @@ -79,37 +79,12 @@ async fn test_handshake_on_both_sides(key1: SecretKey, key2: SecretKey, key3: Se _ = node1.swarm.connect(node2.did()).await; _ = node2.swarm.connect(node1.did()).await; - // Both sides have just initiated an outbound connection (offer created) but no answer has - // been exchanged yet, so neither has reached `Connected`. The exact pre-connected sub-state — - // `New` vs `Connecting` — depends on whether the peer's offer has already arrived and started - // ICE, which is webrtc-version/timing dependent; the invariant we assert is only that the glare - // handshake is still in progress. - let node1_to_node2 = node1 - .swarm - .transport - .get_connection(node2.did()) - .unwrap() - .webrtc_connection_state(); - assert!( - matches!( - node1_to_node2, - WebrtcConnectionState::New | WebrtcConnectionState::Connecting - ), - "swarm1 -> swarm2 should still be handshaking, got {node1_to_node2:?}", - ); - let node2_to_node1 = node2 - .swarm - .transport - .get_connection(node1.did()) - .unwrap() - .webrtc_connection_state(); - assert!( - matches!( - node2_to_node1, - WebrtcConnectionState::New | WebrtcConnectionState::Connecting - ), - "swarm2 -> swarm1 should still be handshaking, got {node2_to_node1:?}", - ); + // Both offers exist but neither handshake has been admitted. Pending peers + // must remain invisible to the public connection view. + assert!(node1.swarm.transport.get_connection(node2.did()).is_none()); + assert!(node2.swarm.transport.get_connection(node1.did()).is_none()); + assert_eq!(node1.swarm.transport.pending_connection_count().unwrap(), 1); + assert_eq!(node2.swarm.transport.pending_connection_count().unwrap(), 1); wait_for_msgs([&node1, &node2, &node3]).await; assert_no_more_msg([&node1, &node2, &node3]).await; diff --git a/crates/core/src/tests/default/test_message_handler.rs b/crates/core/src/tests/default/test_message_handler.rs index 7e8c2fc2f..827888231 100644 --- a/crates/core/src/tests/default/test_message_handler.rs +++ b/crates/core/src/tests/default/test_message_handler.rs @@ -37,6 +37,8 @@ use crate::tests::default::wait_for_msgs; use crate::tests::default::wait_for_predecessor; use crate::tests::default::wait_for_storage_entry; use crate::tests::default::wait_for_successor; +#[cfg(feature = "dummy")] +use crate::tests::default::Node; use crate::tests::manually_establish_connection; #[cfg(feature = "dummy")] @@ -45,6 +47,30 @@ struct NoopCallback; #[cfg(feature = "dummy")] impl SwarmCallback for NoopCallback {} +#[cfg(feature = "dummy")] +async fn drain_controlled_dummy_events() { + while dummy_controlled::pending() > 0 { + assert!(dummy_controlled::deliver(0).await); + tokio::task::yield_now().await; + } +} + +#[cfg(feature = "dummy")] +async fn drain_node_messages(nodes: &[&Node]) { + loop { + let mut drained = false; + for node in nodes { + while node.try_listen_once().await.is_some() { + drained = true; + } + } + if !drained { + return; + } + tokio::task::yield_now().await; + } +} + #[tokio::test] async fn test_handle_join() -> Result<()> { let key1 = SecretKey::random(); @@ -65,7 +91,7 @@ async fn test_handle_join() -> Result<()> { #[tokio::test] async fn test_join_dht_keeps_local_join_when_convergence_send_fails() -> Result<()> { dummy_controlled::enable(true); - dummy_controlled::set_max_message_size(0); + dummy_controlled::set_max_message_size(1); let key1 = SecretKey::random(); let key2 = SecretKey::random(); @@ -78,19 +104,15 @@ async fn test_join_dht_keeps_local_join_when_convergence_send_fails() -> Result< "controlled delivery should prevent automatic DataChannelOpen join" ); - dummy_controlled::enable(false); - dummy_controlled::set_max_message_size(1); - - let handler = MessageHandler::new(node1.swarm.transport.clone(), Arc::new(NoopCallback)); - let join_result = handler.join_dht(node2.did()).await; + drain_controlled_dummy_events().await; + dummy_controlled::enable(false); dummy_controlled::set_max_message_size(0); assert!( - join_result.is_ok(), - "join must not fail when follow-up convergence sends fail: {join_result:?}" + node1.dht().successors().list()?.contains(&node2.did()), + "local join must survive failed follow-up convergence sends" ); - assert!(node1.dht().successors().list()?.contains(&node2.did())); Ok(()) } @@ -106,8 +128,11 @@ async fn test_handle_dht_notify_remote_action_sends_predecessor_to_target() -> R let node3 = prepare_node(keys[2]).await; manually_establish_connection(&node1.swarm, &node2.swarm).await; - // Clear queued connection-open callbacks so this test exercises only the - // explicit handler action below, not automatic join/stabilization traffic. + drain_controlled_dummy_events().await; + drain_node_messages(&[&node1, &node2, &node3]).await; + + // Clear any empty controlled queue so this test exercises only the + // explicit handler action below. dummy_controlled::enable(false); let handler = MessageHandler::new(node1.swarm.transport.clone(), Arc::new(NoopCallback)); diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index ad004f3ca..4e338d974 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,9 +1,9 @@ use crate::swarm::Swarm; -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] pub mod wasm; -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] pub mod default; #[allow(dead_code)] @@ -22,7 +22,4 @@ pub async fn manually_establish_connection(swarm1: &Swarm, swarm2: &Swarm) { let offer = swarm1.create_offer(swarm2.did()).await.unwrap(); let answer = swarm2.answer_offer(offer).await.unwrap(); swarm1.accept_answer(answer).await.unwrap(); - - assert!(swarm1.transport.get_connection(swarm2.did()).is_some()); - assert!(swarm2.transport.get_connection(swarm1.did()).is_some()); } diff --git a/crates/core/src/utils.rs b/crates/core/src/utils.rs index 033d72d9d..8537e0f19 100644 --- a/crates/core/src/utils.rs +++ b/crates/core/src/utils.rs @@ -5,7 +5,7 @@ pub fn get_epoch_ms() -> u128 { Utc::now().timestamp_millis() as u128 } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] /// Toolset for wasm pub mod js_value { use serde::de::DeserializeOwned; @@ -38,7 +38,7 @@ pub mod js_value { } } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] /// Helpers for adapting JavaScript functions into async Rust callbacks. pub mod js_func { /// This macro will generate a wrapper for mapping a js_sys::Function with type fn(T, T, T, T) -> Promise<()> @@ -145,7 +145,7 @@ pub mod js_func { of!(of4, a: T0, b: T1, c: T2, d: T3); } -#[cfg(feature = "wasm")] +#[cfg(all(feature = "wasm", target_family = "wasm"))] /// Browser and worker utility functions for wasm runtimes. pub mod js_utils { use std::future::Future; diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 938c1b770..23f93a737 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -23,10 +23,14 @@ pub fn wasm_export(attr: TokenStream, input: TokenStream) -> TokenStream { .into(); } #[cfg(feature = "wasm")] - return match wasm_bindgen_macro_support::expand(attr.into(), input.into()) { - Ok(tokens) => tokens.into(), - Err(diagnostic) => (quote! { #diagnostic }).into(), - }; + { + let input: proc_macro2::TokenStream = input.into(); + quote! { + #[cfg_attr(target_family = "wasm", wasm_bindgen::prelude::wasm_bindgen)] + #input + } + .into() + } #[cfg(not(feature = "wasm"))] return input; diff --git a/crates/node/src/processor/builder.rs b/crates/node/src/processor/builder.rs index 864457ebd..5d2981a86 100644 --- a/crates/node/src/processor/builder.rs +++ b/crates/node/src/processor/builder.rs @@ -142,7 +142,9 @@ impl ProcessorBuilder { /// Add a custom periodic registration task. pub fn registration_task(mut self, task: T) -> Self - where T: RegistrationTask + 'static { + where + T: RegistrationTask + 'static, + { self.registration_tasks.push(Arc::new(task)); self } diff --git a/crates/node/src/processor/config.rs b/crates/node/src/processor/config.rs index 48292952c..e41b4e164 100644 --- a/crates/node/src/processor/config.rs +++ b/crates/node/src/processor/config.rs @@ -495,7 +495,9 @@ impl Serialize for ProcessorConfig { impl<'de> serde::de::Deserialize<'de> for ProcessorConfig { fn deserialize(deserializer: D) -> core::result::Result - where D: serde::Deserializer<'de> { + where + D: serde::Deserializer<'de>, + { match ProcessorConfigSerialized::deserialize(deserializer) { Ok(ins) => { let cfg: ProcessorConfig = ins diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index d2b7eab80..fd91763fe 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -394,6 +394,21 @@ impl Processor { .map_err(Error::SendMessage) } + /// Send a custom message to an already connected peer without Chord routing. + /// + /// Protocols with their own authenticated hop selection, such as onion circuits, use this + /// to keep the core transport from replacing their selected next hop. + pub async fn send_direct_message(&self, destination: Did, msg: &[u8]) -> Result { + tracing::info!("send_direct_message, message size: {:?}", msg.len()); + + let msg = Message::custom(msg).map_err(Error::SendMessage)?; + + self.swarm + .send_direct_message(msg, destination) + .await + .map_err(Error::SendMessage) + } + /// Send an E2E handshake request to a DID. /// /// The negotiated key is the peer's account/identity secp256k1 key, not @@ -528,6 +543,18 @@ impl Processor { self.send_message(destination, &msg_bytes).await } + /// Send a namespaced envelope directly to an already connected peer. + /// + /// This bypasses Chord routing while retaining the normal custom-message envelope codec. + pub async fn send_direct_envelope( + &self, + destination: Did, + envelope: &crate::extension::ext::Envelope, + ) -> Result { + let msg_bytes = envelope.encode()?; + self.send_direct_message(destination, &msg_bytes).await + } + /// check local cache of dht pub async fn storage_check_cache(&self, entry_key: Did) -> Option { self.swarm.storage_check_cache(entry_key).await diff --git a/crates/node/src/processor/tests/test_config.rs b/crates/node/src/processor/tests/test_config.rs index 5e483eeb5..92b6613ab 100644 --- a/crates/node/src/processor/tests/test_config.rs +++ b/crates/node/src/processor/tests/test_config.rs @@ -300,10 +300,10 @@ fn default_onion_exit_config_uses_native_tcp_backed_services() { assert!(config.advertise_onion_exit); assert_eq!(config.onion_exit_services, default_onion_exit_services()); - assert_eq!(config.onion_exit_services, vec![ - OnionExitService::tcp(), - OnionExitService::https() - ]); + assert_eq!( + config.onion_exit_services, + vec![OnionExitService::tcp(), OnionExitService::https()] + ); } #[test] diff --git a/crates/node/src/processor/tests/test_network.rs b/crates/node/src/processor/tests/test_network.rs index a1acb69b3..b9cffcd04 100644 --- a/crates/node/src/processor/tests/test_network.rs +++ b/crates/node/src/processor/tests/test_network.rs @@ -118,6 +118,30 @@ async fn test_processor_handshake_msg() { assert!(matches!(got_msg1, Message::CustomMessage(_))); } +#[tokio::test] +async fn test_processor_direct_message_reaches_connected_peer() { + let _network_guard = network_test_guard().await; + let callback1 = test_callback(); + let callback2 = test_callback(); + let p1 = prepare_processor().await; + let p2 = prepare_processor().await; + + p1.swarm.set_callback(callback1.clone()).unwrap(); + p2.swarm.set_callback(callback2.clone()).unwrap(); + connect_processors(&p1, &p2, &callback1, &callback2).await; + + p1.send_direct_message(p2.did(), b"direct-message") + .await + .unwrap(); + + let received = wait_for_inbound_message( + &callback2, + |message| matches!(message, Message::CustomMessage(custom) if custom.0 == b"direct-message"), + ) + .await; + assert!(matches!(received, Message::CustomMessage(_))); +} + #[tokio::test] async fn peer_measurement_is_absent_without_measure_or_observation() { let unmeasured = prepare_processor_with_identity_key(SecretKey::random()).await; @@ -170,9 +194,12 @@ async fn provider_exposes_sent_and_received_peer_measurements() { assert!(provider_measurement.evidence.sent >= 1); let rpc_value = provider - .request(Method::PeerMeasurement, PeerMeasurementRequest { - did: p2.did().to_string(), - }) + .request( + Method::PeerMeasurement, + PeerMeasurementRequest { + did: p2.did().to_string(), + }, + ) .await .unwrap(); let rpc_measurement: PeerMeasurementResponse = serde_json::from_value(rpc_value).unwrap(); diff --git a/crates/node/src/provider/browser/provider.rs b/crates/node/src/provider/browser/provider.rs index 8496c223c..52a61efa8 100644 --- a/crates/node/src/provider/browser/provider.rs +++ b/crates/node/src/provider/browser/provider.rs @@ -126,7 +126,7 @@ impl BrowserOnionDirectoryReader { let local = self.processor.did(); self.processor .swarm - .peer_dids() + .connected_peer_dids() .into_iter() .filter(|did| *did != local) .collect() @@ -336,7 +336,7 @@ impl BrowserOnionProxy { }; let envelope = crate::extension::ext::Envelope::new(ONION_CIRCUIT_NAMESPACE.to_string(), payload); - if let Err(error) = p.send_envelope(to, &envelope).await { + if let Err(error) = p.send_direct_envelope(to, &envelope).await { runtime.cancel_request(id); return Err(JsValue::from(JsError::from(error))); } diff --git a/crates/transport/src/connection_ref.rs b/crates/transport/src/connection_ref.rs index e613090de..93b41c063 100644 --- a/crates/transport/src/connection_ref.rs +++ b/crates/transport/src/connection_ref.rs @@ -49,7 +49,7 @@ impl ConnectionRef { } } -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] #[async_trait(?Send)] impl ConnectionInterface for ConnectionRef where @@ -110,7 +110,7 @@ where } } -#[cfg(not(feature = "web-sys-webrtc"))] +#[cfg(not(all(feature = "web-sys-webrtc", target_family = "wasm")))] #[async_trait] impl ConnectionInterface for ConnectionRef where @@ -181,8 +181,11 @@ mod tests { #[derive(Debug)] struct Mock; - #[cfg_attr(feature = "web-sys-webrtc", async_trait(?Send))] - #[cfg_attr(not(feature = "web-sys-webrtc"), async_trait)] + #[cfg_attr(all(feature = "web-sys-webrtc", target_family = "wasm"), async_trait(?Send))] + #[cfg_attr( + not(all(feature = "web-sys-webrtc", target_family = "wasm")), + async_trait + )] impl ConnectionInterface for Mock { type Sdp = String; type Error = Error; diff --git a/crates/transport/src/connections/mod.rs b/crates/transport/src/connections/mod.rs index 8541b266b..6a1ad3741 100644 --- a/crates/transport/src/connections/mod.rs +++ b/crates/transport/src/connections/mod.rs @@ -2,24 +2,24 @@ //! Plus a `WebSysWebrtcConnection` for wasm environment. //! Also provide a `DummyConnection` for testing. -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] mod dummy; -#[cfg(feature = "native-webrtc")] +#[cfg(all(feature = "native-webrtc", not(target_family = "wasm")))] mod native_webrtc; -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] mod web_sys_webrtc; -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] pub use crate::connections::dummy::controlled as dummy_controlled; -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] pub use crate::connections::dummy::DummyConnection; -#[cfg(feature = "dummy")] +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] pub use crate::connections::dummy::DummyTransport; -#[cfg(feature = "native-webrtc")] +#[cfg(all(feature = "native-webrtc", not(target_family = "wasm")))] pub use crate::connections::native_webrtc::WebrtcConnection; -#[cfg(feature = "native-webrtc")] +#[cfg(all(feature = "native-webrtc", not(target_family = "wasm")))] pub use crate::connections::native_webrtc::WebrtcTransport; -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub use crate::connections::web_sys_webrtc::WebSysWebrtcConnection; -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub use crate::connections::web_sys_webrtc::WebSysWebrtcTransport; diff --git a/crates/transport/src/core/callback.rs b/crates/transport/src/core/callback.rs index 039e62e27..0b94b48fe 100644 --- a/crates/transport/src/core/callback.rs +++ b/crates/transport/src/core/callback.rs @@ -12,8 +12,11 @@ use crate::core::transport::WebrtcConnectionState; type CallbackError = Box; /// Any object that implements this trait can be used as a callback for the connection. -#[cfg_attr(feature = "web-sys-webrtc", async_trait(?Send))] -#[cfg_attr(not(feature = "web-sys-webrtc"), async_trait)] +#[cfg_attr(all(feature = "web-sys-webrtc", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr( + not(all(feature = "web-sys-webrtc", target_family = "wasm")), + async_trait +)] pub trait TransportCallback { /// Notify the data channel is open. async fn on_data_channel_open(&self, _cid: &str) -> Result<(), CallbackError> { @@ -46,11 +49,11 @@ pub trait TransportCallback { /// The `new_connection` method of /// [TransportInterface](super::transport::TransportInterface) trait will /// accept boxed [TransportCallback] trait object. -#[cfg(not(feature = "web-sys-webrtc"))] +#[cfg(not(all(feature = "web-sys-webrtc", target_family = "wasm")))] pub type BoxedTransportCallback = Box; /// The `new_connection` method of /// [TransportInterface](super::transport::TransportInterface) trait will /// accept boxed [TransportCallback] trait object. -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub type BoxedTransportCallback = Box; diff --git a/crates/transport/src/core/pool.rs b/crates/transport/src/core/pool.rs index a32366d2e..6608e0adc 100644 --- a/crates/transport/src/core/pool.rs +++ b/crates/transport/src/core/pool.rs @@ -109,9 +109,12 @@ impl RoundRobin for RoundRobinPool { /// Extends `RoundRobin` with functionality for asynchronous message transmission, leveraging the pooled /// resources for communication. It's adaptable to various messaging patterns and data types, specified /// by the generic `Message` associated type. -#[cfg_attr(any(feature = "web-sys-webrtc", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(any(all(feature = "web-sys-webrtc", target_family = "wasm"), target_family = "wasm"), async_trait(?Send))] #[cfg_attr( - not(any(feature = "web-sys-webrtc", target_family = "wasm")), + not(any( + all(feature = "web-sys-webrtc", target_family = "wasm"), + target_family = "wasm" + )), async_trait )] pub trait MessageSenderPool: RoundRobin { diff --git a/crates/transport/src/core/transport.rs b/crates/transport/src/core/transport.rs index 0e766b43d..86c597420 100644 --- a/crates/transport/src/core/transport.rs +++ b/crates/transport/src/core/transport.rs @@ -83,8 +83,11 @@ pub fn effective_max_message_size(remote_sdp: &str) -> usize { /// The [ConnectionInterface] trait defines how to /// make webrtc ice handshake with a remote peer and then send data channel message to it. -#[cfg_attr(feature = "web-sys-webrtc", async_trait(?Send))] -#[cfg_attr(not(feature = "web-sys-webrtc"), async_trait)] +#[cfg_attr(all(feature = "web-sys-webrtc", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr( + not(all(feature = "web-sys-webrtc", target_family = "wasm")), + async_trait +)] pub trait ConnectionInterface { /// Sdp is used to expose local and remote session descriptions when handshaking. type Sdp: Serialize + DeserializeOwned; @@ -133,8 +136,11 @@ pub trait ConnectionInterface { /// This trait specifies how to management [ConnectionInterface] objects. /// Each platform must implement this trait for its own connection implementation. /// See [connections](crate::connections) module for examples. -#[cfg_attr(feature = "web-sys-webrtc", async_trait(?Send))] -#[cfg_attr(not(feature = "web-sys-webrtc"), async_trait)] +#[cfg_attr(all(feature = "web-sys-webrtc", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr( + not(all(feature = "web-sys-webrtc", target_family = "wasm")), + async_trait +)] pub trait TransportInterface { /// The connection type that is created by this trait. type Connection: ConnectionInterface; @@ -170,12 +176,12 @@ pub trait TransportInterface { } /// Used to store a boxed [TransportInterface] trait object. -#[cfg(not(feature = "web-sys-webrtc"))] +#[cfg(not(all(feature = "web-sys-webrtc", target_family = "wasm")))] pub type BoxedTransport = Box + Send + Sync>; /// Used to store a boxed [TransportInterface] trait object. -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub type BoxedTransport = Box>; #[cfg(test)] diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs index 9e885d99b..182fde8cb 100644 --- a/crates/transport/src/delivery.rs +++ b/crates/transport/src/delivery.rs @@ -25,9 +25,9 @@ use crate::error::Result; /// /// It is `Send` on native targets (so it can be spawned on a multi-threaded /// runtime) and `!Send` on wasm, matching the rest of the transport. -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub type DeliveryFuture = Pin>>>; /// A future resolving to the eventual fate of a sent message. -#[cfg(not(feature = "web-sys-webrtc"))] +#[cfg(not(all(feature = "web-sys-webrtc", target_family = "wasm")))] pub type DeliveryFuture = Pin> + Send>>; diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs index 6aef5c768..1fa91d745 100644 --- a/crates/transport/src/error.rs +++ b/crates/transport/src/error.rs @@ -31,7 +31,7 @@ pub enum Error { #[error("WebRTC error: {0}")] Webrtc(#[from] webrtc::error::Error), - #[cfg(feature = "web-sys-webrtc")] + #[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] /// WebSysWebRTC error: {} #[error("WebSysWebRTC error: {}", dump_js_value(.0))] WebSysWebrtc(wasm_bindgen::JsValue), @@ -85,7 +85,7 @@ pub enum Error { RoundRobinPoolEmpty, } -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] fn dump_js_value(v: &wasm_bindgen::JsValue) -> String { let Ok(s) = js_sys::JSON::stringify(v) else { return "Failed to stringify Error(JsValue)".to_string(); diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs index de26fe30d..db3a54e90 100644 --- a/crates/transport/src/lib.rs +++ b/crates/transport/src/lib.rs @@ -10,14 +10,6 @@ )] #![doc = include_str!("../README.md")] -#[cfg(all( - feature = "web-sys-webrtc", - any(feature = "dummy", feature = "native-webrtc") -))] -compile_error!( - "rings-transport feature `web-sys-webrtc` cannot be combined with native transport features" -); - pub mod callback; pub mod connection_ref; pub mod connections; diff --git a/crates/transport/src/notifier.rs b/crates/transport/src/notifier.rs index 28458ecad..a1112c607 100644 --- a/crates/transport/src/notifier.rs +++ b/crates/transport/src/notifier.rs @@ -40,13 +40,16 @@ impl Notifier { } /// Wake the notifier after the specified time. - #[cfg(not(any(feature = "web-sys-webrtc", feature = "native-webrtc")))] + #[cfg(not(any( + all(feature = "web-sys-webrtc", target_family = "wasm"), + all(feature = "native-webrtc", not(target_family = "wasm")) + )))] pub fn set_timeout(&self, seconds: u8) { self.set_timeout_ms(u64::from(seconds) * 1000); } /// Wake the notifier after the specified time. - #[cfg(feature = "native-webrtc")] + #[cfg(all(feature = "native-webrtc", not(target_family = "wasm")))] pub fn set_timeout(&self, seconds: u8) { let this = self.clone(); tokio::spawn(async move { @@ -56,7 +59,7 @@ impl Notifier { } /// Wake the notifier after the specified number of milliseconds. - #[cfg(feature = "native-webrtc")] + #[cfg(all(feature = "native-webrtc", not(target_family = "wasm")))] pub fn set_timeout_ms(&self, millis: u64) { let this = self.clone(); tokio::spawn(async move { @@ -66,19 +69,22 @@ impl Notifier { } /// Wake the notifier after the specified number of milliseconds. - #[cfg(not(any(feature = "web-sys-webrtc", feature = "native-webrtc")))] + #[cfg(not(any( + all(feature = "web-sys-webrtc", target_family = "wasm"), + all(feature = "native-webrtc", not(target_family = "wasm")) + )))] pub fn set_timeout_ms(&self, millis: u64) { native_timeout_scheduler::schedule_wake(self.clone(), millis); } /// Wake the notifier after the specified time. - #[cfg(feature = "web-sys-webrtc")] + #[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub fn set_timeout(&self, seconds: u8) { self.set_timeout_ms(u64::from(seconds) * 1000); } /// Wake the notifier after the specified number of milliseconds. - #[cfg(feature = "web-sys-webrtc")] + #[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] pub fn set_timeout_ms(&self, millis: u64) { use wasm_bindgen::JsCast; @@ -117,7 +123,10 @@ impl Future for Notifier { } } -#[cfg(not(any(feature = "web-sys-webrtc", feature = "native-webrtc")))] +#[cfg(not(any( + all(feature = "web-sys-webrtc", target_family = "wasm"), + all(feature = "native-webrtc", not(target_family = "wasm")) +)))] mod native_timeout_scheduler { use std::cmp::Ordering; use std::collections::BinaryHeap; @@ -301,7 +310,7 @@ mod native_timeout_scheduler { } // This is copied from utils module of rings-core crate. -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] mod js_utils { use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; diff --git a/crates/transport/src/pool.rs b/crates/transport/src/pool.rs index 5a46e78e7..bea4b04d9 100644 --- a/crates/transport/src/pool.rs +++ b/crates/transport/src/pool.rs @@ -54,7 +54,7 @@ impl Pool { } } -#[cfg(not(feature = "web-sys-webrtc"))] +#[cfg(not(all(feature = "web-sys-webrtc", target_family = "wasm")))] impl Pool where C: ConnectionInterface + Send + Sync, @@ -103,7 +103,7 @@ where } } -#[cfg(feature = "web-sys-webrtc")] +#[cfg(all(feature = "web-sys-webrtc", target_family = "wasm"))] impl Pool where C: ConnectionInterface, diff --git a/crates/webview/Cargo.toml b/crates/webview/Cargo.toml new file mode 100644 index 000000000..fbd47fb1c --- /dev/null +++ b/crates/webview/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "rings-webview" +description = "Reusable Rings webview gateway primitives for onion-backed browsing." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[features] +default = [] +browser = [ + "dep:js-sys", + "dep:serde-wasm-bindgen", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", +] + +[dependencies] +async-trait = { workspace = true } +js-sys = { workspace = true, optional = true } +lol_html = "3" +percent-encoding = "2" +serde = { version = "1.0.136", features = ["derive"] } +serde-wasm-bindgen = { workspace = true, optional = true } +thiserror = "1" +url = { version = "2", features = ["serde"] } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } + +[dev-dependencies] +futures = { version = "0.3.21", features = ["executor"] } + +[lints] +workspace = true diff --git a/crates/webview/src/browser.rs b/crates/webview/src/browser.rs new file mode 100644 index 000000000..1c8d5a130 --- /dev/null +++ b/crates/webview/src/browser.rs @@ -0,0 +1,1006 @@ +//! Browser-facing webview helpers. + +use url::Url; + +use crate::error::Result; +use crate::url::GatewayPrefix; + +/// JavaScript bootstrap template marker used by tests and consumers. +pub const BOOTSTRAP_MARKER: &str = "__ringsWebviewGateway"; + +/// Build a small runtime that routes browser-created URLs through the gateway prefix. +pub fn bootstrap_script(gateway_prefix: &str, document_url: &Url) -> String { + format!( + r#"(function() {{ + const prefix = {prefix:?}; + const targetBase = {target_base:?}; + const marker = "{marker}"; + const urlAttributes = new Set(["href", "src", "action", "poster", "data", "cite", "formaction", "manifest", "xlink:href"]); + const srcsetAttributes = new Set(["srcset", "imagesrcset"]); + const styleProxies = new WeakMap(); + if (globalThis[marker]) {{ + globalThis[marker].targetBase = targetBase; + return; + }} + const gatewayState = {{ targetBase }}; + globalThis[marker] = gatewayState; + function gatewayPathFromText(input) {{ + const text = String(input); + if (text.startsWith(prefix)) return text; + try {{ + const url = new URL(text); + if (globalThis.location?.origin && url.origin === globalThis.location.origin && url.pathname.startsWith(prefix)) {{ + return `${{url.pathname}}${{url.search}}${{url.hash}}`; + }} + }} catch (_error) {{}} + return undefined; + }} + function decodeGatewayTarget(input) {{ + const path = gatewayPathFromText(input); + if (!path?.startsWith(prefix)) return undefined; + const encoded = path.slice(prefix.length); + if (!encoded) return undefined; + try {{ + return decodeURIComponent(encoded); + }} catch (_error) {{ + return undefined; + }} + }} + function resolveTargetBase() {{ + const rawBase = globalThis.document?.querySelector?.("base[href]")?.getAttribute?.("href"); + if (!rawBase) return gatewayState.targetBase; + const decoded = decodeGatewayTarget(rawBase); + if (decoded) return decoded; + try {{ + return new URL(String(rawBase), gatewayState.targetBase).href; + }} catch (_error) {{ + return gatewayState.targetBase; + }} + }} + function isUnrewritableTarget(value) {{ + const lower = String(value).trim().toLowerCase(); + return !lower || ["javascript:", "mailto:", "tel:", "data:", "blob:", "about:"].some((scheme) => lower.startsWith(scheme)); + }} + function encodeTarget(input, base = resolveTargetBase()) {{ + const text = String(input); + if (isUnrewritableTarget(text)) return text; + const gatewayPath = gatewayPathFromText(text); + if (gatewayPath) return gatewayPath; + const url = new URL(text, base); + return prefix + encodeURIComponent(url.href); + }} + function requestShellNavigation(input) {{ + void input; + return false; + }} + function reportFormNavigation(message) {{ + try {{ + globalThis.__ringsWebviewDebugOverlay?.record?.("bootstrap", message); + }} catch (_error) {{}} + }} + function encodeUrlList(input, base) {{ + return String(input).trim().split(/\s+/).filter(Boolean).map((value) => encodeTarget(value, base)).join(" "); + }} + function encodeSrcset(input, base) {{ + return String(input).split(",").map((candidate) => {{ + const trimmed = candidate.trim(); + if (!trimmed) return ""; + const parts = trimmed.split(/\s+/, 2); + const rewritten = encodeTarget(parts[0], base); + return parts.length > 1 ? `${{rewritten}} ${{parts[1]}}` : rewritten; + }}).filter(Boolean).join(", "); + }} + function encodeCssText(input) {{ + const imports = String(input).replace(/(@import\s+)(['"])([^'"]+)\2/gi, function(match, importPrefix, quote, value) {{ + try {{ + return `${{importPrefix}}${{quote}}${{encodeTarget(value)}}${{quote}}`; + }} catch (_error) {{ + return match; + }} + }}); + return imports.replace(/url\(\s*(['"]?)(.*?)\1\s*\)/gi, function(match, quote, value) {{ + try {{ + const rewritten = encodeTarget(value); + const delimiter = quote || ""; + return `url(${{delimiter}}${{rewritten}}${{delimiter}})`; + }} catch (_error) {{ + return match; + }} + }}); + }} + function encodeRefreshText(input) {{ + return String(input).replace(/(\burl\s*=\s*)(['"]?)([^'"]+)\2/i, function(match, prefixText, quote, value) {{ + try {{ + const delimiter = quote || ""; + return `${{prefixText}}${{delimiter}}${{encodeTarget(value.trim())}}${{delimiter}}`; + }} catch (_error) {{ + return match; + }} + }}); + }} + function setRawAttribute(element, name, value) {{ + if (nativeSetAttribute) return nativeSetAttribute.call(element, name, value); + return element.setAttribute(name, value); + }} + function rewriteHtmlElement(element, base) {{ + const tagName = String(element.tagName || "").toLowerCase(); + for (const attribute of Array.from(element.attributes || [])) {{ + setRawAttribute(element, attribute.name, encodeElementAttribute(element, attribute.name, attribute.value, base)); + }} + if (tagName === "style") {{ + element.textContent = encodeCssText(element.textContent || ""); + }} + rewriteMetaRefreshElement(element); + }} + function rewriteHtmlTree(root, initialBase, maySetBase) {{ + let base = initialBase; + const elements = []; + if (root?.nodeType === 1) elements.push(root); + elements.push(...Array.from(root.querySelectorAll?.("*") || [])); + for (const element of elements) {{ + const tagName = String(element.tagName || "").toLowerCase(); + const baseHref = tagName === "base" ? element.getAttribute?.("href") : null; + rewriteHtmlElement(element, base); + if (maySetBase && baseHref != null) {{ + try {{ + base = new URL(baseHref, base).href; + maySetBase = false; + }} catch (_error) {{}} + }} + }} + }} + function injectSrcdocRuntime(root) {{ + const doc = globalThis.document; + if (!doc?.createElement) return; + const head = root.querySelector?.("head"); + let container = head || root; + let anchor = container.firstChild || null; + const existingBase = root.querySelector?.("base[href]"); + if (existingBase?.parentNode) {{ + container = existingBase.parentNode; + anchor = existingBase.nextSibling || null; + }} else {{ + const base = doc.createElement("base"); + setRawAttribute(base, "href", encodeTarget(resolveTargetBase())); + container.insertBefore?.(base, anchor); + anchor = base.nextSibling || null; + }} + const source = doc.querySelector?.("script[data-rings-webview-bootstrap]")?.textContent; + if (source) {{ + const script = doc.createElement("script"); + setRawAttribute(script, "data-rings-webview-bootstrap", ""); + script.textContent = source; + container.insertBefore?.(script, anchor); + }} + }} + function encodeSrcdoc(input) {{ + const doc = globalThis.document; + if (!doc?.createElement) return String(input); + const template = doc.createElement("template"); + setNativeInnerHtml(template, String(input)); + const root = template.content || template; + rewriteHtmlTree(root, resolveTargetBase(), true); + injectSrcdocRuntime(root); + return template.innerHTML; + }} + function encodeHtmlFragment(input) {{ + const doc = globalThis.document; + if (!doc?.createElement) return String(input); + const template = doc.createElement("template"); + setNativeInnerHtml(template, String(input)); + const root = template.content || template; + rewriteHtmlTree(root, resolveTargetBase(), !doc.querySelector?.("base[href]")); + return template.innerHTML; + }} + function encodeAttribute(name, value, base) {{ + const lower = String(name).toLowerCase(); + if (lower === "srcdoc") return encodeSrcdoc(value); + if (lower === "style") return encodeCssText(value); + if (lower === "ping") return encodeUrlList(value, base); + if (urlAttributes.has(lower)) return encodeTarget(value, base); + if (srcsetAttributes.has(lower)) return encodeSrcset(value, base); + return value; + }} + function isMetaRefreshElement(element) {{ + return String(element?.tagName || "").toLowerCase() === "meta" + && String(element.getAttribute?.("http-equiv") || "").trim().toLowerCase() === "refresh"; + }} + function encodeElementAttribute(element, name, value, base) {{ + if (String(name).toLowerCase() === "content" && isMetaRefreshElement(element)) {{ + return encodeRefreshText(value); + }} + return encodeAttribute(name, value, base); + }} + function rewriteMetaRefreshElement(element) {{ + if (!isMetaRefreshElement(element)) return; + const content = element.getAttribute?.("content"); + if (content != null) setRawAttribute(element, "content", encodeRefreshText(content)); + }} + function blockUnsupportedConstructor(name) {{ + const NativeConstructor = globalThis[name]; + if (!NativeConstructor) return; + const BlockedConstructor = function() {{ + throw new TypeError(`${{name}} is blocked by Rings WebView until gateway transport supports it`); + }}; + BlockedConstructor.prototype = NativeConstructor.prototype; + Object.setPrototypeOf?.(BlockedConstructor, NativeConstructor); + globalThis[name] = BlockedConstructor; + }} + function findPropertyDescriptor(proto, property) {{ + let current = proto; + while (current) {{ + const descriptor = Object.getOwnPropertyDescriptor(current, property); + if (descriptor) return descriptor; + current = Object.getPrototypeOf(current); + }} + return undefined; + }} + function patchUrlProperty(constructorName, property, attributeName) {{ + const proto = globalThis[constructorName]?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, property); + if (!descriptor?.set) return; + Object.defineProperty(proto, property, {{ + ...descriptor, + set(value) {{ + return descriptor.set.call(this, encodeAttribute(attributeName || property, value)); + }} + }}); + }} + function patchUrlListProperty(constructorName, property) {{ + const proto = globalThis[constructorName]?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, property); + if (!descriptor?.set) return; + Object.defineProperty(proto, property, {{ + ...descriptor, + set(value) {{ + return descriptor.set.call(this, encodeUrlList(value, resolveTargetBase())); + }} + }}); + }} + function patchHtmlProperty(property) {{ + const proto = globalThis.Element?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, property); + if (!descriptor?.set) return; + Object.defineProperty(proto, property, {{ + ...descriptor, + set(value) {{ + const tagName = String(this.tagName || "").toLowerCase(); + const rewritten = property === "innerHTML" && tagName === "style" ? encodeCssText(value) : encodeHtmlFragment(value); + return descriptor.set.call(this, rewritten); + }} + }}); + }} + function isStyleNode(node) {{ + return String(node?.tagName || node?.parentElement?.tagName || "").toLowerCase() === "style"; + }} + function patchStyleTextContent() {{ + const proto = globalThis.Node?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, "textContent"); + if (!descriptor?.set) return; + Object.defineProperty(proto, "textContent", {{ + ...descriptor, + set(value) {{ + return descriptor.set.call(this, isStyleNode(this) ? encodeCssText(value) : value); + }} + }}); + }} + function proxyStyle(style) {{ + if (!style || typeof Proxy === "undefined") return style; + const existing = styleProxies.get(style); + if (existing) return existing; + const proxy = new Proxy(style, {{ + get(target, property) {{ + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }}, + set(target, property, value) {{ + return Reflect.set(target, property, typeof value === "string" ? encodeCssText(value) : value, target); + }} + }}); + styleProxies.set(style, proxy); + return proxy; + }} + function patchStyleGetter(constructorName) {{ + const proto = globalThis[constructorName]?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, "style"); + if (!descriptor?.get) return; + Object.defineProperty(proto, "style", {{ + ...descriptor, + get() {{ + return proxyStyle(descriptor.get.call(this)); + }} + }}); + }} + function patchCssTextMethod(constructorName, method) {{ + const proto = globalThis[constructorName]?.prototype; + const native = proto?.[method]; + if (typeof native !== "function") return; + proto[method] = function(css, ...rest) {{ + return native.call(this, encodeCssText(css), ...rest); + }}; + }} + function patchCssStyleDeclaration() {{ + const proto = globalThis.CSSStyleDeclaration?.prototype; + if (!proto) return; + const nativeSetProperty = proto.setProperty; + if (typeof nativeSetProperty === "function") {{ + proto.setProperty = function(name, value, priority) {{ + return nativeSetProperty.call(this, name, encodeCssText(value), priority); + }}; + }} + const descriptor = findPropertyDescriptor(proto, "cssText"); + if (descriptor?.set) {{ + Object.defineProperty(proto, "cssText", {{ + ...descriptor, + set(value) {{ + return descriptor.set.call(this, encodeCssText(value)); + }} + }}); + }} + }} + function patchMetaRefreshProperty(property) {{ + const proto = globalThis.HTMLMetaElement?.prototype; + if (!proto) return; + const descriptor = findPropertyDescriptor(proto, property); + if (!descriptor?.set) return; + Object.defineProperty(proto, property, {{ + ...descriptor, + set(value) {{ + if (property === "httpEquiv" && String(value).trim().toLowerCase() === "refresh") {{ + const content = this.getAttribute?.("content"); + if (content != null) setRawAttribute(this, "content", encodeRefreshText(content)); + }} + const rewritten = property === "content" && isMetaRefreshElement(this) ? encodeRefreshText(value) : value; + return descriptor.set.call(this, rewritten); + }} + }}); + }} + function patchWindowOpen() {{ + const nativeOpen = globalThis.open?.bind(globalThis); + if (!nativeOpen) return; + globalThis.open = function(url, target, features) {{ + const rewritten = url == null || url === "" ? url : encodeTarget(url); + return nativeOpen(rewritten, target, features); + }}; + }} + function patchLocationNavigation() {{ + const proto = globalThis.Location?.prototype; + if (!proto) return; + for (const method of ["assign", "replace"]) {{ + const native = proto[method]; + if (typeof native !== "function") continue; + proto[method] = function(url) {{ + if (requestShellNavigation(url)) return; + return native.call(this, encodeTarget(url)); + }}; + }} + const descriptor = findPropertyDescriptor(proto, "href"); + if (!descriptor?.set) return; + Object.defineProperty(proto, "href", {{ + ...descriptor, + set(value) {{ + if (requestShellNavigation(value)) return; + return descriptor.set.call(this, encodeTarget(value)); + }} + }}); + }} + function formTargetUrl(form, submitter) {{ + const action = submitter?.getAttribute?.("formaction") + || form.getAttribute?.("action") + || form.action; + const decoded = decodeGatewayTarget(action); + const target = decoded || new URL(String(action || ""), resolveTargetBase()).href; + const url = new URL(target); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + return url; + }} + function navigateGetForm(form, submitter) {{ + if (String(form?.method || "get").toUpperCase() !== "GET") {{ + reportFormNavigation("Skipped non-GET form submission"); + return false; + }} + const targetName = String(form?.target || "").trim().toLowerCase(); + if (targetName && targetName !== "_self") {{ + reportFormNavigation(`Skipped form submission targeting ${{targetName}}`); + return false; + }} + if (typeof globalThis.FormData !== "function") {{ + reportFormNavigation("FormData is unavailable for GET form submission"); + return false; + }} + try {{ + const target = formTargetUrl(form, submitter); + if (!target) {{ + reportFormNavigation("GET form target is not HTTP(S)"); + return false; + }} + const fields = new URLSearchParams(); + const data = submitter ? new FormData(form, submitter) : new FormData(form); + for (const [name, value] of data.entries()) {{ + fields.append(String(name), typeof value === "string" ? value : String(value?.name || "")); + }} + target.search = fields.toString(); + if (requestShellNavigation(target.href)) return true; + const location = globalThis.location; + if (typeof location?.assign !== "function") return false; + location.assign(encodeTarget(target.href)); + return true; + }} catch (error) {{ + reportFormNavigation(`GET form interception failed: ${{String(error)}}`); + return false; + }} + }} + function patchGetFormSubmission() {{ + const document = globalThis.document; + if (document?.addEventListener) {{ + const intercept = function(event, form, submitter) {{ + if (!navigateGetForm(form, submitter)) return false; + event.preventDefault(); + event.stopImmediatePropagation?.(); + return true; + }}; + document.addEventListener("keydown", function(event) {{ + if (event.defaultPrevented || event.isComposing || event.key !== "Enter") return; + const form = event.target?.closest?.("form"); + reportFormNavigation("Captured Enter for GET form submission"); + intercept(event, form, undefined); + }}, true); + document.addEventListener("click", function(event) {{ + if (event.defaultPrevented || (event.button != null && event.button !== 0)) return; + const submitter = event.target?.closest?.("button, input"); + if (!submitter) return; + const tagName = String(submitter.tagName || "").toLowerCase(); + const type = String(submitter.type || "").toLowerCase(); + const isSubmit = (tagName === "button" && (!type || type === "submit")) + || (tagName === "input" && (type === "submit" || type === "image")); + if (!isSubmit) return; + reportFormNavigation("Captured submit control click"); + intercept(event, submitter.form || submitter.closest?.("form"), submitter); + }}, true); + document.addEventListener("submit", function(event) {{ + const form = event.target; + if (!(form instanceof globalThis.HTMLFormElement)) return; + intercept(event, form, event.submitter); + }}, true); + }} + const proto = globalThis.HTMLFormElement?.prototype; + const nativeSubmit = proto?.submit; + if (typeof nativeSubmit === "function") {{ + proto.submit = function() {{ + if (navigateGetForm(this, undefined)) return; + return nativeSubmit.call(this); + }}; + }} + }} + const nativeInnerHtmlSetter = findPropertyDescriptor(globalThis.Element?.prototype, "innerHTML")?.set; + function setNativeInnerHtml(element, value) {{ + if (nativeInnerHtmlSetter) return nativeInnerHtmlSetter.call(element, value); + return element.innerHTML = value; + }} + const nativeFetch = globalThis.fetch?.bind(globalThis); + if (nativeFetch) {{ + globalThis.fetch = function(input, init) {{ + const next = input instanceof Request ? new Request(encodeTarget(input.url), input) : encodeTarget(input); + try {{ + const headers = new Headers(init?.headers || (input instanceof Request ? next.headers : undefined)); + headers.set("X-Rings-Webview-Kind", "fetch"); + return nativeFetch(next, {{ ...init, headers }}); + }} catch (_error) {{ + return nativeFetch(next, init); + }} + }}; + }} + const NativeXHR = globalThis.XMLHttpRequest; + if (NativeXHR) {{ + globalThis.XMLHttpRequest = function() {{ + const xhr = new NativeXHR(); + const open = xhr.open; + xhr.open = function(method, url, async, user, password) {{ + const result = open.call(xhr, method, encodeTarget(url), async, user, password); + try {{ + xhr.setRequestHeader("X-Rings-Webview-Kind", "xhr"); + }} catch (_error) {{}} + return result; + }}; + return xhr; + }}; + }} + blockUnsupportedConstructor("WebSocket"); + blockUnsupportedConstructor("EventSource"); + blockUnsupportedConstructor("Worker"); + blockUnsupportedConstructor("SharedWorker"); + if (globalThis.navigator?.sendBeacon) {{ + globalThis.navigator.sendBeacon = function() {{ + return false; + }}; + }} + const nativeSetAttribute = globalThis.Element?.prototype?.setAttribute; + if (nativeSetAttribute) {{ + globalThis.Element.prototype.setAttribute = function(name, value) {{ + const lower = String(name).toLowerCase(); + if (lower === "http-equiv" && String(value).trim().toLowerCase() === "refresh") {{ + const content = this.getAttribute?.("content"); + if (content != null) setRawAttribute(this, "content", encodeRefreshText(content)); + }} + return nativeSetAttribute.call(this, name, encodeElementAttribute(this, name, value)); + }}; + }} + const nativeSetAttributeNs = globalThis.Element?.prototype?.setAttributeNS; + if (nativeSetAttributeNs) {{ + globalThis.Element.prototype.setAttributeNS = function(namespace, name, value) {{ + return nativeSetAttributeNs.call(this, namespace, name, encodeElementAttribute(this, name, value)); + }}; + }} + patchHtmlProperty("innerHTML"); + patchHtmlProperty("outerHTML"); + const nativeInsertAdjacentHtml = globalThis.Element?.prototype?.insertAdjacentHTML; + if (nativeInsertAdjacentHtml) {{ + globalThis.Element.prototype.insertAdjacentHTML = function(position, html) {{ + return nativeInsertAdjacentHtml.call(this, position, encodeHtmlFragment(html)); + }}; + }} + patchStyleTextContent(); + const nativeDocumentWrite = globalThis.Document?.prototype?.write; + if (nativeDocumentWrite) {{ + globalThis.Document.prototype.write = function(...parts) {{ + return nativeDocumentWrite.call(this, encodeHtmlFragment(parts.join(""))); + }}; + }} + patchCssStyleDeclaration(); + patchMetaRefreshProperty("content"); + patchMetaRefreshProperty("httpEquiv"); + patchWindowOpen(); + patchLocationNavigation(); + patchGetFormSubmission(); + for (const constructorName of ["HTMLElement", "SVGElement", "CSSStyleRule", "CSSFontFaceRule", "CSSPageRule", "CSSKeyframeRule"]) {{ + patchStyleGetter(constructorName); + }} + for (const [constructorName, method] of [ + ["CSSStyleSheet", "insertRule"], + ["CSSStyleSheet", "replace"], + ["CSSStyleSheet", "replaceSync"], + ["CSSGroupingRule", "insertRule"], + ["CSSKeyframesRule", "appendRule"] + ]) {{ + patchCssTextMethod(constructorName, method); + }} + patchUrlProperty("HTMLAnchorElement", "href"); + patchUrlListProperty("HTMLAnchorElement", "ping"); + patchUrlProperty("HTMLAreaElement", "href"); + patchUrlListProperty("HTMLAreaElement", "ping"); + patchUrlProperty("HTMLBaseElement", "href"); + patchUrlProperty("HTMLImageElement", "src"); + patchUrlProperty("HTMLImageElement", "srcset"); + patchUrlProperty("HTMLScriptElement", "src"); + patchUrlProperty("HTMLIFrameElement", "src"); + patchUrlProperty("HTMLIFrameElement", "srcdoc"); + patchUrlProperty("HTMLLinkElement", "href"); + patchUrlProperty("HTMLFormElement", "action"); + patchUrlProperty("HTMLInputElement", "src"); + patchUrlProperty("HTMLInputElement", "formAction", "formaction"); + patchUrlProperty("HTMLButtonElement", "formAction", "formaction"); + patchUrlProperty("HTMLSourceElement", "src"); + patchUrlProperty("HTMLSourceElement", "srcset"); + patchUrlProperty("HTMLVideoElement", "poster"); + patchUrlProperty("HTMLVideoElement", "src"); + patchUrlProperty("HTMLAudioElement", "src"); + patchUrlProperty("HTMLEmbedElement", "src"); + patchUrlProperty("HTMLObjectElement", "data"); + gatewayState.prefix = prefix; +}})();"#, + prefix = gateway_prefix, + target_base = document_url.as_str(), + marker = BOOTSTRAP_MARKER, + ) +} + +/// Resolve a runtime URL the same way the bootstrap does and encode it for the gateway. +pub fn runtime_gateway_url( + gateway_prefix: &GatewayPrefix, + document_url: &Url, + input: &str, +) -> Result> { + gateway_prefix.rewrite_url_value(document_url, input) +} + +#[cfg(target_arch = "wasm32")] +mod wasm { + use async_trait::async_trait; + use js_sys::Function; + use js_sys::Promise; + use js_sys::Reflect; + use wasm_bindgen::JsCast; + use wasm_bindgen::JsValue; + use wasm_bindgen_futures::JsFuture; + + use crate::error::Result; + use crate::error::WebviewError; + use crate::transport::GatewayTransport; + use crate::types::GatewayRequest; + use crate::types::GatewayResponse; + + /// Browser adapter for JS objects that expose `request(url, request)`. + pub struct OnionProxyJsTransport { + proxy: JsValue, + } + + impl OnionProxyJsTransport { + /// Build a transport around an existing browser onion proxy object. + pub fn new(proxy: JsValue) -> Self { + Self { proxy } + } + } + + #[async_trait(?Send)] + impl GatewayTransport for OnionProxyJsTransport { + async fn send(&self, request: GatewayRequest) -> Result { + let method = Reflect::get(&self.proxy, &JsValue::from_str("request")) + .map_err(|error| WebviewError::Browser(format!("{error:?}")))? + .dyn_into::() + .map_err(|_| WebviewError::Browser("proxy.request is not callable".to_string()))?; + let request_value = serde_wasm_bindgen::to_value(&request) + .map_err(|error| WebviewError::Browser(error.to_string()))?; + let value = method + .call2( + &self.proxy, + &JsValue::from_str(request.target.as_str()), + &request_value, + ) + .map_err(|error| WebviewError::Browser(format!("{error:?}")))?; + let response = JsFuture::from(Promise::from(value)) + .await + .map_err(|error| WebviewError::Browser(format!("{error:?}")))?; + serde_wasm_bindgen::from_value(response) + .map_err(|error| WebviewError::Browser(error.to_string())) + } + } + + pub use OnionProxyJsTransport as JsOnionProxyTransport; +} + +#[cfg(target_arch = "wasm32")] +pub use wasm::JsOnionProxyTransport; + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::WebviewError; + + #[test] + fn bootstrap_hooks_browser_network_entrypoints() -> Result<()> { + let document_url = Url::parse("https://example.test/docs/index.html")?; + let script = bootstrap_script("/webview/", &document_url); + + assert!(script.contains(BOOTSTRAP_MARKER)); + assert!(script.contains("targetBase")); + assert!(script.contains("https://example.test/docs/index.html")); + assert!(script.contains("globalThis.fetch")); + assert!(script.contains("X-Rings-Webview-Kind")); + assert!(script.contains("patchLocationNavigation")); + assert!(script.contains("resolveTargetBase")); + assert!(script.contains("XMLHttpRequest")); + assert!(script.contains("encodeSrcdoc")); + assert!(script.contains("HTMLIFrameElement")); + assert!(script.contains("blockUnsupportedConstructor")); + assert!(script.contains("WebSocket")); + assert!(script.contains("EventSource")); + assert!(script.contains("sendBeacon")); + assert!(script.contains("SharedWorker")); + assert!(script.contains("HTMLBaseElement")); + assert!(script.contains("setAttribute")); + assert!(script.contains("encodeURIComponent")); + Ok(()) + } + + #[test] + fn runtime_urls_resolve_against_target_document_not_gateway_location() -> Result<()> { + let prefix = GatewayPrefix::new("/webview/")?; + let document_url = Url::parse("https://example.test/docs/index.html")?; + + let fetch_url = runtime_gateway_url(&prefix, &document_url, "/api/data") + .and_then(|url| required_gateway_url(url, "/api/data"))?; + let xhr_url = runtime_gateway_url(&prefix, &document_url, "forms/submit") + .and_then(|url| required_gateway_url(url, "forms/submit"))?; + + assert_eq!( + prefix.decode_path(&fetch_url)?.as_url().as_str(), + "https://example.test/api/data" + ); + assert_eq!( + prefix.decode_path(&xhr_url)?.as_url().as_str(), + "https://example.test/docs/forms/submit" + ); + Ok(()) + } + + fn required_gateway_url(url: Option, input: &str) -> Result { + url.ok_or_else(|| WebviewError::InvalidGatewayUrl(input.to_string())) + } + + #[test] + fn bootstrap_executes_runtime_routing_in_javascript() -> Result<()> { + let document_url = Url::parse("https://example.test/docs/index.html")?; + let script = bootstrap_script("/webview/", &document_url); + let program = format!( + r#" +const calls = []; +function assert(condition, message) {{ + if (!condition) throw new Error(message); +}} +function assertThrows(operation, message) {{ + let threw = false; + try {{ + operation(); + }} catch (_error) {{ + threw = true; + }} + if (!threw) throw new Error(message); +}} +class Request {{ + constructor(input, init) {{ + this.url = String(input); + this.init = init; + }} +}} +globalThis.Request = Request; +globalThis.fetch = function(input, init) {{ + calls.push(["fetch", input instanceof Request ? input.url : String(input), init]); + return "fetch-result"; +}}; +class XMLHttpRequest {{ + open(method, url, async, user, password) {{ + calls.push(["xhr", method, url, async, user, password]); + return "xhr-result"; + }} +}} +globalThis.XMLHttpRequest = XMLHttpRequest; +class WebSocket {{ + constructor(url, protocols) {{ + calls.push(["websocket", url, protocols]); + }} +}} +globalThis.WebSocket = WebSocket; +class EventSource {{ + constructor(url, init) {{ + calls.push(["eventsource", url, init]); + }} +}} +globalThis.EventSource = EventSource; +class Worker {{ + constructor(url, options) {{ + calls.push(["worker", url, options]); + }} +}} +globalThis.Worker = Worker; +class SharedWorker {{ + constructor(url, options) {{ + calls.push(["sharedworker", url, options]); + }} +}} +globalThis.SharedWorker = SharedWorker; +Object.defineProperty(globalThis, "navigator", {{ + value: {{ + sendBeacon(url, data) {{ + calls.push(["beacon", url, data]); + return true; + }} + }}, + configurable: true +}}); +class Element {{ + setAttribute(name, value) {{ + calls.push(["attribute", name, value]); + }} +}} +globalThis.Element = Element; +class HTMLImageElement extends Element {{ + set src(value) {{ + calls.push(["property", "img.src", value]); + }} + set srcset(value) {{ + calls.push(["property", "img.srcset", value]); + }} +}} +globalThis.HTMLImageElement = HTMLImageElement; +const submitListeners = []; +Object.defineProperty(globalThis, "location", {{ + value: {{ + origin: "http://127.0.0.1:3000", + assign(url) {{ calls.push(["navigate", url]); }} + }}, + configurable: true +}}); +Object.defineProperty(globalThis, "parent", {{ + value: {{ + postMessage(message, targetOrigin) {{ calls.push(["shell-navigation", message, targetOrigin]); }} + }}, + configurable: true +}}); +Object.defineProperty(globalThis, "document", {{ + value: {{ + querySelector() {{ return null; }}, + addEventListener(type, listener, capture) {{ + if (type === "submit" && capture) submitListeners.push(listener); + }} + }}, + configurable: true +}}); +class HTMLFormElement extends Element {{ + constructor() {{ + super(); + this.method = "get"; + this.target = ""; + this.attributes = new Map(); + this.fields = []; + }} + getAttribute(name) {{ return this.attributes.get(name) || null; }} + setAttribute(name, value) {{ this.attributes.set(name, value); }} + submit() {{ calls.push(["native-form-submit"]); }} +}} +globalThis.HTMLFormElement = HTMLFormElement; +globalThis.FormData = class FormData {{ + constructor(form) {{ this.fields = form.fields; }} + entries() {{ return this.fields[Symbol.iterator](); }} +}}; +{script} +const gateway = (url) => "/webview/" + encodeURIComponent(url); +await fetch("/api/data"); +await fetch(new Request("forms/submit"), {{ method: "POST" }}); +const xhr = new globalThis.XMLHttpRequest(); +xhr.open("POST", "forms/x", true); +assertThrows(() => new globalThis.WebSocket("/socket"), "WebSocket was not blocked"); +assertThrows(() => new globalThis.EventSource("events"), "EventSource was not blocked"); +const beaconResult = navigator.sendBeacon("/beacon", "payload"); +assert(beaconResult === false, "sendBeacon was not blocked"); +assertThrows(() => new globalThis.Worker("worker.js"), "Worker was not blocked"); +assertThrows(() => new globalThis.SharedWorker("shared.js"), "SharedWorker was not blocked"); +const element = new Element(); +element.setAttribute("src", "image.png"); +element.setAttribute("srcset", "small.png 1x, /big.png 2x"); +element.setAttribute("aria-label", "unchanged"); +const image = new globalThis.HTMLImageElement(); +image.src = "property.png"; +image.srcset = "property-small.png 1x, /property-big.png 2x"; +const form = new globalThis.HTMLFormElement(); +form.setAttribute("action", gateway("https://example.test/docs/search?existing=1")); +form.fields = [["q", "test"]]; +const submitEvent = {{ + target: form, + submitter: null, + preventDefault() {{ this.prevented = true; }}, + stopImmediatePropagation() {{ this.stopped = true; }} +}}; +for (const listener of submitListeners) listener(submitEvent); +const actual = JSON.stringify(calls); +assert(calls.some((call) => call[0] === "fetch" && call[1] === gateway("https://example.test/api/data")), "fetch URL was not rewritten: " + actual); +assert(calls.some((call) => call[0] === "fetch" && call[1] === gateway("https://example.test/docs/forms/submit")), "Request URL was not rewritten: " + actual); +assert(calls.some((call) => call[0] === "xhr" && call[2] === gateway("https://example.test/docs/forms/x")), "XHR URL was not rewritten: " + actual); +assert(!calls.some((call) => ["websocket", "eventsource", "beacon", "worker", "sharedworker"].includes(call[0])), "unsupported native entrypoint was called: " + actual); +assert(calls.some((call) => call[0] === "attribute" && call[1] === "src" && call[2] === gateway("https://example.test/docs/image.png")), "setAttribute src was not rewritten: " + actual); +assert(calls.some((call) => call[0] === "attribute" && call[1] === "srcset" && call[2].includes(gateway("https://example.test/docs/small.png")) && call[2].includes(gateway("https://example.test/big.png"))), "setAttribute srcset was not rewritten: " + actual); +assert(calls.some((call) => call[0] === "attribute" && call[1] === "aria-label" && call[2] === "unchanged"), "non-URL attribute changed: " + actual); +assert(calls.some((call) => call[0] === "property" && call[1] === "img.src" && call[2] === gateway("https://example.test/docs/property.png")), "img.src property was not rewritten: " + actual); +assert(calls.some((call) => call[0] === "property" && call[1] === "img.srcset" && call[2].includes(gateway("https://example.test/docs/property-small.png")) && call[2].includes(gateway("https://example.test/property-big.png"))), "img.srcset property was not rewritten: " + actual); +assert(submitEvent.prevented && submitEvent.stopped, "GET form submission was not intercepted"); +assert(calls.some((call) => call[0] === "navigate" && call[1] === gateway("https://example.test/docs/search?q=test")), "GET form query did not use native gateway navigation: " + actual); +"#, + script = script + ); + + let output = std::process::Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(program) + .output() + .map_err(|error| WebviewError::Browser(error.to_string()))?; + + if !output.status.success() { + return Err(WebviewError::Browser(format!( + "node bootstrap test failed: stdout={} stderr={}", + String::from_utf8_lossy(output.stdout.as_slice()), + String::from_utf8_lossy(output.stderr.as_slice()) + ))); + } + Ok(()) + } + + #[test] + fn bootstrap_runtime_urls_follow_rewritten_base_href() -> Result<()> { + let document_url = Url::parse("https://example.test/docs/index.html")?; + let script = bootstrap_script("/webview/", &document_url); + let program = format!( + r#" +const calls = []; +const gateway = (url) => "/webview/" + encodeURIComponent(url); +function assert(condition, message) {{ + if (!condition) throw new Error(message); +}} +Object.defineProperty(globalThis, "location", {{ + value: {{ + href: "http://127.0.0.1:3000/webview/" + encodeURIComponent("https://example.test/docs/index.html"), + origin: "http://127.0.0.1:3000" + }}, + configurable: true +}}); +Object.defineProperty(globalThis, "document", {{ + value: {{ + querySelector(selector) {{ + if (selector !== "base[href]") return null; + return {{ + getAttribute(name) {{ + return name === "href" ? gateway("https://example.test/assets/") : null; + }} + }}; + }} + }}, + configurable: true +}}); +class Request {{ + constructor(input) {{ + this.url = String(input); + }} +}} +globalThis.Request = Request; +globalThis.fetch = function(input, init) {{ + calls.push(["fetch", input instanceof Request ? input.url : String(input), init]); + return "fetch-result"; +}}; +class XMLHttpRequest {{ + open(method, url, async, user, password) {{ + calls.push(["xhr", method, url, async, user, password]); + }} +}} +globalThis.XMLHttpRequest = XMLHttpRequest; +class Element {{ + setAttribute(name, value) {{ + calls.push(["attribute", name, value]); + }} +}} +globalThis.Element = Element; +class HTMLImageElement extends Element {{ + set src(value) {{ + calls.push(["property", "img.src", value]); + }} +}} +globalThis.HTMLImageElement = HTMLImageElement; +{script} +await fetch("api/data"); +const xhr = new globalThis.XMLHttpRequest(); +xhr.open("POST", "forms/submit", true); +const element = new Element(); +element.setAttribute("src", "image.png"); +const image = new globalThis.HTMLImageElement(); +image.src = "property.png"; +const alreadyGateway = await fetch("http://127.0.0.1:3000" + gateway("https://example.test/kept")); +const actual = JSON.stringify(calls); +assert(calls.some((call) => call[0] === "fetch" && call[1] === gateway("https://example.test/assets/api/data")), "fetch did not use base href: " + actual); +assert(calls.some((call) => call[0] === "xhr" && call[2] === gateway("https://example.test/assets/forms/submit")), "XHR did not use base href: " + actual); +assert(calls.some((call) => call[0] === "attribute" && call[1] === "src" && call[2] === gateway("https://example.test/assets/image.png")), "setAttribute did not use base href: " + actual); +assert(calls.some((call) => call[0] === "property" && call[1] === "img.src" && call[2] === gateway("https://example.test/assets/property.png")), "property setter did not use base href: " + actual); +assert(calls.some((call) => call[0] === "fetch" && call[1] === gateway("https://example.test/kept")), "same-origin gateway URL was encoded again: " + actual); +"#, + script = script + ); + + let output = std::process::Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(program) + .output() + .map_err(|error| WebviewError::Browser(error.to_string()))?; + + if !output.status.success() { + return Err(WebviewError::Browser(format!( + "node base href bootstrap test failed: stdout={} stderr={}", + String::from_utf8_lossy(output.stdout.as_slice()), + String::from_utf8_lossy(output.stderr.as_slice()) + ))); + } + Ok(()) + } +} diff --git a/crates/webview/src/cookie.rs b/crates/webview/src/cookie.rs new file mode 100644 index 000000000..b8e098f46 --- /dev/null +++ b/crates/webview/src/cookie.rs @@ -0,0 +1,225 @@ +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StoredCookie { + name: String, + value: String, + domain: String, + host_only: bool, + path: String, + secure: bool, +} + +/// Virtual cookie jar keyed by target origin/domain/path. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CookieJar { + cookies: Vec, +} + +impl CookieJar { + /// Create an empty cookie jar. + pub fn new() -> Self { + Self::default() + } + + /// Store one upstream `Set-Cookie` header for `origin`. + pub fn store_set_cookie(&mut self, origin: &Url, set_cookie: &str) -> Result<()> { + let Some(host) = origin.host_str() else { + return Err(WebviewError::Cookie( + "cookie origin host is empty".to_string(), + )); + }; + let mut parts = set_cookie.split(';').map(str::trim); + let Some(name_value) = parts.next() else { + return Err(WebviewError::Cookie("empty Set-Cookie".to_string())); + }; + let Some((name, value)) = name_value.split_once('=') else { + return Err(WebviewError::Cookie(format!( + "invalid Set-Cookie pair {name_value:?}" + ))); + }; + let name = name.trim(); + if name.is_empty() { + return Err(WebviewError::Cookie("cookie name is empty".to_string())); + } + + let mut cookie = StoredCookie { + name: name.to_string(), + value: value.trim().to_string(), + domain: host.to_ascii_lowercase(), + host_only: true, + path: default_cookie_path(origin.path()), + secure: false, + }; + + for part in parts { + let lower = part.to_ascii_lowercase(); + if lower == "secure" { + cookie.secure = true; + continue; + } + if let Some((key, value)) = part.split_once('=') { + if key.eq_ignore_ascii_case("domain") { + return Ok(()); + } else if key.eq_ignore_ascii_case("path") { + let path = value.trim(); + cookie.path = if path.starts_with('/') { + path.to_string() + } else { + "/".to_string() + }; + } + } + } + + self.cookies.retain(|existing| { + !(existing.name == cookie.name + && existing.domain == cookie.domain + && existing.path == cookie.path) + }); + self.cookies.push(cookie); + Ok(()) + } + + /// Build a `Cookie` request header for `target`, if any jar entries match. + pub fn cookie_header(&self, target: &Url) -> Option { + let host = target.host_str()?.to_ascii_lowercase(); + let path = target.path(); + let secure_request = target.scheme() == "https"; + let pairs: Vec = self + .cookies + .iter() + .filter(|cookie| { + (!cookie.secure || secure_request) + && domain_matches(cookie, &host) + && path_matches(cookie.path.as_str(), path) + }) + .map(|cookie| format!("{}={}", cookie.name, cookie.value)) + .collect(); + if pairs.is_empty() { + None + } else { + Some(pairs.join("; ")) + } + } + + /// Return the number of cookies currently stored. + pub fn len(&self) -> usize { + self.cookies.len() + } + + /// Return true when no cookies are stored. + pub fn is_empty(&self) -> bool { + self.cookies.is_empty() + } +} + +fn domain_matches(cookie: &StoredCookie, host: &str) -> bool { + if cookie.host_only { + return cookie.domain == host; + } + host == cookie.domain || host.ends_with(format!(".{}", cookie.domain).as_str()) +} + +fn path_matches(cookie_path: &str, request_path: &str) -> bool { + if request_path == cookie_path { + return true; + } + let Some(rest) = request_path.strip_prefix(cookie_path) else { + return false; + }; + cookie_path.ends_with('/') || rest.starts_with('/') +} + +fn default_cookie_path(path: &str) -> String { + if !path.starts_with('/') { + return "/".to_string(); + } + let mut segments = path.rsplitn(2, '/'); + let _last = segments.next(); + let Some(prefix) = segments.next() else { + return "/".to_string(); + }; + if prefix.is_empty() { + "/".to_string() + } else { + format!("{prefix}/") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cookie_matching_respects_host_only_path_and_secure() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://example.com/app/index.html")?; + jar.store_set_cookie(&origin, "sid=one; Path=/app; Secure; HttpOnly")?; + jar.store_set_cookie(&origin, "theme=dark; Path=/")?; + + let target = Url::parse("https://sub.example.com/app/page")?; + assert_eq!(jar.cookie_header(&target), None); + + let host_target = Url::parse("https://example.com/app/page")?; + assert_eq!( + jar.cookie_header(&host_target).as_deref(), + Some("sid=one; theme=dark") + ); + + let insecure = Url::parse("http://example.com/app/page")?; + assert_eq!(jar.cookie_header(&insecure).as_deref(), Some("theme=dark")); + Ok(()) + } + + #[test] + fn cookie_ignores_domain_attributes() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://evil.example/set-cookie")?; + + jar.store_set_cookie(&origin, "sid=stolen; Domain=example.com; Path=/")?; + assert!(jar.is_empty()); + Ok(()) + } + + #[test] + fn cookie_ignores_registry_controlled_and_platform_domain_attributes() -> Result<()> { + let mut jar = CookieJar::new(); + let dot_com_origin = Url::parse("https://evil.com/set-cookie")?; + let dot_uk_origin = Url::parse("https://service.co.uk/set-cookie")?; + let github_origin = Url::parse("https://evil.github.io/set-cookie")?; + + jar.store_set_cookie(&dot_com_origin, "sid=stolen; Domain=com; Path=/")?; + jar.store_set_cookie(&dot_uk_origin, "sid=stolen; Domain=co.uk; Path=/")?; + jar.store_set_cookie(&github_origin, "sid=stolen; Domain=github.io; Path=/")?; + assert!(jar.is_empty()); + Ok(()) + } + + #[test] + fn cookie_ignores_registrable_domain_attributes_by_default() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://app.example.co.uk/set-cookie")?; + + jar.store_set_cookie(&origin, "sid=one; Domain=example.co.uk; Path=/")?; + assert!(jar.is_empty()); + Ok(()) + } + + #[test] + fn cookie_path_matching_respects_segment_boundary() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://example.com/app/index.html")?; + jar.store_set_cookie(&origin, "sid=one; Path=/app")?; + + let inside = Url::parse("https://example.com/app/page")?; + let sibling = Url::parse("https://example.com/application")?; + + assert_eq!(jar.cookie_header(&inside).as_deref(), Some("sid=one")); + assert_eq!(jar.cookie_header(&sibling), None); + Ok(()) + } +} diff --git a/crates/webview/src/cors.rs b/crates/webview/src/cors.rs new file mode 100644 index 000000000..1bd33e529 --- /dev/null +++ b/crates/webview/src/cors.rs @@ -0,0 +1,277 @@ +//! Virtual CORS policy for runtime requests that share the controlled gateway origin. + +use std::collections::BTreeSet; + +use crate::error::Result; +use crate::error::WebviewError; +use crate::types::GatewayCredentials; +use crate::types::GatewayHeader; +use crate::types::GatewayRequest; +use crate::types::GatewayResponse; + +/// Build an upstream CORS preflight request when `request` requires one. +pub fn preflight_request(request: &GatewayRequest) -> Result> { + if !request.is_cross_origin_runtime_request() || !requires_preflight(request) { + return Ok(None); + } + let source_origin = request.source_origin.clone().ok_or_else(|| { + WebviewError::Cors("cross-origin request has no trusted source origin".to_string()) + })?; + let mut preflight = GatewayRequest::new(request.target.clone(), "OPTIONS", request.kind) + .with_source_origin(source_origin) + .with_credentials(GatewayCredentials::Omit) + .with_header(GatewayHeader::new( + "Access-Control-Request-Method", + request.method.clone(), + )?); + let headers = non_safelisted_headers(request); + if !headers.is_empty() { + preflight = preflight.with_header(GatewayHeader::new( + "Access-Control-Request-Headers", + headers.into_iter().collect::>().join(", "), + )?); + } + Ok(Some(preflight)) +} + +/// Validate an upstream runtime response against the virtual source origin. +pub fn validate_response(request: &GatewayRequest, response: &GatewayResponse) -> Result<()> { + if !request.is_cross_origin_runtime_request() { + return Ok(()); + } + let source_origin = source_origin(request)?; + validate_allowed_origin(response, source_origin, request.credentials)?; + if request.credentials == GatewayCredentials::Include + && !header_has_token(response, "access-control-allow-credentials", "true") + { + return Err(WebviewError::Cors( + "credentialed response lacks Access-Control-Allow-Credentials: true".to_string(), + )); + } + Ok(()) +} + +/// Validate a CORS preflight response before forwarding the actual runtime request. +pub fn validate_preflight_response( + request: &GatewayRequest, + response: &GatewayResponse, +) -> Result<()> { + if !(200..300).contains(&response.status) { + return Err(WebviewError::Cors(format!( + "preflight returned HTTP {}", + response.status + ))); + } + let source_origin = source_origin(request)?; + validate_allowed_origin(response, source_origin, request.credentials)?; + let method_allowed = header_values(response, "access-control-allow-methods") + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case(request.method.as_str())); + if !method_allowed { + return Err(WebviewError::Cors(format!( + "preflight does not allow method {}", + request.method + ))); + } + let allowed_headers = header_values(response, "access-control-allow-headers") + .flat_map(|value| value.split(',')) + .map(|value| value.trim().to_ascii_lowercase()) + .collect::>(); + let requested_headers = non_safelisted_headers(request); + if !requested_headers.is_empty() + && !allowed_headers.contains("*") + && !requested_headers + .iter() + .all(|header| allowed_headers.contains(header)) + { + return Err(WebviewError::Cors( + "preflight does not allow every requested header".to_string(), + )); + } + if request.credentials == GatewayCredentials::Include + && !header_has_token(response, "access-control-allow-credentials", "true") + { + return Err(WebviewError::Cors( + "credentialed preflight lacks Access-Control-Allow-Credentials: true".to_string(), + )); + } + Ok(()) +} + +fn requires_preflight(request: &GatewayRequest) -> bool { + !is_safelisted_method(request.method.as_str()) || !non_safelisted_headers(request).is_empty() +} + +fn is_safelisted_method(method: &str) -> bool { + matches!(method, "GET" | "HEAD" | "POST") +} + +fn non_safelisted_headers(request: &GatewayRequest) -> BTreeSet { + request + .headers + .iter() + .filter(|header| !is_gateway_stripped_header(header.name.as_str())) + .filter(|header| !is_safelisted_header(header)) + .map(|header| header.name.to_ascii_lowercase()) + .collect() +} + +fn is_gateway_stripped_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("sec-") + || matches!( + lower.as_str(), + "accept-encoding" + | "connection" + | "content-length" + | "cookie" + | "expect" + | "host" + | "keep-alive" + | "origin" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "referer" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "via" + ) +} + +fn is_safelisted_header(header: &GatewayHeader) -> bool { + if matches!( + header.name.to_ascii_lowercase().as_str(), + "accept" | "accept-language" | "content-language" + ) { + return true; + } + if !header.name_eq("content-type") { + return false; + } + let content_type = header + .value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + matches!( + content_type.as_str(), + "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain" + ) +} + +fn source_origin(request: &GatewayRequest) -> Result { + request + .source_origin + .as_ref() + .map(|source| source.origin().ascii_serialization()) + .ok_or_else(|| { + WebviewError::Cors("cross-origin request has no trusted source origin".to_string()) + }) +} + +fn validate_allowed_origin( + response: &GatewayResponse, + source_origin: String, + credentials: GatewayCredentials, +) -> Result<()> { + let allowed = header_values(response, "access-control-allow-origin").any(|value| { + let value = value.trim(); + value == source_origin || (value == "*" && credentials != GatewayCredentials::Include) + }); + if allowed { + Ok(()) + } else { + Err(WebviewError::Cors(format!( + "response does not allow origin {source_origin}" + ))) + } +} + +fn header_values<'a>( + response: &'a GatewayResponse, + name: &'a str, +) -> impl Iterator { + response + .headers + .iter() + .filter(move |header| header.name_eq(name)) + .map(|header| header.value.as_str()) +} + +fn header_has_token(response: &GatewayResponse, name: &str, expected: &str) -> bool { + header_values(response, name).any(|value| value.trim().eq_ignore_ascii_case(expected)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::GatewayRequestKind; + use url::Url; + + fn request(credentials: GatewayCredentials) -> Result { + Ok(GatewayRequest::new( + Url::parse("https://api.example.test/data")?, + "PATCH", + GatewayRequestKind::Fetch, + ) + .with_source_origin(Url::parse("https://app.example.test/page")?) + .with_credentials(credentials) + .with_header(GatewayHeader::new("Origin", "http://127.0.0.1:8080")?) + .with_header(GatewayHeader::new("Sec-Fetch-Mode", "cors")?) + .with_header(GatewayHeader::new("X-Requested-With", "Rings")?)) + } + + #[test] + fn preflight_contains_virtual_origin_method_and_headers() -> Result<()> { + let request = request(GatewayCredentials::SameOrigin)?; + let preflight = preflight_request(&request)?.ok_or_else(|| { + WebviewError::Cors("expected cross-origin request to require preflight".to_string()) + })?; + + assert_eq!(preflight.method, "OPTIONS"); + assert_eq!(preflight.credentials, GatewayCredentials::Omit); + assert!(preflight.headers.iter().any(|header| header + .name_eq("access-control-request-method") + && header.value == "PATCH")); + assert!(preflight.headers.iter().any(|header| { + header.name_eq("access-control-request-headers") && header.value == "x-requested-with" + })); + Ok(()) + } + + #[test] + fn wildcard_response_is_denied_for_credentialed_runtime_requests() -> Result<()> { + let request = request(GatewayCredentials::Include)?; + let response = GatewayResponse::new( + 200, + vec![GatewayHeader::new("Access-Control-Allow-Origin", "*")?], + Vec::new(), + )?; + + assert!(matches!( + validate_response(&request, &response), + Err(WebviewError::Cors(_)) + )); + Ok(()) + } + + #[test] + fn exact_origin_and_credentials_allow_cross_origin_runtime_response() -> Result<()> { + let request = request(GatewayCredentials::Include)?; + let response = GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Access-Control-Allow-Origin", "https://app.example.test")?, + GatewayHeader::new("Access-Control-Allow-Credentials", "true")?, + ], + Vec::new(), + )?; + + validate_response(&request, &response) + } +} diff --git a/crates/webview/src/error.rs b/crates/webview/src/error.rs new file mode 100644 index 000000000..57b5e9b96 --- /dev/null +++ b/crates/webview/src/error.rs @@ -0,0 +1,46 @@ +use thiserror::Error; + +/// Result type used by the webview gateway. +pub type Result = std::result::Result; + +/// Errors raised while normalizing, rewriting, or forwarding gateway traffic. +#[derive(Debug, Error)] +pub enum WebviewError { + /// A gateway prefix is not a path prefix owned by the local application. + #[error("invalid gateway prefix {0:?}")] + InvalidGatewayPrefix(String), + /// A gateway URL did not contain an encoded target URL. + #[error("invalid gateway URL {0:?}")] + InvalidGatewayUrl(String), + /// Percent-decoding failed. + #[error("failed to decode gateway URL {0:?}")] + Decode(String), + /// Controlled-origin configuration is invalid. + #[error("invalid controlled origin {0:?}")] + InvalidControlledOrigin(String), + /// The target URL could not be parsed. + #[error("invalid target URL: {0}")] + Url(#[from] url::ParseError), + /// The target scheme is outside the gateway policy. + #[error("unsupported target URL scheme {0:?}")] + UnsupportedScheme(String), + /// Header validation or policy normalization failed. + #[error("header policy error: {0}")] + Header(String), + /// Cookie parsing or matching failed. + #[error("cookie policy error: {0}")] + Cookie(String), + /// A cross-origin runtime response did not satisfy the virtual CORS policy. + #[error("CORS policy error: {0}")] + Cors(String), + /// The pluggable transport failed. + #[error("gateway transport failed: {0}")] + Transport(String), + /// A gateway response could not be rendered as a page. + #[error("webview render failed: {0}")] + Render(String), + /// Browser integration failed. + #[cfg(feature = "browser")] + #[error("browser integration failed: {0}")] + Browser(String), +} diff --git a/crates/webview/src/header.rs b/crates/webview/src/header.rs new file mode 100644 index 000000000..b9b4e9877 --- /dev/null +++ b/crates/webview/src/header.rs @@ -0,0 +1,218 @@ +use url::Url; + +use crate::error::Result; +use crate::rewrite::rewrite_refresh_value; +use crate::types::GatewayHeader; +use crate::types::GatewayRequest; +use crate::types::GatewayResponse; +use crate::url::GatewayPrefix; + +const GATEWAY_CONTENT_SECURITY_POLICY: &str = "default-src 'self' data: blob:; base-uri 'self'; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src 'self' data: blob:; img-src 'self' data: blob:; media-src 'self' data: blob:; object-src 'self'; script-src 'self' data: 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:; style-src 'self' data: 'unsafe-inline'; worker-src 'none'"; + +/// Header policy for controlled webview documents. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HeaderPolicy { + gateway_prefix: GatewayPrefix, +} + +impl HeaderPolicy { + /// Build a header policy that rewrites target redirects into `gateway_prefix`. + pub fn new(gateway_prefix: GatewayPrefix) -> Self { + Self { gateway_prefix } + } + + /// Normalize controlled-origin request headers before they reach a target transport. + pub fn normalize_request(&self, mut request: GatewayRequest) -> GatewayRequest { + request + .headers + .retain(|header| !should_strip_request_header(header.name.as_str())); + if let Some(source_origin) = request.source_origin.as_ref() { + request.headers.push(GatewayHeader { + name: "Origin".to_string(), + value: source_origin.origin().ascii_serialization(), + }); + } + request + } + + /// Normalize transport response headers for a proxied target URL. + pub fn normalize_response( + &self, + target: &Url, + response: GatewayResponse, + ) -> Result { + let mut headers = Vec::new(); + for header in response.headers { + if should_strip_response_header(header.name.as_str()) { + continue; + } + if header.name_eq("location") { + if let Some(location) = self + .gateway_prefix + .rewrite_url_value(target, header.value.as_str())? + { + headers.push(GatewayHeader::new(header.name, location)?); + } + continue; + } + if header.name_eq("refresh") { + headers.push(GatewayHeader::new( + header.name, + rewrite_refresh_value(header.value.as_str(), target, &self.gateway_prefix)?, + )?); + continue; + } + headers.push(header); + } + headers.push(GatewayHeader::new( + "Content-Security-Policy", + GATEWAY_CONTENT_SECURITY_POLICY, + )?); + GatewayResponse::new(response.status, headers, response.body) + } +} + +fn should_strip_request_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("sec-fetch-") + || matches!( + lower.as_str(), + "accept-encoding" + | "connection" + | "content-length" + | "cookie" + | "expect" + | "host" + | "keep-alive" + | "origin" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "referer" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "via" + ) +} + +fn should_strip_response_header(name: &str) -> bool { + const STRIPPED: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-security-policy", + "content-security-policy-report-only", + "x-frame-options", + "strict-transport-security", + "set-cookie", + ]; + STRIPPED + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::GatewayRequestKind; + + #[test] + fn redirect_location_rewrites_to_gateway_url() -> Result<()> { + let target = Url::parse("https://example.com/app/page")?; + let policy = HeaderPolicy::new(GatewayPrefix::new("/webview/")?); + let response = GatewayResponse::new( + 302, + vec![ + GatewayHeader::new("Location", "../login?next=1")?, + GatewayHeader::new("Content-Security-Policy", "default-src 'none'")?, + GatewayHeader::new("Content-Type", "text/html")?, + ], + Vec::new(), + )?; + + let normalized = policy.normalize_response(&target, response)?; + + assert_eq!(normalized.headers.len(), 3); + assert!(normalized + .headers + .iter() + .any(|header| { header.name_eq("location") && header.value.starts_with("/webview/") })); + assert!(normalized.headers.iter().any(|header| { + header.name_eq("content-security-policy") + && header.value == GATEWAY_CONTENT_SECURITY_POLICY + })); + Ok(()) + } + + #[test] + fn refresh_header_rewrites_to_gateway_url() -> Result<()> { + let target = Url::parse("https://example.com/app/page")?; + let policy = HeaderPolicy::new(GatewayPrefix::new("/webview/")?); + let response = GatewayResponse::new( + 200, + vec![GatewayHeader::new("Refresh", "0; URL='../login?next=1'")?], + Vec::new(), + )?; + + let normalized = policy.normalize_response(&target, response)?; + + let refresh = normalized + .headers + .iter() + .find(|header| header.name_eq("refresh")) + .ok_or_else(|| { + crate::error::WebviewError::Header("missing refresh header".to_string()) + })?; + assert!(refresh + .value + .contains("/webview/https%3A%2F%2Fexample%2Ecom%2Flogin%3Fnext%3D1")); + assert!(!refresh.value.contains("../login?next=1")); + Ok(()) + } + + #[test] + fn request_policy_strips_controlled_origin_and_hop_headers() -> Result<()> { + let target = Url::parse("https://example.com/app/page")?; + let policy = HeaderPolicy::new(GatewayPrefix::new("/webview/")?); + let request = GatewayRequest { + target, + method: "GET".to_string(), + headers: vec![ + GatewayHeader::new("Host", "127.0.0.1:3000")?, + GatewayHeader::new("Origin", "http://127.0.0.1:3000")?, + GatewayHeader::new("Referer", "http://127.0.0.1:3000/webview/x")?, + GatewayHeader::new("Sec-Fetch-Dest", "document")?, + GatewayHeader::new("Cookie", "caller=leak")?, + GatewayHeader::new("Accept", "text/html")?, + GatewayHeader::new("X-App-Trace", "kept")?, + ], + body: Vec::new(), + kind: GatewayRequestKind::Navigation, + source_origin: None, + credentials: crate::types::GatewayCredentials::SameOrigin, + }; + + let normalized = policy.normalize_request(request); + + assert!(normalized.headers.iter().all(|header| !matches!( + header.name.to_ascii_lowercase().as_str(), + "host" | "origin" | "referer" | "sec-fetch-dest" | "cookie" + ))); + assert!(normalized + .headers + .iter() + .any(|header| header.name_eq("accept") && header.value == "text/html")); + assert!(normalized + .headers + .iter() + .any(|header| header.name_eq("x-app-trace") && header.value == "kept")); + Ok(()) + } +} diff --git a/crates/webview/src/lib.rs b/crates/webview/src/lib.rs new file mode 100644 index 000000000..ba934b75d --- /dev/null +++ b/crates/webview/src/lib.rs @@ -0,0 +1,59 @@ +#![warn(missing_docs)] +//! Rings webview gateway primitives. +//! +//! This crate owns the reusable, UI-independent pieces required to serve remote +//! pages through a Rings-owned gateway route: target URL encoding, typed gateway +//! requests and responses, header policy, virtual cookies, response rewriting, +//! and a pluggable transport boundary. +//! +//! `browser` bootstrap hooks preserve ordinary page behavior, but a WebView host +//! must apply [`GatewayRoutePolicy`] before opening a browser connection. That +//! trusted boundary redirects navigation, rejects cross-target runtime reads, +//! and keeps direct remote traffic out of the browser network stack. + +/// Virtual cookie jar for target-origin cookies. +pub mod cookie; +/// Virtual CORS request and response policy. +pub mod cors; +/// Error and result types. +pub mod error; +/// Response header normalization policy. +pub mod header; +/// Render target pages into gateway-rewritten HTML. +pub mod render; +/// HTML and CSS rewriting helpers. +pub mod rewrite; +/// Host request routing policy for controlled webview origins. +pub mod route; +/// Pluggable gateway transport and policy wrapper. +pub mod transport; +/// Typed gateway request and response DTOs. +pub mod types; +/// Target URL encoding and gateway route helpers. +pub mod url; + +#[cfg(feature = "browser")] +pub mod browser; + +pub use cookie::CookieJar; +pub use cors::preflight_request; +pub use cors::validate_response; +pub use error::Result; +pub use error::WebviewError; +pub use header::HeaderPolicy; +pub use render::RenderedPage; +pub use render::WebviewRenderer; +pub use rewrite::RewriteContext; +pub use route::GatewayRoute; +pub use route::GatewayRoutePolicy; +pub use route::GatewayRouteRejection; +pub use transport::ConcurrentWebviewGateway; +pub use transport::GatewayTransport; +pub use transport::WebviewGateway; +pub use types::GatewayCredentials; +pub use types::GatewayHeader; +pub use types::GatewayRequest; +pub use types::GatewayRequestKind; +pub use types::GatewayResponse; +pub use url::GatewayPrefix; +pub use url::TargetUrl; diff --git a/crates/webview/src/render.rs b/crates/webview/src/render.rs new file mode 100644 index 000000000..cd817140b --- /dev/null +++ b/crates/webview/src/render.rs @@ -0,0 +1,169 @@ +//! Render target pages into gateway-rewritten HTML. + +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; +use crate::transport::GatewayTransport; +use crate::transport::WebviewGateway; +use crate::types::GatewayHeader; +use crate::types::GatewayRequest; +use crate::url::TargetUrl; + +/// A target page rendered through the webview gateway. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenderedPage { + /// Original target URL. + pub target: Url, + /// Controlled-origin URL that represents the target. + pub gateway_url: String, + /// Upstream status after gateway policy normalization. + pub status: u16, + /// Response headers after gateway policy normalization. + pub headers: Vec, + html: String, +} + +impl RenderedPage { + /// Borrow the rewritten HTML body suitable for a controlled renderer. + pub fn html(&self) -> &str { + &self.html + } + + /// Consume this page and return the rewritten HTML body. + pub fn into_html(self) -> String { + self.html + } +} + +/// Page renderer backed by a reusable gateway transport. +pub struct WebviewRenderer { + gateway: WebviewGateway, +} + +impl WebviewRenderer +where + T: GatewayTransport, +{ + /// Build a renderer around an existing gateway. + pub fn new(gateway: WebviewGateway) -> Self { + Self { gateway } + } + + /// Access the underlying gateway policy state. + pub fn gateway(&self) -> &WebviewGateway { + &self.gateway + } + + /// Mutably access the underlying gateway policy state. + pub fn gateway_mut(&mut self) -> &mut WebviewGateway { + &mut self.gateway + } + + /// Render a target page into gateway-rewritten HTML. + pub async fn render(&mut self, target: TargetUrl) -> Result { + let target = target.into_url(); + let gateway_url = self.gateway.prefix().encode(&target); + let response = self + .gateway + .send(GatewayRequest::navigation(target.clone())) + .await?; + let html = String::from_utf8(response.body).map_err(|error| { + WebviewError::Render(format!("HTML response is not UTF-8: {error}")) + })?; + Ok(RenderedPage { + target, + gateway_url, + status: response.status, + headers: response.headers, + html, + }) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use async_trait::async_trait; + + use super::*; + use crate::types::GatewayResponse; + use crate::GatewayPrefix; + + struct FixtureTransport { + requests: RefCell>, + } + + impl FixtureTransport { + fn new() -> Self { + Self { + requests: RefCell::new(Vec::new()), + } + } + } + + #[async_trait(?Send)] + impl GatewayTransport for FixtureTransport { + async fn send(&self, request: GatewayRequest) -> Result { + self.requests.borrow_mut().push(request); + GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Content-Type", "text/html; charset=utf-8")?, + GatewayHeader::new("Set-Cookie", "sid=fixture; Path=/docs; Secure")?, + GatewayHeader::new("Content-Security-Policy", "default-src 'none'")?, + ], + br#" + + + + + + + next + + +"# + .to_vec(), + ) + } + } + + #[test] + fn renderer_outputs_gateway_rewritten_html_page() -> Result<()> { + let gateway = + WebviewGateway::new(GatewayPrefix::new("/webview/")?, FixtureTransport::new()) + .with_bootstrap_script("globalThis.__ringsWebview = true;"); + let mut renderer = WebviewRenderer::new(gateway); + + let page = futures::executor::block_on( + renderer.render(TargetUrl::parse("https://example.test/docs/index.html")?), + )?; + + assert_eq!(page.status, 200); + assert!(page.gateway_url.starts_with("/webview/")); + assert!(page.html().contains("data-rings-webview-bootstrap")); + assert!(page + .html() + .contains("/webview/https%3A%2F%2Fexample%2Etest%2Fassets%2Fsite%2Ecss")); + assert!(page + .html() + .contains("/webview/https%3A%2F%2Fexample%2Etest%2Fdocs%2Fapp%2Ejs")); + assert!(page + .html() + .contains("/webview/https%3A%2F%2Fexample%2Etest%2Fnext")); + assert!(page + .html() + .contains("/webview/https%3A%2F%2Fexample%2Etest%2Fhero%2Epng")); + assert!(!page + .headers + .iter() + .any(|header| header.name_eq("set-cookie"))); + assert!(page.headers.iter().any(|header| { + header.name_eq("content-security-policy") && header.value.contains("connect-src 'self'") + })); + assert_eq!(renderer.gateway().cookies().len(), 1); + Ok(()) + } +} diff --git a/crates/webview/src/rewrite.rs b/crates/webview/src/rewrite.rs new file mode 100644 index 000000000..ba62a2768 --- /dev/null +++ b/crates/webview/src/rewrite.rs @@ -0,0 +1,677 @@ +use std::cell::Cell; +use std::cell::RefCell; +use std::error::Error; + +use lol_html::element; +use lol_html::end; +use lol_html::html_content::ContentType; +use lol_html::html_content::Element; +use lol_html::html_content::TextChunk; +use lol_html::rewrite_str; +use lol_html::text; +use lol_html::HandlerTypes; +use lol_html::RewriteStrSettings; +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; +use crate::url::GatewayPrefix; + +/// Context required to rewrite one target document or stylesheet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RewriteContext { + gateway_prefix: GatewayPrefix, + document_url: Url, + bootstrap_script: Option, +} + +impl RewriteContext { + /// Build a rewrite context for `document_url`. + pub fn new(gateway_prefix: GatewayPrefix, document_url: Url) -> Self { + Self { + gateway_prefix, + document_url, + bootstrap_script: None, + } + } + + /// Attach a bootstrap runtime script to inject into HTML documents. + pub fn with_bootstrap_script(mut self, script: impl Into) -> Self { + self.bootstrap_script = Some(script.into()); + self + } + + /// Rewrite one HTML document body. + pub fn rewrite_html(&self, html: &str) -> Result { + let bootstrap = self + .bootstrap_script + .as_ref() + .map(|script| bootstrap_tag(script)); + let injected = Cell::new(bootstrap.is_none()); + let html_state = HtmlRewriteState::new( + &self.gateway_prefix, + self.document_url.clone(), + self.bootstrap_script.as_deref(), + ); + let style_text = RefCell::new(String::new()); + rewrite_str( + html, + RewriteStrSettings::new() + .append_element_content_handler(element!("*", |element| { + rewrite_html_element(element, &html_state)?; + if !injected.get() { + if let Some(tag) = bootstrap.as_deref() { + if element.tag_name().eq_ignore_ascii_case("head") { + element.prepend(tag, ContentType::Html); + injected.set(true); + } else if element.tag_name().eq_ignore_ascii_case("script") { + element.before(tag, ContentType::Html); + injected.set(true); + } + } + } + Ok(()) + })) + .append_element_content_handler(text!("style", |text| { + rewrite_style_text(text, &html_state, &style_text)?; + Ok(()) + })) + .append_document_content_handler(end!(|end| { + if !injected.get() { + if let Some(tag) = bootstrap.as_deref() { + end.append(tag, ContentType::Html); + injected.set(true); + } + } + Ok(()) + })), + ) + .map_err(|error| WebviewError::Render(format!("HTML rewrite failed: {error}"))) + } + + /// Rewrite one CSS stylesheet body. + pub fn rewrite_css(&self, css: &str) -> Result { + let imports = rewrite_css_imports(css, self)?; + rewrite_css_urls(&imports, self) + } + + fn rewrite_url(&self, value: &str) -> Result> { + self.gateway_prefix + .rewrite_url_value(&self.document_url, value) + } +} + +struct HtmlRewriteState<'a> { + gateway_prefix: &'a GatewayPrefix, + current_base: RefCell, + base_href_seen: Cell, + bootstrap_script: Option<&'a str>, +} + +impl<'a> HtmlRewriteState<'a> { + fn new( + gateway_prefix: &'a GatewayPrefix, + document_url: Url, + bootstrap_script: Option<&'a str>, + ) -> Self { + Self { + gateway_prefix, + current_base: RefCell::new(document_url), + base_href_seen: Cell::new(false), + bootstrap_script, + } + } + + fn rewrite_url(&self, value: &str) -> Result> { + let base = self.current_base.borrow(); + self.gateway_prefix.rewrite_url_value(&base, value) + } + + fn rewrite_base_href(&self, value: &str) -> Result> { + let base = self.current_base.borrow().clone(); + let Some(target) = self.gateway_prefix.resolve_url_value(&base, value)? else { + return Ok(None); + }; + if !self.base_href_seen.get() { + self.current_base.replace(target.clone()); + self.base_href_seen.set(true); + } + Ok(Some(self.gateway_prefix.encode(&target))) + } + + fn rewrite_css(&self, css: &str) -> Result { + let base = self.current_base.borrow().clone(); + RewriteContext::new(self.gateway_prefix.clone(), base).rewrite_css(css) + } + + fn rewrite_srcdoc(&self, html: &str) -> Result { + let base = self.current_base.borrow().clone(); + let mut context = RewriteContext::new(self.gateway_prefix.clone(), base); + if let Some(script) = self.bootstrap_script { + context = context.with_bootstrap_script(script); + } + context.rewrite_html(decode_srcdoc_attribute_value(html).as_str()) + } + + fn rewrite_refresh(&self, value: &str) -> Result { + let base = self.current_base.borrow(); + rewrite_refresh_value(value, &base, self.gateway_prefix) + } +} + +fn rewrite_html_element( + element: &mut Element<'_, '_, H>, + state: &HtmlRewriteState<'_>, +) -> std::result::Result<(), Box> +where + H: HandlerTypes, +{ + let tag_name = element.tag_name(); + let is_base = tag_name.eq_ignore_ascii_case("base"); + let attributes: Vec<(String, String)> = element + .attributes() + .iter() + .map(|attribute| (attribute.name(), attribute.value())) + .collect(); + let is_meta_refresh = tag_name.eq_ignore_ascii_case("meta") + && attributes.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("http-equiv") && value.trim().eq_ignore_ascii_case("refresh") + }); + for (name, value) in attributes { + let lower_name = name.to_ascii_lowercase(); + if is_base && lower_name == "href" { + if let Some(rewritten) = state.rewrite_base_href(value.as_str())? { + element.set_attribute(name.as_str(), rewritten.as_str())?; + } + } else if lower_name == "srcdoc" { + let rewritten = state.rewrite_srcdoc(value.as_str())?; + element.set_attribute(name.as_str(), rewritten.as_str())?; + } else if lower_name == "ping" { + let rewritten = rewrite_url_list_value(value.as_str(), state)?; + element.set_attribute(name.as_str(), rewritten.as_str())?; + } else if is_meta_refresh && lower_name == "content" { + let rewritten = state.rewrite_refresh(value.as_str())?; + element.set_attribute(name.as_str(), rewritten.as_str())?; + } else if is_url_attribute(lower_name.as_str()) { + if let Some(rewritten) = state.rewrite_url(value.as_str())? { + element.set_attribute(name.as_str(), rewritten.as_str())?; + } + } else if is_srcset_attribute(lower_name.as_str()) { + let rewritten = rewrite_srcset_value(value.as_str(), state)?; + element.set_attribute(name.as_str(), rewritten.as_str())?; + } else if lower_name == "style" { + let rewritten = state.rewrite_css(value.as_str())?; + element.set_attribute(name.as_str(), rewritten.as_str())?; + } + } + Ok(()) +} + +fn rewrite_style_text( + text: &mut TextChunk<'_>, + state: &HtmlRewriteState<'_>, + buffer: &RefCell, +) -> std::result::Result<(), Box> { + let mut buffered = buffer.borrow_mut(); + buffered.push_str(text.as_str()); + if !text.last_in_text_node() { + text.replace("", ContentType::Text); + return Ok(()); + } + + let css = std::mem::take(&mut *buffered); + drop(buffered); + let rewritten = state.rewrite_css(css.as_str())?; + text.replace(rewritten.as_str(), ContentType::Text); + Ok(()) +} + +fn is_url_attribute(name: &str) -> bool { + matches!( + name, + "href" + | "src" + | "action" + | "poster" + | "data" + | "cite" + | "formaction" + | "manifest" + | "xlink:href" + ) +} + +fn is_srcset_attribute(name: &str) -> bool { + matches!(name, "srcset" | "imagesrcset") +} + +fn rewrite_srcset_value(value: &str, state: &HtmlRewriteState<'_>) -> Result { + let mut out = Vec::new(); + for candidate in value.split(',') { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + continue; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let url = parts.next().unwrap_or_default(); + let descriptor = parts.next().unwrap_or_default().trim(); + let rewritten = state.rewrite_url(url)?.unwrap_or_else(|| url.to_string()); + if descriptor.is_empty() { + out.push(rewritten); + } else { + out.push(format!("{rewritten} {descriptor}")); + } + } + Ok(out.join(", ")) +} + +/// Rewrite a space-separated HTML URL list, such as an anchor `ping` value. +fn rewrite_url_list_value(value: &str, state: &HtmlRewriteState<'_>) -> Result { + let mut rewritten = Vec::new(); + for candidate in value.split_whitespace() { + rewritten.push( + state + .rewrite_url(candidate)? + .unwrap_or_else(|| candidate.to_string()), + ); + } + Ok(rewritten.join(" ")) +} + +fn rewrite_css_urls(input: &str, ctx: &RewriteContext) -> Result { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(index) = find_ascii_case_insensitive(rest, "url(") { + let (before, after_url) = split_at_checked(rest, index)?; + let (url_token, after_open) = split_at_checked(after_url, "url(".len())?; + output.push_str(before); + output.push_str(url_token); + let Some((raw_value, tail)) = after_open.split_once(')') else { + output.push_str(after_open); + return Ok(output); + }; + let (quote, value) = trim_css_url(raw_value); + if let Some(rewritten) = ctx.rewrite_url(value)? { + if let Some(quote) = quote { + output.push(quote); + output.push_str(rewritten.as_str()); + output.push(quote); + } else { + output.push_str(rewritten.as_str()); + } + } else { + output.push_str(raw_value); + } + output.push(')'); + rest = tail; + } + output.push_str(rest); + Ok(output) +} + +fn trim_css_url(raw_value: &str) -> (Option, &str) { + let trimmed = raw_value.trim(); + if let Some(value) = trimmed + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + (Some('"'), value) + } else if let Some(value) = trimmed + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + (Some('\''), value) + } else { + (None, trimmed) + } +} + +fn rewrite_css_imports(input: &str, ctx: &RewriteContext) -> Result { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(index) = find_ascii_case_insensitive(rest, "@import") { + let (before, after_import) = split_at_checked(rest, index)?; + let (import_token, after_token) = split_at_checked(after_import, "@import".len())?; + output.push_str(before); + output.push_str(import_token); + let whitespace_len = after_token + .bytes() + .take_while(u8::is_ascii_whitespace) + .count(); + let (whitespace, after_whitespace) = split_at_checked(after_token, whitespace_len)?; + output.push_str(whitespace); + let Some(quote) = after_whitespace + .chars() + .next() + .filter(|ch| matches!(ch, '"' | '\'')) + else { + rest = after_whitespace; + continue; + }; + let Some(after_quote) = after_whitespace.strip_prefix(quote) else { + output.push_str(after_whitespace); + return Ok(output); + }; + let Some((value, tail)) = after_quote.split_once(quote) else { + output.push(quote); + output.push_str(after_quote); + return Ok(output); + }; + output.push(quote); + if let Some(rewritten) = ctx.rewrite_url(value)? { + output.push_str(rewritten.as_str()); + } else { + output.push_str(value); + } + output.push(quote); + rest = tail; + } + output.push_str(rest); + Ok(output) +} + +/// Rewrite a Refresh header or meta refresh content value through the gateway. +pub(crate) fn rewrite_refresh_value( + value: &str, + base_url: &Url, + gateway_prefix: &GatewayPrefix, +) -> Result { + let Some(url_index) = find_refresh_url_key(value) else { + return Ok(value.to_string()); + }; + let (before_url, after_url) = split_at_checked(value, url_index)?; + let (url_token, after_token) = split_at_checked(after_url, "url".len())?; + let whitespace_len = after_token + .bytes() + .take_while(u8::is_ascii_whitespace) + .count(); + let (whitespace, after_whitespace) = split_at_checked(after_token, whitespace_len)?; + let Some(after_equals) = after_whitespace.strip_prefix('=') else { + return Ok(value.to_string()); + }; + let value_whitespace_len = after_equals + .bytes() + .take_while(u8::is_ascii_whitespace) + .count(); + let (value_whitespace, raw_target) = split_at_checked(after_equals, value_whitespace_len)?; + let (quote, target, suffix) = split_refresh_target(raw_target); + let Some(rewritten) = gateway_prefix.rewrite_url_value(base_url, target)? else { + return Ok(value.to_string()); + }; + + let mut output = String::with_capacity(value.len() + rewritten.len()); + output.push_str(before_url); + output.push_str(url_token); + output.push_str(whitespace); + output.push('='); + output.push_str(value_whitespace); + if let Some(quote) = quote { + output.push(quote); + output.push_str(rewritten.as_str()); + output.push(quote); + } else { + output.push_str(rewritten.as_str()); + } + output.push_str(suffix); + Ok(output) +} + +fn find_refresh_url_key(value: &str) -> Option { + let mut rest = value; + let mut offset = 0_usize; + while let Some(index) = find_ascii_case_insensitive(rest, "url") { + let absolute_index = offset.checked_add(index)?; + let after_url = rest.get(index.checked_add("url".len())?..)?; + let whitespace_len = after_url + .bytes() + .take_while(u8::is_ascii_whitespace) + .count(); + if after_url + .get(whitespace_len..) + .is_some_and(|tail| tail.starts_with('=')) + { + return Some(absolute_index); + } + let advance = index.checked_add("url".len())?; + rest = rest.get(advance..)?; + offset = offset.checked_add(advance)?; + } + None +} + +fn split_refresh_target(raw_target: &str) -> (Option, &str, &str) { + if let Some(quote) = raw_target + .chars() + .next() + .filter(|ch| matches!(ch, '"' | '\'')) + { + if let Some(after_quote) = raw_target.strip_prefix(quote) { + if let Some((target, suffix)) = after_quote.split_once(quote) { + return (Some(quote), target, suffix); + } + return (Some(quote), after_quote, ""); + } + } + + let target = raw_target.trim_end(); + let suffix = raw_target.get(target.len()..).unwrap_or_default(); + (None, target, suffix) +} + +fn decode_srcdoc_attribute_value(value: &str) -> String { + value + .replace(""", "\"") + .replace(""", "\"") + .replace(""", "\"") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn find_ascii_case_insensitive(input: &str, pattern: &str) -> Option { + input + .to_ascii_lowercase() + .find(pattern.to_ascii_lowercase().as_str()) +} + +fn split_at_checked(input: &str, index: usize) -> Result<(&str, &str)> { + let Some(before) = input.get(..index) else { + return Err(WebviewError::Render(format!( + "invalid UTF-8 split boundary {index}" + ))); + }; + let Some(after) = input.get(index..) else { + return Err(WebviewError::Render(format!( + "invalid UTF-8 split boundary {index}" + ))); + }; + Ok((before, after)) +} + +fn bootstrap_tag(script: &str) -> String { + format!("") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::url::GatewayPrefix; + use crate::url::TargetUrl; + + fn context() -> Result { + Ok(RewriteContext::new( + GatewayPrefix::new("/webview/")?, + TargetUrl::parse("https://example.com/app/page.html")?.into_url(), + )) + } + + #[test] + fn html_rewrites_relative_subresources_and_srcset() -> Result<()> { + let ctx = context()?; + let html = r#"
"#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fnext%2Ehtml")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fimg%2Epng")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fsubmit")); + assert!(rewritten.contains("1x")); + assert!(rewritten.contains("2x")); + Ok(()) + } + + #[test] + fn css_rewrites_urls_and_imports() -> Result<()> { + let ctx = context()?; + let css = r#"@import "theme/base.css"; body { background: url('/assets/bg.png'); }"#; + + let rewritten = ctx.rewrite_css(css)?; + + assert!( + rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Ftheme%2Fbase%2Ecss") + ); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fassets%2Fbg%2Epng")); + Ok(()) + } + + #[test] + fn bootstrap_injects_before_page_scripts() -> Result<()> { + let ctx = context()?.with_bootstrap_script("globalThis.__ringsWebview = true;"); + + let rewritten = ctx.rewrite_html("")?; + + assert!(rewritten.contains("data-rings-webview-bootstrap")); + assert!(rewritten.contains("__ringsWebview")); + Ok(()) + } + + #[test] + fn html_rewrites_case_whitespace_unquoted_and_inline_style_urls() -> Result<()> { + let ctx = context()?; + let html = r#"
"#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fnext%2Ehtml")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fimg%2Epng")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fsubmit")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fbg%2Epng")); + Ok(()) + } + + #[test] + fn html_rewrites_style_element_urls() -> Result<()> { + let ctx = context()?; + let html = r#""#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!( + rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Ftheme%2Fbase%2Ecss") + ); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fassets%2Fbg%2Epng")); + assert_absent(&rewritten, "@import \"theme/base.css\""); + assert_absent(&rewritten, "url(\"/assets/bg.png\")"); + Ok(()) + } + + #[test] + fn html_rewrites_anchor_ping_url_lists() -> Result<()> { + let ctx = context()?; + let html = + r#"next"#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fping%2Fone")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fmetrics%2Eexample%2Fping%2Ftwo")); + assert_absent(&rewritten, "ping/one https://metrics.example/ping/two"); + Ok(()) + } + + #[test] + fn html_rewrites_srcdoc_document_urls() -> Result<()> { + let ctx = context()?.with_bootstrap_script("globalThis.__ringsWebview = true;"); + let html = r#""#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!( + rewritten.contains("/webview/https%3A%2F%2Fexample%2Etest%2Fx%2Epng"), + "{rewritten}" + ); + assert!( + rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fnext%2Ehtml"), + "{rewritten}" + ); + assert!(rewritten.contains("data-rings-webview-bootstrap")); + assert_absent(&rewritten, "https://example.test/x.png"); + assert_absent(&rewritten, "href=\"next.html\""); + Ok(()) + } + + #[test] + fn html_rewrites_meta_refresh_urls() -> Result<()> { + let ctx = context()?; + let html = r#""#; + + let rewritten = ctx.rewrite_html(html)?; + + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Flogin%3Fnext%3D1")); + assert_absent(&rewritten, "../login?next=1"); + Ok(()) + } + + #[test] + fn html_base_href_controls_following_relative_rewrites() -> Result<()> { + let ctx = context()?; + let html = r#""#; + + let rewritten = ctx.rewrite_html(html)?; + + for expected in [ + "https://example.com/assets/", + "https://example.com/assets/site.css", + "https://example.com/assets/hero.png", + "https://example.com/assets/photo.png", + "https://example.com/assets/app.js", + ] { + assert!(rewritten.contains( + GatewayPrefix::new("/webview/")? + .encode(&Url::parse(expected)?) + .as_str() + )); + } + assert_absent(&rewritten, "href=\"site.css\""); + assert_absent(&rewritten, "url(hero.png)"); + assert_absent(&rewritten, "src=\"photo.png\""); + Ok(()) + } + + #[test] + fn css_rewrites_case_whitespace_and_import_url_forms() -> Result<()> { + let ctx = context()?; + let css = r#"@IMPORT "theme/base.css"; @import url(extra.css); body { background: URL("/assets/bg.png"); }"#; + + let rewritten = ctx.rewrite_css(css)?; + + assert!( + rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Ftheme%2Fbase%2Ecss") + ); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fapp%2Fextra%2Ecss")); + assert!(rewritten.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fassets%2Fbg%2Epng")); + Ok(()) + } + + fn assert_absent(haystack: &str, needle: &str) { + assert!( + !haystack.contains(needle), + "unexpected substring {needle:?} in {haystack:?}" + ); + } +} diff --git a/crates/webview/src/route.rs b/crates/webview/src/route.rs new file mode 100644 index 000000000..54ba41b37 --- /dev/null +++ b/crates/webview/src/route.rs @@ -0,0 +1,346 @@ +//! Trusted host routing policy for a controlled webview origin. + +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; +use crate::types::GatewayRequestKind; +use crate::url::GatewayPrefix; +use crate::url::TargetUrl; + +/// A request-routing policy implemented by the WebView host, service worker, or equivalent. +/// +/// The host supplies `source_target` from its frame state, never from an untrusted page header. +/// Runtime requests retain that target so the transport layer can apply virtual CORS after the +/// request is forwarded through the controlled gateway origin. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GatewayRoutePolicy { + controlled_origin: Url, + gateway_prefix: GatewayPrefix, +} + +impl GatewayRoutePolicy { + /// Build a policy for one Rings-controlled browser origin and gateway path prefix. + pub fn new(controlled_origin: Url, gateway_prefix: GatewayPrefix) -> Result { + if !is_controlled_origin(&controlled_origin) { + return Err(WebviewError::InvalidControlledOrigin( + controlled_origin.to_string(), + )); + } + Ok(Self { + controlled_origin, + gateway_prefix, + }) + } + + /// Return the controlled browser origin used to host gateway routes. + pub fn controlled_origin(&self) -> &Url { + &self.controlled_origin + } + + /// Return the gateway route prefix handled by this policy. + pub fn gateway_prefix(&self) -> &GatewayPrefix { + &self.gateway_prefix + } + + /// Build the controlled-origin URL that represents `target`. + pub fn gateway_url(&self, target: &Url) -> Result { + self.controlled_origin + .join(self.gateway_prefix.encode(target).as_str()) + .map_err(WebviewError::Url) + } + + /// Decide how a host must handle one browser request before the browser opens a connection. + /// + /// `source_target` is the target URL associated with the initiating frame. It is required for + /// fetch and XHR virtual CORS enforcement, but navigation and static subresources may cross + /// target origins. The host must treat an error as a rejection and never fall back to + /// `requested_url`. + pub fn route( + &self, + requested_url: &Url, + source_target: Option<&TargetUrl>, + kind: GatewayRequestKind, + ) -> Result { + if runtime_request_lacks_source_target(source_target, kind) { + return Ok(GatewayRoute::Reject( + GatewayRouteRejection::MissingRuntimeSourceTarget, + )); + } + if self.is_controlled_url(requested_url) && !self.is_gateway_url(requested_url) { + return Ok(GatewayRoute::AllowControlled); + } + let target = self.target_from_requested_url(requested_url, kind)?; + self.route_target(requested_url, target) + } + + fn target_from_requested_url( + &self, + requested_url: &Url, + kind: GatewayRequestKind, + ) -> Result { + if self.is_controlled_url(requested_url) { + let target = self.gateway_prefix.decode_path(requested_url.path())?; + let Some(form_query) = requested_url.query() else { + return Ok(target); + }; + if kind != GatewayRequestKind::Navigation { + return Err(WebviewError::InvalidGatewayUrl(requested_url.to_string())); + } + + // Native GET form submissions append fields to the gateway URL rather than to its + // percent-encoded target. Only a document navigation can use that representation. + let mut target_url = target.into_url(); + let target_query = target_url + .query() + .map(|query| format!("{query}&{form_query}")) + .unwrap_or_else(|| form_query.to_string()); + target_url.set_query(Some(&target_query)); + return TargetUrl::parse(target_url.as_str()); + } + TargetUrl::parse(requested_url.as_str()) + } + + fn route_target(&self, requested_url: &Url, target: TargetUrl) -> Result { + if self.is_controlled_url(requested_url) { + return Ok(GatewayRoute::Serve(target)); + } + Ok(GatewayRoute::Redirect(self.gateway_url(target.as_url())?)) + } + + fn is_controlled_url(&self, url: &Url) -> bool { + url.origin() == self.controlled_origin.origin() + } + + fn is_gateway_url(&self, url: &Url) -> bool { + url.path().starts_with(self.gateway_prefix.as_str()) + } +} + +/// A complete host action for one browser request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GatewayRoute { + /// Allow a Rings-owned shell or static asset to load without forwarding it upstream. + AllowControlled, + /// Serve a decoded target through the `GatewayTransport` boundary. + Serve(TargetUrl), + /// Redirect an external navigation or subresource to this controlled gateway URL. + Redirect(Url), + /// Reject the request before the browser can open a remote connection. + Reject(GatewayRouteRejection), +} + +/// The named reason a host must reject a browser request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GatewayRouteRejection { + /// The host did not provide a trusted initiating-frame target for fetch or XHR. + MissingRuntimeSourceTarget, +} + +fn is_controlled_origin(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() +} + +fn runtime_request_lacks_source_target( + source_target: Option<&TargetUrl>, + kind: GatewayRequestKind, +) -> bool { + matches!(kind, GatewayRequestKind::Fetch | GatewayRequestKind::Xhr) && source_target.is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy() -> Result { + GatewayRoutePolicy::new( + Url::parse("https://webview.rings.test/")?, + GatewayPrefix::new("/webview/")?, + ) + } + + #[test] + fn external_navigation_redirects_to_controlled_gateway_url() -> Result<()> { + let policy = policy()?; + let requested = Url::parse("https://example.test/docs/index.html")?; + + let route = policy.route(&requested, None, GatewayRequestKind::Navigation)?; + + assert_eq!( + route, + GatewayRoute::Redirect(Url::parse( + "https://webview.rings.test/webview/https%3A%2F%2Fexample%2Etest%2Fdocs%2Findex%2Ehtml" + )?) + ); + Ok(()) + } + + #[test] + fn controlled_gateway_url_decodes_to_target_transport_route() -> Result<()> { + let policy = policy()?; + let target = Url::parse("https://example.test/docs/index.html")?; + let requested = policy.gateway_url(&target)?; + + let route = policy.route(&requested, None, GatewayRequestKind::Navigation)?; + + assert_eq!( + route, + GatewayRoute::Serve(TargetUrl::parse(target.as_str())?) + ); + Ok(()) + } + + #[test] + fn navigation_form_query_is_merged_into_gateway_target() -> Result<()> { + let policy = policy()?; + let target = Url::parse("https://www.google.com/search?source=hp")?; + let mut requested = policy.gateway_url(&target)?; + requested.set_query(Some("q=test+query&hl=en")); + + let route = policy.route(&requested, None, GatewayRequestKind::Navigation)?; + + assert_eq!( + route, + GatewayRoute::Serve(TargetUrl::parse( + "https://www.google.com/search?source=hp&q=test+query&hl=en" + )?) + ); + Ok(()) + } + + #[test] + fn gateway_outer_query_is_rejected_for_non_navigation_requests() -> Result<()> { + let policy = policy()?; + let target = Url::parse("https://example.test/asset.css")?; + let mut requested = policy.gateway_url(&target)?; + requested.set_query(Some("cache_bust=1")); + + assert!(matches!( + policy.route(&requested, None, GatewayRequestKind::Subresource), + Err(WebviewError::InvalidGatewayUrl(_)) + )); + Ok(()) + } + + #[test] + fn cross_target_fetch_is_served_for_virtual_cors_evaluation() -> Result<()> { + let policy = policy()?; + let source = TargetUrl::parse("https://app.example.test/index.html")?; + let target = Url::parse("https://bank.example.test/account")?; + let requested = policy.gateway_url(&target)?; + + let route = policy.route(&requested, Some(&source), GatewayRequestKind::Fetch)?; + + assert_eq!( + route, + GatewayRoute::Serve(TargetUrl::parse(target.as_str())?) + ); + Ok(()) + } + + #[test] + fn cross_target_direct_fetch_is_redirected_through_the_gateway() -> Result<()> { + let policy = policy()?; + let source = TargetUrl::parse("https://app.example.test/index.html")?; + let requested = Url::parse("https://bank.example.test/account")?; + + let route = policy.route(&requested, Some(&source), GatewayRequestKind::Fetch)?; + + assert_eq!( + route, + GatewayRoute::Redirect(policy.gateway_url(&requested)?) + ); + Ok(()) + } + + #[test] + fn same_target_xhr_is_served_through_gateway() -> Result<()> { + let policy = policy()?; + let source = TargetUrl::parse("https://app.example.test/docs/index.html")?; + let target = Url::parse("https://app.example.test/api/data")?; + let requested = policy.gateway_url(&target)?; + + let route = policy.route(&requested, Some(&source), GatewayRequestKind::Xhr)?; + + assert_eq!( + route, + GatewayRoute::Serve(TargetUrl::parse(target.as_str())?) + ); + Ok(()) + } + + #[test] + fn runtime_requests_without_a_trusted_source_target_are_rejected() -> Result<()> { + let policy = policy()?; + let requested = Url::parse("https://app.example.test/api/data")?; + + let route = policy.route(&requested, None, GatewayRequestKind::Fetch)?; + + assert_eq!( + route, + GatewayRoute::Reject(GatewayRouteRejection::MissingRuntimeSourceTarget) + ); + Ok(()) + } + + #[test] + fn cross_target_subresources_redirect_without_granting_script_read_access() -> Result<()> { + let policy = policy()?; + let source = TargetUrl::parse("https://app.example.test/index.html")?; + let requested = Url::parse("https://cdn.example.test/site.css")?; + + let route = policy.route(&requested, Some(&source), GatewayRequestKind::Subresource)?; + + assert_eq!( + route, + GatewayRoute::Redirect(policy.gateway_url(&requested)?) + ); + Ok(()) + } + + #[test] + fn controlled_origin_configuration_excludes_paths_and_credentials() -> Result<()> { + for value in [ + "https://webview.rings.test/app", + "https://user@webview.rings.test/", + "file:///tmp/webview", + ] { + assert!(matches!( + GatewayRoutePolicy::new(Url::parse(value)?, GatewayPrefix::new("/webview/")?), + Err(WebviewError::InvalidControlledOrigin(_)) + )); + } + Ok(()) + } + + #[test] + fn controlled_shell_assets_remain_local() -> Result<()> { + let policy = policy()?; + let requested = Url::parse("https://webview.rings.test/assets/app.js")?; + + let route = policy.route(&requested, None, GatewayRequestKind::Subresource)?; + + assert_eq!(route, GatewayRoute::AllowControlled); + Ok(()) + } + + #[test] + fn runtime_requests_to_controlled_assets_still_require_a_trusted_source() -> Result<()> { + let policy = policy()?; + let requested = Url::parse("https://webview.rings.test/assets/app.js")?; + + let route = policy.route(&requested, None, GatewayRequestKind::Xhr)?; + + assert_eq!( + route, + GatewayRoute::Reject(GatewayRouteRejection::MissingRuntimeSourceTarget) + ); + Ok(()) + } +} diff --git a/crates/webview/src/transport.rs b/crates/webview/src/transport.rs new file mode 100644 index 000000000..fe7053b95 --- /dev/null +++ b/crates/webview/src/transport.rs @@ -0,0 +1,678 @@ +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::cookie::CookieJar; +use crate::cors; +use crate::error::Result; +use crate::header::HeaderPolicy; +use crate::rewrite::RewriteContext; +use crate::types::GatewayHeader; +use crate::types::GatewayRequest; +use crate::types::GatewayRequestKind; +use crate::types::GatewayResponse; +use crate::url::GatewayPrefix; + +/// Pluggable transport for normalized webview gateway requests. +#[async_trait(?Send)] +pub trait GatewayTransport { + /// Send `request` through the concrete transport and return a raw response. + async fn send(&self, request: GatewayRequest) -> Result; +} + +/// Policy wrapper that applies cookies, header policy, and body rewriting around a transport. +pub struct WebviewGateway { + policy: GatewayResponsePolicy, + transport: T, + cookies: CookieJar, +} + +/// A gateway that permits concurrent transport requests while sharing one virtual cookie jar. +/// +/// The cookie jar is locked only while a request is prepared and while upstream `Set-Cookie` +/// headers are committed. Network I/O and response rewriting run outside that lock, so one slow +/// upstream request cannot block unrelated page resources. +pub struct ConcurrentWebviewGateway { + policy: GatewayResponsePolicy, + transport: T, + cookies: Mutex, +} + +struct GatewayResponsePolicy { + prefix: GatewayPrefix, + header_policy: HeaderPolicy, + bootstrap_script: Option, +} + +enum BootstrapScript { + Static(String), + PerTarget(fn(&url::Url) -> String), +} + +impl GatewayResponsePolicy { + fn new(prefix: GatewayPrefix) -> Self { + Self { + header_policy: HeaderPolicy::new(prefix.clone()), + prefix, + bootstrap_script: None, + } + } + + fn prepare_request( + &self, + cookies: &CookieJar, + request: GatewayRequest, + ) -> Result { + let mut request = self.header_policy.normalize_request(request); + if request.allows_target_cookies() { + if let Some(cookie_header) = cookies.cookie_header(&request.target) { + request + .headers + .push(GatewayHeader::new("Cookie", cookie_header)?); + } + } + Ok(request) + } + + fn store_response_cookies( + &self, + cookies: &mut CookieJar, + target: &url::Url, + response: &GatewayResponse, + ) -> Result<()> { + for header in response + .headers + .iter() + .filter(|header| header.name_eq("set-cookie")) + { + cookies.store_set_cookie(target, header.value.as_str())?; + } + Ok(()) + } + + fn finish_response( + &self, + target: &url::Url, + response: GatewayResponse, + ) -> Result { + let mut response = self.header_policy.normalize_response(target, response)?; + response.body = self.rewrite_body(target, &response)?; + Ok(response) + } + + fn rewrite_body(&self, target: &url::Url, response: &GatewayResponse) -> Result> { + let Some(content_type) = response + .headers + .iter() + .find(|header| header.name_eq("content-type")) + .map(|header| header.value.to_ascii_lowercase()) + else { + return Ok(response.body.clone()); + }; + if !(content_type.contains("text/html") || content_type.contains("text/css")) { + return Ok(response.body.clone()); + } + let Ok(text) = std::str::from_utf8(response.body.as_slice()) else { + return Ok(response.body.clone()); + }; + let mut ctx = RewriteContext::new(self.prefix.clone(), target.clone()); + if let Some(script) = self.bootstrap_for(target) { + ctx = ctx.with_bootstrap_script(script); + } + let rewritten = if content_type.contains("text/html") { + ctx.rewrite_html(text)? + } else { + ctx.rewrite_css(text)? + }; + Ok(rewritten.into_bytes()) + } + + fn bootstrap_for(&self, target: &url::Url) -> Option { + match &self.bootstrap_script { + Some(BootstrapScript::Static(script)) => Some(script.clone()), + Some(BootstrapScript::PerTarget(build)) => Some(build(target)), + None => None, + } + } +} + +impl WebviewGateway +where + T: GatewayTransport, +{ + /// Build a webview gateway around `transport`. + pub fn new(prefix: GatewayPrefix, transport: T) -> Self { + Self { + policy: GatewayResponsePolicy::new(prefix), + transport, + cookies: CookieJar::new(), + } + } + + /// Return the controlled-origin gateway prefix. + pub fn prefix(&self) -> &GatewayPrefix { + &self.policy.prefix + } + + /// Attach a runtime bootstrap script that is injected into rendered HTML. + pub fn with_bootstrap_script(mut self, script: impl Into) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::Static(script.into())); + self + } + + /// Attach a runtime bootstrap factory evaluated for each rendered target page. + /// + /// Use this when the bootstrap resolves relative runtime URLs against the target document. + /// The factory is evaluated after the upstream response is received and before its HTML is + /// rewritten, so navigating between targets cannot retain an earlier page's base URL. + pub fn with_target_bootstrap(mut self, script: fn(&url::Url) -> String) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::PerTarget(script)); + self + } + + /// Access the virtual cookie jar. + pub fn cookies(&self) -> &CookieJar { + &self.cookies + } + + /// Send one request through the gateway policy stack. + pub async fn send(&mut self, request: GatewayRequest) -> Result { + let cors_request = request.clone(); + let preflight = cors::preflight_request(&request)?; + let request = self.policy.prepare_request(&self.cookies, request)?; + if let Some(preflight) = preflight { + let preflight = self.policy.header_policy.normalize_request(preflight); + let response = self.transport.send(preflight).await?; + cors::validate_preflight_response(&cors_request, &response)?; + } + let target = request.target.clone(); + let stores_cookies = request.allows_target_cookies(); + let response = self.transport.send(request).await?; + cors::validate_response(&cors_request, &response)?; + if stores_cookies { + self.policy + .store_response_cookies(&mut self.cookies, &target, &response)?; + } + self.policy.finish_response(&target, response) + } + + /// Build a typed request from a controlled-origin gateway path. + pub fn request_from_gateway_path( + &self, + path: &str, + kind: GatewayRequestKind, + ) -> Result { + let target = self.policy.prefix.decode_path(path)?.into_url(); + Ok(match kind { + GatewayRequestKind::Navigation => GatewayRequest::navigation(target), + GatewayRequestKind::Subresource => GatewayRequest::subresource(target), + GatewayRequestKind::Fetch => GatewayRequest::fetch(target, "GET"), + GatewayRequestKind::Xhr => GatewayRequest::xhr(target, "GET"), + }) + } + + /// Send one request addressed by a controlled-origin gateway path. + pub async fn send_gateway_path( + &mut self, + path: &str, + kind: GatewayRequestKind, + ) -> Result { + let request = self.request_from_gateway_path(path, kind)?; + self.send(request).await + } +} + +impl ConcurrentWebviewGateway +where + T: GatewayTransport, +{ + /// Build a concurrent webview gateway around `transport`. + pub fn new(prefix: GatewayPrefix, transport: T) -> Self { + Self { + policy: GatewayResponsePolicy::new(prefix), + transport, + cookies: Mutex::new(CookieJar::new()), + } + } + + /// Return the controlled-origin gateway prefix. + pub fn prefix(&self) -> &GatewayPrefix { + &self.policy.prefix + } + + /// Attach a runtime bootstrap script that is injected into rendered HTML. + pub fn with_bootstrap_script(mut self, script: impl Into) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::Static(script.into())); + self + } + + /// Attach a runtime bootstrap factory evaluated for each rendered target page. + pub fn with_target_bootstrap(mut self, script: fn(&url::Url) -> String) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::PerTarget(script)); + self + } + + /// Send one request without holding the virtual-cookie lock during upstream I/O. + pub async fn send(&self, request: GatewayRequest) -> Result { + let cors_request = request.clone(); + let preflight = cors::preflight_request(&request)?; + let request = { + let cookies = self.lock_cookies()?; + self.policy.prepare_request(&cookies, request)? + }; + if let Some(preflight) = preflight { + let preflight = self.policy.header_policy.normalize_request(preflight); + let response = self.transport.send(preflight).await?; + cors::validate_preflight_response(&cors_request, &response)?; + } + let target = request.target.clone(); + let stores_cookies = request.allows_target_cookies(); + let response = self.transport.send(request).await?; + cors::validate_response(&cors_request, &response)?; + if stores_cookies { + let mut cookies = self.lock_cookies()?; + self.policy + .store_response_cookies(&mut cookies, &target, &response)?; + } + self.policy.finish_response(&target, response) + } + + /// Build a typed request from a controlled-origin gateway path. + pub fn request_from_gateway_path( + &self, + path: &str, + kind: GatewayRequestKind, + ) -> Result { + let target = self.policy.prefix.decode_path(path)?.into_url(); + Ok(match kind { + GatewayRequestKind::Navigation => GatewayRequest::navigation(target), + GatewayRequestKind::Subresource => GatewayRequest::subresource(target), + GatewayRequestKind::Fetch => GatewayRequest::fetch(target, "GET"), + GatewayRequestKind::Xhr => GatewayRequest::xhr(target, "GET"), + }) + } + + /// Send one request addressed by a controlled-origin gateway path. + pub async fn send_gateway_path( + &self, + path: &str, + kind: GatewayRequestKind, + ) -> Result { + let request = self.request_from_gateway_path(path, kind)?; + self.send(request).await + } + + fn lock_cookies(&self) -> Result> { + self.cookies.lock().map_err(|_| { + crate::error::WebviewError::Cookie("virtual cookie jar lock is poisoned".to_string()) + }) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use futures::channel::mpsc; + use futures::channel::oneshot; + use futures::executor::LocalPool; + use futures::stream::StreamExt; + use futures::task::LocalSpawnExt; + + use super::*; + use crate::types::GatewayCredentials; + use crate::types::GatewayRequest; + use crate::types::GatewayRequestKind; + use crate::url::TargetUrl; + use crate::WebviewError; + + struct StaticTransport; + + #[async_trait(?Send)] + impl GatewayTransport for StaticTransport { + async fn send(&self, request: GatewayRequest) -> Result { + assert!(request + .headers + .iter() + .all(|header| !header.name_eq("cookie"))); + GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Content-Type", "text/html")?, + GatewayHeader::new("Set-Cookie", "sid=one; Path=/")?, + ], + br#""#.to_vec(), + ) + } + } + + #[test] + fn gateway_rewrites_html_and_stores_cookies() -> Result<()> { + let target = TargetUrl::parse("https://example.com/index.html")?.into_url(); + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, StaticTransport) + .with_bootstrap_script("globalThis.__rings = true;"); + let request = GatewayRequest { + target, + method: "GET".to_string(), + headers: Vec::new(), + body: Vec::new(), + kind: GatewayRequestKind::Navigation, + source_origin: None, + credentials: GatewayCredentials::SameOrigin, + }; + + let response = futures::executor::block_on(gateway.send(request))?; + let body = String::from_utf8(response.body) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + + assert!(body.contains("/webview/https%3A%2F%2Fexample%2Ecom%2Fasset%2Epng")); + assert!(body.contains("data-rings-webview-bootstrap")); + assert_eq!(gateway.cookies().len(), 1); + assert!(!response + .headers + .iter() + .any(|header| header.name_eq("set-cookie"))); + Ok(()) + } + + fn target_bootstrap(target: &url::Url) -> String { + format!("globalThis.__ringsTarget = {:?};", target.as_str()) + } + + #[test] + fn per_target_bootstrap_tracks_each_navigated_document() -> Result<()> { + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, StaticTransport) + .with_target_bootstrap(target_bootstrap); + let first = TargetUrl::parse("https://one.example.test/first")?.into_url(); + let second = TargetUrl::parse("https://two.example.test/second")?.into_url(); + + let first = futures::executor::block_on(gateway.send(GatewayRequest::navigation(first)))?; + let second = futures::executor::block_on(gateway.send(GatewayRequest::navigation(second)))?; + let first_body = String::from_utf8(first.body) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let second_body = String::from_utf8(second.body) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + + assert!(first_body.contains("https://one.example.test/first")); + assert!(!first_body.contains("https://two.example.test/second")); + assert!(second_body.contains("https://two.example.test/second")); + assert!(!second_body.contains("https://one.example.test/first")); + Ok(()) + } + + struct DomainCookieTransport; + + #[async_trait(?Send)] + impl GatewayTransport for DomainCookieTransport { + async fn send(&self, _request: GatewayRequest) -> Result { + GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Content-Type", "text/html")?, + GatewayHeader::new("Set-Cookie", "sid=domain; Domain=example.com; Path=/")?, + ], + br#"

ok

"#.to_vec(), + ) + } + } + + #[test] + fn gateway_ignores_domain_cookie_without_failing_response() -> Result<()> { + let target = TargetUrl::parse("https://example.com/index.html")?.into_url(); + let mut gateway = + WebviewGateway::new(GatewayPrefix::new("/webview/")?, DomainCookieTransport); + + let response = + futures::executor::block_on(gateway.send(GatewayRequest::navigation(target)))?; + let body = String::from_utf8(response.body) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + + assert!(body.contains("

ok

")); + assert!(gateway.cookies().is_empty()); + assert!(!response + .headers + .iter() + .any(|header| header.name_eq("set-cookie"))); + Ok(()) + } + + struct RecordingTransport { + requests: std::cell::RefCell>, + } + + impl RecordingTransport { + fn new() -> Self { + Self { + requests: std::cell::RefCell::new(Vec::new()), + } + } + } + + #[async_trait(?Send)] + impl GatewayTransport for RecordingTransport { + async fn send(&self, request: GatewayRequest) -> Result { + self.requests.borrow_mut().push(request); + GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Content-Type", "application/json")?, + GatewayHeader::new("Set-Cookie", "sid=one; Path=/")?, + ], + br#"{}"#.to_vec(), + ) + } + } + + #[test] + fn gateway_replaces_caller_cookie_header_with_virtual_target_cookie() -> Result<()> { + let transport = RecordingTransport::new(); + let target = TargetUrl::parse("https://example.com/index.html")?.into_url(); + let fetch_target = TargetUrl::parse("https://example.com/api")?.into_url(); + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, transport); + + futures::executor::block_on(gateway.send(GatewayRequest::navigation(target)))?; + futures::executor::block_on( + gateway.send( + GatewayRequest::fetch(fetch_target, "GET") + .with_header(GatewayHeader::new("Cookie", "caller=leak")?), + ), + )?; + + let requests = gateway.transport.requests.borrow(); + let second = requests + .get(1) + .ok_or_else(|| WebviewError::Transport("missing second request".to_string()))?; + let cookies: Vec<&str> = second + .headers + .iter() + .filter(|header| header.name_eq("cookie")) + .map(|header| header.value.as_str()) + .collect(); + + assert_eq!(cookies, vec!["sid=one"]); + Ok(()) + } + + #[test] + fn gateway_strips_controlled_origin_headers_before_transport() -> Result<()> { + let transport = RecordingTransport::new(); + let target = TargetUrl::parse("https://example.com/index.html")?.into_url(); + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, transport); + + futures::executor::block_on( + gateway.send( + GatewayRequest::navigation(target) + .with_header(GatewayHeader::new("Host", "127.0.0.1:3000")?) + .with_header(GatewayHeader::new("Origin", "http://127.0.0.1:3000")?) + .with_header(GatewayHeader::new( + "Referer", + "http://127.0.0.1:3000/webview/target", + )?) + .with_header(GatewayHeader::new("Sec-Fetch-Dest", "document")?) + .with_header(GatewayHeader::new("Accept", "text/html")?), + ), + )?; + + let requests = gateway.transport.requests.borrow(); + let first = requests + .first() + .ok_or_else(|| WebviewError::Transport("missing first request".to_string()))?; + assert!(first.headers.iter().all(|header| { + !header.name_eq("host") + && !header.name_eq("origin") + && !header.name_eq("referer") + && !header.name_eq("sec-fetch-dest") + })); + assert!(first + .headers + .iter() + .any(|header| header.name_eq("accept") && header.value == "text/html")); + Ok(()) + } + + struct CorsRecordingTransport { + requests: std::cell::RefCell>, + } + + #[async_trait(?Send)] + impl GatewayTransport for CorsRecordingTransport { + async fn send(&self, request: GatewayRequest) -> Result { + let is_preflight = request.method == "OPTIONS"; + self.requests.borrow_mut().push(request); + let mut headers = vec![GatewayHeader::new( + "Access-Control-Allow-Origin", + "https://app.example.test", + )?]; + if is_preflight { + headers.push(GatewayHeader::new("Access-Control-Allow-Methods", "PATCH")?); + headers.push(GatewayHeader::new( + "Access-Control-Allow-Headers", + "x-requested-with", + )?); + } + GatewayResponse::new(200, headers, b"cors response".to_vec()) + } + } + + #[test] + fn gateway_forwards_cross_origin_runtime_requests_after_virtual_cors_preflight() -> Result<()> { + let target = TargetUrl::parse("https://api.example.test/data")?.into_url(); + let source = TargetUrl::parse("https://app.example.test/page")?.into_url(); + let transport = CorsRecordingTransport { + requests: std::cell::RefCell::new(Vec::new()), + }; + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, transport); + let response = futures::executor::block_on( + gateway.send( + GatewayRequest::fetch(target, "PATCH") + .with_source_origin(source) + .with_header(GatewayHeader::new("X-Requested-With", "Rings")?), + ), + )?; + + assert_eq!(response.body, b"cors response"); + let requests = gateway.transport.requests.borrow(); + assert_eq!(requests.len(), 2); + let preflight = requests + .first() + .ok_or_else(|| WebviewError::Transport("missing CORS preflight".to_string()))?; + assert_eq!(preflight.method, "OPTIONS"); + assert!(preflight.headers.iter().any(|header| { + header.name_eq("origin") && header.value == "https://app.example.test" + })); + assert!(preflight.headers.iter().any(|header| { + header.name_eq("access-control-request-method") && header.value == "PATCH" + })); + let actual = requests + .get(1) + .ok_or_else(|| WebviewError::Transport("missing CORS runtime request".to_string()))?; + assert_eq!(actual.method, "PATCH"); + assert!(actual.headers.iter().any(|header| { + header.name_eq("origin") && header.value == "https://app.example.test" + })); + Ok(()) + } + + struct SlowFirstTransport { + started: mpsc::UnboundedSender, + release_slow_request: RefCell>>, + } + + #[async_trait(?Send)] + impl GatewayTransport for SlowFirstTransport { + async fn send(&self, request: GatewayRequest) -> Result { + let path = request.target.path().to_string(); + let _ = self.started.unbounded_send(path.clone()); + if path == "/slow" { + let receiver = self + .release_slow_request + .borrow_mut() + .take() + .ok_or_else(|| { + WebviewError::Transport("slow request was released twice".to_string()) + })?; + receiver.await.map_err(|_| { + WebviewError::Transport("slow request release channel was dropped".to_string()) + })?; + } + GatewayResponse::new( + 200, + vec![GatewayHeader::new("content-type", "text/plain")?], + path.into_bytes(), + ) + } + } + + #[test] + fn concurrent_gateway_allows_fast_resource_while_slow_resource_waits() -> Result<()> { + let (started_sender, mut started_receiver) = mpsc::unbounded(); + let (release_slow_sender, release_slow_receiver) = oneshot::channel(); + let gateway = Rc::new(ConcurrentWebviewGateway::new( + GatewayPrefix::new("/webview/")?, + SlowFirstTransport { + started: started_sender, + release_slow_request: RefCell::new(Some(release_slow_receiver)), + }, + )); + let slow = TargetUrl::parse("https://example.test/slow")?.into_url(); + let fast = TargetUrl::parse("https://example.test/fast")?.into_url(); + let (slow_result_sender, slow_result_receiver) = oneshot::channel(); + let (fast_result_sender, fast_result_receiver) = oneshot::channel(); + let mut pool = LocalPool::new(); + let spawner = pool.spawner(); + + let slow_gateway = Rc::clone(&gateway); + spawner + .spawn_local(async move { + let _ = slow_result_sender + .send(slow_gateway.send(GatewayRequest::navigation(slow)).await); + }) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + assert_eq!( + pool.run_until(started_receiver.next()), + Some("/slow".to_string()) + ); + + let fast_gateway = Rc::clone(&gateway); + spawner + .spawn_local(async move { + let _ = fast_result_sender + .send(fast_gateway.send(GatewayRequest::subresource(fast)).await); + }) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let fast_response = pool + .run_until(fast_result_receiver) + .map_err(|_| WebviewError::Transport("fast resource task was dropped".to_string()))??; + assert_eq!(fast_response.body, b"/fast"); + + release_slow_sender.send(()).map_err(|_| { + WebviewError::Transport("slow resource task stopped waiting unexpectedly".to_string()) + })?; + let slow_response = pool + .run_until(slow_result_receiver) + .map_err(|_| WebviewError::Transport("slow resource task was dropped".to_string()))??; + assert_eq!(slow_response.body, b"/slow"); + Ok(()) + } +} diff --git a/crates/webview/src/types.rs b/crates/webview/src/types.rs new file mode 100644 index 000000000..28ab4def9 --- /dev/null +++ b/crates/webview/src/types.rs @@ -0,0 +1,216 @@ +use serde::Deserialize; +use serde::Serialize; +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; + +/// One HTTP header passed through the gateway boundary. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GatewayHeader { + /// Header name. + pub name: String, + /// Header value. + pub value: String, +} + +impl GatewayHeader { + /// Build a validated gateway header. + pub fn new(name: impl Into, value: impl Into) -> Result { + let name = name.into(); + if name.trim().is_empty() { + return Err(WebviewError::Header("header name is empty".to_string())); + } + if name.chars().any(|ch| ch.is_control()) { + return Err(WebviewError::Header(format!( + "header name {name:?} contains control characters" + ))); + } + let value = value.into(); + if value.chars().any(|ch| ch == '\r' || ch == '\n') { + return Err(WebviewError::Header(format!( + "header {name:?} value contains newline" + ))); + } + Ok(Self { name, value }) + } + + /// Return true when this header has `name`, ignoring ASCII case. + pub fn name_eq(&self, name: &str) -> bool { + self.name.eq_ignore_ascii_case(name) + } +} + +/// Browser request class that produced a gateway request. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum GatewayRequestKind { + /// Top-level document navigation. + Navigation, + /// Static page subresource such as image, stylesheet, or script. + Subresource, + /// Runtime `fetch` request. + Fetch, + /// Runtime `XMLHttpRequest`. + Xhr, +} + +/// Credential mode captured from a browser runtime request. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum GatewayCredentials { + /// Never attach or accept target cookies for this request. + Omit, + /// Attach target cookies only when the virtual source and target origins match. + SameOrigin, + /// Attach target cookies even for a permitted cross-origin request. + Include, +} + +/// Normalized request passed from the controlled origin to a gateway transport. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct GatewayRequest { + /// Absolute target URL. + pub target: Url, + /// HTTP method. + pub method: String, + /// Request headers. + pub headers: Vec, + /// Request body bytes. + pub body: Vec, + /// Browser request class. + pub kind: GatewayRequestKind, + /// Trusted source document URL for runtime requests, when one exists. + /// + /// The gateway serializes only its origin upstream and uses it to apply virtual CORS and + /// credential rules. It must never be populated from an untrusted page header. + pub source_origin: Option, + /// Browser credential mode for this request. + pub credentials: GatewayCredentials, +} + +impl GatewayRequest { + /// Build a gateway request for `target`, `method`, and browser request `kind`. + pub fn new(target: Url, method: impl Into, kind: GatewayRequestKind) -> Self { + Self { + target, + method: method.into(), + headers: Vec::new(), + body: Vec::new(), + kind, + source_origin: None, + credentials: GatewayCredentials::SameOrigin, + } + } + + /// Build a GET navigation request for `target`. + pub fn navigation(target: Url) -> Self { + Self::new(target, "GET", GatewayRequestKind::Navigation) + } + + /// Build a GET subresource request for `target`. + pub fn subresource(target: Url) -> Self { + Self::new(target, "GET", GatewayRequestKind::Subresource) + } + + /// Build a runtime `fetch` request for `target`. + pub fn fetch(target: Url, method: impl Into) -> Self { + Self::new(target, method, GatewayRequestKind::Fetch) + } + + /// Build a runtime `XMLHttpRequest` request for `target`. + pub fn xhr(target: Url, method: impl Into) -> Self { + Self::new(target, method, GatewayRequestKind::Xhr) + } + + /// Attach a request header. + pub fn with_header(mut self, header: GatewayHeader) -> Self { + self.headers.push(header); + self + } + + /// Attach a request body. + pub fn with_body(mut self, body: impl Into>) -> Self { + self.body = body.into(); + self + } + + /// Attach the trusted virtual source document for a browser runtime request. + pub fn with_source_origin(mut self, source_origin: Url) -> Self { + self.source_origin = Some(source_origin); + self + } + + /// Set the credential mode captured from a browser runtime request. + pub fn with_credentials(mut self, credentials: GatewayCredentials) -> Self { + self.credentials = credentials; + self + } + + /// Return true when this runtime request crosses virtual target origins. + pub fn is_cross_origin_runtime_request(&self) -> bool { + matches!( + self.kind, + GatewayRequestKind::Fetch | GatewayRequestKind::Xhr + ) && self + .source_origin + .as_ref() + .is_some_and(|source| source.origin() != self.target.origin()) + } + + /// Return whether the gateway may attach or store target cookies for this request. + pub fn allows_target_cookies(&self) -> bool { + if !matches!( + self.kind, + GatewayRequestKind::Fetch | GatewayRequestKind::Xhr + ) { + return true; + } + match (self.source_origin.as_ref(), self.credentials) { + (_, GatewayCredentials::Omit) => false, + (_, GatewayCredentials::Include) => true, + (Some(source), GatewayCredentials::SameOrigin) => { + source.origin() == self.target.origin() + } + (None, GatewayCredentials::SameOrigin) => true, + } + } + + /// Return the path and query component used by HTTPS onion request adapters. + pub fn path_and_query(&self) -> String { + let mut out = self.target.path().to_string(); + if out.is_empty() { + out.push('/'); + } + if let Some(query) = self.target.query() { + out.push('?'); + out.push_str(query); + } + out + } +} + +/// Normalized response returned by a gateway transport. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GatewayResponse { + /// HTTP status code. + pub status: u16, + /// Response headers after transport execution. + pub headers: Vec, + /// Response body bytes. + pub body: Vec, +} + +impl GatewayResponse { + /// Build a gateway response. + pub fn new(status: u16, headers: Vec, body: Vec) -> Result { + if !(100..=599).contains(&status) { + return Err(WebviewError::Header(format!( + "invalid response status {status}" + ))); + } + Ok(Self { + status, + headers, + body, + }) + } +} diff --git a/crates/webview/src/url.rs b/crates/webview/src/url.rs new file mode 100644 index 000000000..8a6d276d8 --- /dev/null +++ b/crates/webview/src/url.rs @@ -0,0 +1,156 @@ +use percent_encoding::percent_decode_str; +use percent_encoding::utf8_percent_encode; +use percent_encoding::NON_ALPHANUMERIC; +use serde::Deserialize; +use serde::Serialize; +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; + +/// Absolute target URL accepted by the gateway. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TargetUrl { + inner: Url, +} + +impl TargetUrl { + /// Parse and validate an absolute HTTP(S) target URL. + pub fn parse(input: &str) -> Result { + let inner = Url::parse(input.trim())?; + match inner.scheme() { + "http" | "https" => Ok(Self { inner }), + scheme => Err(WebviewError::UnsupportedScheme(scheme.to_string())), + } + } + + /// Borrow the underlying URL. + pub fn as_url(&self) -> &Url { + &self.inner + } + + /// Consume this wrapper and return the URL. + pub fn into_url(self) -> Url { + self.inner + } +} + +/// Rings-owned route prefix used to serve controlled webview pages. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GatewayPrefix { + prefix: String, +} + +impl GatewayPrefix { + /// Build a gateway prefix such as `/webview/`. + pub fn new(prefix: impl Into) -> Result { + let mut prefix = prefix.into(); + if !prefix.starts_with('/') || prefix.contains('?') || prefix.contains('#') { + return Err(WebviewError::InvalidGatewayPrefix(prefix)); + } + if !prefix.ends_with('/') { + prefix.push('/'); + } + Ok(Self { prefix }) + } + + /// Return the route prefix. + pub fn as_str(&self) -> &str { + &self.prefix + } + + /// Encode `target` into a local controlled-origin gateway URL. + pub fn encode(&self, target: &Url) -> String { + format!( + "{}{}", + self.prefix, + utf8_percent_encode(target.as_str(), NON_ALPHANUMERIC) + ) + } + + /// Decode the target URL embedded in a local gateway path. + pub fn decode_path(&self, path: &str) -> Result { + let Some(encoded) = path.strip_prefix(self.prefix.as_str()) else { + return Err(WebviewError::InvalidGatewayUrl(path.to_string())); + }; + if encoded.is_empty() { + return Err(WebviewError::InvalidGatewayUrl(path.to_string())); + } + let decoded = percent_decode_str(encoded) + .decode_utf8() + .map_err(|error| WebviewError::Decode(error.to_string()))?; + TargetUrl::parse(decoded.as_ref()) + } + + /// Resolve a page URL-bearing value against `document_url`. + pub fn resolve_url_value(&self, document_url: &Url, value: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() || is_unrewritable_url(trimmed) { + return Ok(None); + } + let target = document_url.join(trimmed)?; + match target.scheme() { + "http" | "https" => Ok(Some(target)), + _ => Ok(None), + } + } + + /// Resolve a page URL-bearing value against `document_url` and encode it for the gateway. + pub fn rewrite_url_value(&self, document_url: &Url, value: &str) -> Result> { + Ok(self + .resolve_url_value(document_url, value)? + .map(|target| self.encode(&target))) + } +} + +fn is_unrewritable_url(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.starts_with("javascript:") + || lower.starts_with("mailto:") + || lower.starts_with("tel:") + || lower.starts_with("data:") + || lower.starts_with("blob:") + || lower.starts_with("about:") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gateway_url_round_trips_absolute_target() -> Result<()> { + let prefix = GatewayPrefix::new("/webview")?; + let target = TargetUrl::parse("https://example.com/a path/?q=1#section")?; + + let gateway_url = prefix.encode(target.as_url()); + let decoded = prefix.decode_path(&gateway_url)?; + + assert_eq!(decoded, target); + assert!(gateway_url.starts_with("/webview/")); + Ok(()) + } + + #[test] + fn gateway_rejects_non_http_targets() { + assert!(matches!( + TargetUrl::parse("file:///tmp/index.html"), + Err(WebviewError::UnsupportedScheme(_)) + )); + } + + #[test] + fn relative_url_rewrites_against_document_url() -> Result<()> { + let prefix = GatewayPrefix::new("/webview/")?; + let document = TargetUrl::parse("https://example.com/a/page.html")?; + + let rewritten = prefix + .rewrite_url_value(document.as_url(), "next.html?x=1")? + .ok_or_else(|| WebviewError::InvalidGatewayUrl("missing rewrite".to_string()))?; + + assert_eq!( + prefix.decode_path(&rewritten)?.as_url().as_str(), + "https://example.com/a/next.html?x=1" + ); + Ok(()) + } +} diff --git a/crates/webview/tests/browser_gateway.rs b/crates/webview/tests/browser_gateway.rs new file mode 100644 index 000000000..05d1932e9 --- /dev/null +++ b/crates/webview/tests/browser_gateway.rs @@ -0,0 +1,1056 @@ +//! Browser fixture coverage for the webview gateway. +#![cfg(feature = "browser")] + +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::process::Command; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; +use rings_webview::browser::bootstrap_script; +use rings_webview::GatewayHeader; +use rings_webview::GatewayPrefix; +use rings_webview::GatewayRequest; +use rings_webview::GatewayRequestKind; +use rings_webview::GatewayResponse; +use rings_webview::GatewayTransport; +use rings_webview::Result; +use rings_webview::TargetUrl; +use rings_webview::WebviewError; +use rings_webview::WebviewGateway; + +#[derive(Clone, Default)] +struct FixtureLog { + requests: Arc>>, +} + +impl FixtureLog { + fn push(&self, request: GatewayRequest) { + if let Ok(mut requests) = self.requests.lock() { + requests.push(request); + } + } + + fn requests(&self) -> Result> { + self.requests + .lock() + .map(|requests| requests.clone()) + .map_err(|_| WebviewError::Transport("fixture log lock poisoned".to_string())) + } +} + +struct BrowserFixtureTransport { + log: FixtureLog, +} + +impl BrowserFixtureTransport { + fn new(log: FixtureLog) -> Self { + Self { log } + } +} + +#[async_trait(?Send)] +impl GatewayTransport for BrowserFixtureTransport { + async fn send(&self, request: GatewayRequest) -> Result { + let target = request.target.clone(); + self.log.push(request); + + match target.as_str() { + "https://example.test/docs/index.html" => response( + 200, + "text/html; charset=utf-8", + vec![ + GatewayHeader::new("Set-Cookie", "sid=browser; Path=/; Secure")?, + GatewayHeader::new("Content-Security-Policy", "default-src 'none'")?, + GatewayHeader::new("X-Frame-Options", "DENY")?, + ], + br##" + + + Rings WebView Fixture + + + + + +

Rings WebView Fixture

+ hero + base +
+
+

+

+

+
+ + +
+ + +"## + .to_vec(), + ), + "https://example.test/assets/site.css" => response( + 200, + "text/css; charset=utf-8", + Vec::new(), + b"@import \"inline-import.css\"; #title { color: rgb(1, 2, 3); }".to_vec(), + ), + "https://example.test/assets/inline-import.css" => response( + 200, + "text/css; charset=utf-8", + Vec::new(), + b"#imported-bg { width: 1px; height: 1px; background-image: url('inline-import-bg.png'); }" + .to_vec(), + ), + "https://example.test/assets/dynamic-import.css" => response( + 200, + "text/css; charset=utf-8", + Vec::new(), + b"#dynamic-import-bg { width: 1px; height: 1px; background-image: url('dynamic-import-bg.png'); }" + .to_vec(), + ), + "https://example.test/hero.png" + | "https://example.test/assets/dynamic.png" + | "https://example.test/assets/inline-bg.png" + | "https://example.test/assets/inline-image.png" + | "https://example.test/assets/inline-import-bg.png" + | "https://example.test/assets/srcdoc-image.png" + | "https://example.test/assets/srcdoc-attr-image.png" + | "https://example.test/assets/dynamic-html-image.png" + | "https://example.test/assets/dynamic-html-bg.png" + | "https://example.test/assets/adjacent-html-image.png" + | "https://example.test/assets/outer-html-image.png" + | "https://example.test/assets/dynamic-css-bg.png" + | "https://example.test/assets/dynamic-import-bg.png" + | "https://example.test/assets/dynamic-inline-style-bg.png" + | "https://example.test/assets/cssom-insert-bg.png" + | "https://example.test/assets/cssom-replace-bg.png" + | "https://example.test/assets/document-write-image.png" + | "https://example.test/assets/namespace-image.png" => { + response(200, "image/png", Vec::new(), ONE_PIXEL_PNG.to_vec()) + } + "https://example.test/api/data" => response( + 200, + "application/json", + Vec::new(), + br#"{"message":"fetch ok"}"#.to_vec(), + ), + "https://example.test/assets/runtime-base.json" => response( + 200, + "application/json", + Vec::new(), + br#"{"message":"base fetch ok"}"#.to_vec(), + ), + "https://example.test/assets/srcdoc-fetch.json" => response( + 200, + "application/json", + Vec::new(), + br#"{"message":"srcdoc fetch ok"}"#.to_vec(), + ), + "https://example.test/assets/forms/submit" => response( + 200, + "text/plain; charset=utf-8", + Vec::new(), + b"xhr ok".to_vec(), + ), + "https://example.test/assets/form-result.html?q=test" => response( + 200, + "text/html; charset=utf-8", + Vec::new(), + b"form result

test result

" + .to_vec(), + ), + "https://example.test/assets/ping-one" + | "https://example.test/assets/ping-two" => { + response(200, "text/plain; charset=utf-8", Vec::new(), Vec::new()) + } + "https://example.test/assets/ping-navigation.html" => response( + 200, + "text/html; charset=utf-8", + Vec::new(), + b"ping navigation".to_vec(), + ), + "https://example.test/assets/refresh-navigation.html" => response( + 200, + "text/html; charset=utf-8", + Vec::new(), + b"refresh navigation".to_vec(), + ), + "https://example.test/assets/window-open.html" => response( + 200, + "text/html; charset=utf-8", + Vec::new(), + b"window open".to_vec(), + ), + other => Err(WebviewError::Transport(format!( + "unexpected browser fixture request {other}" + ))), + } + } +} + +#[test] +fn playwright_browser_renders_gateway_fixture_without_direct_remote_requests() -> Result<()> { + let log = FixtureLog::default(); + let prefix = GatewayPrefix::new("/webview/")?; + let target = TargetUrl::parse("https://example.test/docs/index.html")?; + let bootstrap = bootstrap_script(prefix.as_str(), target.as_url()); + let gateway = WebviewGateway::new(prefix.clone(), BrowserFixtureTransport::new(log.clone())) + .with_bootstrap_script(bootstrap); + let server = BrowserFixtureServer::start(prefix.clone(), gateway)?; + let page_url = server.gateway_url(&prefix.encode(target.as_url())); + + run_playwright_fixture(page_url.as_str())?; + + let requests = log.requests()?; + assert_recorded_target( + &requests, + GatewayRequestKind::Navigation, + "GET", + target.as_url().as_str(), + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/assets/site.css", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/assets/inline-import.css", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/hero.png", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Fetch, + "GET", + "https://example.test/api/data", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Fetch, + "GET", + "https://example.test/assets/runtime-base.json", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Xhr, + "POST", + "https://example.test/assets/forms/submit", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Navigation, + "GET", + "https://example.test/assets/form-result.html?q=test", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/assets/dynamic.png", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/assets/srcdoc-image.png", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Subresource, + "GET", + "https://example.test/assets/srcdoc-attr-image.png", + )?; + assert_recorded_target( + &requests, + GatewayRequestKind::Fetch, + "GET", + "https://example.test/assets/srcdoc-fetch.json", + )?; + for target in [ + "https://example.test/assets/dynamic-html-image.png", + "https://example.test/assets/dynamic-html-bg.png", + "https://example.test/assets/adjacent-html-image.png", + "https://example.test/assets/outer-html-image.png", + "https://example.test/assets/dynamic-css-bg.png", + "https://example.test/assets/dynamic-import.css", + "https://example.test/assets/dynamic-import-bg.png", + "https://example.test/assets/dynamic-inline-style-bg.png", + "https://example.test/assets/cssom-insert-bg.png", + "https://example.test/assets/cssom-replace-bg.png", + "https://example.test/assets/document-write-image.png", + "https://example.test/assets/namespace-image.png", + "https://example.test/assets/refresh-navigation.html", + ] { + assert_recorded_target(&requests, GatewayRequestKind::Subresource, "GET", target)?; + } + for target in [ + "https://example.test/assets/ping-one", + "https://example.test/assets/ping-two", + ] { + assert_recorded_target(&requests, GatewayRequestKind::Fetch, "POST", target)?; + } + for target in [ + "https://example.test/assets/ping-navigation.html", + "https://example.test/assets/window-open.html", + ] { + assert_recorded_target(&requests, GatewayRequestKind::Navigation, "GET", target)?; + } + assert!(requests.iter().all(|request| { + request.target.scheme() == "https" && request.target.host_str() == Some("example.test") + })); + assert!(requests + .iter() + .all(|request| request.headers.iter().all(|header| { + !header.name_eq("host") + && !header.name_eq("origin") + && !header.name_eq("referer") + && !header.name_eq("sec-fetch-dest") + && !header.name_eq("sec-fetch-mode") + && !header.name_eq("sec-fetch-site") + }))); + + server.stop()?; + Ok(()) +} + +struct BrowserFixtureServer { + addr: std::net::SocketAddr, + shutdown: Arc, + handle: Option>>, +} + +impl BrowserFixtureServer { + fn start( + prefix: GatewayPrefix, + gateway: WebviewGateway, + ) -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|error| WebviewError::Transport(error.to_string()))?; + listener + .set_nonblocking(true) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let addr = listener + .local_addr() + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let shutdown = Arc::new(AtomicBool::new(false)); + let thread_shutdown = Arc::clone(&shutdown); + let gateway = Arc::new(Mutex::new(gateway)); + let handle = + std::thread::spawn(move || serve_gateway(listener, thread_shutdown, prefix, gateway)); + Ok(Self { + addr, + shutdown, + handle: Some(handle), + }) + } + + fn gateway_url(&self, gateway_path: &str) -> String { + format!("http://{}{}", self.addr, gateway_path) + } + + fn stop(mut self) -> Result<()> { + self.shutdown.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.addr); + if let Some(handle) = self.handle.take() { + handle.join().map_err(|_| { + WebviewError::Transport("browser fixture server panicked".to_string()) + })??; + } + Ok(()) + } +} + +fn serve_gateway( + listener: TcpListener, + shutdown: Arc, + prefix: GatewayPrefix, + gateway: Arc>>, +) -> Result<()> { + while !shutdown.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _peer)) => { + let thread_prefix = prefix.clone(); + let thread_gateway = Arc::clone(&gateway); + std::thread::spawn(move || { + let mut stream = stream; + if let Err(error) = + handle_connection(&mut stream, &thread_prefix, &thread_gateway) + { + write_fixture_error(&mut stream, error); + } + }); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(WebviewError::Transport(error.to_string())), + } + } + Ok(()) +} + +fn handle_connection( + stream: &mut TcpStream, + prefix: &GatewayPrefix, + gateway: &Arc>>, +) -> Result<()> { + stream + .set_nonblocking(false) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let request = read_http_request(stream)?; + if !request.path.starts_with(prefix.as_str()) { + return write_http_response( + stream, + &GatewayResponse::new( + 404, + vec![GatewayHeader::new("Content-Type", "text/plain")?], + b"not found".to_vec(), + )?, + ); + } + + let kind = gateway_request_kind(&request); + let mut gateway = gateway.lock().map_err(|_| { + WebviewError::Transport("browser fixture gateway lock poisoned".to_string()) + })?; + let mut gateway_request = gateway.request_from_gateway_path(request.path.as_str(), kind)?; + gateway_request.method = request.method; + gateway_request.headers = request.headers; + gateway_request.body = request.body; + let response = futures::executor::block_on(gateway.send(gateway_request))?; + write_http_response(stream, &response) +} + +fn write_fixture_error(stream: &mut TcpStream, error: WebviewError) { + let body = format!("gateway fixture error: {error}"); + let Ok(content_type) = GatewayHeader::new("Content-Type", "text/plain") else { + return; + }; + let Ok(response) = GatewayResponse::new(500, vec![content_type], body.into_bytes()) else { + return; + }; + let _ = write_http_response(stream, &response); +} + +struct HttpRequest { + method: String, + path: String, + headers: Vec, + body: Vec, +} + +fn read_http_request(stream: &mut TcpStream) -> Result { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let mut buffer = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 1024]; + let read = stream + .read(&mut chunk) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + if read == 0 { + return Err(WebviewError::Transport( + "connection closed before request headers".to_string(), + )); + } + let Some(bytes) = chunk.get(..read) else { + return Err(WebviewError::Transport("invalid read size".to_string())); + }; + buffer.extend_from_slice(bytes); + if let Some(index) = find_bytes(buffer.as_slice(), b"\r\n\r\n") { + break index; + } + }; + let body_start = header_end + .checked_add(4) + .ok_or_else(|| WebviewError::Transport("request header offset overflow".to_string()))?; + let header_bytes = buffer + .get(..header_end) + .ok_or_else(|| WebviewError::Transport("invalid request header slice".to_string()))?; + let header_text = std::str::from_utf8(header_bytes) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + let mut lines = header_text.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| WebviewError::Transport("missing request line".to_string()))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| WebviewError::Transport("missing request method".to_string()))? + .to_string(); + let path = request_parts + .next() + .ok_or_else(|| WebviewError::Transport("missing request path".to_string()))? + .to_string(); + let mut headers = Vec::new(); + let mut content_length = 0_usize; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let trimmed_value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + content_length = trimmed_value + .parse::() + .map_err(|error| WebviewError::Transport(error.to_string()))?; + } + headers.push(GatewayHeader::new(name, trimmed_value)?); + } + let total_len = body_start + .checked_add(content_length) + .ok_or_else(|| WebviewError::Transport("request body offset overflow".to_string()))?; + while buffer.len() < total_len { + let mut chunk = [0_u8; 1024]; + let read = stream + .read(&mut chunk) + .map_err(|error| WebviewError::Transport(error.to_string()))?; + if read == 0 { + return Err(WebviewError::Transport( + "connection closed before request body".to_string(), + )); + } + let Some(bytes) = chunk.get(..read) else { + return Err(WebviewError::Transport("invalid read size".to_string())); + }; + buffer.extend_from_slice(bytes); + } + let body = buffer + .get(body_start..total_len) + .ok_or_else(|| WebviewError::Transport("invalid request body slice".to_string()))? + .to_vec(); + Ok(HttpRequest { + method, + path, + headers, + body, + }) +} + +fn gateway_request_kind(request: &HttpRequest) -> GatewayRequestKind { + if header_value(&request.headers, "x-requested-with") + .is_some_and(|value| value.eq_ignore_ascii_case("XMLHttpRequest")) + { + return GatewayRequestKind::Xhr; + } + if let Some(kind) = header_value(&request.headers, "x-rings-webview-kind") { + return match kind { + "fetch" => GatewayRequestKind::Fetch, + "navigation" => GatewayRequestKind::Navigation, + _ => GatewayRequestKind::Subresource, + }; + } + if request.method != "GET" { + return GatewayRequestKind::Fetch; + } + match header_value(&request.headers, "sec-fetch-dest") { + Some("document") => GatewayRequestKind::Navigation, + _ => GatewayRequestKind::Subresource, + } +} + +fn write_http_response(stream: &mut TcpStream, response: &GatewayResponse) -> Result<()> { + let reason = match response.status { + 200 => "OK", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "OK", + }; + let mut head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status, + reason, + response.body.len() + ); + for header in &response.headers { + head.push_str(header.name.as_str()); + head.push_str(": "); + head.push_str(header.value.as_str()); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream + .write_all(head.as_bytes()) + .and_then(|_| stream.write_all(response.body.as_slice())) + .map_err(|error| WebviewError::Transport(error.to_string())) +} + +fn response( + status: u16, + content_type: &str, + extra_headers: Vec, + body: Vec, +) -> Result { + let mut headers = vec![GatewayHeader::new("Content-Type", content_type)?]; + headers.extend(extra_headers); + GatewayResponse::new(status, headers, body) +} + +fn run_playwright_fixture(page_url: &str) -> Result<()> { + if !playwright_available()? { + return Err(WebviewError::Browser( + "Playwright is not available to run browser fixture".to_string(), + )); + } + let program = format!( + r##" +const {{ chromium }} = require("playwright"); +const pageUrl = {page_url:?}; + +(async () => {{ + const browser = await chromium.launch({{ headless: true }}); + try {{ + const context = await browser.newContext(); + const page = await context.newPage(); + const requests = []; + const failures = []; + context.on("request", (request) => requests.push(request.url())); + context.on("requestfailed", (request) => failures.push(`${{request.url()}} ${{request.failure()?.errorText || ""}}`)); + await page.goto(pageUrl, {{ waitUntil: "domcontentloaded" }}); + try {{ + await page.waitForFunction(() => {{ + const srcdocFrame = document.querySelector("#dynamic-srcdoc"); + const srcdocDoc = srcdocFrame?.contentDocument; + const srcdocAttrFrame = document.querySelector("#dynamic-srcdoc-attr"); + const srcdocAttrDoc = srcdocAttrFrame?.contentDocument; + const documentWriteFrame = document.querySelector("#document-write-srcdoc"); + const documentWriteDoc = documentWriteFrame?.contentDocument; + const refreshFrame = document.querySelector("#refresh-navigation-srcdoc"); + const refreshDoc = refreshFrame?.contentDocument; + const hasGatewayBackground = (selector) => {{ + const element = document.querySelector(selector); + return Boolean(element && getComputedStyle(element).backgroundImage.includes("/webview/")); + }}; + return document.querySelector("#fetch-result")?.textContent === "fetch ok" + && document.querySelector("#base-fetch-result")?.textContent === "base fetch ok" + && document.querySelector("#xhr-result")?.textContent === "xhr ok" + && document.querySelector("#static-image")?.complete + && document.querySelector("#base-image")?.complete + && document.querySelector("#dynamic-image")?.complete + && srcdocDoc?.querySelector("#srcdoc-image")?.complete + && srcdocDoc?.body?.dataset?.srcdocFetch === "srcdoc fetch ok" + && !srcdocDoc?.body?.dataset?.srcdocError + && srcdocAttrDoc?.querySelector("#srcdoc-attr-image")?.complete + && document.querySelector("#dynamic-html-image")?.complete + && document.querySelector("#adjacent-html-image")?.complete + && document.querySelector("#outer-html-image")?.complete + && document.querySelector("#namespace-image")?.complete + && documentWriteDoc?.querySelector("#document-write-image")?.complete + && refreshDoc?.title === "refresh navigation" + && hasGatewayBackground("#dynamic-html-bg") + && hasGatewayBackground("#dynamic-css-bg") + && hasGatewayBackground("#dynamic-import-bg") + && hasGatewayBackground("#dynamic-inline-style-bg") + && hasGatewayBackground("#cssom-insert-bg") + && hasGatewayBackground("#cssom-replace-bg"); + }}, null, {{ timeout: 10000 }}); + }} catch (error) {{ + const diagnostic = await page.evaluate(() => {{ + const frameState = (selector) => {{ + const frame = document.querySelector(selector); + return {{ + url: frame?.contentWindow?.location?.href || "", + title: frame?.contentDocument?.title || "" + }}; + }}; + return {{ + fixtureError: document.body.dataset.fixtureError || "", + refresh: frameState("#refresh-navigation-srcdoc"), + namespaceImage: document.querySelector("#namespace-image")?.src || "" + }}; + }}); + throw new Error(`${{error.message}} fixture state=${{JSON.stringify(diagnostic)}}`); + }} + const pingOne = encodeURIComponent("https://example.test/assets/ping-one"); + const pingTwo = encodeURIComponent("https://example.test/assets/ping-two"); + const pingRequests = new Promise((resolve, reject) => {{ + const observed = new Set(); + const timeout = setTimeout(() => reject(new Error(`timed out waiting for gateway ping requests: ${{JSON.stringify([...observed])}}`)), 10000); + const observe = (request) => {{ + if (request.url().includes(pingOne)) observed.add(pingOne); + if (request.url().includes(pingTwo)) observed.add(pingTwo); + if (observed.size === 2) {{ + clearTimeout(timeout); + context.off("request", observe); + resolve(); + }} + }}; + context.on("request", observe); + }}); + const popupPromise = page.waitForEvent("popup"); + await page.locator("#dynamic-ping-link").click(); + const popup = await popupPromise; + await popup.waitForLoadState("domcontentloaded"); + await pingRequests; + const runtimeOpenPopupPromise = page.waitForEvent("popup"); + await page.locator("#runtime-open-button").click(); + const runtimeOpenPopup = await runtimeOpenPopupPromise; + await runtimeOpenPopup.waitForLoadState("domcontentloaded"); + const result = await page.evaluate(() => {{ + const title = document.querySelector("#title"); + const dynamicLink = document.querySelector("#dynamic-link"); + const staticImage = document.querySelector("#static-image"); + const baseImage = document.querySelector("#base-image"); + const dynamicImage = document.querySelector("#dynamic-image"); + const styleBg = document.querySelector("#style-bg"); + const importedBg = document.querySelector("#imported-bg"); + const srcdocFrame = document.querySelector("#dynamic-srcdoc"); + const srcdocDoc = srcdocFrame?.contentDocument; + const srcdocImage = srcdocDoc?.querySelector("#srcdoc-image"); + const srcdocAttrFrame = document.querySelector("#dynamic-srcdoc-attr"); + const srcdocAttrDoc = srcdocAttrFrame?.contentDocument; + const srcdocAttrImage = srcdocAttrDoc?.querySelector("#srcdoc-attr-image"); + const documentWriteFrame = document.querySelector("#document-write-srcdoc"); + const documentWriteImage = documentWriteFrame?.contentDocument?.querySelector("#document-write-image"); + const dynamicHtmlImage = document.querySelector("#dynamic-html-image"); + const adjacentHtmlImage = document.querySelector("#adjacent-html-image"); + const outerHtmlImage = document.querySelector("#outer-html-image"); + const dynamicPing = document.querySelector("#dynamic-ping-link"); + const namespaceImage = document.querySelector("#namespace-image"); + const refreshFrame = document.querySelector("#refresh-navigation-srcdoc"); + const backgroundImage = (selector) => {{ + const element = document.querySelector(selector); + return element ? getComputedStyle(element).backgroundImage : ""; + }}; + return {{ + titleText: title?.textContent, + titleColor: title ? getComputedStyle(title).color : "", + fetchText: document.querySelector("#fetch-result")?.textContent, + baseFetchText: document.querySelector("#base-fetch-result")?.textContent, + xhrText: document.querySelector("#xhr-result")?.textContent, + staticImageSrc: staticImage?.src, + staticImageComplete: Boolean(staticImage?.complete), + baseImageSrc: baseImage?.src, + baseImageComplete: Boolean(baseImage?.complete), + dynamicImageSrc: dynamicImage?.src, + dynamicImageComplete: Boolean(dynamicImage?.complete), + dynamicLinkHref: dynamicLink?.href, + styleBgImage: styleBg ? getComputedStyle(styleBg).backgroundImage : "", + importedBgImage: importedBg ? getComputedStyle(importedBg).backgroundImage : "", + dynamicHtmlImageSrc: dynamicHtmlImage?.src, + dynamicHtmlImageComplete: Boolean(dynamicHtmlImage?.complete), + adjacentHtmlImageSrc: adjacentHtmlImage?.src, + adjacentHtmlImageComplete: Boolean(adjacentHtmlImage?.complete), + outerHtmlImageSrc: outerHtmlImage?.src, + outerHtmlImageComplete: Boolean(outerHtmlImage?.complete), + documentWriteImageSrc: documentWriteImage?.src, + documentWriteImageComplete: Boolean(documentWriteImage?.complete), + dynamicHtmlBgImage: backgroundImage("#dynamic-html-bg"), + dynamicCssBgImage: backgroundImage("#dynamic-css-bg"), + dynamicImportBgImage: backgroundImage("#dynamic-import-bg"), + dynamicInlineStyleBgImage: backgroundImage("#dynamic-inline-style-bg"), + cssomInsertBgImage: backgroundImage("#cssom-insert-bg"), + cssomReplaceBgImage: backgroundImage("#cssom-replace-bg"), + dynamicPingHref: dynamicPing?.href, + dynamicPingValue: dynamicPing?.getAttribute("ping"), + namespaceImageSrc: namespaceImage?.src, + refreshFrameUrl: refreshFrame?.contentWindow?.location?.href || "", + srcdocImageSrc: srcdocImage?.src, + srcdocImageComplete: Boolean(srcdocImage?.complete), + srcdocFetchText: srcdocDoc?.body?.dataset?.srcdocFetch || "", + srcdocError: srcdocDoc?.body?.dataset?.srcdocError || "", + srcdocAttrImageSrc: srcdocAttrImage?.src, + srcdocAttrImageComplete: Boolean(srcdocAttrImage?.complete), + fixtureError: document.body.dataset.fixtureError || "" + }}; + }}); + await page.locator("#gateway-search-submit").click(); + const encodedFormTarget = encodeURIComponent("https://example.test/assets/form-result.html?q=test"); + await page.waitForFunction((target) => ( + document.title === "form result" + && document.querySelector("#form-result")?.textContent === "test result" + && location.href.endsWith(`/webview/${{target}}`) + ), encodedFormTarget); + const formResult = await page.evaluate(() => ({{ + text: document.querySelector("#form-result")?.textContent || "", + title: document.title, + url: location.href + }})); + if (formResult.title !== "form result" || formResult.text !== "test result") {{ + throw new Error(`GET form result did not render: ${{JSON.stringify(formResult)}}`); + }} + if (!formResult.url.endsWith(`/webview/${{encodedFormTarget}}`)) {{ + throw new Error(`GET form escaped its encoded gateway target: ${{JSON.stringify(formResult)}}`); + }} + const directRemoteRequests = requests.filter((url) => {{ + try {{ + return new URL(url).hostname === "example.test"; + }} catch (_error) {{ + return false; + }} + }}); + const failuresWithoutFavicon = failures.filter((failure) => !failure.includes("/favicon.ico")); + if (directRemoteRequests.length > 0) {{ + throw new Error(`direct remote requests escaped gateway: ${{directRemoteRequests.join(", ")}}`); + }} + if (failuresWithoutFavicon.length > 0) {{ + throw new Error(`browser request failures: ${{failuresWithoutFavicon.join(", ")}}`); + }} + if (result.fixtureError) {{ + throw new Error(`page fixture error: ${{result.fixtureError}}`); + }} + if (result.titleText !== "Rings WebView Fixture") {{ + throw new Error(`page title did not render: ${{JSON.stringify(result)}}`); + }} + if (result.titleColor !== "rgb(1, 2, 3)") {{ + throw new Error(`stylesheet did not apply: ${{JSON.stringify(result)}}`); + }} + if (result.fetchText !== "fetch ok" || result.xhrText !== "xhr ok") {{ + throw new Error(`dynamic requests did not complete: ${{JSON.stringify(result)}}`); + }} + if (result.baseFetchText !== "base fetch ok") {{ + throw new Error(`base-relative fetch did not complete: ${{JSON.stringify(result)}}`); + }} + if (result.srcdocError) {{ + throw new Error(`srcdoc fixture error: ${{result.srcdocError}} ${{JSON.stringify(result)}}`); + }} + if (result.srcdocFetchText !== "srcdoc fetch ok") {{ + throw new Error(`srcdoc fetch did not complete: ${{JSON.stringify(result)}}`); + }} + if (!result.staticImageSrc.includes("/webview/") || !result.baseImageSrc.includes("/webview/") || !result.dynamicImageSrc.includes("/webview/")) {{ + throw new Error(`image URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.srcdocImageSrc.includes("/webview/") || !result.srcdocAttrImageSrc.includes("/webview/")) {{ + throw new Error(`srcdoc image URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.dynamicHtmlImageSrc.includes("/webview/") || !result.adjacentHtmlImageSrc.includes("/webview/") || !result.outerHtmlImageSrc.includes("/webview/") || !result.documentWriteImageSrc.includes("/webview/")) {{ + throw new Error(`runtime HTML URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.namespaceImageSrc.includes("/webview/")) {{ + throw new Error(`setAttributeNS URL did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.styleBgImage.includes("/webview/") || !result.importedBgImage.includes("/webview/")) {{ + throw new Error(`CSS URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (![result.dynamicHtmlBgImage, result.dynamicCssBgImage, result.dynamicImportBgImage, result.dynamicInlineStyleBgImage, result.cssomInsertBgImage, result.cssomReplaceBgImage].every((value) => value.includes("/webview/"))) {{ + throw new Error(`runtime CSS URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.dynamicLinkHref.includes("/webview/")) {{ + throw new Error(`dynamic link URL did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.dynamicPingHref.includes("/webview/") || !result.dynamicPingValue.includes("/webview/")) {{ + throw new Error(`dynamic ping URLs did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + if (!result.refreshFrameUrl.includes("/webview/")) {{ + throw new Error(`runtime refresh navigation did not stay on gateway: ${{JSON.stringify(result)}}`); + }} + }} finally {{ + await browser.close(); + }} +}})().catch(async (error) => {{ + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}}); +"## + ); + let output = Command::new("node") + .arg("-e") + .arg(program) + .output() + .map_err(|error| WebviewError::Browser(error.to_string()))?; + if !output.status.success() { + return Err(WebviewError::Browser(format!( + "Playwright browser fixture failed: stdout={} stderr={}", + String::from_utf8_lossy(output.stdout.as_slice()), + String::from_utf8_lossy(output.stderr.as_slice()) + ))); + } + Ok(()) +} + +fn playwright_available() -> Result { + let output = Command::new("node") + .arg("-e") + .arg("require.resolve('playwright')") + .output() + .map_err(|error| WebviewError::Browser(error.to_string()))?; + Ok(output.status.success()) +} + +fn assert_recorded_target( + requests: &[GatewayRequest], + kind: GatewayRequestKind, + method: &str, + target: &str, +) -> Result<()> { + let found = requests.iter().any(|request| { + request.kind == kind && request.method == method && request.target.as_str() == target + }); + if found { + Ok(()) + } else { + Err(WebviewError::Transport(format!( + "missing recorded {kind:?} {method} request to {target}" + ))) + } +} + +fn header_value<'a>(headers: &'a [GatewayHeader], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|header| header.name_eq(name)) + .map(|header| header.value.as_str()) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +const ONE_PIXEL_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]; diff --git a/crates/webview/tests/webview_flow.rs b/crates/webview/tests/webview_flow.rs new file mode 100644 index 000000000..e1eb6071c --- /dev/null +++ b/crates/webview/tests/webview_flow.rs @@ -0,0 +1,281 @@ +//! End-to-end policy flow coverage for the webview gateway. +#![cfg(feature = "browser")] + +use std::cell::RefCell; +use std::rc::Rc; + +use async_trait::async_trait; +use rings_webview::browser::bootstrap_script; +use rings_webview::browser::runtime_gateway_url; +use rings_webview::browser::BOOTSTRAP_MARKER; +use rings_webview::GatewayHeader; +use rings_webview::GatewayPrefix; +use rings_webview::GatewayRequest; +use rings_webview::GatewayRequestKind; +use rings_webview::GatewayResponse; +use rings_webview::GatewayTransport; +use rings_webview::Result; +use rings_webview::TargetUrl; +use rings_webview::WebviewError; +use rings_webview::WebviewGateway; +use rings_webview::WebviewRenderer; +use url::Url; + +#[derive(Clone, Default)] +struct FixtureLog { + requests: Rc>>, +} + +impl FixtureLog { + fn push(&self, request: GatewayRequest) { + self.requests.borrow_mut().push(request); + } + + fn requests(&self) -> Vec { + self.requests.borrow().clone() + } +} + +struct FixtureTransport { + log: FixtureLog, +} + +impl FixtureTransport { + fn new(log: FixtureLog) -> Self { + Self { log } + } +} + +#[async_trait(?Send)] +impl GatewayTransport for FixtureTransport { + async fn send(&self, request: GatewayRequest) -> Result { + let target = request.target.clone(); + self.log.push(request); + + match target.as_str() { + "https://example.test/docs/index.html" => response( + 200, + "text/html; charset=utf-8", + vec![ + GatewayHeader::new("Set-Cookie", "sid=fixture; Path=/; Secure")?, + GatewayHeader::new("Content-Security-Policy", "default-src 'none'")?, + GatewayHeader::new("X-Frame-Options", "DENY")?, + ], + br#" + + + + + + + + + next + +
+ +"# + .to_vec(), + ), + "https://example.test/assets/site.css" => response( + 200, + "text/css; charset=utf-8", + Vec::new(), + br#"@import "theme.css"; body { background: url('/img/bg.png'); }"#.to_vec(), + ), + "https://example.test/api/data" => response( + 200, + "application/json", + Vec::new(), + br#"{"ok":true}"#.to_vec(), + ), + "https://example.test/docs/forms/submit" => { + response(204, "text/plain", Vec::new(), Vec::new()) + } + "https://example.test/assets/forms/submit" => { + response(204, "text/plain", Vec::new(), Vec::new()) + } + "https://example.test/redirect" => response( + 302, + "text/html", + vec![GatewayHeader::new("Location", "/login")?], + Vec::new(), + ), + other => Err(WebviewError::Transport(format!( + "unexpected fixture request {other}" + ))), + } + } +} + +#[test] +fn webview_gateway_renders_and_routes_page_flow() -> Result<()> { + let log = FixtureLog::default(); + let prefix = GatewayPrefix::new("/webview/")?; + let target = TargetUrl::parse("https://example.test/docs/index.html")?; + let bootstrap = bootstrap_script(prefix.as_str(), target.as_url()); + let gateway = WebviewGateway::new(prefix.clone(), FixtureTransport::new(log.clone())) + .with_bootstrap_script(bootstrap); + let mut renderer = WebviewRenderer::new(gateway); + + let page = futures::executor::block_on(renderer.render(target.clone()))?; + + assert_eq!(page.status, 200); + assert_eq!(page.gateway_url, prefix.encode(target.as_url())); + assert_contains(page.html(), "data-rings-webview-bootstrap"); + assert_contains(page.html(), BOOTSTRAP_MARKER); + assert_contains(page.html(), "targetBase"); + assert_contains(page.html(), target.as_url().as_str()); + assert_absent(page.html(), "href=\"/assets/site.css\""); + assert_absent(page.html(), "src=\"app.js\""); + assert_absent(page.html(), "href=\"../next\""); + assert_absent(page.html(), "action=\"forms/submit\""); + assert_absent(page.html(), "url(inline-bg.png)"); + assert_header_absent(&page.headers, "set-cookie"); + assert!(page.headers.iter().any(|header| { + header.name_eq("content-security-policy") && header.value.contains("connect-src 'self'") + })); + assert_header_absent(&page.headers, "x-frame-options"); + + let stylesheet = Url::parse("https://example.test/assets/site.css")?; + let script = Url::parse("https://example.test/assets/app.js")?; + let next = Url::parse("https://example.test/next")?; + let thumb = Url::parse("https://example.test/assets/thumb.png")?; + let hero = Url::parse("https://example.test/hero.png")?; + let submit = Url::parse("https://example.test/assets/forms/submit")?; + let inline_bg = Url::parse("https://example.test/assets/inline-bg.png")?; + let base = Url::parse("https://example.test/assets/")?; + for expected in [ + &stylesheet, + &script, + &next, + &thumb, + &hero, + &submit, + &inline_bg, + &base, + ] { + assert_contains(page.html(), prefix.encode(expected).as_str()); + } + + let css_path = prefix.encode(&stylesheet); + let css_response = futures::executor::block_on( + renderer + .gateway_mut() + .send_gateway_path(&css_path, GatewayRequestKind::Subresource), + )?; + let css = utf8_body(css_response)?; + assert_contains( + &css, + prefix + .encode(&Url::parse("https://example.test/assets/theme.css")?) + .as_str(), + ); + assert_contains( + &css, + prefix + .encode(&Url::parse("https://example.test/img/bg.png")?) + .as_str(), + ); + + let fetch_path = required_runtime_gateway_url(&prefix, target.as_url(), "/api/data")?; + assert!(fetch_path.starts_with(prefix.as_str())); + assert!(!fetch_path.starts_with("https://")); + let fetch_response = futures::executor::block_on( + renderer + .gateway_mut() + .send_gateway_path(&fetch_path, GatewayRequestKind::Fetch), + )?; + assert_eq!(utf8_body(fetch_response)?, r#"{"ok":true}"#); + + let runtime_base = Url::parse("https://example.test/assets/")?; + let xhr_path = required_runtime_gateway_url(&prefix, &runtime_base, "forms/submit")?; + let mut xhr_request = renderer + .gateway() + .request_from_gateway_path(&xhr_path, GatewayRequestKind::Xhr)?; + xhr_request.method = "POST".to_string(); + xhr_request.body = b"name=value".to_vec(); + let xhr_response = futures::executor::block_on(renderer.gateway_mut().send(xhr_request))?; + assert_eq!(xhr_response.status, 204); + + let redirect_path = prefix.encode(&Url::parse("https://example.test/redirect")?); + let redirect_response = futures::executor::block_on( + renderer + .gateway_mut() + .send_gateway_path(&redirect_path, GatewayRequestKind::Navigation), + )?; + assert_eq!(redirect_response.status, 302); + let location = header_value(&redirect_response.headers, "location") + .ok_or_else(|| WebviewError::Header("missing redirect location".to_string()))?; + assert_eq!( + prefix.decode_path(location)?.as_url().as_str(), + "https://example.test/login" + ); + + let requests = log.requests(); + assert!(requests.iter().all(|request| { + matches!(request.target.scheme(), "http" | "https") + && !request.target.as_str().starts_with(prefix.as_str()) + })); + assert!(requests.iter().any(|request| { + request.kind == GatewayRequestKind::Subresource + && request.target.as_str() == "https://example.test/assets/site.css" + })); + assert!(requests.iter().any(|request| { + request.kind == GatewayRequestKind::Fetch + && request.target.as_str() == "https://example.test/api/data" + && header_value(&request.headers, "cookie") == Some("sid=fixture") + })); + assert!(requests.iter().any(|request| { + request.kind == GatewayRequestKind::Xhr + && request.method == "POST" + && request.target.as_str() == "https://example.test/assets/forms/submit" + && request.body.as_slice() == b"name=value" + && header_value(&request.headers, "cookie") == Some("sid=fixture") + })); + + Ok(()) +} + +fn response( + status: u16, + content_type: &str, + extra_headers: Vec, + body: Vec, +) -> Result { + let mut headers = vec![GatewayHeader::new("Content-Type", content_type)?]; + headers.extend(extra_headers); + GatewayResponse::new(status, headers, body) +} + +fn required_runtime_gateway_url( + prefix: &GatewayPrefix, + document_url: &Url, + input: &str, +) -> Result { + runtime_gateway_url(prefix, document_url, input)? + .ok_or_else(|| WebviewError::InvalidGatewayUrl(input.to_string())) +} + +fn utf8_body(response: GatewayResponse) -> Result { + String::from_utf8(response.body).map_err(|error| WebviewError::Render(error.to_string())) +} + +fn header_value<'a>(headers: &'a [GatewayHeader], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|header| header.name_eq(name)) + .map(|header| header.value.as_str()) +} + +fn assert_header_absent(headers: &[GatewayHeader], name: &str) { + assert!(header_value(headers, name).is_none(), "unexpected {name}"); +} + +fn assert_contains(haystack: &str, needle: &str) { + assert!(haystack.contains(needle), "missing {needle}"); +} + +fn assert_absent(haystack: &str, needle: &str) { + assert!(!haystack.contains(needle), "unexpected {needle}"); +} diff --git a/docker/cluster/Dockerfile b/docker/cluster/Dockerfile index a36c31dc8..22fa59f64 100644 --- a/docker/cluster/Dockerfile +++ b/docker/cluster/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /src COPY Cargo.lock Cargo.toml README.md rust-toolchain.toml ./ COPY crates ./crates COPY examples ./examples +COPY third_party ./third_party RUN cargo build -p rings-node --bin rings --features node --no-default-features --release diff --git a/frontend/Cargo.lock b/frontend/Cargo.lock index 525cb1ba1..4774c96f1 100644 --- a/frontend/Cargo.lock +++ b/frontend/Cargo.lock @@ -135,7 +135,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -146,7 +146,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -474,7 +474,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -1187,6 +1187,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1414,6 +1437,21 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -1514,6 +1552,15 @@ dependencies = [ "log", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -1568,7 +1615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2334,6 +2381,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2754,7 +2803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3026,6 +3075,25 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lol_html" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae574a677ef0443a0bd3c291f0cab9d1e61a3ad85a1515e3cfc0bc720d5a48e" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown 0.17.1", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.18", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3101,6 +3169,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minicov" version = "0.3.8" @@ -3239,7 +3313,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3478,6 +3552,50 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -3487,6 +3605,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4202,14 +4329,17 @@ dependencies = [ name = "rings-frontend" version = "0.1.0" dependencies = [ + "async-trait", "base58", "base64 0.22.1", "futures", "gloo-timers 0.3.0", "js-sys", "rings-node", + "rings-webview", "serde", "serde_json", + "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -4328,6 +4458,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rings-webview" +version = "0.14.0" +dependencies = [ + "async-trait", + "js-sys", + "lol_html", + "percent-encoding", + "serde", + "serde-wasm-bindgen 0.6.5", + "thiserror 1.0.69", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rkyv" version = "0.8.16" @@ -4418,7 +4564,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4506,6 +4652,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -4637,6 +4802,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4777,7 +4951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4826,7 +5000,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] @@ -4948,7 +5122,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5854,7 +6028,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index b4ab01864..6feea96b0 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -13,12 +13,15 @@ publish = false [dependencies] base58 = "0.2" base64 = "0.22" +async-trait = "0.1" futures = "0.3" gloo-timers = { version = "0.3", features = ["futures"] } js-sys = "=0.3.98" rings-node = { path = "../crates/node", default-features = false, features = ["browser_default"] } +rings-webview = { path = "../crates/webview", features = ["browser"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +url = "2" wasm-bindgen = "=0.2.121" wasm-bindgen-futures = "=0.4.71" web-sys = { version = "=0.3.98", features = [ @@ -30,6 +33,7 @@ web-sys = { version = "=0.3.98", features = [ "HtmlTextAreaElement", "InputEvent", "Location", + "SubmitEvent", "Url", "Window", ] } diff --git a/frontend/Trunk.toml b/frontend/Trunk.toml index f46a0b246..0ed44a76d 100644 --- a/frontend/Trunk.toml +++ b/frontend/Trunk.toml @@ -3,6 +3,7 @@ watch = [ "index.html", "src", "assets", + "rings-webview-service-worker.js", "Cargo.toml", "Trunk.toml", "extension-assets", diff --git a/frontend/assets/webview-host.js b/frontend/assets/webview-host.js new file mode 100644 index 000000000..035b22ff1 --- /dev/null +++ b/frontend/assets/webview-host.js @@ -0,0 +1,182 @@ +(() => { + "use strict"; + + const workerUrl = "/rings-webview-service-worker.js?gateway-host-protocol=3"; + let registrationPromise; + const debugEntries = []; + + function recordDebug(scope, message, level = "info", resource = undefined) { + const entry = { + at: new Date().toISOString(), + scope, + message, + level, + }; + if (resource) { + entry.resource = resource; + } + debugEntries.push(entry); + if (debugEntries.length > 200) { + debugEntries.splice(0, debugEntries.length - 200); + } + } + + function ensureServiceWorkerSupport() { + if (!navigator.serviceWorker) { + throw new Error("Service Worker is unavailable in this browser context"); + } + } + + function waitForController() { + if (navigator.serviceWorker.controller) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout(() => { + navigator.serviceWorker.removeEventListener("controllerchange", onChange); + reject(new Error("Service Worker did not take control of this page")); + }, 5_000); + function onChange() { + globalThis.clearTimeout(timeout); + navigator.serviceWorker.removeEventListener("controllerchange", onChange); + resolve(); + } + navigator.serviceWorker.addEventListener("controllerchange", onChange, { once: true }); + }); + } + + async function registration() { + ensureServiceWorkerSupport(); + if (!registrationPromise) { + registrationPromise = navigator.serviceWorker + .register(workerUrl, { scope: "/" }) + .then(async (activeRegistration) => { + await activeRegistration.update(); + return activeRegistration; + }); + } + return registrationPromise; + } + + async function ensureReady() { + const activeRegistration = await registration(); + await navigator.serviceWorker.ready; + await waitForController(); + recordDebug("popup", "Service Worker controls this popup"); + return activeRegistration; + } + + async function registerGatewayHost() { + const activeRegistration = await ensureReady(); + const worker = navigator.serviceWorker.controller || activeRegistration.active; + if (!worker) { + throw new Error("Service Worker has no active controller"); + } + await postWorkerMessage(worker, { type: "rings-webview-host-register" }); + recordDebug("host", "Registered the local Rings node as gateway host"); + } + + async function enableDebug() { + const activeRegistration = await ensureReady(); + const worker = navigator.serviceWorker.controller || activeRegistration.active; + if (!worker) { + throw new Error("Service Worker has no active controller"); + } + const acknowledged = await postWorkerMessage(worker, { type: "rings-webview-debug-register" }); + if (!acknowledged) { + recordDebug("popup", "Service Worker did not acknowledge debug registration; continuing"); + } + recordDebug("popup", "Registered popup debug listener"); + } + + function postWorkerMessage(worker, message) { + return new Promise((resolve) => { + const channel = new MessageChannel(); + const timeout = globalThis.setTimeout(() => { + channel.port1.close(); + resolve(false); + }, 500); + channel.port1.onmessage = (event) => { + globalThis.clearTimeout(timeout); + channel.port1.close(); + resolve(Boolean(event.data?.ok)); + }; + worker.postMessage(message, [channel.port2]); + }); + } + + function takeDebugEntries() { + return debugEntries.splice(0, debugEntries.length); + } + + function clearDebugEntries() { + debugEntries.splice(0, debugEntries.length); + } + + navigator.serviceWorker?.addEventListener("message", (event) => { + const message = event.data; + if (message?.type === "rings-webview-debug") { + recordDebug( + message.scope || "worker", + message.message || "unknown event", + message.level || "info", + message.resource, + ); + return; + } + if (message?.type === "rings-webview-gateway-host-query") { + const ready = typeof globalThis.RingsWebviewGateway?.handle === "function"; + event.ports?.[0]?.postMessage({ ready }); + const worker = navigator.serviceWorker.controller; + if (ready && worker) { + void postWorkerMessage(worker, { type: "rings-webview-host-register" }) + .then(() => recordDebug("host", "Restored the local Rings node gateway host")); + } + return; + } + if (message?.type !== "rings-webview-gateway-request") { + return; + } + const port = event.ports?.[0]; + if (!port) { + return; + } + const handler = globalThis.RingsWebviewGateway?.handle; + if (typeof handler !== "function") { + recordDebug("host", "Rejected request because the local node gateway is unavailable", "error"); + port.postMessage({ + ok: false, + status: 503, + error: "the local Rings node gateway is unavailable", + }); + return; + } + recordDebug("host", `Received ${message.request.kind} ${message.request.method} request`); + Promise.resolve(handler(message.request)) + .then((response) => { + if (response?.ok) { + recordDebug("host", `Returned gateway response ${response.status}`); + } else { + recordDebug("host", `Gateway response ${response?.status || 502}: ${response?.error || "unknown error"}`, "error"); + } + port.postMessage(response); + }) + .catch((error) => { + recordDebug("host", `Gateway handler failed: ${String(error)}`, "error"); + port.postMessage({ + ok: false, + status: 502, + error: String(error), + }); + }); + }); + + globalThis.RingsWebviewHost = Object.freeze({ + ensureReady, + registerGatewayHost, + enableDebug, + recordDebugEntry: recordDebug, + takeDebugEntries, + clearDebugEntries, + }); +})(); diff --git a/frontend/index.html b/frontend/index.html index 974deb65f..06330b23e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,8 +5,11 @@ Rings - A P2P network for the sovereign age + - + + + diff --git a/frontend/rings-webview-service-worker.js b/frontend/rings-webview-service-worker.js new file mode 100644 index 000000000..73669b02d --- /dev/null +++ b/frontend/rings-webview-service-worker.js @@ -0,0 +1,422 @@ +"use strict"; + +const gatewayPrefix = "/webview/"; +const requestTimeoutMs = 30_000; +let gatewayHostClientId = null; +const debugClientIds = new Set(); +const debugHistory = []; +let nextRequestId = 1; + +self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", (event) => { + const clientId = event.source?.id; + const reply = event.ports?.[0]; + if (event.data?.type === "rings-webview-host-register" && typeof clientId === "string" && clientId) { + updateGatewayHost(clientId); + void emitDebug("worker", "Updated local Rings node gateway host"); + reply?.postMessage({ ok: true }); + return; + } + if (event.data?.type === "rings-webview-debug-register" && typeof clientId === "string" && clientId) { + debugClientIds.add(clientId); + void self.clients.get(clientId).then(async (client) => { + for (const entry of debugHistory) { + client?.postMessage(entry); + } + await emitDebug("worker", "Registered popup debug client"); + }); + reply?.postMessage({ ok: true }); + return; + } + reply?.postMessage({ ok: false, error: "unsupported gateway registration" }); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(gatewayPrefix)) { + return; + } + event.respondWith(handleGatewayFetch(event)); +}); + +async function handleGatewayFetch(event) { + const requestId = nextRequestId; + nextRequestId += 1; + const startedAt = performance.now(); + const request = await serializeRequest(event); + await emitResourceDebug( + requestId, + request, + startedAt, + "intercepted", + `#${requestId} intercepted ${request.kind} ${request.method} ${requestedTarget(request.requested)} (mode=${event.request.mode}, destination=${event.request.destination || "none"})`, + ); + const host = await gatewayHostClient(); + if (!host) { + await emitResourceDebug( + requestId, + request, + startedAt, + "failed", + `#${requestId} rejected: no local gateway host`, + "error", + 503, + ); + return gatewayFailure(503, "Start a local Rings node before opening WebView."); + } + await emitResourceDebug( + requestId, + request, + startedAt, + "dispatched", + `#${requestId} dispatched to the local gateway host`, + ); + const response = await requestGatewayResponse(host, request); + if (!response?.ok) { + const status = response?.status || 502; + await emitResourceDebug( + requestId, + request, + startedAt, + "failed", + `#${requestId} gateway failure ${status}: ${response?.error || "unknown error"}`, + "error", + status, + ); + return gatewayFailure(response?.status || 502, response?.error || "gateway request failed"); + } + try { + const headers = new Headers(); + for (const header of response.headers || []) { + headers.append(header.name, header.value); + } + await emitResourceDebug( + requestId, + request, + startedAt, + "completed", + `#${requestId} returned ${response.status}`, + "info", + response.status, + ); + return new Response(responseMustNotHaveBody(response.status) ? null : response.body || null, { + status: response.status, + headers, + }); + } catch (error) { + await emitResourceDebug( + requestId, + request, + startedAt, + "failed", + `#${requestId} invalid gateway response: ${String(error)}`, + "error", + 502, + ); + return gatewayFailure(502, `invalid gateway response: ${String(error)}`); + } +} + +function responseMustNotHaveBody(status) { + return status === 204 || status === 205 || status === 304; +} + +async function emitResourceDebug(requestId, request, startedAt, phase, message, level = "info", status = undefined) { + const resource = { + requestId, + target: requestedTarget(request.requested), + method: request.method, + kind: request.kind, + phase, + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + }; + if (status !== undefined) { + resource.status = status; + } + await emitDebug("worker", message, level, resource); +} + +async function emitDebug(scope, message, level = "info", resource = undefined) { + const entry = { + type: "rings-webview-debug", + at: new Date().toISOString(), + scope, + message, + level, + }; + if (resource) { + entry.resource = resource; + } + debugHistory.push(entry); + if (debugHistory.length > 200) { + debugHistory.splice(0, debugHistory.length - 200); + } + const clients = await debugClients(); + await Promise.all( + clients.map((client) => client.postMessage(entry)), + ); +} + +async function debugClients() { + const clientsById = new Map(); + for (const clientId of debugClientIds) { + const client = await self.clients.get(clientId); + if (client) { + clientsById.set(client.id, client); + } else { + debugClientIds.delete(clientId); + } + } + const candidates = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + for (const client of candidates) { + if (isWebviewPopup(client.url)) { + debugClientIds.add(client.id); + clientsById.set(client.id, client); + } + } + return [...clientsById.values()]; +} + +function isWebviewPopup(url) { + try { + const parsed = new URL(url); + return parsed.hash.startsWith("#webview") || parsed.pathname.startsWith(gatewayPrefix); + } catch (_error) { + return false; + } +} + +function requestedTarget(url) { + const path = new URL(url).pathname; + const encoded = path.slice(gatewayPrefix.length); + try { + return encoded ? decodeURIComponent(encoded) : url; + } catch (_error) { + return url; + } +} + +async function gatewayHostClient() { + const registered = await registeredGatewayHostClient(); + if (registered) { + return registered; + } + const candidateClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + if (candidateClients.length === 0) { + return undefined; + } + const discovered = await Promise.all( + candidateClients.map(async (client) => ({ + client, + ready: await queryGatewayHost(client), + })), + ); + const host = discovered.find(({ ready }) => ready)?.client; + if (!host) { + return undefined; + } + updateGatewayHost(host.id); + return host; +} + +async function registeredGatewayHostClient() { + if (!gatewayHostClientId) { + return undefined; + } + const client = await self.clients.get(gatewayHostClientId); + if (!client) { + gatewayHostClientId = null; + } + return client; +} + +function updateGatewayHost(clientId) { + gatewayHostClientId = clientId; +} + +function queryGatewayHost(client) { + return new Promise((resolve) => { + const channel = new MessageChannel(); + const timeout = globalThis.setTimeout(() => { + channel.port1.close(); + resolve(false); + }, 500); + channel.port1.onmessage = (event) => { + globalThis.clearTimeout(timeout); + channel.port1.close(); + resolve(Boolean(event.data?.ready)); + }; + client.postMessage( + { type: "rings-webview-gateway-host-query" }, + [channel.port2], + ); + }); +} + +async function serializeRequest(event) { + const request = event.request; + const sourceTarget = await sourceTargetForClient(event.clientId); + const body = request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.clone().arrayBuffer(); + return { + requested: request.url, + sourceTarget, + method: request.method, + credentials: request.credentials, + headers: [...request.headers] + .filter(([name]) => name.toLowerCase() !== "x-rings-webview-kind") + .map(([name, value]) => ({ name, value })), + body, + kind: requestKind(request), + }; +} + +async function sourceTargetForClient(clientId) { + if (!clientId) { + return undefined; + } + const client = await self.clients.get(clientId); + if (!client) { + return undefined; + } + const url = new URL(client.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(gatewayPrefix)) { + return undefined; + } + const encoded = url.pathname.slice(gatewayPrefix.length); + if (!encoded) { + return undefined; + } + try { + return decodeURIComponent(encoded); + } catch (_error) { + return undefined; + } +} + +function requestKind(request) { + const taggedKind = request.headers.get("x-rings-webview-kind"); + if (taggedKind === "fetch" || taggedKind === "xhr" || taggedKind === "navigation") { + return taggedKind; + } + if ( + request.mode === "navigate" + || request.destination === "document" + || request.destination === "iframe" + ) { + return "navigation"; + } + return "subresource"; +} + +function requestGatewayResponse(host, request) { + return new Promise((resolve) => { + const channel = new MessageChannel(); + const timeout = globalThis.setTimeout(() => { + channel.port1.close(); + resolve({ ok: false, status: 504, error: "local Rings node gateway timed out" }); + }, requestTimeoutMs); + channel.port1.onmessage = (event) => { + globalThis.clearTimeout(timeout); + channel.port1.close(); + resolve(event.data); + }; + host.postMessage( + { type: "rings-webview-gateway-request", request }, + [channel.port2], + ); + }); +} + +function gatewayFailure(status, message) { + return new Response(gatewayFailureDocument(status, message), { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + }, + }); +} + +function gatewayFailureDocument(status, message) { + const detail = JSON.stringify(String(message)).replace(/ + + +Rings gateway failure ${status} + +
+

Rings gateway failure ${status}

+

+  
Gateway events
+
+ +`; +} diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 17467c0fe..3192c0d5b 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -34,6 +34,8 @@ use crate::node::PeerView; use crate::styles; use crate::wallet::WalletAccount; use crate::wallet::WalletKind; +use crate::webview; +use crate::webview_ui; use crate::workbench; mod actions; @@ -72,6 +74,7 @@ struct NodeState { storage_name: UseStateHandle, peers: UseStateHandle>, seed_url: UseStateHandle, + webview_ready: UseStateHandle, } struct LinkState { @@ -184,6 +187,7 @@ fn use_node_state() -> NodeState { ) .unwrap_or_default() }), + webview_ready: use_state(|| false), } } @@ -488,6 +492,9 @@ fn use_shell_history( fn render_app(ctx: AppRenderContext<'_>) -> Html { let effective_page = effective_shell_page(ctx.shell, ctx.extension_mode); + if effective_page == ShellPage::Webview { + return html! { }; + } let navigate_page = navigate_page_callback(ctx.shell); let header = controls::app_header(effective_page, navigate_page.clone(), !ctx.extension_mode); if effective_page == ShellPage::Guide { @@ -553,10 +560,23 @@ fn render_console_shell(ctx: AppRenderContext<'_>, header: Html) -> Html { true, ctx.extension_mode, ); + let webview_control = (!ctx.extension_mode).then(|| { + let status = ctx.node.status.clone(); + let ready = *ctx.node.webview_ready; + controls::webview_control( + ready, + Callback::from(move |_| { + if let Err(error) = webview::open_webview_popup() { + status.set(format!("open webview: {error}")); + } + }), + ) + }); let control_sidebar = controls::control_sidebar( control_view(ctx.node), ctx.launch_actions, workbench_control, + webview_control, *ctx.shell.active_dialog, dialog_actions, ctx.shell.control_sidebar_collapsed.clone(), @@ -725,6 +745,10 @@ fn routed_shell_route() -> Option { page: ShellPage::Console, dialog: ActiveDialog::None, }), + "webview" => Some(ShellRoute { + page: ShellPage::Webview, + dialog: ActiveDialog::None, + }), "node/settings" | "settings" => Some(ShellRoute { page: ShellPage::Console, dialog: ActiveDialog::Settings, @@ -823,6 +847,7 @@ fn shell_route_fragment(page: ShellPage, dialog: ActiveDialog) -> Option<&'stati match (page, dialog) { (ShellPage::Guide, ActiveDialog::None) => None, (ShellPage::Console, ActiveDialog::None) => Some("node"), + (ShellPage::Webview, ActiveDialog::None) => Some("webview"), (_, ActiveDialog::Settings) => Some("node/settings"), (_, ActiveDialog::Workbench) => Some("node/workbench"), } diff --git a/frontend/src/app/actions.rs b/frontend/src/app/actions.rs index 83e8f66d1..248f8e2b7 100644 --- a/frontend/src/app/actions.rs +++ b/frontend/src/app/actions.rs @@ -27,6 +27,7 @@ use crate::peer_sync; use crate::wallet; use crate::wallet::WalletAccount; use crate::wallet::WalletKind; +use crate::webview; #[derive(Clone)] struct StartAction { @@ -46,6 +47,7 @@ struct StartAction { seed_url: UseStateHandle, custom_events: UseStateHandle>, active_dialog: UseStateHandle, + webview_ready: UseStateHandle, } struct StartRequest { @@ -72,6 +74,7 @@ struct DisconnectAction { remote_answer: UseStateHandle, link_dialog_open: UseStateHandle, active_dialog: UseStateHandle, + webview_ready: UseStateHandle, } pub(super) fn launch_actions( @@ -96,6 +99,7 @@ pub(super) fn launch_actions( stabilize_interval: node.stabilize_interval.clone(), storage_name: node.storage_name.clone(), seed_url: node.seed_url.clone(), + webview_ready: node.webview_ready.clone(), custom_events: custom_state.events.clone(), active_dialog: shell.active_dialog.clone(), } @@ -114,6 +118,7 @@ pub(super) fn launch_actions( remote_answer: link.remote_answer.clone(), link_dialog_open: link.link_dialog_open.clone(), active_dialog: shell.active_dialog.clone(), + webview_ready: node.webview_ready.clone(), } .callback(); LaunchActions { @@ -130,6 +135,7 @@ impl StartAction { let request = action.request(); let start_token = action.generation.bump(); action.node_starting.set(true); + action.webview_ready.set(false); action .status .set(format!("connecting {}", request.kind.label())); @@ -292,6 +298,15 @@ impl StartAction { self.did.set(my_did); self.wallet_account.set(Some(account)); *self.node_ref.borrow_mut() = Some(built.clone()); + let webview_ready = match webview::install_browser_gateway(built.webview.clone()) { + Ok(true) => webview::register_browser_gateway().await.is_ok(), + Ok(false) => false, + Err(error) => { + self.status.set(format!("webview gateway: {error}")); + false + } + }; + self.webview_ready.set(webview_ready); super::clear_shell_dialog_route(); self.active_dialog.set(ActiveDialog::None); self.node_starting.set(false); @@ -432,11 +447,13 @@ impl DisconnectAction { let was_starting = *self.node_starting; let cleanup_token = self.generation.bump(); let Some(node) = self.node_ref.borrow_mut().take() else { + webview::clear_browser_gateway(); self.node_starting.set(false); self.status.set(offline_disconnect_message(was_starting)); return; }; let provider = node.provider.clone(); + webview::clear_browser_gateway(); self.clear_session(); self.status.set("node disconnected".to_string()); let status = self.status.clone(); @@ -453,6 +470,7 @@ impl DisconnectAction { self.did.set(String::new()); self.wallet_account.set(None); self.node_starting.set(false); + self.webview_ready.set(false); self.peers.set(Vec::new()); self.generated_offer.set(String::new()); self.remote_offer.set(String::new()); diff --git a/frontend/src/browser_api.rs b/frontend/src/browser_api.rs index 140e05b60..e1cc77e72 100644 --- a/frontend/src/browser_api.rs +++ b/frontend/src/browser_api.rs @@ -69,6 +69,36 @@ pub(crate) async fn open_debug_url(url: &str) -> Result<(), String> { } } +/// Open the application-owned WebView shell in a named browser popup. +/// +/// The caller supplies no remote target here: remote addresses are entered only inside the +/// controlled shell and become `/webview/` paths before an iframe receives them. +pub(crate) fn open_webview_popup() -> Result<(), String> { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; + let location = window.location(); + let mut url = location.origin().map_err(js_error_label)?; + url.push_str(location.pathname().map_err(js_error_label)?.as_str()); + if let Ok(search) = location.search() { + url.push_str(search.as_str()); + } + url.push_str("#webview"); + + let window_value: JsValue = window.into(); + let open = js_method(&window_value, "open")?; + let opened = open + .call3( + &window_value, + &JsValue::from_str(url.as_str()), + &JsValue::from_str("rings-webview"), + &JsValue::from_str("popup=yes,width=1280,height=860,noopener"), + ) + .map_err(js_error_label)?; + if opened.is_null() || opened.is_undefined() { + return Err("browser blocked the WebView popup".to_string()); + } + Ok(()) +} + async fn open_debug_url_with_extension_tabs(namespace: &str, url: &str) -> Result<(), String> { let extension_api = Reflect::get(&js_sys::global(), &JsValue::from_str(namespace)).map_err(js_error_label)?; diff --git a/frontend/src/controls.rs b/frontend/src/controls.rs index 62f64c6bd..0493107ee 100644 --- a/frontend/src/controls.rs +++ b/frontend/src/controls.rs @@ -25,6 +25,7 @@ const FIREFOX_EXTENSION_MANAGER_URL: &str = "about:debugging#/runtime/this-firef pub(crate) enum ShellPage { Guide, Console, + Webview, } impl ShellPage { @@ -32,6 +33,7 @@ impl ShellPage { match self { Self::Guide => "Home", Self::Console => "Node", + Self::Webview => "WebView", } } } @@ -71,6 +73,7 @@ enum UiIcon { Power, PowerOff, Terminal, + Globe, Sliders, PanelOpen, PanelClose, @@ -98,6 +101,14 @@ fn ui_icon(icon: UiIcon) -> Html { }, + UiIcon::Globe => html! { + <> + + + + + + }, UiIcon::Sliders => html! { <> @@ -174,6 +185,31 @@ pub(crate) struct SessionView<'a> { pub(crate) peers: &'a UseStateHandle>, } +/// Render the Node-only entry point for the controlled browser WebView. +pub(crate) fn webview_control(ready: bool, on_open: Callback) -> Html { + let title = if ready { + "Open WebView" + } else { + "WebView is available after the local node gateway is ready" + }; + html! { + + } +} + struct SettingsDialogView<'a> { wallet_kind: WalletKind, actions: LaunchActions, diff --git a/frontend/src/controls/sidebar.rs b/frontend/src/controls/sidebar.rs index 66318c5af..bafb7a99e 100644 --- a/frontend/src/controls/sidebar.rs +++ b/frontend/src/controls/sidebar.rs @@ -51,6 +51,7 @@ pub(crate) fn control_sidebar( view: ControlView<'_>, actions: LaunchActions, workbench_control: Html, + webview_control: Option, active_dialog: ActiveDialog, dialog_actions: DialogActions, collapsed: UseStateHandle, @@ -74,7 +75,14 @@ pub(crate) fn control_sidebar( { sidebar_toggle_button(*collapsed, toggle_sidebar) } } if extension_mode || !*collapsed { - { sidebar_content(&derived, &actions, workbench_control, open_settings_dialog, on_copy_did.clone()) } + { sidebar_content( + &derived, + &actions, + workbench_control, + webview_control, + open_settings_dialog, + on_copy_did.clone(), + ) } } { settings_dialog_if_open(active_dialog, &view, actions, &derived, on_copy_did, close_settings_dialog) } @@ -180,15 +188,17 @@ fn sidebar_content( derived: &ControlSidebarDerived, actions: &LaunchActions, workbench_control: Html, + webview_control: Option, open_settings_dialog: Callback, on_copy_did: Callback, ) -> Html { html! {