diff --git a/.github/workflows/qaci.yml b/.github/workflows/qaci.yml index b8f3d2c46..6e591075f 100644 --- a/.github/workflows/qaci.yml +++ b/.github/workflows/qaci.yml @@ -89,6 +89,9 @@ jobs: - name: Install frontend extension script dependencies run: npm ci --ignore-scripts + - name: Install frontend Playwright browser + run: npx playwright install --with-deps chromium + - name: Check frontend extension scripts run: npm run build:frontend-extension-scripts @@ -267,6 +270,9 @@ jobs: -D clippy::unimplemented \ -D clippy::indexing_slicing + - name: Check node native with browser feature enabled + run: cargo check -p rings-node --all-features + - name: Run clippy run: cargo clippy --all --tests -- -D warnings diff --git a/.gitignore b/.gitignore index 5c78a733b..321778b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ config*.yaml **/*.ipynb **/*.js **/*.mjs +!frontend/assets/webview-host.js +!frontend/assets/webview-overlay.js +!frontend/rings-webview-service-worker.js +frontend/dist-e2e/ .ipynb_checkpoints # Local environment configuration .envrc @@ -19,6 +23,10 @@ docs # Tests *.log +# Local scripts are allowed in the worktree but should not be added accidentally. +scripts/* +!scripts/cargo-publish-crates.sh + # OS related ### MacOS ### .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 21ed39eca..86b9e611e 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" @@ -3845,6 +3966,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0dedad316e05de220cbf3fd8bfb69f3df8755dde2d7d479211827b595168a93" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.3.0" @@ -4288,7 +4424,7 @@ dependencies = [ [[package]] name = "rings-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ark-bls12-381", "ark-ec", @@ -4361,7 +4497,7 @@ dependencies = [ [[package]] name = "rings-derive" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proc-macro2", "quote", @@ -4371,7 +4507,7 @@ dependencies = [ [[package]] name = "rings-native-example" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "base64 0.13.1", @@ -4385,7 +4521,7 @@ dependencies = [ [[package]] name = "rings-node" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "arrayref", @@ -4444,7 +4580,7 @@ dependencies = [ [[package]] name = "rings-relay-example" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rings-core", "rings-node", @@ -4453,7 +4589,7 @@ dependencies = [ [[package]] name = "rings-rpc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "base64 0.13.1", @@ -4469,7 +4605,7 @@ dependencies = [ [[package]] name = "rings-snark" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bellpepper-core", "byteorder", @@ -4497,7 +4633,7 @@ dependencies = [ [[package]] name = "rings-snark-example" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rings-snark", "tokio", @@ -4505,7 +4641,7 @@ dependencies = [ [[package]] name = "rings-transport" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "bincode", @@ -4529,6 +4665,25 @@ dependencies = [ "webrtc", ] +[[package]] +name = "rings-webview" +version = "0.15.0" +dependencies = [ + "async-trait", + "futures", + "httpdate", + "js-sys", + "lol_html", + "percent-encoding", + "psl", + "serde", + "serde-wasm-bindgen", + "thiserror 1.0.69", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rkyv" version = "0.8.16" @@ -4754,6 +4909,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 +5066,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 +5312,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot 0.12.4", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] diff --git a/Cargo.toml b/Cargo.toml index 95e47f5e7..8d7752de3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "examples/native", "examples/relay", "examples/snark"] exclude = ["third_party/proc-macro-error2"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2021" license = "GPL-3.0" authors = ["RND "] @@ -14,12 +14,12 @@ repository = "https://github.com/RingsNetwork/rings" async-trait = "0.1.77" js-sys = "0.3.98" jsonrpc-core = "18.0.0" -rings-core = { path = "crates/core", version = "0.14.0", default-features = false } -rings-derive = { path = "crates/derive", version = "0.14.0", default-features = false } -rings-node = { path = "crates/node", version = "0.14.0" } -rings-rpc = { path = "crates/rpc", version = "0.14.0", default-features = false } -rings-snark = { path = "crates/snark", version = "0.14.0", default-features = false } -rings-transport = { path = "crates/transport", version = "0.14.0" } +rings-core = { path = "crates/core", version = "0.15.0", default-features = false } +rings-derive = { path = "crates/derive", version = "0.15.0", default-features = false } +rings-node = { path = "crates/node", version = "0.15.0" } +rings-rpc = { path = "crates/rpc", version = "0.15.0", default-features = false } +rings-snark = { path = "crates/snark", version = "0.15.0", default-features = false } +rings-transport = { path = "crates/transport", version = "0.15.0" } serde-wasm-bindgen = "0.6.5" wasm-bindgen = "0.2.121" wasm-bindgen-futures = "0.4.71" diff --git a/crates/core/src/chunk.rs b/crates/core/src/chunk.rs index bdad7a3a1..cdf59f258 100644 --- a/crates/core/src/chunk.rs +++ b/crates/core/src/chunk.rs @@ -686,619 +686,4 @@ enum Rejected { } #[cfg(test)] -mod test { - use super::*; - - fn chunks_of(data: &Bytes, mtu: usize) -> Vec { - ChunkList::split(data, mtu).into() - } - - /// Tiny limits so the admission rule can be exercised without giant synthetic payloads. - fn small_limits() -> ReassemblyLimits { - ReassemblyLimits { - max_pending_messages: 4, - max_chunk_data_len: 16, - max_message_bytes: 100, - max_chunks_per_message: 64, - max_total_buffered_cost: 256, - slot_overhead: 8, - max_completed_ids: 8, - } - } - - #[test] - fn constrained_reassembly_limits_are_smaller_than_production() { - let production = ReassemblyLimits::production(); - let constrained = ReassemblyLimits::constrained(); - - assert!(constrained.max_pending_messages < production.max_pending_messages); - assert!(constrained.max_message_bytes < production.max_message_bytes); - assert!(constrained.max_chunks_per_message < production.max_chunks_per_message); - assert!(constrained.max_total_buffered_cost < production.max_total_buffered_cost); - assert!(constrained.max_completed_ids < production.max_completed_ids); - assert_eq!( - constrained.max_chunk_data_len, - production.max_chunk_data_len - ); - } - - #[test] - fn test_data_chunks() { - let data = "helloworld".repeat(2).into(); - let ret: Vec = ChunkList::split(&data, 32).into(); - assert_eq!(ret.len(), 1); - assert_eq!(ret[ret.len() - 1].chunk, [0, 1]); - - let data = "helloworld".repeat(1024).into(); - let ret: Vec = ChunkList::split(&data, 32).into(); - assert_eq!(ret.len(), 10 * 1024 / 32); - assert_eq!(ret[ret.len() - 1].chunk, [319, 320]); - } - - #[test] - fn split_empty_yields_no_chunks() { - assert!(ChunkList::split(&Bytes::new(), 32).to_vec().is_empty()); - } - - #[test] - fn split_exact_multiple_all_full() { - let data: Bytes = vec![0u8; 64].into(); - let chunks = ChunkList::split(&data, 32).to_vec(); - assert_eq!(chunks.len(), 2); - assert!(chunks.iter().all(|c| c.data.len() == 32)); - assert_eq!(chunks[0].chunk, [0, 2]); - assert_eq!(chunks[1].chunk, [1, 2]); - } - - #[test] - fn split_non_multiple_last_is_remainder() { - let data: Bytes = vec![0u8; 70].into(); - let chunks = ChunkList::split(&data, 32).to_vec(); - assert_eq!(chunks.len(), 3); - assert_eq!(chunks[0].data.len(), 32); - assert_eq!(chunks[1].data.len(), 32); - assert_eq!(chunks[2].data.len(), 6); - } - - #[test] - fn split_larger_than_data_is_single_chunk() { - let data: Bytes = vec![0u8; 10].into(); - let chunks = ChunkList::split(&data, 1024).to_vec(); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].chunk, [0, 1]); - } - - #[test] - fn split_zero_size_is_clamped_to_one() { - let data: Bytes = vec![0u8; 4].into(); - let chunks = ChunkList::split(&data, 0).to_vec(); - assert_eq!(chunks.len(), 4); - assert!(chunks.iter().all(|c| c.data.len() == 1)); - } - - #[test] - fn split_chunks_share_one_message_id() { - let data: Bytes = vec![0u8; 100].into(); - let chunks = ChunkList::split(&data, 32).to_vec(); - let id = chunks[0].meta.id; - assert!(chunks.iter().all(|c| c.meta.id == id)); - } - - /// Cutting at any size and feeding the pieces back through the reassembler (in order) yields the - /// original bytes — across exact multiples, remainders, single-chunk, and one-byte cuts. - #[test] - fn split_then_reassemble_round_trips() { - for (len, size) in [ - (1usize, 7usize), - (7, 7), - (8, 7), - (100, 7), - (1000, 64), - (5, 1), - ] { - let data: Bytes = (0..len).map(|i| i as u8).collect::>().into(); - let mut r = MessageReassembler::new(); - let mut out = None; - for c in ChunkList::split(&data, size) { - out = r.handle(c).or(out); - } - assert_eq!(out.unwrap(), data, "len={len} size={size}"); - } - } - - /// Test reserves with readable, distinct values (`whole < chunk`) so the two paths are easy to - /// tell apart in the assertions below. - fn reserves(whole: usize, chunk: usize, min_chunk_data: usize) -> WireReserves { - WireReserves { - whole, - chunk, - min_chunk_data, - } - } - - #[test] - fn plan_whole_includes_whole_overhead() { - let r = reserves(10, 20, 1); - // Whole fits while payload + whole ≤ limit, up to and including the boundary. - assert_eq!(r.plan(0, 100), Some(Framing::Whole)); - assert_eq!(r.plan(90, 100), Some(Framing::Whole)); - // One past the boundary must chunk. - assert_eq!(r.plan(91, 100), Some(Framing::Chunked { chunk_size: 80 })); - } - - /// The chunk size reserves the chunk overhead, so `chunk_size + chunk ≤ limit`: a wrapped chunk - /// can never exceed the negotiated limit. - #[test] - fn plan_chunk_size_reserves_overhead() { - let (limit, chunk_overhead) = (65536usize, 4096usize); - let Some(Framing::Chunked { chunk_size }) = - reserves(16, chunk_overhead, 16).plan(limit * 2, limit) - else { - panic!("expected chunked"); - }; - assert_eq!(chunk_size, limit - chunk_overhead); - assert!(chunk_size + chunk_overhead <= limit); - } - - #[test] - fn plan_none_when_chunk_too_small() { - // A limit that cannot fit `chunk + min_chunk_data` is rejected outright, not split tiny. - assert_eq!(reserves(4, 10, 1).plan(100, 5), None); // below the overhead - assert_eq!(reserves(4, 10, 1).plan(100, 10), None); // == overhead, 0 data bytes - // limit just clears chunk + min: the smallest *allowed* cut. - assert_eq!( - reserves(4, 10, 1).plan(100, 11), - Some(Framing::Chunked { chunk_size: 1 }) - ); - // a realistic floor: min_chunk_data = 8 needs limit ≥ chunk + 8. - assert_eq!(reserves(4, 10, 8).plan(100, 17), None); // 17 < 10 + 8 - assert_eq!( - reserves(4, 10, 8).plan(100, 18), - Some(Framing::Chunked { chunk_size: 8 }) - ); - } - - #[test] - fn plan_is_total_on_overflow() { - // `payload_len + whole` overflows usize; must not panic, and (not a whole fit) falls through - // to the chunked decision rather than wrapping around. - assert_eq!( - reserves(10, 20, 1).plan(usize::MAX, 100), - Some(Framing::Chunked { chunk_size: 80 }) - ); - // overflow with a too-small limit still yields None, not a panic. - assert_eq!(reserves(10, 20, 1).plan(usize::MAX, 10), None); - } - - #[test] - fn reassembles_in_order() { - let data: Bytes = "helloworld".repeat(1024).into(); - let mut r = MessageReassembler::new(); - let chunks = chunks_of(&data, 32); - let mut out = None; - for c in chunks { - out = r.handle(c).or(out); - } - assert_eq!(out.unwrap(), data); - assert_eq!(r.pending_count(), 0, "completed message is forgotten"); - } - - #[test] - fn reassembles_out_of_order() { - let data: Bytes = "helloworld".repeat(64).into(); - let mut chunks = chunks_of(&data, 32); - chunks.reverse(); - let mut r = MessageReassembler::new(); - let mut out = None; - for c in chunks { - out = r.handle(c).or(out); - } - assert_eq!(out.unwrap(), data); - } - - #[test] - fn full_retransmit_after_completion_is_not_redelivered() { - // A message that completes, then is *fully* retransmitted within its TTL window, must not be - // delivered a second time — the completed id is tombstoned. - let data: Bytes = "helloworld".repeat(64).into(); - let chunks = chunks_of(&data, 32); - assert!(chunks.len() > 1, "need a multi-chunk message for this test"); - - let mut r = MessageReassembler::new(); - let mut first = None; - for c in chunks.clone() { - first = r.handle(c).or(first); - } - assert_eq!(first.unwrap(), data, "first assembly delivers once"); - assert_eq!(r.pending_count(), 0); - - // Replay every chunk of the same message; none should re-open a pending entry or re-deliver. - for c in chunks { - assert!( - r.handle(c).is_none(), - "a retransmit of an already-completed message must be dropped" - ); - } - assert_eq!( - r.pending_count(), - 0, - "no pending re-opened by the retransmit" - ); - } - - #[test] - fn duplicate_chunk_does_not_break_reassembly() { - // Regression: arrival order [0, 1, 0] used to dedup-before-sort and never complete. - let data: Bytes = "helloworld".repeat(8).into(); // > 32 bytes => 3 chunks - let chunks = chunks_of(&data, 32); - assert!(chunks.len() >= 2); - let mut r = MessageReassembler::new(); - - // Feed every chunk, re-feeding chunk 0 in the middle as a duplicate. - assert!(r.handle(chunks[0].clone()).is_none()); - for c in &chunks[1..] { - let _ = r.handle(chunks[0].clone()); // duplicate of position 0, repeatedly - if let Some(out) = r.handle(c.clone()) { - assert_eq!(out, data); - assert_eq!(r.pending_count(), 0); - return; - } - } - panic!("message never completed despite all chunks arriving"); - } - - #[test] - fn interleaved_messages_are_isolated() { - let d1: Bytes = "hello".repeat(64).into(); - let d2: Bytes = "world".repeat(64).into(); - let c1 = chunks_of(&d1, 32); - let c2 = chunks_of(&d2, 32); - let mut r = MessageReassembler::new(); - - // interleave the two messages - let (mut o1, mut o2) = (None, None); - for pair in c1.iter().zip(c2.iter()) { - o1 = r.handle(pair.0.clone()).or(o1); - o2 = r.handle(pair.1.clone()).or(o2); - } - // drain any tail (lengths may differ) - for c in c1.iter().chain(c2.iter()) { - let out = r.handle(c.clone()); - o1 = out.clone().filter(|b| *b == d1).or(o1); - o2 = out.filter(|b| *b == d2).or(o2); - } - assert_eq!(o1.unwrap(), d1); - assert_eq!(o2.unwrap(), d2); - } - - #[test] - fn incomplete_message_stays_pending() { - let data: Bytes = "helloworld".repeat(64).into(); - let chunks = chunks_of(&data, 32); - let mut r = MessageReassembler::new(); - for c in &chunks[..chunks.len() - 1] { - assert!(r.handle(c.clone()).is_none()); - } - assert_eq!(r.pending_count(), 1); - let out = r.handle(chunks.last().unwrap().clone()); - assert_eq!(out.unwrap(), data); - } - - #[test] - fn malformed_chunks_are_dropped() { - let mut r = MessageReassembler::new(); - // total == 0 - assert!(r - .handle(Chunk { - chunk: [0, 0], - data: Bytes::from_static(b"x"), - meta: ChunkMeta::default(), - }) - .is_none()); - // position >= total - assert!(r - .handle(Chunk { - chunk: [5, 3], - data: Bytes::from_static(b"x"), - meta: ChunkMeta::default(), - }) - .is_none()); - assert_eq!(r.pending_count(), 0); - } - - #[test] - fn old_timestamp_is_dropped_without_panic() { - // ts_ms < TS_OFFSET_TOLERANCE_MS would underflow a plain `u128` subtraction (no panic with - // saturating arithmetic), and a chunk stamped at the epoch is already long expired — it must - // be dropped, not delivered, even though it is a complete `total == 1` message. - let mut r = MessageReassembler::new(); - let out = r.handle(Chunk { - chunk: [0, 1], - data: Bytes::from_static(b"ok"), - meta: ChunkMeta { - id: Uuid::new_v4(), - ts_ms: 0, - ttl_ms: DEFAULT_TTL_MS, - }, - }); - assert!(out.is_none()); - assert_eq!(r.pending_count(), 0); - } - - #[test] - fn expired_single_chunk_is_not_delivered() { - // Regression: sweeping *other* pending entries before insertion let an already-expired - // `total == 1` chunk be delivered immediately. It must be rejected up front. - let mut r = MessageReassembler::new(); - let now = get_epoch_ms(); - let out = r.handle(Chunk { - chunk: [0, 1], - data: Bytes::from_static(b"x"), - meta: ChunkMeta { - id: Uuid::new_v4(), - ts_ms: now.saturating_sub(1000), - ttl_ms: 100, // expired 900ms ago - }, - }); - assert!(out.is_none()); - assert_eq!(r.pending_count(), 0); - } - - #[test] - fn oversize_chunk_data_is_rejected() { - let limits = small_limits(); - let mut r = MessageReassembler::with_limits(limits); - let data: Bytes = vec![0u8; limits.max_chunk_data_len + 1].into(); - let out = r.handle(Chunk { - chunk: [0, 1], - data, - meta: ChunkMeta::default(), - }); - assert!(out.is_none()); - assert_eq!(r.pending_count(), 0); - assert_eq!(r.buffered_cost, 0); - } - - #[test] - fn buffered_cost_returns_to_zero_after_completion() { - let data: Bytes = "helloworld".repeat(100).into(); - let mut r = MessageReassembler::new(); - for c in ChunkList::split(&data, 32) { - r.handle(c); - } - assert_eq!(r.pending_count(), 0); - assert_eq!(r.buffered_cost, 0, "completing a message frees its budget"); - } - - /// A single id advertising a huge `total` and streaming distinct positions cannot grow without - /// bound: the per-message byte cap stops it. - #[test] - fn per_message_byte_cap_bounds_one_id() { - let limits = small_limits(); - let mut r = MessageReassembler::with_limits(limits); - let meta = ChunkMeta::default(); - let data: Bytes = vec![0u8; limits.max_chunk_data_len].into(); - // Within the slot cap, but its data far exceeds the per-message byte cap so it never fills. - let total = limits.max_chunks_per_message; - - let mut accepted = 0usize; - for position in 0..50 { - let before = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0); - r.handle(Chunk { - meta, - chunk: [position, total], - data: data.clone(), - }); - let after = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0); - if after > before { - accepted += 1; - } - } - - let pending = r.pending.get(&meta.id).expect("still pending"); - assert!( - pending.data_bytes <= limits.max_message_bytes, - "per-message buffered data must stay within the cap" - ); - assert!( - accepted < 50, - "the cap must reject some chunks, got {accepted}" - ); - assert_eq!( - r.buffered_cost, - pending.cost(limits.slot_overhead), - "accounting stays exact" - ); - } - - /// Spreading the flood across many ids is bounded too: the global buffered-cost ceiling caps - /// total memory regardless of how many ids are used. - #[test] - fn global_cost_cap_bounds_total() { - let limits = small_limits(); - let mut r = MessageReassembler::with_limits(limits); - // Each id contributes one slot of `max_chunk_data_len` data; keep them all pending. - for _ in 0..(limits.max_pending_messages * 4) { - r.handle(Chunk { - chunk: [0, 2], - data: vec![0u8; limits.max_chunk_data_len].into(), - meta: ChunkMeta::default(), - }); - } - assert!( - r.buffered_cost <= limits.max_total_buffered_cost, - "global buffered cost {} exceeded cap {}", - r.buffered_cost, - limits.max_total_buffered_cost - ); - } - - #[test] - fn future_timestamp_is_dropped() { - let mut r = MessageReassembler::new(); - let out = r.handle(Chunk { - chunk: [0, 1], - data: Bytes::from_static(b"x"), - meta: ChunkMeta { - id: Uuid::new_v4(), - ts_ms: get_epoch_ms() + 10 * TS_OFFSET_TOLERANCE_MS, - ttl_ms: DEFAULT_TTL_MS, - }, - }); - assert!(out.is_none()); - } - - #[test] - fn expired_partial_messages_are_evicted() { - let mut r = MessageReassembler::new(); - let now = get_epoch_ms(); - // a partial (1 of 2) message that is already expired - r.handle(Chunk { - chunk: [0, 2], - data: Bytes::from_static(b"x"), - meta: ChunkMeta { - id: Uuid::new_v4(), - ts_ms: now.saturating_sub(1000), - ttl_ms: 100, - }, - }); - // a fresh partial message triggers remove_expired, dropping the stale one - r.handle(Chunk { - chunk: [0, 2], - data: Bytes::from_static(b"y"), - meta: ChunkMeta { - id: Uuid::new_v4(), - ts_ms: now, - ttl_ms: DEFAULT_TTL_MS, - }, - }); - assert_eq!(r.pending_count(), 1, "only the fresh partial remains"); - } - - #[test] - fn pending_messages_are_capped() { - let limits = small_limits(); - let mut r = MessageReassembler::with_limits(limits); - // each is the first of two chunks => stays pending - for _ in 0..(limits.max_pending_messages + 10) { - r.handle(Chunk { - chunk: [0, 2], - data: Bytes::from_static(b"x"), - meta: ChunkMeta::default(), // fresh id, fresh ts each time - }); - } - assert_eq!(r.pending_count(), limits.max_pending_messages); - } - - #[test] - fn round_trip_reordered_with_duplicates() { - let data: Bytes = "abcdefghij".repeat(500).into(); - let mut chunks = chunks_of(&data, 64); - // reorder + inject duplicates mid-stream (not after the final chunk, which would just - // start a fresh, TTL-evicted pending entry — a late retransmit, not a reassembly bug). - chunks.reverse(); - let dup = chunks[chunks.len() / 2].clone(); - chunks.insert(1, dup.clone()); - chunks.insert(chunks.len() / 3, dup); - - let mut r = MessageReassembler::new(); - let mut out = None; - for c in chunks { - out = r.handle(c).or(out); - } - assert_eq!(out.unwrap(), data); - assert_eq!(r.pending_count(), 0); - } - - /// A forged `total` larger than the per-message slot cap is rejected before it can allocate a - /// huge slot map, even though each individual chunk's data is tiny. - #[test] - fn total_over_slot_cap_is_rejected() { - let limits = small_limits(); - let mut r = MessageReassembler::with_limits(limits); - let out = r.handle(Chunk { - chunk: [0, limits.max_chunks_per_message + 1], - data: Bytes::from_static(b"x"), - meta: ChunkMeta::default(), - }); - assert!(out.is_none()); - assert_eq!(r.pending_count(), 0); - assert_eq!(r.buffered_cost, 0); - } - - /// Two chunks sharing an id/total but from different transmissions (different `ts_ms`/`ttl_ms`) - /// must not be merged into one pending entry. - #[test] - fn mismatched_ts_or_ttl_for_same_id_is_rejected() { - let mut r = MessageReassembler::new(); - let id = Uuid::new_v4(); - let now = get_epoch_ms(); - assert!(r - .handle(Chunk { - chunk: [0, 2], - data: Bytes::from_static(b"a"), - meta: ChunkMeta { - id, - ts_ms: now, - ttl_ms: DEFAULT_TTL_MS - }, - }) - .is_none()); - // Same id/total, different ts_ms → rejected (a chunk from another transmission). - let out = r.handle(Chunk { - chunk: [1, 2], - data: Bytes::from_static(b"b"), - meta: ChunkMeta { - id, - ts_ms: now + 1, - ttl_ms: DEFAULT_TTL_MS, - }, - }); - assert!(out.is_none(), "must not complete by mixing transmissions"); - let p = r.pending.get(&id).expect("first chunk still pending"); - assert_eq!(p.slots.len(), 1, "the mismatched chunk left no trace"); - } - - /// Once a completed message's TTL elapses, its tombstone is evicted by the real - /// `remove_expired_at` path (driven here by an injected clock, not by poking internal state), - /// and a fresh message reusing the same id is then accepted rather than suppressed. - #[test] - fn tombstone_expires_then_id_is_reusable() { - let mut r = MessageReassembler::new(); - let id = Uuid::new_v4(); - // A fixed base well above the future-skew tolerance, so timestamps are unambiguous. - let base = 1_000_000u128; - let ttl = 100u64; - let one_chunk = |label: &'static [u8], ts_ms: u128, ttl_ms: u64| Chunk { - chunk: [0, 1], - data: Bytes::from_static(label), - meta: ChunkMeta { id, ts_ms, ttl_ms }, - }; - - // Complete a 1-chunk message at t = base; its tombstone expires at base + ttl. - let first = r.handle_at(one_chunk(b"first", base, ttl), base); - assert_eq!(first.as_deref(), Some(&b"first"[..])); - assert!(r.completed_ids.contains(&id), "tombstoned after completion"); - - // A full retransmit *within* the TTL window (t = base + ttl/2) is suppressed. - let dup = r.handle_at(one_chunk(b"first", base, ttl), base + (ttl as u128) / 2); - assert!( - dup.is_none(), - "post-completion retransmit suppressed within TTL" - ); - assert!( - r.completed_ids.contains(&id), - "tombstone still live within TTL" - ); - - // Past the tombstone's expiry (t = base + ttl + 1), a brand-new message reusing the id is - // delivered: `remove_expired_at` evicts the now-expired tombstone before classify runs. - let later = base + ttl as u128 + 1; - let reused = r.handle_at(one_chunk(b"second", later, ttl), later); - assert_eq!( - reused.as_deref(), - Some(&b"second"[..]), - "id reusable after its tombstone expired via remove_expired_at" - ); - } -} +mod test; diff --git a/crates/core/src/chunk/test.rs b/crates/core/src/chunk/test.rs new file mode 100644 index 000000000..ba6fef1d1 --- /dev/null +++ b/crates/core/src/chunk/test.rs @@ -0,0 +1,614 @@ +use super::*; + +fn chunks_of(data: &Bytes, mtu: usize) -> Vec { + ChunkList::split(data, mtu).into() +} + +/// Tiny limits so the admission rule can be exercised without giant synthetic payloads. +fn small_limits() -> ReassemblyLimits { + ReassemblyLimits { + max_pending_messages: 4, + max_chunk_data_len: 16, + max_message_bytes: 100, + max_chunks_per_message: 64, + max_total_buffered_cost: 256, + slot_overhead: 8, + max_completed_ids: 8, + } +} + +#[test] +fn constrained_reassembly_limits_are_smaller_than_production() { + let production = ReassemblyLimits::production(); + let constrained = ReassemblyLimits::constrained(); + + assert!(constrained.max_pending_messages < production.max_pending_messages); + assert!(constrained.max_message_bytes < production.max_message_bytes); + assert!(constrained.max_chunks_per_message < production.max_chunks_per_message); + assert!(constrained.max_total_buffered_cost < production.max_total_buffered_cost); + assert!(constrained.max_completed_ids < production.max_completed_ids); + assert_eq!( + constrained.max_chunk_data_len, + production.max_chunk_data_len + ); +} + +#[test] +fn test_data_chunks() { + let data = "helloworld".repeat(2).into(); + let ret: Vec = ChunkList::split(&data, 32).into(); + assert_eq!(ret.len(), 1); + assert_eq!(ret[ret.len() - 1].chunk, [0, 1]); + + let data = "helloworld".repeat(1024).into(); + let ret: Vec = ChunkList::split(&data, 32).into(); + assert_eq!(ret.len(), 10 * 1024 / 32); + assert_eq!(ret[ret.len() - 1].chunk, [319, 320]); +} + +#[test] +fn split_empty_yields_no_chunks() { + assert!(ChunkList::split(&Bytes::new(), 32).to_vec().is_empty()); +} + +#[test] +fn split_exact_multiple_all_full() { + let data: Bytes = vec![0u8; 64].into(); + let chunks = ChunkList::split(&data, 32).to_vec(); + assert_eq!(chunks.len(), 2); + assert!(chunks.iter().all(|c| c.data.len() == 32)); + assert_eq!(chunks[0].chunk, [0, 2]); + assert_eq!(chunks[1].chunk, [1, 2]); +} + +#[test] +fn split_non_multiple_last_is_remainder() { + let data: Bytes = vec![0u8; 70].into(); + let chunks = ChunkList::split(&data, 32).to_vec(); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].data.len(), 32); + assert_eq!(chunks[1].data.len(), 32); + assert_eq!(chunks[2].data.len(), 6); +} + +#[test] +fn split_larger_than_data_is_single_chunk() { + let data: Bytes = vec![0u8; 10].into(); + let chunks = ChunkList::split(&data, 1024).to_vec(); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].chunk, [0, 1]); +} + +#[test] +fn split_zero_size_is_clamped_to_one() { + let data: Bytes = vec![0u8; 4].into(); + let chunks = ChunkList::split(&data, 0).to_vec(); + assert_eq!(chunks.len(), 4); + assert!(chunks.iter().all(|c| c.data.len() == 1)); +} + +#[test] +fn split_chunks_share_one_message_id() { + let data: Bytes = vec![0u8; 100].into(); + let chunks = ChunkList::split(&data, 32).to_vec(); + let id = chunks[0].meta.id; + assert!(chunks.iter().all(|c| c.meta.id == id)); +} + +/// Cutting at any size and feeding the pieces back through the reassembler (in order) yields the +/// original bytes — across exact multiples, remainders, single-chunk, and one-byte cuts. +#[test] +fn split_then_reassemble_round_trips() { + for (len, size) in [ + (1usize, 7usize), + (7, 7), + (8, 7), + (100, 7), + (1000, 64), + (5, 1), + ] { + let data: Bytes = (0..len).map(|i| i as u8).collect::>().into(); + let mut r = MessageReassembler::new(); + let mut out = None; + for c in ChunkList::split(&data, size) { + out = r.handle(c).or(out); + } + assert_eq!(out.unwrap(), data, "len={len} size={size}"); + } +} + +/// Test reserves with readable, distinct values (`whole < chunk`) so the two paths are easy to +/// tell apart in the assertions below. +fn reserves(whole: usize, chunk: usize, min_chunk_data: usize) -> WireReserves { + WireReserves { + whole, + chunk, + min_chunk_data, + } +} + +#[test] +fn plan_whole_includes_whole_overhead() { + let r = reserves(10, 20, 1); + // Whole fits while payload + whole ≤ limit, up to and including the boundary. + assert_eq!(r.plan(0, 100), Some(Framing::Whole)); + assert_eq!(r.plan(90, 100), Some(Framing::Whole)); + // One past the boundary must chunk. + assert_eq!(r.plan(91, 100), Some(Framing::Chunked { chunk_size: 80 })); +} + +/// The chunk size reserves the chunk overhead, so `chunk_size + chunk ≤ limit`: a wrapped chunk +/// can never exceed the negotiated limit. +#[test] +fn plan_chunk_size_reserves_overhead() { + let (limit, chunk_overhead) = (65536usize, 4096usize); + let Some(Framing::Chunked { chunk_size }) = + reserves(16, chunk_overhead, 16).plan(limit * 2, limit) + else { + panic!("expected chunked"); + }; + assert_eq!(chunk_size, limit - chunk_overhead); + assert!(chunk_size + chunk_overhead <= limit); +} + +#[test] +fn plan_none_when_chunk_too_small() { + // A limit that cannot fit `chunk + min_chunk_data` is rejected outright, not split tiny. + assert_eq!(reserves(4, 10, 1).plan(100, 5), None); // below the overhead + assert_eq!(reserves(4, 10, 1).plan(100, 10), None); // == overhead, 0 data bytes + // limit just clears chunk + min: the smallest *allowed* cut. + assert_eq!( + reserves(4, 10, 1).plan(100, 11), + Some(Framing::Chunked { chunk_size: 1 }) + ); + // a realistic floor: min_chunk_data = 8 needs limit ≥ chunk + 8. + assert_eq!(reserves(4, 10, 8).plan(100, 17), None); // 17 < 10 + 8 + assert_eq!( + reserves(4, 10, 8).plan(100, 18), + Some(Framing::Chunked { chunk_size: 8 }) + ); +} + +#[test] +fn plan_is_total_on_overflow() { + // `payload_len + whole` overflows usize; must not panic, and (not a whole fit) falls through + // to the chunked decision rather than wrapping around. + assert_eq!( + reserves(10, 20, 1).plan(usize::MAX, 100), + Some(Framing::Chunked { chunk_size: 80 }) + ); + // overflow with a too-small limit still yields None, not a panic. + assert_eq!(reserves(10, 20, 1).plan(usize::MAX, 10), None); +} + +#[test] +fn reassembles_in_order() { + let data: Bytes = "helloworld".repeat(1024).into(); + let mut r = MessageReassembler::new(); + let chunks = chunks_of(&data, 32); + let mut out = None; + for c in chunks { + out = r.handle(c).or(out); + } + assert_eq!(out.unwrap(), data); + assert_eq!(r.pending_count(), 0, "completed message is forgotten"); +} + +#[test] +fn reassembles_out_of_order() { + let data: Bytes = "helloworld".repeat(64).into(); + let mut chunks = chunks_of(&data, 32); + chunks.reverse(); + let mut r = MessageReassembler::new(); + let mut out = None; + for c in chunks { + out = r.handle(c).or(out); + } + assert_eq!(out.unwrap(), data); +} + +#[test] +fn full_retransmit_after_completion_is_not_redelivered() { + // A message that completes, then is *fully* retransmitted within its TTL window, must not be + // delivered a second time — the completed id is tombstoned. + let data: Bytes = "helloworld".repeat(64).into(); + let chunks = chunks_of(&data, 32); + assert!(chunks.len() > 1, "need a multi-chunk message for this test"); + + let mut r = MessageReassembler::new(); + let mut first = None; + for c in chunks.clone() { + first = r.handle(c).or(first); + } + assert_eq!(first.unwrap(), data, "first assembly delivers once"); + assert_eq!(r.pending_count(), 0); + + // Replay every chunk of the same message; none should re-open a pending entry or re-deliver. + for c in chunks { + assert!( + r.handle(c).is_none(), + "a retransmit of an already-completed message must be dropped" + ); + } + assert_eq!( + r.pending_count(), + 0, + "no pending re-opened by the retransmit" + ); +} + +#[test] +fn duplicate_chunk_does_not_break_reassembly() { + // Regression: arrival order [0, 1, 0] used to dedup-before-sort and never complete. + let data: Bytes = "helloworld".repeat(8).into(); // > 32 bytes => 3 chunks + let chunks = chunks_of(&data, 32); + assert!(chunks.len() >= 2); + let mut r = MessageReassembler::new(); + + // Feed every chunk, re-feeding chunk 0 in the middle as a duplicate. + assert!(r.handle(chunks[0].clone()).is_none()); + for c in &chunks[1..] { + let _ = r.handle(chunks[0].clone()); // duplicate of position 0, repeatedly + if let Some(out) = r.handle(c.clone()) { + assert_eq!(out, data); + assert_eq!(r.pending_count(), 0); + return; + } + } + panic!("message never completed despite all chunks arriving"); +} + +#[test] +fn interleaved_messages_are_isolated() { + let d1: Bytes = "hello".repeat(64).into(); + let d2: Bytes = "world".repeat(64).into(); + let c1 = chunks_of(&d1, 32); + let c2 = chunks_of(&d2, 32); + let mut r = MessageReassembler::new(); + + // interleave the two messages + let (mut o1, mut o2) = (None, None); + for pair in c1.iter().zip(c2.iter()) { + o1 = r.handle(pair.0.clone()).or(o1); + o2 = r.handle(pair.1.clone()).or(o2); + } + // drain any tail (lengths may differ) + for c in c1.iter().chain(c2.iter()) { + let out = r.handle(c.clone()); + o1 = out.clone().filter(|b| *b == d1).or(o1); + o2 = out.filter(|b| *b == d2).or(o2); + } + assert_eq!(o1.unwrap(), d1); + assert_eq!(o2.unwrap(), d2); +} + +#[test] +fn incomplete_message_stays_pending() { + let data: Bytes = "helloworld".repeat(64).into(); + let chunks = chunks_of(&data, 32); + let mut r = MessageReassembler::new(); + for c in &chunks[..chunks.len() - 1] { + assert!(r.handle(c.clone()).is_none()); + } + assert_eq!(r.pending_count(), 1); + let out = r.handle(chunks.last().unwrap().clone()); + assert_eq!(out.unwrap(), data); +} + +#[test] +fn malformed_chunks_are_dropped() { + let mut r = MessageReassembler::new(); + // total == 0 + assert!(r + .handle(Chunk { + chunk: [0, 0], + data: Bytes::from_static(b"x"), + meta: ChunkMeta::default(), + }) + .is_none()); + // position >= total + assert!(r + .handle(Chunk { + chunk: [5, 3], + data: Bytes::from_static(b"x"), + meta: ChunkMeta::default(), + }) + .is_none()); + assert_eq!(r.pending_count(), 0); +} + +#[test] +fn old_timestamp_is_dropped_without_panic() { + // ts_ms < TS_OFFSET_TOLERANCE_MS would underflow a plain `u128` subtraction (no panic with + // saturating arithmetic), and a chunk stamped at the epoch is already long expired — it must + // be dropped, not delivered, even though it is a complete `total == 1` message. + let mut r = MessageReassembler::new(); + let out = r.handle(Chunk { + chunk: [0, 1], + data: Bytes::from_static(b"ok"), + meta: ChunkMeta { + id: Uuid::new_v4(), + ts_ms: 0, + ttl_ms: DEFAULT_TTL_MS, + }, + }); + assert!(out.is_none()); + assert_eq!(r.pending_count(), 0); +} + +#[test] +fn expired_single_chunk_is_not_delivered() { + // Regression: sweeping *other* pending entries before insertion let an already-expired + // `total == 1` chunk be delivered immediately. It must be rejected up front. + let mut r = MessageReassembler::new(); + let now = get_epoch_ms(); + let out = r.handle(Chunk { + chunk: [0, 1], + data: Bytes::from_static(b"x"), + meta: ChunkMeta { + id: Uuid::new_v4(), + ts_ms: now.saturating_sub(1000), + ttl_ms: 100, // expired 900ms ago + }, + }); + assert!(out.is_none()); + assert_eq!(r.pending_count(), 0); +} + +#[test] +fn oversize_chunk_data_is_rejected() { + let limits = small_limits(); + let mut r = MessageReassembler::with_limits(limits); + let data: Bytes = vec![0u8; limits.max_chunk_data_len + 1].into(); + let out = r.handle(Chunk { + chunk: [0, 1], + data, + meta: ChunkMeta::default(), + }); + assert!(out.is_none()); + assert_eq!(r.pending_count(), 0); + assert_eq!(r.buffered_cost, 0); +} + +#[test] +fn buffered_cost_returns_to_zero_after_completion() { + let data: Bytes = "helloworld".repeat(100).into(); + let mut r = MessageReassembler::new(); + for c in ChunkList::split(&data, 32) { + r.handle(c); + } + assert_eq!(r.pending_count(), 0); + assert_eq!(r.buffered_cost, 0, "completing a message frees its budget"); +} + +/// A single id advertising a huge `total` and streaming distinct positions cannot grow without +/// bound: the per-message byte cap stops it. +#[test] +fn per_message_byte_cap_bounds_one_id() { + let limits = small_limits(); + let mut r = MessageReassembler::with_limits(limits); + let meta = ChunkMeta::default(); + let data: Bytes = vec![0u8; limits.max_chunk_data_len].into(); + // Within the slot cap, but its data far exceeds the per-message byte cap so it never fills. + let total = limits.max_chunks_per_message; + + let mut accepted = 0usize; + for position in 0..50 { + let before = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0); + r.handle(Chunk { + meta, + chunk: [position, total], + data: data.clone(), + }); + let after = r.pending.get(&meta.id).map(|p| p.slots.len()).unwrap_or(0); + if after > before { + accepted += 1; + } + } + + let pending = r.pending.get(&meta.id).expect("still pending"); + assert!( + pending.data_bytes <= limits.max_message_bytes, + "per-message buffered data must stay within the cap" + ); + assert!( + accepted < 50, + "the cap must reject some chunks, got {accepted}" + ); + assert_eq!( + r.buffered_cost, + pending.cost(limits.slot_overhead), + "accounting stays exact" + ); +} + +/// Spreading the flood across many ids is bounded too: the global buffered-cost ceiling caps +/// total memory regardless of how many ids are used. +#[test] +fn global_cost_cap_bounds_total() { + let limits = small_limits(); + let mut r = MessageReassembler::with_limits(limits); + // Each id contributes one slot of `max_chunk_data_len` data; keep them all pending. + for _ in 0..(limits.max_pending_messages * 4) { + r.handle(Chunk { + chunk: [0, 2], + data: vec![0u8; limits.max_chunk_data_len].into(), + meta: ChunkMeta::default(), + }); + } + assert!( + r.buffered_cost <= limits.max_total_buffered_cost, + "global buffered cost {} exceeded cap {}", + r.buffered_cost, + limits.max_total_buffered_cost + ); +} + +#[test] +fn future_timestamp_is_dropped() { + let mut r = MessageReassembler::new(); + let out = r.handle(Chunk { + chunk: [0, 1], + data: Bytes::from_static(b"x"), + meta: ChunkMeta { + id: Uuid::new_v4(), + ts_ms: get_epoch_ms() + 10 * TS_OFFSET_TOLERANCE_MS, + ttl_ms: DEFAULT_TTL_MS, + }, + }); + assert!(out.is_none()); +} + +#[test] +fn expired_partial_messages_are_evicted() { + let mut r = MessageReassembler::new(); + let now = get_epoch_ms(); + // a partial (1 of 2) message that is already expired + r.handle(Chunk { + chunk: [0, 2], + data: Bytes::from_static(b"x"), + meta: ChunkMeta { + id: Uuid::new_v4(), + ts_ms: now.saturating_sub(1000), + ttl_ms: 100, + }, + }); + // a fresh partial message triggers remove_expired, dropping the stale one + r.handle(Chunk { + chunk: [0, 2], + data: Bytes::from_static(b"y"), + meta: ChunkMeta { + id: Uuid::new_v4(), + ts_ms: now, + ttl_ms: DEFAULT_TTL_MS, + }, + }); + assert_eq!(r.pending_count(), 1, "only the fresh partial remains"); +} + +#[test] +fn pending_messages_are_capped() { + let limits = small_limits(); + let mut r = MessageReassembler::with_limits(limits); + // each is the first of two chunks => stays pending + for _ in 0..(limits.max_pending_messages + 10) { + r.handle(Chunk { + chunk: [0, 2], + data: Bytes::from_static(b"x"), + meta: ChunkMeta::default(), // fresh id, fresh ts each time + }); + } + assert_eq!(r.pending_count(), limits.max_pending_messages); +} + +#[test] +fn round_trip_reordered_with_duplicates() { + let data: Bytes = "abcdefghij".repeat(500).into(); + let mut chunks = chunks_of(&data, 64); + // reorder + inject duplicates mid-stream (not after the final chunk, which would just + // start a fresh, TTL-evicted pending entry — a late retransmit, not a reassembly bug). + chunks.reverse(); + let dup = chunks[chunks.len() / 2].clone(); + chunks.insert(1, dup.clone()); + chunks.insert(chunks.len() / 3, dup); + + let mut r = MessageReassembler::new(); + let mut out = None; + for c in chunks { + out = r.handle(c).or(out); + } + assert_eq!(out.unwrap(), data); + assert_eq!(r.pending_count(), 0); +} + +/// A forged `total` larger than the per-message slot cap is rejected before it can allocate a +/// huge slot map, even though each individual chunk's data is tiny. +#[test] +fn total_over_slot_cap_is_rejected() { + let limits = small_limits(); + let mut r = MessageReassembler::with_limits(limits); + let out = r.handle(Chunk { + chunk: [0, limits.max_chunks_per_message + 1], + data: Bytes::from_static(b"x"), + meta: ChunkMeta::default(), + }); + assert!(out.is_none()); + assert_eq!(r.pending_count(), 0); + assert_eq!(r.buffered_cost, 0); +} + +/// Two chunks sharing an id/total but from different transmissions (different `ts_ms`/`ttl_ms`) +/// must not be merged into one pending entry. +#[test] +fn mismatched_ts_or_ttl_for_same_id_is_rejected() { + let mut r = MessageReassembler::new(); + let id = Uuid::new_v4(); + let now = get_epoch_ms(); + assert!(r + .handle(Chunk { + chunk: [0, 2], + data: Bytes::from_static(b"a"), + meta: ChunkMeta { + id, + ts_ms: now, + ttl_ms: DEFAULT_TTL_MS + }, + }) + .is_none()); + // Same id/total, different ts_ms → rejected (a chunk from another transmission). + let out = r.handle(Chunk { + chunk: [1, 2], + data: Bytes::from_static(b"b"), + meta: ChunkMeta { + id, + ts_ms: now + 1, + ttl_ms: DEFAULT_TTL_MS, + }, + }); + assert!(out.is_none(), "must not complete by mixing transmissions"); + let p = r.pending.get(&id).expect("first chunk still pending"); + assert_eq!(p.slots.len(), 1, "the mismatched chunk left no trace"); +} + +/// Once a completed message's TTL elapses, its tombstone is evicted by the real +/// `remove_expired_at` path (driven here by an injected clock, not by poking internal state), +/// and a fresh message reusing the same id is then accepted rather than suppressed. +#[test] +fn tombstone_expires_then_id_is_reusable() { + let mut r = MessageReassembler::new(); + let id = Uuid::new_v4(); + // A fixed base well above the future-skew tolerance, so timestamps are unambiguous. + let base = 1_000_000u128; + let ttl = 100u64; + let one_chunk = |label: &'static [u8], ts_ms: u128, ttl_ms: u64| Chunk { + chunk: [0, 1], + data: Bytes::from_static(label), + meta: ChunkMeta { id, ts_ms, ttl_ms }, + }; + + // Complete a 1-chunk message at t = base; its tombstone expires at base + ttl. + let first = r.handle_at(one_chunk(b"first", base, ttl), base); + assert_eq!(first.as_deref(), Some(&b"first"[..])); + assert!(r.completed_ids.contains(&id), "tombstoned after completion"); + + // A full retransmit *within* the TTL window (t = base + ttl/2) is suppressed. + let dup = r.handle_at(one_chunk(b"first", base, ttl), base + (ttl as u128) / 2); + assert!( + dup.is_none(), + "post-completion retransmit suppressed within TTL" + ); + assert!( + r.completed_ids.contains(&id), + "tombstone still live within TTL" + ); + + // Past the tombstone's expiry (t = base + ttl + 1), a brand-new message reusing the id is + // delivered: `remove_expired_at` evicts the now-expired tombstone before classify runs. + let later = base + ttl as u128 + 1; + let reused = r.handle_at(one_chunk(b"second", later, ttl), later); + assert_eq!( + reused.as_deref(), + Some(&b"second"[..]), + "id reusable after its tombstone expired via remove_expired_at" + ); +} 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/entry.rs b/crates/core/src/dht/entry.rs index 940040dd2..0d8242ff2 100644 --- a/crates/core/src/dht/entry.rs +++ b/crates/core/src/dht/entry.rs @@ -763,567 +763,4 @@ impl Entry { } #[cfg(test)] -mod tests { - use num_bigint::BigUint; - - use super::*; - use crate::algebra::assert_join_semilattice_laws; - use crate::algebra::assert_strong_eventual_consistency; - use crate::ecc::SecretKey; - use crate::message::Message; - use crate::session::SessionSk; - - fn encoded(value: &str) -> Result { - value.to_string().encode() - } - - fn data_entry(topic: &str, value: &str) -> Result { - (topic.to_string(), encoded(value)?).try_into() - } - - fn data_entry_from_values(topic: &str, values: Vec) -> Result { - let data = values - .into_iter() - .map(|value| value.encode()) - .collect::>>()?; - Ok(Entry::new(Entry::gen_did(topic)?, data, EntryKind::Data)) - } - - fn overflowing_data_entry(topic: &str, overflow: usize) -> Result<(Entry, usize)> { - let incoming_count = ENTRY_DATA_MAX_LEN + overflow; - let entry = data_entry_from_values( - topic, - (0..incoming_count) - .map(|i| format!("incoming{i}")) - .collect::>(), - )?; - Ok((entry, incoming_count)) - } - - fn decode_entry_data(entry: &Entry) -> Result> { - entry - .data - .iter() - .map(|item| item.decode()) - .collect::>>() - } - - fn assert_entry_keeps_recent_overflow( - entry: &Entry, - incoming_count: usize, - overflow: usize, - ) -> Result<()> { - assert_eq!(entry.data.len(), ENTRY_DATA_MAX_LEN); - let decoded = decode_entry_data(entry)?; - assert_eq!(decoded.first(), Some(&format!("incoming{overflow}"))); - assert_eq!( - decoded.last(), - Some(&format!("incoming{}", incoming_count - 1)) - ); - Ok(()) - } - - fn subring_entry(name: &str) -> Result { - let creator = Entry::gen_did("creator")?; - Subring::new(name, creator)?.try_into() - } - - fn actor() -> Did { - Did::from(42u32) - } - - fn version(counter: u32) -> EntryVersion { - EntryVersion::new( - u128::from(counter), - Did::from(counter), - Did::from(counter.saturating_add(1000)), - ) - } - - fn data_delta(topic: &str, value: &str, counter: u32) -> Result { - data_entry(topic, value)?.stamp_delta(version(counter)) - } - - fn overwrite_delta(topic: &str, value: &str, counter: u32) -> Result { - data_entry(topic, value)?.stamp_overwrite(version(counter)) - } - - fn relay_delta(did: Did, value: &str, counter: u32) -> Result { - Entry::new(did, vec![encoded(value)?], EntryKind::RelayMessage) - .stamp_delta(version(counter)) - } - - #[test] - fn gset_satisfies_join_semilattice_laws() { - let mut a = GSet::new(); - a.insert(Did::from(1u32)); - let mut b = GSet::new(); - b.insert(Did::from(2u32)); - let mut ab = GSet::new(); - ab.insert(Did::from(1u32)); - ab.insert(Did::from(2u32)); - - assert_join_semilattice_laws(&[GSet::new(), a, b, ab]); - } - - #[test] - fn data_topic_buffer_satisfies_join_semilattice_laws() -> Result<()> { - let carrier = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data) - .join(data_delta("topic", "a", 1)?)? - .join(data_delta("topic", "b", 2)?)?; - let tombstoned_a = carrier - .tombstone(data_delta("topic", "a", 1)?)? - .topic_buffer()?; - let samples = [ - Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data).topic_buffer()?, - data_delta("topic", "a", 1)?.topic_buffer()?, - data_delta("topic", "b", 2)?.topic_buffer()?, - overwrite_delta("topic", "c", 3)?.topic_buffer()?, - tombstoned_a, - ]; - - assert_join_semilattice_laws(&samples); - Ok(()) - } - - #[test] - fn relay_message_set_satisfies_join_semilattice_laws() -> Result<()> { - let did = Did::from(10u32); - let a = Entry::new(did, vec![encoded("a")?], EntryKind::RelayMessage) - .stamp_delta(version(1))? - .relay_set()?; - let b = Entry::new(did, vec![encoded("b")?], EntryKind::RelayMessage) - .stamp_delta(version(2))? - .relay_set()?; - let ab = Entry::new(did, vec![], EntryKind::RelayMessage) - .join(relay_delta(did, "a", 1)?)? - .join(relay_delta(did, "b", 2)?)?; - let tombstoned_a = ab.tombstone(relay_delta(did, "a", 1)?)?.relay_set()?; - - assert_join_semilattice_laws(&[RelayMessageSet::default(), a, b, tombstoned_a]); - Ok(()) - } - - #[test] - fn entry_join_is_strongly_eventually_consistent_for_data_deltas() -> Result<()> { - let base = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data); - let deltas = [ - data_delta("topic", "a", 1)?, - data_delta("topic", "b", 2)?, - data_delta("topic", "a", 3)?, - ]; - - let forward = deltas - .iter() - .cloned() - .try_fold(base.clone(), |acc, delta| acc.join(delta))?; - let reverse = deltas - .iter() - .rev() - .cloned() - .try_fold(base.clone(), |acc, delta| acc.join(delta))?; - let duplicated = deltas - .iter() - .cloned() - .chain(deltas.iter().cloned()) - .try_fold(base, |acc, delta| acc.join(delta))?; - - assert_eq!(forward, reverse); - assert_eq!(forward, duplicated); - assert_eq!(decode_entry_data(&forward)?, vec![ - String::from("b"), - String::from("a") - ]); - Ok(()) - } - - #[test] - fn generic_sec_witness_accepts_data_topic_buffer_deltas() -> Result<()> { - let base = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data).topic_buffer()?; - let deltas = vec![ - data_delta("topic", "a", 1)?.topic_buffer()?, - data_delta("topic", "b", 2)?.topic_buffer()?, - ]; - - assert_strong_eventual_consistency(base, &deltas); - Ok(()) - } - - #[test] - fn storage_normalization_uses_lattice_top_n_order() -> Result<()> { - let incoming_count = ENTRY_DATA_MAX_LEN + 3; - let mut entry = data_entry_from_values( - "topic", - (0..incoming_count) - .map(|i| format!("incoming{i}")) - .collect::>(), - )?; - entry.crdt.dots = entry - .data - .iter() - .enumerate() - .map(|(index, _)| { - let counter = if index == 0 { - 10_000 - } else { - u32::try_from(index).map_err(|_| Error::EntryDotIndexOutOfBounds { index })? - }; - EntryDot::for_index(version(counter), index) - }) - .collect::>>()?; - - let normalized = entry.try_into_storage_entry()?; - let decoded = decode_entry_data(&normalized)?; - - assert_eq!(normalized.data.len(), ENTRY_DATA_MAX_LEN); - assert_eq!(normalized.data.len(), normalized.crdt.dots.len()); - assert!(decoded.contains(&String::from("incoming0"))); - assert!(!decoded.contains(&String::from("incoming1"))); - assert!(!decoded.contains(&String::from("incoming2"))); - assert!(!decoded.contains(&String::from("incoming3"))); - Ok(()) - } - - #[test] - fn storage_normalization_realigns_legacy_mismatched_dots() -> Result<()> { - let mut entry = data_entry_from_values( - "topic", - (0..ENTRY_DATA_MAX_LEN + 2) - .map(|i| format!("legacy{i}")) - .collect::>(), - )?; - entry.crdt.dots = vec![EntryDot::for_index(version(10_000), 0)?]; - - let normalized = entry.try_into_storage_entry()?; - - assert_eq!(normalized.data.len(), ENTRY_DATA_MAX_LEN); - assert_eq!(normalized.data.len(), normalized.crdt.dots.len()); - Ok(()) - } - - #[test] - fn crdt_constructors_normalize_carrier_invariants() -> Result<()> { - let register = version(10); - let stale = encoded("stale")?; - let live = encoded("live")?; - let mut values = BTreeMap::new(); - values.insert(stale.clone(), EntryDot::for_index(version(1), 0)?); - let live_dot = EntryDot::for_index(version(11), 0)?; - values.insert(live.clone(), live_dot); - - let buffer = DataTopicBuffer::new(Some(register), values, BTreeSet::new()); - assert_eq!(buffer.values.len(), 1); - assert!(buffer.values.contains_key(&live)); - - let relay = RelayMessageSet::new(buffer, BTreeSet::from([live_dot])); - assert!(relay.adds.values.is_empty()); - assert!(relay.removes.contains(&live_dot)); - Ok(()) - } - - #[test] - fn overwrite_register_tiebreaker_converges_for_same_timestamp_actor() -> Result<()> { - let did = Entry::gen_did("topic")?; - let issuer = actor(); - let lower = Entry::new(did, vec![encoded("lower")?], EntryKind::Data) - .stamp_overwrite(EntryVersion::new(1, issuer, Did::from(1u32)))?; - let higher = Entry::new(did, vec![encoded("higher")?], EntryKind::Data) - .stamp_overwrite(EntryVersion::new(1, issuer, Did::from(2u32)))?; - let base = Entry::new(did, vec![], EntryKind::Data); - - let forward = base.clone().join(lower.clone())?.join(higher.clone())?; - let reverse = base.join(higher)?.join(lower)?; - - assert_eq!(forward, reverse); - assert_eq!(decode_entry_data(&forward)?, vec![String::from("higher")]); - Ok(()) - } - - #[test] - fn operation_digest_hashes_canonical_bytes_not_legacy_base58() -> Result<()> { - let entry = data_entry("topic", "value")?; - let digest = OperationDigest { - kind: entry.kind, - did: entry.did, - data: &entry.data, - }; - let bytes = bincode::serialize(&digest).map_err(Error::BincodeSerialize)?; - - let direct = Did::try_from(HashStr::from_bytes(&bytes))?; - let legacy_encoded = bytes.encode()?; - let legacy_base58 = Entry::gen_did(legacy_encoded.value())?; - - assert_eq!(entry.operation_digest()?, direct); - assert_ne!(direct, legacy_base58); - Ok(()) - } - - #[test] - fn forwarded_overwrite_witness_is_not_reissued_after_local_floor() -> Result<()> { - let current = overwrite_delta("topic", "current", 10)?; - let stale_forwarded = overwrite_delta("topic", "stale", 1)?; - - let updated = current.overwrite(stale_forwarded, actor())?; - - assert_eq!(decode_entry_data(&updated)?, vec![String::from("current")]); - Ok(()) - } - - #[test] - fn overwrite_replaces_data_for_same_data_entry() -> Result<()> { - let entry = data_entry("topic", "old")?; - let other = data_entry("topic", "new")?; - let updated = entry.overwrite(other, actor())?; - assert_eq!(decode_entry_data(&updated)?, vec![String::from("new")]); - Ok(()) - } - - #[test] - fn overwrite_rejects_non_data_entry() -> Result<()> { - let entry = subring_entry("ring")?; - let other = entry.clone(); - - assert!(matches!( - entry.overwrite(other, actor()), - Err(Error::EntryNotOverwritable) - )); - Ok(()) - } - - #[test] - fn overwrite_rejects_kind_mismatch() -> Result<()> { - let entry = data_entry("topic", "old")?; - let mut other = entry.clone(); - other.kind = EntryKind::RelayMessage; - - assert!(matches!( - entry.overwrite(other, actor()), - Err(Error::EntryKindNotEqual) - )); - Ok(()) - } - - #[test] - fn overwrite_rejects_key_mismatch() -> Result<()> { - let entry = data_entry("topic-a", "old")?; - let other = data_entry("topic-b", "new")?; - - assert!(matches!( - entry.overwrite(other, actor()), - Err(Error::EntryDidNotEqual) - )); - Ok(()) - } - - #[test] - fn overwrite_caps_payloads_larger_than_max_len() -> Result<()> { - let overflow = 3; - let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; - let entry = data_entry("topic", "base")?; - let updated = entry.overwrite(incoming, actor())?; - assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) - } - - #[test] - fn extend_appends_data_for_same_entry() -> Result<()> { - let entry = data_entry("topic", "first")?; - let other = data_entry("topic", "second")?; - let updated = entry.extend(other, actor())?; - assert_eq!(decode_entry_data(&updated)?, vec![ - String::from("first"), - String::from("second") - ]); - Ok(()) - } - - #[test] - fn extend_trims_oldest_items_at_max_len() -> Result<()> { - let mut entry = data_entry("topic", "test0")?; - for i in 1..ENTRY_DATA_MAX_LEN { - let data = format!("test{i}"); - let other = data_entry("topic", &data)?; - entry = entry.extend(other, actor())?; - assert_eq!(entry.data.len(), i + 1); - } - - for i in ENTRY_DATA_MAX_LEN..ENTRY_DATA_MAX_LEN + 10 { - let data = format!("test{i}"); - let other = data_entry("topic", &data)?; - entry = entry.extend(other, actor())?; - assert_eq!(entry.data.len(), ENTRY_DATA_MAX_LEN); - let decoded = decode_entry_data(&entry)?; - assert_eq!( - decoded.first(), - Some(&format!("test{}", i - ENTRY_DATA_MAX_LEN + 1)) - ); - assert_eq!(decoded.last(), Some(&data)); - } - Ok(()) - } - - #[test] - fn extend_caps_incoming_payloads_larger_than_max_len() -> Result<()> { - let overflow = 3; - let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; - let entry = data_entry("topic", "base")?; - let updated = entry.extend(incoming, actor())?; - assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) - } - - #[test] - fn extend_rejects_non_data_entry() -> Result<()> { - let entry = subring_entry("ring")?; - let other = entry.clone(); - - assert!(matches!( - entry.extend(other, actor()), - Err(Error::EntryNotAppendable) - )); - Ok(()) - } - - #[test] - fn touch_moves_existing_items_to_end_once() -> Result<()> { - let entry = data_entry("topic", "a")? - .extend(data_entry("topic", "b")?, actor())? - .extend(data_entry("topic", "c")?, actor())?; - let touched = data_entry("topic", "b")?; - let updated = entry.touch(touched, actor())?; - assert_eq!(decode_entry_data(&updated)?, vec![ - String::from("a"), - String::from("c"), - String::from("b") - ]); - Ok(()) - } - - #[test] - fn touch_trims_oldest_non_touched_items_at_max_len() -> Result<()> { - let mut entry = data_entry("topic", "test0")?; - for i in 1..ENTRY_DATA_MAX_LEN { - entry = entry.extend(data_entry("topic", &format!("test{i}"))?, actor())?; - } - let updated = entry.touch(data_entry("topic", "test0")?, actor())?; - assert_eq!(updated.data.len(), ENTRY_DATA_MAX_LEN); - let decoded = decode_entry_data(&updated)?; - assert_eq!(decoded.first(), Some(&String::from("test1"))); - assert_eq!(decoded.last(), Some(&String::from("test0"))); - Ok(()) - } - - #[test] - fn relay_tombstone_removes_observed_message_by_join() -> Result<()> { - let did = Did::from(30u32); - let first = relay_delta(did, "first", 1)?; - let second = relay_delta(did, "second", 2)?; - let carrier = Entry::new(did, vec![], EntryKind::RelayMessage) - .join(first.clone())? - .join(second.clone())?; - - let removed = carrier.tombstone(first.clone())?; - - assert_eq!(decode_entry_data(&removed)?, vec![String::from("second")]); - let joined_with_stale_add = removed.join(first)?; - assert_eq!(decode_entry_data(&joined_with_stale_add)?, vec![ - String::from("second") - ]); - Ok(()) - } - - #[test] - fn data_tombstone_removes_observed_payload_by_join() -> Result<()> { - let first = data_delta("topic", "first", 1)?; - let second = data_delta("topic", "second", 2)?; - let carrier = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data) - .join(first.clone())? - .join(second.clone())?; - - let removed = carrier.tombstone(first.clone())?; - - assert_eq!(decode_entry_data(&removed)?, vec![String::from("second")]); - let joined_with_stale_add = removed.join(first)?; - assert_eq!(decode_entry_data(&joined_with_stale_add)?, vec![ - String::from("second") - ]); - Ok(()) - } - - #[test] - fn tombstone_rejects_non_data_or_relay_entry() -> Result<()> { - let entry = subring_entry("ring")?; - let other = entry.clone(); - - assert!(matches!( - entry.tombstone(other), - Err(Error::EntryNotTombstonable) - )); - Ok(()) - } - - #[test] - fn touch_caps_incoming_payloads_larger_than_max_len() -> Result<()> { - let overflow = 3; - let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; - let entry = data_entry("topic", "base")?; - let updated = entry.touch(incoming, actor())?; - assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) - } - - #[test] - fn join_subring_adds_member_to_subring_entry() -> Result<()> { - let entry = subring_entry("ring")?; - let member = Entry::gen_did("member")?; - let updated = entry.join_subring(member)?; - let subring = Subring::try_from(updated)?; - assert_eq!(subring.finger.first(), Some(member)); - Ok(()) - } - - #[test] - fn join_subring_rejects_non_subring_entry() -> Result<()> { - let entry = data_entry("topic", "value")?; - let member = Entry::gen_did("member")?; - - assert!(matches!( - entry.join_subring(member), - Err(Error::EntryNotJoinable) - )); - Ok(()) - } - - #[test] - fn operation_default_entry_matches_operation_kind() -> Result<()> { - let target = data_entry("topic", "value")?; - let default = EntryOperation::Extend(target.clone()).gen_default_entry()?; - assert_eq!(default.did, target.did); - assert_eq!(default.kind, EntryKind::Data); - assert!(default.data.is_empty()); - Ok(()) - } - - #[test] - fn message_payload_entry_key_targets_successor_of_signer() -> Result<()> { - let key = SecretKey::random(); - let session = SessionSk::new_with_seckey(&key)?; - let signer: Did = key.address().into(); - let payload = - MessagePayload::new_send(Message::custom(b"relay")?, &session, signer, signer)?; - let entry = Entry::try_from(payload)?; - let expected = BigUint::from(signer) + BigUint::from(1u16); - assert_eq!(entry.did, expected.into()); - assert_eq!(entry.kind, EntryKind::RelayMessage); - Ok(()) - } - - #[test] - fn affine_preserves_payload_and_kind_while_rotating_keys() -> Result<()> { - let entry = data_entry("topic", "value")?; - let affined = entry.affine(3)?; - assert_eq!(affined.len(), 3); - for rotated in affined { - assert_eq!(rotated.data, entry.data); - assert_eq!(rotated.kind, entry.kind); - } - Ok(()) - } -} +mod tests; diff --git a/crates/core/src/dht/entry/tests.rs b/crates/core/src/dht/entry/tests.rs new file mode 100644 index 000000000..22b318264 --- /dev/null +++ b/crates/core/src/dht/entry/tests.rs @@ -0,0 +1,560 @@ +use num_bigint::BigUint; + +use super::*; +use crate::algebra::assert_join_semilattice_laws; +use crate::algebra::assert_strong_eventual_consistency; +use crate::ecc::SecretKey; +use crate::message::Message; +use crate::session::SessionSk; + +fn encoded(value: &str) -> Result { + value.to_string().encode() +} + +fn data_entry(topic: &str, value: &str) -> Result { + (topic.to_string(), encoded(value)?).try_into() +} + +fn data_entry_from_values(topic: &str, values: Vec) -> Result { + let data = values + .into_iter() + .map(|value| value.encode()) + .collect::>>()?; + Ok(Entry::new(Entry::gen_did(topic)?, data, EntryKind::Data)) +} + +fn overflowing_data_entry(topic: &str, overflow: usize) -> Result<(Entry, usize)> { + let incoming_count = ENTRY_DATA_MAX_LEN + overflow; + let entry = data_entry_from_values( + topic, + (0..incoming_count) + .map(|i| format!("incoming{i}")) + .collect::>(), + )?; + Ok((entry, incoming_count)) +} + +fn decode_entry_data(entry: &Entry) -> Result> { + entry + .data + .iter() + .map(|item| item.decode()) + .collect::>>() +} + +fn assert_entry_keeps_recent_overflow( + entry: &Entry, + incoming_count: usize, + overflow: usize, +) -> Result<()> { + assert_eq!(entry.data.len(), ENTRY_DATA_MAX_LEN); + let decoded = decode_entry_data(entry)?; + assert_eq!(decoded.first(), Some(&format!("incoming{overflow}"))); + assert_eq!( + decoded.last(), + Some(&format!("incoming{}", incoming_count - 1)) + ); + Ok(()) +} + +fn subring_entry(name: &str) -> Result { + let creator = Entry::gen_did("creator")?; + Subring::new(name, creator)?.try_into() +} + +fn actor() -> Did { + Did::from(42u32) +} + +fn version(counter: u32) -> EntryVersion { + EntryVersion::new( + u128::from(counter), + Did::from(counter), + Did::from(counter.saturating_add(1000)), + ) +} + +fn data_delta(topic: &str, value: &str, counter: u32) -> Result { + data_entry(topic, value)?.stamp_delta(version(counter)) +} + +fn overwrite_delta(topic: &str, value: &str, counter: u32) -> Result { + data_entry(topic, value)?.stamp_overwrite(version(counter)) +} + +fn relay_delta(did: Did, value: &str, counter: u32) -> Result { + Entry::new(did, vec![encoded(value)?], EntryKind::RelayMessage).stamp_delta(version(counter)) +} + +#[test] +fn gset_satisfies_join_semilattice_laws() { + let mut a = GSet::new(); + a.insert(Did::from(1u32)); + let mut b = GSet::new(); + b.insert(Did::from(2u32)); + let mut ab = GSet::new(); + ab.insert(Did::from(1u32)); + ab.insert(Did::from(2u32)); + + assert_join_semilattice_laws(&[GSet::new(), a, b, ab]); +} + +#[test] +fn data_topic_buffer_satisfies_join_semilattice_laws() -> Result<()> { + let carrier = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data) + .join(data_delta("topic", "a", 1)?)? + .join(data_delta("topic", "b", 2)?)?; + let tombstoned_a = carrier + .tombstone(data_delta("topic", "a", 1)?)? + .topic_buffer()?; + let samples = [ + Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data).topic_buffer()?, + data_delta("topic", "a", 1)?.topic_buffer()?, + data_delta("topic", "b", 2)?.topic_buffer()?, + overwrite_delta("topic", "c", 3)?.topic_buffer()?, + tombstoned_a, + ]; + + assert_join_semilattice_laws(&samples); + Ok(()) +} + +#[test] +fn relay_message_set_satisfies_join_semilattice_laws() -> Result<()> { + let did = Did::from(10u32); + let a = Entry::new(did, vec![encoded("a")?], EntryKind::RelayMessage) + .stamp_delta(version(1))? + .relay_set()?; + let b = Entry::new(did, vec![encoded("b")?], EntryKind::RelayMessage) + .stamp_delta(version(2))? + .relay_set()?; + let ab = Entry::new(did, vec![], EntryKind::RelayMessage) + .join(relay_delta(did, "a", 1)?)? + .join(relay_delta(did, "b", 2)?)?; + let tombstoned_a = ab.tombstone(relay_delta(did, "a", 1)?)?.relay_set()?; + + assert_join_semilattice_laws(&[RelayMessageSet::default(), a, b, tombstoned_a]); + Ok(()) +} + +#[test] +fn entry_join_is_strongly_eventually_consistent_for_data_deltas() -> Result<()> { + let base = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data); + let deltas = [ + data_delta("topic", "a", 1)?, + data_delta("topic", "b", 2)?, + data_delta("topic", "a", 3)?, + ]; + + let forward = deltas + .iter() + .cloned() + .try_fold(base.clone(), |acc, delta| acc.join(delta))?; + let reverse = deltas + .iter() + .rev() + .cloned() + .try_fold(base.clone(), |acc, delta| acc.join(delta))?; + let duplicated = deltas + .iter() + .cloned() + .chain(deltas.iter().cloned()) + .try_fold(base, |acc, delta| acc.join(delta))?; + + assert_eq!(forward, reverse); + assert_eq!(forward, duplicated); + assert_eq!(decode_entry_data(&forward)?, vec![ + String::from("b"), + String::from("a") + ]); + Ok(()) +} + +#[test] +fn generic_sec_witness_accepts_data_topic_buffer_deltas() -> Result<()> { + let base = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data).topic_buffer()?; + let deltas = vec![ + data_delta("topic", "a", 1)?.topic_buffer()?, + data_delta("topic", "b", 2)?.topic_buffer()?, + ]; + + assert_strong_eventual_consistency(base, &deltas); + Ok(()) +} + +#[test] +fn storage_normalization_uses_lattice_top_n_order() -> Result<()> { + let incoming_count = ENTRY_DATA_MAX_LEN + 3; + let mut entry = data_entry_from_values( + "topic", + (0..incoming_count) + .map(|i| format!("incoming{i}")) + .collect::>(), + )?; + entry.crdt.dots = entry + .data + .iter() + .enumerate() + .map(|(index, _)| { + let counter = if index == 0 { + 10_000 + } else { + u32::try_from(index).map_err(|_| Error::EntryDotIndexOutOfBounds { index })? + }; + EntryDot::for_index(version(counter), index) + }) + .collect::>>()?; + + let normalized = entry.try_into_storage_entry()?; + let decoded = decode_entry_data(&normalized)?; + + assert_eq!(normalized.data.len(), ENTRY_DATA_MAX_LEN); + assert_eq!(normalized.data.len(), normalized.crdt.dots.len()); + assert!(decoded.contains(&String::from("incoming0"))); + assert!(!decoded.contains(&String::from("incoming1"))); + assert!(!decoded.contains(&String::from("incoming2"))); + assert!(!decoded.contains(&String::from("incoming3"))); + Ok(()) +} + +#[test] +fn storage_normalization_realigns_legacy_mismatched_dots() -> Result<()> { + let mut entry = data_entry_from_values( + "topic", + (0..ENTRY_DATA_MAX_LEN + 2) + .map(|i| format!("legacy{i}")) + .collect::>(), + )?; + entry.crdt.dots = vec![EntryDot::for_index(version(10_000), 0)?]; + + let normalized = entry.try_into_storage_entry()?; + + assert_eq!(normalized.data.len(), ENTRY_DATA_MAX_LEN); + assert_eq!(normalized.data.len(), normalized.crdt.dots.len()); + Ok(()) +} + +#[test] +fn crdt_constructors_normalize_carrier_invariants() -> Result<()> { + let register = version(10); + let stale = encoded("stale")?; + let live = encoded("live")?; + let mut values = BTreeMap::new(); + values.insert(stale.clone(), EntryDot::for_index(version(1), 0)?); + let live_dot = EntryDot::for_index(version(11), 0)?; + values.insert(live.clone(), live_dot); + + let buffer = DataTopicBuffer::new(Some(register), values, BTreeSet::new()); + assert_eq!(buffer.values.len(), 1); + assert!(buffer.values.contains_key(&live)); + + let relay = RelayMessageSet::new(buffer, BTreeSet::from([live_dot])); + assert!(relay.adds.values.is_empty()); + assert!(relay.removes.contains(&live_dot)); + Ok(()) +} + +#[test] +fn overwrite_register_tiebreaker_converges_for_same_timestamp_actor() -> Result<()> { + let did = Entry::gen_did("topic")?; + let issuer = actor(); + let lower = Entry::new(did, vec![encoded("lower")?], EntryKind::Data) + .stamp_overwrite(EntryVersion::new(1, issuer, Did::from(1u32)))?; + let higher = Entry::new(did, vec![encoded("higher")?], EntryKind::Data) + .stamp_overwrite(EntryVersion::new(1, issuer, Did::from(2u32)))?; + let base = Entry::new(did, vec![], EntryKind::Data); + + let forward = base.clone().join(lower.clone())?.join(higher.clone())?; + let reverse = base.join(higher)?.join(lower)?; + + assert_eq!(forward, reverse); + assert_eq!(decode_entry_data(&forward)?, vec![String::from("higher")]); + Ok(()) +} + +#[test] +fn operation_digest_hashes_canonical_bytes_not_legacy_base58() -> Result<()> { + let entry = data_entry("topic", "value")?; + let digest = OperationDigest { + kind: entry.kind, + did: entry.did, + data: &entry.data, + }; + let bytes = bincode::serialize(&digest).map_err(Error::BincodeSerialize)?; + + let direct = Did::try_from(HashStr::from_bytes(&bytes))?; + let legacy_encoded = bytes.encode()?; + let legacy_base58 = Entry::gen_did(legacy_encoded.value())?; + + assert_eq!(entry.operation_digest()?, direct); + assert_ne!(direct, legacy_base58); + Ok(()) +} + +#[test] +fn forwarded_overwrite_witness_is_not_reissued_after_local_floor() -> Result<()> { + let current = overwrite_delta("topic", "current", 10)?; + let stale_forwarded = overwrite_delta("topic", "stale", 1)?; + + let updated = current.overwrite(stale_forwarded, actor())?; + + assert_eq!(decode_entry_data(&updated)?, vec![String::from("current")]); + Ok(()) +} + +#[test] +fn overwrite_replaces_data_for_same_data_entry() -> Result<()> { + let entry = data_entry("topic", "old")?; + let other = data_entry("topic", "new")?; + let updated = entry.overwrite(other, actor())?; + assert_eq!(decode_entry_data(&updated)?, vec![String::from("new")]); + Ok(()) +} + +#[test] +fn overwrite_rejects_non_data_entry() -> Result<()> { + let entry = subring_entry("ring")?; + let other = entry.clone(); + + assert!(matches!( + entry.overwrite(other, actor()), + Err(Error::EntryNotOverwritable) + )); + Ok(()) +} + +#[test] +fn overwrite_rejects_kind_mismatch() -> Result<()> { + let entry = data_entry("topic", "old")?; + let mut other = entry.clone(); + other.kind = EntryKind::RelayMessage; + + assert!(matches!( + entry.overwrite(other, actor()), + Err(Error::EntryKindNotEqual) + )); + Ok(()) +} + +#[test] +fn overwrite_rejects_key_mismatch() -> Result<()> { + let entry = data_entry("topic-a", "old")?; + let other = data_entry("topic-b", "new")?; + + assert!(matches!( + entry.overwrite(other, actor()), + Err(Error::EntryDidNotEqual) + )); + Ok(()) +} + +#[test] +fn overwrite_caps_payloads_larger_than_max_len() -> Result<()> { + let overflow = 3; + let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; + let entry = data_entry("topic", "base")?; + let updated = entry.overwrite(incoming, actor())?; + assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) +} + +#[test] +fn extend_appends_data_for_same_entry() -> Result<()> { + let entry = data_entry("topic", "first")?; + let other = data_entry("topic", "second")?; + let updated = entry.extend(other, actor())?; + assert_eq!(decode_entry_data(&updated)?, vec![ + String::from("first"), + String::from("second") + ]); + Ok(()) +} + +#[test] +fn extend_trims_oldest_items_at_max_len() -> Result<()> { + let mut entry = data_entry("topic", "test0")?; + for i in 1..ENTRY_DATA_MAX_LEN { + let data = format!("test{i}"); + let other = data_entry("topic", &data)?; + entry = entry.extend(other, actor())?; + assert_eq!(entry.data.len(), i + 1); + } + + for i in ENTRY_DATA_MAX_LEN..ENTRY_DATA_MAX_LEN + 10 { + let data = format!("test{i}"); + let other = data_entry("topic", &data)?; + entry = entry.extend(other, actor())?; + assert_eq!(entry.data.len(), ENTRY_DATA_MAX_LEN); + let decoded = decode_entry_data(&entry)?; + assert_eq!( + decoded.first(), + Some(&format!("test{}", i - ENTRY_DATA_MAX_LEN + 1)) + ); + assert_eq!(decoded.last(), Some(&data)); + } + Ok(()) +} + +#[test] +fn extend_caps_incoming_payloads_larger_than_max_len() -> Result<()> { + let overflow = 3; + let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; + let entry = data_entry("topic", "base")?; + let updated = entry.extend(incoming, actor())?; + assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) +} + +#[test] +fn extend_rejects_non_data_entry() -> Result<()> { + let entry = subring_entry("ring")?; + let other = entry.clone(); + + assert!(matches!( + entry.extend(other, actor()), + Err(Error::EntryNotAppendable) + )); + Ok(()) +} + +#[test] +fn touch_moves_existing_items_to_end_once() -> Result<()> { + let entry = data_entry("topic", "a")? + .extend(data_entry("topic", "b")?, actor())? + .extend(data_entry("topic", "c")?, actor())?; + let touched = data_entry("topic", "b")?; + let updated = entry.touch(touched, actor())?; + assert_eq!(decode_entry_data(&updated)?, vec![ + String::from("a"), + String::from("c"), + String::from("b") + ]); + Ok(()) +} + +#[test] +fn touch_trims_oldest_non_touched_items_at_max_len() -> Result<()> { + let mut entry = data_entry("topic", "test0")?; + for i in 1..ENTRY_DATA_MAX_LEN { + entry = entry.extend(data_entry("topic", &format!("test{i}"))?, actor())?; + } + let updated = entry.touch(data_entry("topic", "test0")?, actor())?; + assert_eq!(updated.data.len(), ENTRY_DATA_MAX_LEN); + let decoded = decode_entry_data(&updated)?; + assert_eq!(decoded.first(), Some(&String::from("test1"))); + assert_eq!(decoded.last(), Some(&String::from("test0"))); + Ok(()) +} + +#[test] +fn relay_tombstone_removes_observed_message_by_join() -> Result<()> { + let did = Did::from(30u32); + let first = relay_delta(did, "first", 1)?; + let second = relay_delta(did, "second", 2)?; + let carrier = Entry::new(did, vec![], EntryKind::RelayMessage) + .join(first.clone())? + .join(second.clone())?; + + let removed = carrier.tombstone(first.clone())?; + + assert_eq!(decode_entry_data(&removed)?, vec![String::from("second")]); + let joined_with_stale_add = removed.join(first)?; + assert_eq!(decode_entry_data(&joined_with_stale_add)?, vec![ + String::from("second") + ]); + Ok(()) +} + +#[test] +fn data_tombstone_removes_observed_payload_by_join() -> Result<()> { + let first = data_delta("topic", "first", 1)?; + let second = data_delta("topic", "second", 2)?; + let carrier = Entry::new(Entry::gen_did("topic")?, vec![], EntryKind::Data) + .join(first.clone())? + .join(second.clone())?; + + let removed = carrier.tombstone(first.clone())?; + + assert_eq!(decode_entry_data(&removed)?, vec![String::from("second")]); + let joined_with_stale_add = removed.join(first)?; + assert_eq!(decode_entry_data(&joined_with_stale_add)?, vec![ + String::from("second") + ]); + Ok(()) +} + +#[test] +fn tombstone_rejects_non_data_or_relay_entry() -> Result<()> { + let entry = subring_entry("ring")?; + let other = entry.clone(); + + assert!(matches!( + entry.tombstone(other), + Err(Error::EntryNotTombstonable) + )); + Ok(()) +} + +#[test] +fn touch_caps_incoming_payloads_larger_than_max_len() -> Result<()> { + let overflow = 3; + let (incoming, incoming_count) = overflowing_data_entry("topic", overflow)?; + let entry = data_entry("topic", "base")?; + let updated = entry.touch(incoming, actor())?; + assert_entry_keeps_recent_overflow(&updated, incoming_count, overflow) +} + +#[test] +fn join_subring_adds_member_to_subring_entry() -> Result<()> { + let entry = subring_entry("ring")?; + let member = Entry::gen_did("member")?; + let updated = entry.join_subring(member)?; + let subring = Subring::try_from(updated)?; + assert_eq!(subring.finger.first(), Some(member)); + Ok(()) +} + +#[test] +fn join_subring_rejects_non_subring_entry() -> Result<()> { + let entry = data_entry("topic", "value")?; + let member = Entry::gen_did("member")?; + + assert!(matches!( + entry.join_subring(member), + Err(Error::EntryNotJoinable) + )); + Ok(()) +} + +#[test] +fn operation_default_entry_matches_operation_kind() -> Result<()> { + let target = data_entry("topic", "value")?; + let default = EntryOperation::Extend(target.clone()).gen_default_entry()?; + assert_eq!(default.did, target.did); + assert_eq!(default.kind, EntryKind::Data); + assert!(default.data.is_empty()); + Ok(()) +} + +#[test] +fn message_payload_entry_key_targets_successor_of_signer() -> Result<()> { + let key = SecretKey::random(); + let session = SessionSk::new_with_seckey(&key)?; + let signer: Did = key.address().into(); + let payload = MessagePayload::new_send(Message::custom(b"relay")?, &session, signer, signer)?; + let entry = Entry::try_from(payload)?; + let expected = BigUint::from(signer) + BigUint::from(1u16); + assert_eq!(entry.did, expected.into()); + assert_eq!(entry.kind, EntryKind::RelayMessage); + Ok(()) +} + +#[test] +fn affine_preserves_payload_and_kind_while_rotating_keys() -> Result<()> { + let entry = data_entry("topic", "value")?; + let affined = entry.affine(3)?; + assert_eq!(affined.len(), 3); + for rotated in affined { + assert_eq!(rotated.data, entry.data); + assert_eq!(rotated.kind, entry.kind); + } + Ok(()) +} diff --git a/crates/core/src/dht/stabilization.rs b/crates/core/src/dht/stabilization.rs index 0ffe34740..2f7baf68d 100644 --- a/crates/core/src/dht/stabilization.rs +++ b/crates/core/src/dht/stabilization.rs @@ -1,18 +1,33 @@ //! Stabilization run daemons to maintain dht. +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::future::Future; use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; +use futures::future::FutureExt; +use futures::pin_mut; +use futures::select; use rings_transport::core::transport::WebrtcConnectionState; use crate::dht::successor::SuccessorReader; use crate::dht::types::ChordStorageRepair; use crate::dht::types::CorrectChord; use crate::dht::Chord; +use crate::dht::Did; use crate::dht::PeerRing; use crate::dht::PeerRingAction; use crate::dht::PeerRingRemoteAction; +use crate::dht::StorageSyncDelivery; +use crate::dht::StorageSyncDestination; +use crate::dht::TopoInfo; use crate::error::Error; use crate::error::Result; +use crate::lifecycle::StopToken; +use crate::measure::PeerMeasurement; +use crate::measure::PeerQualityThresholds; use crate::message::FindSuccessorReportHandler; use crate::message::FindSuccessorSend; use crate::message::FindSuccessorThen; @@ -20,102 +35,762 @@ use crate::message::Message; use crate::message::MessagePayload; use crate::message::NotifyPredecessorSend; use crate::message::PayloadSender; +use crate::message::PeerLivenessProbe; use crate::message::QueryForTopoInfoSend; use crate::message::SyncEntriesWithSuccessor; use crate::swarm::transport::SwarmTransport; +use crate::swarm::transport::PEER_LIVENESS_IDLE_MS; +use crate::utils::get_epoch_ms_i64; +use crate::utils::sleep; + +const STABILIZATION_STEP_TIMEOUT: Duration = Duration::from_secs(30); +const STABILIZATION_STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); +const DISCONNECTED_CONNECTION_GRACE_MS: i64 = 30_000; +pub(crate) const STORAGE_REPAIR_MAX_DELIVERIES_PER_STEP: usize = 64; +pub(crate) const STORAGE_REPAIR_FRESH_CONNECTION_GRACE_MS: i64 = 30_000; +const DHT_TOPOLOGY_EVICTION_THRESHOLDS: PeerQualityThresholds = + PeerQualityThresholds::new(3, 10, 10); + +#[derive(Clone, Copy, Debug)] +enum TopologyPeerRemovalReason { + NoAdmittedTransport, + MissingTransportObject, + TerminalTransport(WebrtcConnectionState), + DisconnectedGraceElapsed { + disconnected_for_ms: i64, + grace_ms: i64, + }, + UnansweredLivenessProbe { + unanswered_for_ms: i64, + timeout_ms: i64, + }, + LocalFailureLimit(PeerMeasurement), +} + +#[derive(Clone, Copy, Debug)] +enum StorageRepairDeferReason { + MissingNextHop, + NextHopNotAdmitted, + PhysicalOwnerNotAdmitted, + NextHopFresh { connected_for_ms: i64 }, + PhysicalOwnerFresh { connected_for_ms: i64 }, +} + +impl StorageRepairDeferReason { + const fn as_str(self) -> &'static str { + match self { + Self::MissingNextHop => "missing_next_hop", + Self::NextHopNotAdmitted => "next_hop_not_admitted", + Self::PhysicalOwnerNotAdmitted => "physical_owner_not_admitted", + Self::NextHopFresh { .. } => "next_hop_fresh", + Self::PhysicalOwnerFresh { .. } => "physical_owner_fresh", + } + } + + const fn connected_for_ms(self) -> Option { + match self { + Self::NextHopFresh { connected_for_ms } + | Self::PhysicalOwnerFresh { connected_for_ms } => Some(connected_for_ms), + _ => None, + } + } +} + +impl TopologyPeerRemovalReason { + const fn as_str(self) -> &'static str { + match self { + Self::NoAdmittedTransport => "no_admitted_transport", + Self::MissingTransportObject => "missing_transport_object", + Self::TerminalTransport(_) => "terminal_transport", + Self::DisconnectedGraceElapsed { .. } => "disconnected_grace_elapsed", + Self::UnansweredLivenessProbe { .. } => "unanswered_liveness_probe", + Self::LocalFailureLimit(_) => "local_failure_limit", + } + } + + const fn transport_state(self) -> Option { + match self { + Self::TerminalTransport(state) => Some(state), + _ => None, + } + } + + const fn disconnected_for_ms(self) -> Option { + match self { + Self::DisconnectedGraceElapsed { + disconnected_for_ms, + .. + } => Some(disconnected_for_ms), + _ => None, + } + } + + const fn disconnected_grace_ms(self) -> Option { + match self { + Self::DisconnectedGraceElapsed { grace_ms, .. } => Some(grace_ms), + _ => None, + } + } + + const fn liveness_unanswered_for_ms(self) -> Option { + match self { + Self::UnansweredLivenessProbe { + unanswered_for_ms, .. + } => Some(unanswered_for_ms), + _ => None, + } + } + + const fn liveness_timeout_ms(self) -> Option { + match self { + Self::UnansweredLivenessProbe { timeout_ms, .. } => Some(timeout_ms), + _ => None, + } + } + + const fn measurement(self) -> Option { + match self { + Self::LocalFailureLimit(measurement) => Some(measurement), + _ => None, + } + } + + const fn should_disconnect_transport(self) -> bool { + !matches!(self, Self::NoAdmittedTransport) + } +} + +const fn is_terminal_transport_state(state: WebrtcConnectionState) -> bool { + matches!( + state, + WebrtcConnectionState::Failed | WebrtcConnectionState::Closed + ) +} /// The stabilization runner. #[derive(Clone)] pub struct Stabilizer { transport: Arc, dht: Arc, + storage_repair_cursor: Arc>, } impl Stabilizer { /// Create a new stabilization runner. pub fn new(transport: Arc) -> Self { let dht = transport.dht.clone(); - Self { transport, dht } + Self { + transport, + dht, + storage_repair_cursor: Arc::new(Mutex::new(0)), + } } /// Run stabilization once. pub async fn stabilize(&self) -> Result<()> { - tracing::debug!("STABILIZATION notify_predecessor start"); - if let Err(e) = self.notify_predecessor().await { - tracing::error!("[stabilize] Failed on notify predecessor {:?}", e); - } - tracing::debug!("STABILIZATION notify_predecessor end"); - tracing::debug!("STABILIZATION fix_fingers start"); - if let Err(e) = self.fix_fingers().await { - tracing::error!("[stabilize] Failed on fix_finger {:?}", e); - } - tracing::debug!("STABILIZATION fix_fingers end"); - tracing::debug!("STABILIZATION clean_unavailable_connections start"); - if let Err(e) = self.clean_unavailable_connections().await { - tracing::error!( - "[stabilize] Failed on clean unavailable connections {:?}", - e - ); - } - tracing::debug!("STABILIZATION clean_unavailable_connections end"); + self.stabilize_with_step_timeout(STABILIZATION_STEP_TIMEOUT) + .await + } + + pub(crate) async fn stabilize_with_step_timeout(&self, timeout: Duration) -> Result<()> { + self.run_step("notify_predecessor", timeout, self.notify_predecessor()) + .await; + self.run_step("fix_fingers", timeout, self.fix_fingers()) + .await; + self.run_step("probe_peer_liveness", timeout, self.probe_peer_liveness()) + .await; + self.run_step( + "clean_unavailable_connections", + timeout, + self.clean_unavailable_connections(), + ) + .await; // Default HMCC/Zave stabilization path. The pure operation is specified // as `CorrectStabilize` in tests/default/test_dht_convergence.rs. - tracing::debug!("STABILIZATION correct_stabilize start"); - if let Err(e) = self.correct_stabilize().await { - tracing::error!("[stabilize] Failed on call correct stabilize {:?}", e); - } - tracing::debug!("STABILIZATION correct_stabilize end"); - tracing::debug!("STABILIZATION repair_storage start"); - if let Err(e) = self.repair_storage().await { - tracing::error!("[stabilize] Failed on repair storage {:?}", e); - } - tracing::debug!("STABILIZATION repair_storage end"); + self.run_step("correct_stabilize", timeout, self.correct_stabilize()) + .await; + self.run_step("repair_storage", timeout, self.repair_storage()) + .await; Ok(()) } + async fn run_step(&self, step: &'static str, timeout: Duration, future: F) + where F: Future> { + let started_at_ms = get_epoch_ms_i64(); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + step, + timeout_ms = timeout.as_millis(), + "STABILIZATION step start" + ); + + let future = future.fuse(); + let timer = sleep(timeout).fuse(); + pin_mut!(future, timer); + let (result, timed_out) = select! { + result = future => (result, false), + _ = timer => { + self.log_step_timeout(step, timeout, elapsed_since_ms(started_at_ms)); + (future.await, true) + }, + }; + + match result { + Ok(()) => { + let elapsed_ms = elapsed_since_ms(started_at_ms); + if !timed_out && u128::try_from(elapsed_ms).unwrap_or(0) > timeout.as_millis() { + self.log_step_timeout(step, timeout, elapsed_ms); + } + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + step, + elapsed_ms, + "STABILIZATION step end" + ); + } + Err(e) => { + tracing::error!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + step, + error = ?e, + "STABILIZATION step failed" + ); + } + } + } + + fn log_step_timeout(&self, step: &'static str, timeout: Duration, elapsed_ms: i64) { + let topology = TopoInfo::try_from(self.dht.as_ref()).ok(); + let mut connections: Vec<(Did, WebrtcConnectionState)> = self + .transport + .admitted_connections() + .into_iter() + .map(|(did, conn)| (did, conn.webrtc_connection_state())) + .collect(); + connections.sort_by_key(|(did, _)| *did); + + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + step, + timeout_ms = timeout.as_millis(), + elapsed_ms, + reason = "stabilization_step_overran_deadline", + topology = ?topology, + connections = ?connections, + "STABILIZATION step exceeded timeout" + ); + } + async fn handle_storage_repair_action(&self, act: PeerRingAction) -> Result<()> { - for delivery in act.storage_sync_deliveries()? { + let deliveries = self.storage_repair_window(act.coalesced_storage_sync_deliveries()?); + if deliveries.is_empty() { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + "STABILIZATION storage repair has no deliveries" + ); + return Ok(()); + } + + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + deliveries = deliveries.len(), + "STABILIZATION storage repair deliveries prepared" + ); + + let now_ms = get_epoch_ms_i64(); + let mut sent = 0usize; + let mut deferred = 0usize; + for delivery in deliveries { let msg = SyncEntriesWithSuccessor::from_delivery(delivery); - self.transport.send_storage_sync(msg).await?; + let purpose = msg.purpose; + let destination = msg.destination; + let destination_did = destination.did(); + let entries = msg.data.len(); + let next_hop = self + .dht + .next_hop_for_storage_sync(destination) + .ok() + .flatten(); + let next_hop_state = next_hop + .and_then(|did| self.transport.get_connection(did)) + .map(|conn| conn.webrtc_connection_state()); + + if let Some(reason) = self.storage_repair_defer_reason(destination, next_hop, now_ms)? { + deferred = deferred.saturating_add(1); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + purpose = ?purpose, + destination = ?destination, + destination_did = %destination_did, + next_hop = ?next_hop, + next_hop_state = ?next_hop_state, + entries, + reason = reason.as_str(), + connected_for_ms = ?reason.connected_for_ms(), + grace_ms = STORAGE_REPAIR_FRESH_CONNECTION_GRACE_MS, + "STABILIZATION storage repair deferred" + ); + continue; + } + + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + purpose = ?purpose, + destination = ?destination, + destination_did = %destination_did, + next_hop = ?next_hop, + next_hop_state = ?next_hop_state, + entries, + "STABILIZATION storage repair send start" + ); + + match self.transport.send_storage_sync(msg).await { + Ok(tx_id) => tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + tx_id = %tx_id, + purpose = ?purpose, + destination = ?destination, + destination_did = %destination_did, + next_hop = ?next_hop, + entries, + "STABILIZATION storage repair send complete" + ), + Err(e) if e.is_data_channel_backpressure() => { + deferred = deferred.saturating_add(1); + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + purpose = ?purpose, + destination = ?destination, + destination_did = %destination_did, + next_hop = ?next_hop, + next_hop_state = ?next_hop_state, + entries, + error = ?e, + "STABILIZATION storage repair deferred by data-channel backpressure" + ); + continue; + } + Err(e) => { + tracing::error!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + purpose = ?purpose, + destination = ?destination, + destination_did = %destination_did, + next_hop = ?next_hop, + next_hop_state = ?next_hop_state, + entries, + error = ?e, + "STABILIZATION storage repair send failed" + ); + return Err(e); + } + } + sent = sent.saturating_add(1); + } + + if deferred > 0 { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + sent, + deferred, + "STABILIZATION storage repair deliveries finished with deferrals" + ); } Ok(()) } + fn storage_repair_window( + &self, + mut deliveries: Vec, + ) -> Vec { + let total = deliveries.len(); + if total <= STORAGE_REPAIR_MAX_DELIVERIES_PER_STEP { + return deliveries; + } + + let start = match self.storage_repair_cursor.lock() { + Ok(mut cursor) => { + let start = *cursor % total; + *cursor = (start + STORAGE_REPAIR_MAX_DELIVERIES_PER_STEP) % total; + start + } + Err(_) => { + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + "STABILIZATION storage repair cursor lock failed" + ); + 0 + } + }; + deliveries.rotate_left(start); + deliveries.truncate(STORAGE_REPAIR_MAX_DELIVERIES_PER_STEP); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + total_deliveries = total, + selected_deliveries = deliveries.len(), + start, + "STABILIZATION storage repair delivery window selected" + ); + deliveries + } + + fn storage_repair_defer_reason( + &self, + destination: StorageSyncDestination, + next_hop: Option, + now_ms: i64, + ) -> Result> { + let Some(next_hop) = next_hop else { + return Ok(Some(StorageRepairDeferReason::MissingNextHop)); + }; + if !self.transport.is_admitted_connection(next_hop) { + return Ok(Some(StorageRepairDeferReason::NextHopNotAdmitted)); + } + if let Some(connected_for_ms) = self.peer_connected_for_ms(next_hop, now_ms) { + if connected_for_ms < STORAGE_REPAIR_FRESH_CONNECTION_GRACE_MS { + return Ok(Some(StorageRepairDeferReason::NextHopFresh { + connected_for_ms, + })); + } + } + + if let Some(owner) = self.dht.observed_storage_sync_physical_owner(destination)? { + if !self.transport.is_admitted_connection(owner) { + return Ok(Some(StorageRepairDeferReason::PhysicalOwnerNotAdmitted)); + } + if let Some(connected_for_ms) = self.peer_connected_for_ms(owner, now_ms) { + if connected_for_ms < STORAGE_REPAIR_FRESH_CONNECTION_GRACE_MS { + return Ok(Some(StorageRepairDeferReason::PhysicalOwnerFresh { + connected_for_ms, + })); + } + } + } + + Ok(None) + } + + fn peer_connected_for_ms(&self, peer: Did, now_ms: i64) -> Option { + match self.transport.peer_connected_for_ms(peer, now_ms) { + Ok(age) => age, + Err(error) => { + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %peer, + error = %error, + "STABILIZATION storage repair connection age check failed" + ); + None + } + } + } + /// Republish locally-held entries to their current affine owners. pub async fn repair_storage(&self) -> Result<()> { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + redundancy = self.transport.storage_redundancy(), + "STABILIZATION repair_storage republish start" + ); let action = self .dht .republish_local_entries(self.transport.storage_redundancy()) .await?; - self.handle_storage_repair_action(action).await + let (action_kind, action_count) = match &action { + PeerRingAction::None => ("None", 0), + PeerRingAction::Some(_) => ("Some", 1), + PeerRingAction::SomeEntry(_) => ("SomeEntry", 1), + PeerRingAction::EntryMisses(misses) => ("EntryMisses", misses.len()), + PeerRingAction::RemoteAction(_, _) => ("RemoteAction", 1), + PeerRingAction::MultiActions(actions) => ("MultiActions", actions.len()), + }; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + action_kind, + action_count, + "STABILIZATION repair_storage republish action prepared" + ); + self.handle_storage_repair_action(action).await?; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + "STABILIZATION repair_storage republish complete" + ); + Ok(()) } /// Clean unavailable connections in transport. + /// + /// State relation: + /// - `TopologyPeer(n, p)` iff `p` appears in `n`'s successor list, + /// predecessor slot, or finger table. + /// - `Routable(n, p)` iff `p` has an admitted local transport whose raw + /// connection object is non-terminal. + /// - `Evictable(n, p)` iff `p` has no admitted transport, has no raw + /// connection object, is terminal, stayed disconnected past grace, or has + /// reached the local failure-evidence limit. + /// + /// Post: after this step returns `Ok`, every observed local + /// `TopologyPeer(n, p) ∪ AdmittedPeer(n, p)` that was `Evictable(n, p)` at + /// the step's snapshot time has been removed through `PeerRing::remove`, so + /// successor, predecessor, and finger state are cleaned together. pub async fn clean_unavailable_connections(&self) -> Result<()> { - let conns = self.transport.get_connections(); - - for (did, conn) in conns.into_iter() { - // Only terminal states are cleaned. `Disconnected` is transient: ICE - // can recover from it, so tearing it down here (the stabilizer runs - // every few seconds) would kill connections during a brief blip - // before WebRTC self-heals. This mirrors the swarm callback, which - // also only leaves the DHT on `Failed`/`Closed`. - if matches!( - conn.webrtc_connection_state(), - WebrtcConnectionState::Failed | WebrtcConnectionState::Closed - ) { - tracing::info!("STABILIZATION clean_unavailable_transports: {:?}", did); - let should_repair = self - .dht - .peer_may_share_storage_responsibility(did, self.transport.storage_redundancy()) - .await?; - self.transport.disconnect(did).await?; - if should_repair { - self.repair_storage().await?; + self.transport.expire_pending_connections().await?; + let admitted_states = self.admitted_connection_states(); + let topology_peers = self.dht_topology_peers()?; + let mut candidates = topology_peers; + candidates.extend(self.transport.admitted_connection_ids()); + let now_ms = get_epoch_ms_i64(); + + for did in candidates { + let admitted = self.transport.is_admitted_connection(did); + let transport_state = admitted_states.get(&did).copied(); + if let Some(reason) = self + .topology_peer_removal_reason(did, admitted, transport_state, now_ms) + .await + { + self.remove_unavailable_peer(did, reason).await?; + } + } + + Ok(()) + } + + fn admitted_connection_states(&self) -> BTreeMap { + self.transport + .admitted_connections() + .into_iter() + .map(|(did, conn)| (did, conn.webrtc_connection_state())) + .collect() + } + + fn dht_topology_peers(&self) -> Result> { + let mut peers = BTreeSet::new(); + + for did in self.dht.successors().list()? { + if did != self.dht.did { + peers.insert(did); + } + } + + if let Some(predecessor) = *self.dht.lock_predecessor()? { + if predecessor != self.dht.did { + peers.insert(predecessor); + } + } + + { + let finger = self.dht.lock_finger()?; + for did in finger.list().iter().flatten().copied() { + if did != self.dht.did { + peers.insert(did); + } + } + } + + Ok(peers) + } + + async fn topology_peer_removal_reason( + &self, + did: Did, + admitted: bool, + transport_state: Option, + now_ms: i64, + ) -> Option { + if !admitted { + return Some(TopologyPeerRemovalReason::NoAdmittedTransport); + } + + let Some(state) = transport_state else { + return Some(TopologyPeerRemovalReason::MissingTransportObject); + }; + + if is_terminal_transport_state(state) { + return Some(TopologyPeerRemovalReason::TerminalTransport(state)); + } + + if let Some(measurement) = self.transport.peer_measurement(did).await { + if measurement + .evidence + .reaches_failure_limit(DHT_TOPOLOGY_EVICTION_THRESHOLDS) + { + return Some(TopologyPeerRemovalReason::LocalFailureLimit(measurement)); + } + } + + match self.transport.peer_liveness_expiry(did, now_ms) { + Ok(Some(expiry)) => { + return Some(TopologyPeerRemovalReason::UnansweredLivenessProbe { + unanswered_for_ms: expiry.unanswered_for_ms, + timeout_ms: expiry.timeout_ms, + }); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + error = %error, + "STABILIZATION clean_unavailable liveness state check failed" + ); + } + } + + if matches!(state, WebrtcConnectionState::Disconnected) { + if let Some(disconnected_since_ms) = self.transport.peer_disconnected_since_ms(did) { + let disconnected_for_ms = now_ms.saturating_sub(disconnected_since_ms); + if disconnected_for_ms >= DISCONNECTED_CONNECTION_GRACE_MS { + return Some(TopologyPeerRemovalReason::DisconnectedGraceElapsed { + disconnected_for_ms, + grace_ms: DISCONNECTED_CONNECTION_GRACE_MS, + }); } } } + None + } + + async fn remove_unavailable_peer( + &self, + did: Did, + reason: TopologyPeerRemovalReason, + ) -> Result<()> { + let should_repair = self + .dht + .peer_may_share_storage_responsibility(did, self.transport.storage_redundancy()) + .await?; + tracing::info!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + state = ?reason.transport_state(), + disconnected_for_ms = ?reason.disconnected_for_ms(), + disconnected_grace_ms = ?reason.disconnected_grace_ms(), + liveness_unanswered_for_ms = ?reason.liveness_unanswered_for_ms(), + liveness_timeout_ms = ?reason.liveness_timeout_ms(), + measurement = ?reason.measurement(), + should_repair, + "STABILIZATION clean_unavailable selected peer" + ); + + if reason.should_disconnect_transport() { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable disconnect start" + ); + self.transport.disconnect(did).await?; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable disconnect complete" + ); + } else { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable topology remove start" + ); + self.dht.remove(did)?; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable topology remove complete" + ); + } + + if should_repair { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable repair start" + ); + self.repair_storage().await?; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %did, + reason = reason.as_str(), + "STABILIZATION clean_unavailable repair complete" + ); + } + + Ok(()) + } + + async fn probe_peer_liveness(&self) -> Result<()> { + let now_ms = get_epoch_ms_i64(); + let candidates = self.transport.liveness_probe_candidates(now_ms)?; + for peer in candidates { + let state = self + .transport + .get_connection(peer) + .map(|conn| conn.webrtc_connection_state()); + let msg = Message::PeerLivenessProbe(PeerLivenessProbe { sent_at_ms: now_ms }); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %peer, + state = ?state, + idle_ms = PEER_LIVENESS_IDLE_MS, + "STABILIZATION peer liveness probe send start" + ); + match self.transport.send_direct_message(msg, peer).await { + Ok(tx_id) => { + self.transport + .record_peer_liveness_probe_sent(peer, now_ms)?; + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %peer, + tx_id = %tx_id, + "STABILIZATION peer liveness probe send complete" + ); + } + Err(error) => { + tracing::warn!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + peer = %peer, + state = ?state, + error = ?error, + records_peer_failure = error.records_peer_send_failure(), + "STABILIZATION peer liveness probe send failed" + ); + } + } + } Ok(()) } @@ -129,13 +804,49 @@ impl Stabilizer { let msg = Message::NotifyPredecessorSend(NotifyPredecessorSend { did: self.dht.did }); if self.dht.did != successor_min { for s in successor_list { - tracing::debug!("STABILIZATION notify_predecessor: {:?}", s); let payload = MessagePayload::new_send(msg.clone(), self.transport.session_sk(), s, s)?; - self.transport.send_payload(payload).await?; + let tx_id = payload.transaction.tx_id; + let target_state = self + .transport + .get_connection(s) + .map(|conn| conn.webrtc_connection_state()); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + successor = %s, + tx_id = %tx_id, + target_state = ?target_state, + "STABILIZATION notify_predecessor send start" + ); + if let Err(e) = self.transport.send_payload(payload).await { + tracing::error!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + successor = %s, + tx_id = %tx_id, + target_state = ?target_state, + error = ?e, + "STABILIZATION notify_predecessor send failed" + ); + return Err(e); + } + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + successor = %s, + tx_id = %tx_id, + "STABILIZATION notify_predecessor send complete" + ); } Ok(()) } else { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + successor = %successor_min, + "STABILIZATION notify_predecessor skip local successor" + ); Ok(()) } } @@ -144,7 +855,14 @@ impl Stabilizer { async fn fix_fingers(&self) -> Result<()> { match self.dht.fix_fingers() { Ok(action) => match action { - PeerRingAction::None => Ok(()), + PeerRingAction::None => { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + "STABILIZATION fix_fingers no remote action" + ); + Ok(()) + } PeerRingAction::RemoteAction( closest_predecessor, PeerRingRemoteAction::FindSuccessorForFix { @@ -152,7 +870,6 @@ impl Stabilizer { index, }, ) => { - tracing::debug!("STABILIZATION fix_fingers: {:?}", finger_did); let msg = Message::FindSuccessorSend(FindSuccessorSend { did: finger_did, then: FindSuccessorThen::Report( @@ -166,7 +883,44 @@ impl Stabilizer { closest_predecessor, closest_predecessor, )?; - self.transport.send_payload(payload).await?; + let tx_id = payload.transaction.tx_id; + let next_hop_state = self + .transport + .get_connection(closest_predecessor) + .map(|conn| conn.webrtc_connection_state()); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next_hop = %closest_predecessor, + next_hop_state = ?next_hop_state, + finger_did = %finger_did, + index, + tx_id = %tx_id, + "STABILIZATION fix_fingers send start" + ); + if let Err(e) = self.transport.send_payload(payload).await { + tracing::error!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next_hop = %closest_predecessor, + next_hop_state = ?next_hop_state, + finger_did = %finger_did, + index, + tx_id = %tx_id, + error = ?e, + "STABILIZATION fix_fingers send failed" + ); + return Err(e); + } + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next_hop = %closest_predecessor, + finger_did = %finger_did, + index, + tx_id = %tx_id, + "STABILIZATION fix_fingers send complete" + ); Ok(()) } _ => { @@ -183,76 +937,109 @@ impl Stabilizer { /// Call stabilization from correct chord implementation pub async fn correct_stabilize(&self) -> Result<()> { - if let PeerRingAction::RemoteAction( - next, - PeerRingRemoteAction::QueryForSuccessorListAndPred, - ) = self.dht.pre_stabilize()? - { - self.transport - .send_direct_message( - Message::QueryForTopoInfoSend(QueryForTopoInfoSend::new_for_stab(next)), - next, - ) - .await?; + match self.dht.pre_stabilize()? { + PeerRingAction::RemoteAction( + next, + PeerRingRemoteAction::QueryForSuccessorListAndPred, + ) => { + let next_hop_state = self + .transport + .get_connection(next) + .map(|conn| conn.webrtc_connection_state()); + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next = %next, + next_hop_state = ?next_hop_state, + "STABILIZATION correct_stabilize query start" + ); + match self + .transport + .send_direct_message( + Message::QueryForTopoInfoSend(QueryForTopoInfoSend::new_for_stab(next)), + next, + ) + .await + { + Ok(tx_id) => tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next = %next, + tx_id = %tx_id, + "STABILIZATION correct_stabilize query complete" + ), + Err(e) => { + tracing::error!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + next = %next, + next_hop_state = ?next_hop_state, + error = ?e, + "STABILIZATION correct_stabilize query failed" + ); + return Err(e); + } + } + } + action => { + tracing::debug!( + target: "rings_core::dht::stabilization", + local = %self.dht.did, + action = ?action, + "STABILIZATION correct_stabilize no remote query" + ); + } } Ok(()) } } -#[cfg(not(feature = "wasm"))] +fn elapsed_since_ms(started_at_ms: i64) -> i64 { + get_epoch_ms_i64().saturating_sub(started_at_ms).max(0) +} + mod stabilizer { use std::sync::Arc; use std::time::Duration; - use futures::future::FutureExt; - use futures::pin_mut; - use futures::select; - use futures_timer::Delay; - use super::*; impl Stabilizer { /// Run stabilization in a loop. pub async fn wait(self: Arc, interval: Duration) { + self.wait_with(interval, StopToken::never()).await; + } + + /// Run stabilization until `stop` asks this loop to exit. + /// + /// The token is checked between ticks and before each stabilization run. + /// It intentionally does not cancel an in-flight stabilization future; + /// browser IndexedDB requests are not cancellation-safe. + pub async fn wait_with(self: Arc, interval: Duration, stop: StopToken) { loop { - let timeout = Delay::new(interval).fuse(); - pin_mut!(timeout); - select! { - _ = timeout => self - .stabilize() - .await - .unwrap_or_else(|e| tracing::error!("failed to stabilize {:?}", e)), + if stop.should_stop() { + return; } + if !sleep_until_next_tick_or_stop(interval, &stop).await { + return; + } + self.stabilize() + .await + .unwrap_or_else(|e| tracing::error!("failed to stabilize {:?}", e)); } } } -} - -#[cfg(feature = "wasm")] -mod stabilizer { - use std::sync::Arc; - use std::time::Duration; - use super::*; - use crate::poll; - - impl Stabilizer { - /// Run stabilization in a loop. - pub async fn wait(self: Arc, interval: Duration) { - let millis = i32::try_from(interval.as_millis()).unwrap_or(i32::MAX); - let stabilizer = self; - poll!( - { - let stabilizer = Arc::clone(&stabilizer); - async move { - stabilizer - .stabilize() - .await - .unwrap_or_else(|e| tracing::error!("failed to stabilize {:?}", e)); - } - }, - millis - ); + async fn sleep_until_next_tick_or_stop(interval: Duration, stop: &StopToken) -> bool { + let mut remaining = interval; + while !remaining.is_zero() { + if stop.should_stop() { + return false; + } + let step = std::cmp::min(remaining, STABILIZATION_STOP_POLL_INTERVAL); + sleep(step).await; + remaining = remaining.saturating_sub(step); } + !stop.should_stop() } } diff --git a/crates/core/src/dht/storage/mod.rs b/crates/core/src/dht/storage/mod.rs index 13002660f..9ed86d3f1 100644 --- a/crates/core/src/dht/storage/mod.rs +++ b/crates/core/src/dht/storage/mod.rs @@ -4,6 +4,7 @@ //! storage-specific ownership on top of that topology: affine replica //! placement, storage virtual-node ownership, read repair, and sync hand-off. +use std::collections::BTreeMap; use std::collections::BTreeSet; use serde::Deserialize; @@ -120,6 +121,18 @@ pub(crate) struct StorageSyncDelivery { } impl StorageSyncDelivery { + fn from_parts( + purpose: StorageSyncPurpose, + destination: StorageSyncDestination, + data: Vec, + ) -> Self { + Self { + purpose, + destination, + data, + } + } + fn from_route( purpose: StorageSyncPurpose, target: Did, @@ -184,6 +197,33 @@ impl PeerRingAction { Ok(deliveries) } + /// Lower this action tree into storage-sync deliveries, merging delivery + /// leaves that share the same wire purpose and destination. + /// + /// Safety law: coalescing is restricted to identical `(purpose, + /// destination)` pairs. `PlacementKey` destinations therefore keep their + /// placement identity, and physical-owner batches still let the receiver + /// validate each placement independently before acking. + pub(crate) fn coalesced_storage_sync_deliveries(self) -> Result> { + let mut by_route = + BTreeMap::<(StorageSyncPurpose, StorageSyncDestination), Vec>::new(); + for delivery in self.storage_sync_deliveries()? { + let (purpose, destination, data) = delivery.into_message_parts(); + by_route + .entry((purpose, destination)) + .or_default() + .extend(data); + } + + let mut deliveries = Vec::new(); + for ((purpose, destination), data) in by_route { + for batch in sync::sync_entries_batches(data, sync::SYNC_BATCH_MAX_BYTES)? { + deliveries.push(StorageSyncDelivery::from_parts(purpose, destination, batch)); + } + } + Ok(deliveries) + } + fn collect_storage_sync_deliveries( self, deliveries: &mut Vec, @@ -302,6 +342,43 @@ impl PeerRing { } } + pub(crate) fn observed_storage_sync_physical_owner( + &self, + destination: StorageSyncDestination, + ) -> Result> { + // Pre: `destination` is the wire-level storage sync destination. + // Post: `Some(owner)` names the physical receiver currently observed by + // the local storage-routing model. Placement-key routes without a + // virtual owner do not expose a final physical owner locally, so only + // the next hop can be guarded at this boundary. + match destination { + StorageSyncDestination::PhysicalOwner(owner) => Ok(Some(owner)), + StorageSyncDestination::PlacementKey(key) => self.observed_storage_virtual_owner(key), + } + } + + fn observed_physical_peer_registered(&self, peer: Did) -> Result { + let state = self.topology_state()?; + Ok(peer == state.local + || state.successors.contains(&peer) + || state.predecessor == Some(peer) + || state.fingers.into_iter().flatten().any(|did| did == peer)) + } + + pub(crate) fn storage_sync_route_still_permits( + &self, + destination: StorageSyncDestination, + next_hop: Did, + ) -> Result { + if let StorageSyncDestination::PhysicalOwner(owner) = destination { + if !self.observed_physical_peer_registered(owner)? { + return Ok(false); + } + } + + Ok(self.next_hop_for_storage_sync(destination)? == Some(next_hop)) + } + fn next_hop_to_physical_owner(&self, owner: Did) -> Result> { if owner == self.did { return Ok(None); @@ -327,5 +404,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..6e454080d 100644 --- a/crates/core/src/dht/storage/tests.rs +++ b/crates/core/src/dht/storage/tests.rs @@ -158,10 +158,71 @@ fn placed_entries_by_key(entries: impl IntoIterator) -> BTre .collect() } +#[test] +fn coalesced_storage_sync_deliveries_merge_identical_physical_destinations() -> Result<()> { + let owner = Did::from(50u32); + let first = PlacedEntry::new(Did::from(100u32), data_entry(Did::from(10u32))); + let second = PlacedEntry::new(Did::from(120u32), data_entry(Did::from(20u32))); + let action = PeerRingAction::MultiActions(vec![ + PeerRingAction::sync_entries_for_repair( + StorageSyncDestination::PhysicalOwner(owner), + vec![first.clone()], + ), + PeerRingAction::sync_entries_for_repair( + StorageSyncDestination::PhysicalOwner(owner), + vec![second.clone()], + ), + ]); + + let deliveries = action.coalesced_storage_sync_deliveries()?; + + assert_eq!(deliveries.len(), 1); + let (purpose, destination, data) = deliveries + .into_iter() + .next() + .expect("coalesced delivery") + .into_message_parts(); + assert_eq!(purpose, StorageSyncPurpose::AdditiveRepair); + assert_eq!(destination, StorageSyncDestination::PhysicalOwner(owner)); + assert_eq!(data, vec![first, second]); + Ok(()) +} + +#[test] +fn coalesced_storage_sync_deliveries_keep_placement_destinations_separate() -> Result<()> { + let first_key = Did::from(100u32); + let second_key = Did::from(120u32); + let first = PlacedEntry::new(first_key, data_entry(Did::from(10u32))); + let second = PlacedEntry::new(second_key, data_entry(Did::from(20u32))); + let action = PeerRingAction::MultiActions(vec![ + PeerRingAction::sync_entries_for_repair( + StorageSyncDestination::PlacementKey(first_key), + vec![first], + ), + PeerRingAction::sync_entries_for_repair( + StorageSyncDestination::PlacementKey(second_key), + vec![second], + ), + ]); + + let deliveries = action.coalesced_storage_sync_deliveries()?; + + assert_eq!(deliveries.len(), 2); + let destinations = deliveries + .into_iter() + .map(|delivery| delivery.into_message_parts().1) + .collect::>(); + assert_eq!(destinations, vec![ + StorageSyncDestination::PlacementKey(first_key), + StorageSyncDestination::PlacementKey(second_key), + ]); + Ok(()) +} + 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. @@ -365,6 +426,55 @@ async fn virtual_storage_sync_copies_entries_to_observed_virtual_owner() -> Resu Ok(()) } +#[test] +fn virtual_storage_sync_route_is_cancelled_when_owner_leaves_topology() -> Result<()> { + let local = Did::from(1u32); + let remote = Did::from(2u32); + let node = PeerRing::new_with_storage_finger_table_size_and_virtual_nodes( + local, + 3, + Box::new(MemStorage::new()), + 8, + VirtualNodeConfig::new(7, 2), + ); + let _ = node.join(remote)?; + let destination = StorageSyncDestination::PhysicalOwner(remote); + + assert!(node.storage_sync_route_still_permits(destination, remote)?); + + node.remove(remote)?; + + assert!(!node.storage_sync_route_still_permits(destination, remote)?); + Ok(()) +} + +#[test] +fn placement_key_destination_exposes_observed_virtual_owner() -> Result<()> { + let local = Did::from(1u32); + let remote = Did::from(2u32); + let node = PeerRing::new_with_storage_finger_table_size_and_virtual_nodes( + local, + 3, + Box::new(MemStorage::new()), + 8, + VirtualNodeConfig::new(7, 2), + ); + let _ = node.join(remote)?; + let placement = first_virtual_position(&node, remote)?; + + assert_eq!( + node.observed_storage_sync_physical_owner(StorageSyncDestination::PlacementKey(placement))?, + Some(remote) + ); + + node.remove(remote)?; + + let owner_after_removal = + node.observed_storage_sync_physical_owner(StorageSyncDestination::PlacementKey(placement))?; + assert_ne!(owner_after_removal, Some(remote)); + Ok(()) +} + #[tokio::test] async fn sync_without_ack_retains_entry_for_next_handoff() -> Result<()> { let node_did = Did::from(0u32); 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..b120ab546 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -268,6 +268,21 @@ 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, + }, + + /// Pending WebRTC connection generation id space is exhausted. + #[error("Pending WebRTC connection generation is exhausted")] + PendingConnectionGenerationExhausted, + + /// 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 +384,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 +393,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 +417,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 +435,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 +522,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 +531,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 +576,37 @@ pub enum Error { #[error("Peer's negotiated max_message_size {0} is too small to carry even one chunk")] PeerMaxMessageSizeTooSmall(usize), - #[cfg(feature = "wasm")] + /// Timed out while waiting for the data-channel send queue to accept bytes + #[error( + "Timed out after {timeout_ms}ms waiting for data-channel send queue to accept {bytes} bytes for {peer} during {context}" + )] + DataChannelSendQueueTimeout { + /// Peer whose data-channel send queue did not accept the bytes. + peer: crate::dht::Did, + /// Timeout budget in milliseconds. + timeout_ms: u128, + /// Serialized bytes that were waiting to be accepted. + bytes: usize, + /// Send context used for diagnostics. + context: &'static str, + }, + + #[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), @@ -598,16 +628,28 @@ impl Error { pub(crate) fn unexpected_peer_ring_action(action: crate::dht::PeerRingAction) -> Self { Self::PeerRingUnexpectedAction(Box::new(action)) } + + /// True when a send failed because the local data-channel write queue did not accept bytes + /// before the bounded admission timeout. This is a local backpressure signal, not evidence + /// that the remote peer is unreachable or malicious. + pub(crate) const fn is_data_channel_backpressure(&self) -> bool { + matches!(self, Self::DataChannelSendQueueTimeout { .. }) + } + + /// Whether this error should degrade peer quality through `FailedToSend`. + pub(crate) const fn records_peer_send_failure(&self) -> bool { + !self.is_data_channel_backpressure() + } } -#[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/lib.rs b/crates/core/src/lib.rs index 31d74f2b2..9c42f618e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -74,6 +74,7 @@ pub mod algebra; pub mod dht; pub mod ecc; pub mod error; +pub mod lifecycle; pub mod macros; pub mod message; pub mod prelude; diff --git a/crates/core/src/lifecycle.rs b/crates/core/src/lifecycle.rs new file mode 100644 index 000000000..5b5db7116 --- /dev/null +++ b/crates/core/src/lifecycle.rs @@ -0,0 +1,104 @@ +//! Cooperative lifecycle primitives shared by native and browser runtimes. +//! +//! A [`StopSource`] is the authority that may request shutdown. A [`StopToken`] +//! is the read-only capability handed to long-running loops. The model is +//! intentionally monotonic: once a source requests stop, every token cloned from +//! that source observes stop forever. + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +/// Authority that can request cooperative shutdown for one lifecycle scope. +#[derive(Clone, Debug, Default)] +pub struct StopSource { + requested: Arc, +} + +impl StopSource { + /// Create a fresh lifecycle source in the running state. + pub fn new() -> Self { + Self::default() + } + + /// Create a read-only token linked to this source. + pub fn token(&self) -> StopToken { + StopToken { + requested: self.requested.clone(), + } + } + + /// Request shutdown for every token linked to this source. + /// + /// This operation is idempotent and monotonic; there is no resume state. + pub fn request_stop(&self) { + self.requested.store(true, Ordering::Release); + } + + /// Return whether this source has requested shutdown. + pub fn is_stop_requested(&self) -> bool { + self.requested.load(Ordering::Acquire) + } +} + +/// Read-only cooperative shutdown capability for long-running loops. +#[derive(Clone, Debug, Default)] +pub struct StopToken { + requested: Arc, +} + +impl StopToken { + /// Create a token that is never stopped by an external source. + pub fn never() -> Self { + Self::default() + } + + /// Return whether the owner has requested cooperative shutdown. + pub fn should_stop(&self) -> bool { + self.requested.load(Ordering::Acquire) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stop_source_propagates_stop_to_existing_and_cloned_tokens() { + let source = StopSource::new(); + let first = source.token(); + let second = first.clone(); + + assert!(!first.should_stop()); + assert!(!second.should_stop()); + assert!(!source.is_stop_requested()); + + source.request_stop(); + + assert!(first.should_stop()); + assert!(second.should_stop()); + assert!(source.is_stop_requested()); + } + + #[test] + fn cloned_stop_source_controls_the_same_lifecycle_scope() { + let source = StopSource::new(); + let cloned_source = source.clone(); + let token = source.token(); + + cloned_source.request_stop(); + + assert!(token.should_stop()); + assert!(source.is_stop_requested()); + } + + #[test] + fn never_token_is_independent_from_other_sources() { + let source = StopSource::new(); + let token = StopToken::never(); + + source.request_stop(); + + assert!(!token.should_stop()); + } +} 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..01e0f33d5 100644 --- a/crates/core/src/message/handlers/connection.rs +++ b/crates/core/src/message/handlers/connection.rs @@ -1,7 +1,11 @@ use async_trait::async_trait; +use crate::dht::successor::SuccessorReader; +use crate::dht::topology; use crate::dht::types::Chord; use crate::dht::types::CorrectChord; +use crate::dht::Did; +use crate::dht::PeerRing; use crate::dht::PeerRingAction; use crate::dht::TopoInfo; use crate::error::Error; @@ -12,6 +16,8 @@ use crate::message::types::ConnectNodeSend; use crate::message::types::FindSuccessorReport; use crate::message::types::FindSuccessorSend; use crate::message::types::Message; +use crate::message::types::PeerLivenessProbe; +use crate::message::types::PeerLivenessReport; use crate::message::types::QueryForTopoInfoReport; use crate::message::types::QueryForTopoInfoSend; use crate::message::types::Then; @@ -21,9 +27,69 @@ 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() +} + +fn connect_successor_hint(dht: &PeerRing, requester: Did, reported: Did) -> Result { + if reported != requester { + return Ok(reported); + } + + let mut candidates = dht.successors().list()?; + candidates.push(dht.did); + candidates.retain(|candidate| *candidate != requester); + + Ok(topology::successors(&candidates, requester, 1) + .into_iter() + .next() + .unwrap_or(reported)) +} + +/// PeerLivenessProbe is a direct overlay liveness probe. +#[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: &PeerLivenessProbe) -> Result<()> { + if ctx.should_forward_from(self.dht.did) { + return self + .run_effects([PayloadRelayFunctor::forward_payload(ctx, None).into()]) + .await; + } + + self.run_effects([PayloadRelayFunctor::send_report_message( + ctx, + Message::PeerLivenessReport(msg.resp()), + ) + .into()]) + .await + } +} + +/// PeerLivenessReport is handled by the callback's verified-inbound liveness update. +#[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: &PeerLivenessReport) -> Result<()> { + Ok(()) + } +} + /// 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,55 +106,108 @@ 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 { ::Then::SyncSuccessor => { - for peer in msg.info.successors.iter() { - if self.transport.get_connection(*peer).is_some() { - self.join_dht(*peer).await?; + let successors = msg.info.successors.clone(); + self.connect_dht_peers(successors.iter().copied()).await?; + for peer in successors { + if self.transport.get_connection(peer).is_some() { + self.join_dht(peer).await?; } } } ::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) { + tracing::warn!( + local = %self.dht.did, + tx_id = %ctx.transaction.tx_id, + origin = ?ctx.relay.try_origin_sender().ok(), + relay_destination = %ctx.relay.destination, + transaction_destination = %ctx.transaction.destination, + mode = ?msg.dht_protocol_mode(), + "CONNECT_NODE offer rejected by DHT protocol mismatch" + ); return Ok(()); } if ctx.should_forward_from(self.dht.did) { + tracing::info!( + local = %self.dht.did, + tx_id = %ctx.transaction.tx_id, + origin = ?ctx.relay.try_origin_sender().ok(), + next_hop = %ctx.relay.next_hop, + relay_destination = %ctx.relay.destination, + transaction_destination = %ctx.transaction.destination, + sdp_bytes = msg.sdp.len(), + "CONNECT_NODE offer forward" + ); self.run_effects([PayloadRelayFunctor::forward_payload(ctx, None).into()]) .await } else { - let answer = self + let peer = ctx.relay.try_origin_sender()?; + tracing::info!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + sdp_bytes = msg.sdp.len(), + "CONNECT_NODE offer answer start" + ); + let answer = match self .transport - .answer_remote_connection( - ctx.relay.try_origin_sender()?, - self.inner_callback(), - msg, - ) - .await?; + .answer_remote_connection(peer, self.inner_callback(), msg) + .await + { + Ok(answer) => { + tracing::info!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + sdp_bytes = answer.sdp.len(), + "CONNECT_NODE offer answer complete" + ); + answer + } + Err(error) => { + tracing::warn!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + error = ?error, + "CONNECT_NODE offer answer failed" + ); + return Err(error); + } + }; self.run_effects([PayloadRelayFunctor::send_report_message( ctx, Message::ConnectNodeReport(answer), @@ -99,23 +218,59 @@ 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) { + tracing::info!( + local = %self.dht.did, + tx_id = %ctx.transaction.tx_id, + origin = ?ctx.relay.try_origin_sender().ok(), + next_hop = %ctx.relay.next_hop, + relay_destination = %ctx.relay.destination, + transaction_destination = %ctx.transaction.destination, + sdp_bytes = msg.sdp.len(), + "CONNECT_NODE answer forward" + ); self.run_effects([PayloadRelayFunctor::forward_payload(ctx, None).into()]) .await } else { - self.transport - .accept_remote_connection(ctx.relay.try_origin_sender()?, msg) - .await + let peer = ctx.relay.try_origin_sender()?; + tracing::info!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + sdp_bytes = msg.sdp.len(), + "CONNECT_NODE answer accept start" + ); + match self.transport.accept_remote_connection(peer, msg).await { + Ok(()) => { + tracing::info!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + "CONNECT_NODE answer accept complete" + ); + Ok(()) + } + Err(error) => { + tracing::warn!( + local = %self.dht.did, + peer = %peer, + tx_id = %ctx.transaction.tx_id, + error = ?error, + "CONNECT_NODE answer accept failed" + ); + Err(error) + } + } } } } -#[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)? { @@ -123,6 +278,14 @@ impl HandleMsg for MessageHandler { if msg.accepts_local_successor(self.dht.did) { match &msg.then { FindSuccessorThen::Report(handler) => { + let did = match handler { + FindSuccessorReportHandler::Connect => connect_successor_hint( + self.dht.as_ref(), + ctx.relay.try_origin_sender()?, + did, + )?, + _ => did, + }; self.run_effects([PayloadRelayFunctor::send_report_message( ctx, Message::FindSuccessorReport(FindSuccessorReport { @@ -148,8 +311,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,9 +323,20 @@ 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?; + if self.transport.get_connection(msg.did).is_some() { + self.dht.apply_fixed_finger(*index, msg.did)?; + } else if let Some(attempt) = self.transport.pending_attempt(msg.did)? { + self.transport + .queue_pending_finger_update(attempt, *index)?; + } else if self.transport.get_connection(msg.did).is_some() { + // `pending_attempt` synchronizes with admission; the peer may become active + // while that call waits on the lifecycle lock. + self.dht.apply_fixed_finger(*index, msg.did)?; + } } } FindSuccessorReportHandler::Connect if msg.reports_remote_successor(self.dht.did) => { @@ -175,10 +349,11 @@ impl HandleMsg for MessageHandler { } } -#[cfg(not(feature = "wasm"))] +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] #[cfg(test)] pub mod tests { //! tests + use rings_transport::core::transport::WebrtcConnectionState; use tokio::time::sleep; use tokio::time::Duration; @@ -187,11 +362,90 @@ pub mod tests { use crate::ecc::tests::gen_ordered_keys; use crate::ecc::SecretKey; use crate::tests::default::assert_no_more_msg; + use crate::tests::default::gen_pure_dht; use crate::tests::default::prepare_node; + use crate::tests::default::wait_for_connection_state; use crate::tests::default::wait_for_msgs; + use crate::tests::default::wait_for_successor; 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)); + } + + #[test] + fn connect_successor_hint_skips_requester_self_report() -> Result<()> { + let keys = gen_ordered_keys(4); + let local = keys[0].address().into(); + let requester = keys[1].address().into(); + let next = keys[2].address().into(); + let tail = keys[3].address().into(); + let dht = gen_pure_dht(local); + + dht.join(next)?; + dht.join(tail)?; + dht.join(requester)?; + + assert_eq!(dht.successors().list()?, vec![requester, next, tail]); + assert_eq!(connect_successor_hint(&dht, requester, requester)?, next); + Ok(()) + } + + #[tokio::test] + async fn sync_successor_report_connects_advertised_successor() -> Result<()> { + let keys = gen_ordered_keys(3); + let node1 = prepare_node(keys[0]).await; + let node2 = prepare_node(keys[1]).await; + let node3 = prepare_node(keys[2]).await; + + manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_msgs([&node1, &node2, &node3]).await; + manually_establish_connection(&node2.swarm, &node3.swarm).await; + wait_for_msgs([&node1, &node2, &node3]).await; + + if node1.swarm.transport.get_connection(node3.did()).is_some() { + node1.swarm.disconnect(node3.did()).await?; + wait_for_msgs([&node1, &node2, &node3]).await; + } + assert!(node1.swarm.transport.get_connection(node3.did()).is_none()); + assert!(!node1.dht().successors().contains(&node3.did())?); + + node2 + .swarm + .send_direct_message( + Message::QueryForTopoInfoReport(QueryForTopoInfoReport { + info: TopoInfo { + successors: vec![node3.did()], + predecessor: None, + }, + then: ::Then::SyncSuccessor, + }), + node1.did(), + ) + .await?; + + wait_for_connection_state(&node1, node3.did(), WebrtcConnectionState::Connected).await?; + wait_for_successor(&node1, node3.did()).await?; + wait_for_msgs([&node1, &node2, &node3]).await; + assert_no_more_msg([&node1, &node2, &node3]).await; + Ok(()) + } + // node1.key < node2.key < node3.key // // Firstly, we connect node1 to node2, node2 to node3. @@ -337,26 +591,41 @@ pub mod tests { assert_no_more_msg([&node1, &node2, &node3]).await; println!("=== Check state before connect via DHT ==="); - node1.assert_transports(vec![node2.did()]); - node2.assert_transports(vec![node1.did(), node3.did()]); - node3.assert_transports(vec![node2.did()]); - assert_eq!(node1.dht().successors().list()?, vec![node2.did(),]); - assert_eq!(node2.dht().successors().list()?, vec![ - node3.did(), - node1.did() - ]); - assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + if node1.swarm.transport.get_connection(node3.did()).is_some() { + node1.assert_transports(vec![node2.did(), node3.did()]); + node2.assert_transports(vec![node1.did(), node3.did()]); + node3.assert_transports(vec![node1.did(), node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![ + node2.did(), + node3.did() + ]); + assert_eq!(node2.dht().successors().list()?, vec![ + node3.did(), + node1.did() + ]); + assert_eq!(node3.dht().successors().list()?, vec![ + node1.did(), + node2.did() + ]); + } else { + node1.assert_transports(vec![node2.did()]); + node2.assert_transports(vec![node1.did(), node3.did()]); + node3.assert_transports(vec![node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![node2.did(),]); + assert_eq!(node2.dht().successors().list()?, vec![ + node3.did(), + node1.did() + ]); + assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + } println!("============================================="); println!("|| now we connect node1 to node3 via DHT ||"); println!("============================================="); - // check node1 and node3 is not connected to each other - assert!(node1.swarm.transport.get_connection(node3.did()).is_none()); - // node1's successor should be node2 now - assert_eq!(node1.dht().successors().max()?, node2.did()); - - node1.swarm.connect(node3.did()).await?; + if node1.swarm.transport.get_connection(node3.did()).is_none() { + node1.swarm.connect(node3.did()).await?; + } wait_for_msgs([&node1, &node2, &node3]).await; assert_no_more_msg([&node1, &node2, &node3]).await; @@ -410,26 +679,41 @@ pub mod tests { assert_no_more_msg([&node1, &node2, &node3]).await; println!("=== Check state before connect via DHT ==="); - node1.assert_transports(vec![node2.did()]); - node2.assert_transports(vec![node1.did(), node3.did()]); - node3.assert_transports(vec![node2.did()]); - assert_eq!(node1.dht().successors().list()?, vec![node2.did()]); - assert_eq!(node2.dht().successors().list()?, vec![ - node1.did(), - node3.did() - ]); - assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + if node1.swarm.transport.get_connection(node3.did()).is_some() { + node1.assert_transports(vec![node2.did(), node3.did()]); + node2.assert_transports(vec![node1.did(), node3.did()]); + node3.assert_transports(vec![node1.did(), node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![ + node3.did(), + node2.did() + ]); + assert_eq!(node2.dht().successors().list()?, vec![ + node1.did(), + node3.did() + ]); + assert_eq!(node3.dht().successors().list()?, vec![ + node2.did(), + node1.did() + ]); + } else { + node1.assert_transports(vec![node2.did()]); + node2.assert_transports(vec![node1.did(), node3.did()]); + node3.assert_transports(vec![node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![node2.did()]); + assert_eq!(node2.dht().successors().list()?, vec![ + node1.did(), + node3.did() + ]); + assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + } println!("============================================="); println!("|| now we connect node1 to node3 via DHT ||"); println!("============================================="); - // check node1 and node3 is not connected to each other - assert!(node1.swarm.transport.get_connection(node3.did()).is_none()); - // node1's successor should be node2 now - assert_eq!(node1.dht().successors().max()?, node2.did()); - - node1.swarm.connect(node3.did()).await?; + if node1.swarm.transport.get_connection(node3.did()).is_none() { + node1.swarm.connect(node3.did()).await?; + } wait_for_msgs([&node1, &node2, &node3]).await; assert_no_more_msg([&node1, &node2, &node3]).await; @@ -476,23 +760,28 @@ pub mod tests { // Poll for convergence rather than sleeping a fixed amount: under the // release-LTO CI run with native WebRTC, 6s is not always enough and the // assertions below would flake. The expected final state is unchanged. - wait_until("node4 joined: DHT successors converged", || { - Ok( - node1.dht().successors().list()? == vec![node2.did(), node3.did(), node4.did()] - && node2.dht().successors().list()? - == vec![node3.did(), node4.did(), node1.did()] - && node3.dht().successors().list()? == vec![node1.did(), node2.did()] - && node4.dht().successors().list()? == vec![node1.did(), node2.did()], - ) - }) + wait_until_with_state( + "node4 joined: DHT successors converged", + || { + Ok( + node1.dht().successors().list()? == vec![node2.did(), node3.did(), node4.did()] + && node2.dht().successors().list()? + == vec![node3.did(), node4.did(), node1.did()] + && node3.dht().successors().list()? + == vec![node4.did(), node1.did(), node2.did()] + && node4.dht().successors().list()? + == vec![node1.did(), node2.did(), node3.did()], + ) + }, + || describe_nodes([&node1, &node2, &node3, &node4]), + ) .await?; println!("=== Check state before connect via DHT ==="); node1.assert_transports(vec![node2.did(), node3.did(), node4.did()]); node2.assert_transports(vec![node3.did(), node4.did(), node1.did()]); - node3.assert_transports(vec![node1.did(), node2.did()]); - // node4 will connect node1 after connecting node2, because node2 notified node4 that node1 is its predecessor. - node4.assert_transports(vec![node1.did(), node2.did()]); + node3.assert_transports(vec![node4.did(), node1.did(), node2.did()]); + node4.assert_transports(vec![node1.did(), node2.did(), node3.did()]); assert_eq!(node1.dht().successors().list()?, vec![ node2.did(), node3.did(), @@ -504,12 +793,14 @@ pub mod tests { node1.did(), ]); assert_eq!(node3.dht().successors().list()?, vec![ + node4.did(), node1.did(), node2.did(), ]); assert_eq!(node4.dht().successors().list()?, vec![ node1.did(), node2.did(), + node3.did(), ]); println!("========================================"); @@ -524,20 +815,26 @@ pub mod tests { ); println!("=================================================="); - node4.swarm.connect(node3.did()).await?; + if node4.swarm.transport.get_connection(node3.did()).is_none() { + node4.swarm.connect(node3.did()).await?; + } // Same as above: poll for the post-connect converged state instead of a // fixed 6s sleep so the test is robust under CI contention. - wait_until("node4 connected node3: DHT successors converged", || { - Ok( - node1.dht().successors().list()? == vec![node2.did(), node3.did(), node4.did()] - && node2.dht().successors().list()? - == vec![node3.did(), node4.did(), node1.did()] - && node3.dht().successors().list()? - == vec![node4.did(), node1.did(), node2.did()] - && node4.dht().successors().list()? - == vec![node1.did(), node2.did(), node3.did()], - ) - }) + wait_until_with_state( + "node4 connected node3: DHT successors converged", + || { + Ok( + node1.dht().successors().list()? == vec![node2.did(), node3.did(), node4.did()] + && node2.dht().successors().list()? + == vec![node3.did(), node4.did(), node1.did()] + && node3.dht().successors().list()? + == vec![node4.did(), node1.did(), node2.did()] + && node4.dht().successors().list()? + == vec![node1.did(), node2.did(), node3.did()], + ) + }, + || describe_nodes([&node1, &node2, &node3, &node4]), + ) .await?; println!("=== Check state after connect via DHT ==="); @@ -569,6 +866,45 @@ pub mod tests { Ok(()) } + #[cfg(feature = "dummy")] + #[tokio::test] + async fn joining_between_bootstrap_and_successor_connects_successor_hint() -> Result<()> { + let keys = gen_ordered_keys(4); + let (node1, node2, node3) = + test_triple_ordered_nodes_connection(keys[0], keys[2], keys[3]).await?; + let joining = prepare_node(keys[1]).await; + + manually_establish_connection(&joining.swarm, &node1.swarm).await; + wait_until( + "joining peer connects past bootstrap successor self-report", + || { + Ok(joining + .swarm + .transport + .get_connection(node2.did()) + .is_some()) + }, + ) + .await?; + + wait_for_msgs([&node1, &node2, &node3, &joining]).await; + assert_no_more_msg([&node1, &node2, &node3, &joining]).await; + + joining.assert_transports(vec![node1.did(), node2.did(), node3.did()]); + assert_eq!(node1.dht().successors().list()?, vec![ + joining.did(), + node2.did(), + node3.did(), + ]); + assert_eq!(joining.dht().successors().list()?, vec![ + node2.did(), + node3.did(), + node1.did(), + ]); + + Ok(()) + } + /// Poll `cond` every 200ms until it returns true, failing after ~60s. /// Used instead of fixed sleeps so the test is deterministic regardless of /// how long the WebRTC handshake/teardown takes on a given machine. @@ -577,13 +913,43 @@ pub mod tests { /// ~200ms each, so on a host with many network interfaces (lots of /// candidate pairs) establishing the connection can legitimately take ~20s. async fn wait_until(msg: &str, mut cond: impl FnMut() -> Result) -> Result<()> { + wait_until_with_state(msg, &mut cond, String::new).await + } + + async fn wait_until_with_state( + msg: &str, + mut cond: impl FnMut() -> Result, + state: impl Fn() -> String, + ) -> Result<()> { for _ in 0..300 { if cond()? { return Ok(()); } sleep(Duration::from_millis(200)).await; } - Err(Error::InvalidMessage(format!("timeout waiting for: {msg}"))) + let state = state(); + if state.is_empty() { + Err(Error::InvalidMessage(format!("timeout waiting for: {msg}"))) + } else { + Err(Error::InvalidMessage(format!( + "timeout waiting for: {msg}\n{state}" + ))) + } + } + + fn describe_nodes<'a>(nodes: impl IntoIterator) -> String { + nodes + .into_iter() + .map(|node| { + format!( + "{:?}: successors={:?}, transports={:?}", + node.did(), + node.dht().successors().list().unwrap_or_default(), + node.swarm.transport.get_connection_ids(), + ) + }) + .collect::>() + .join("\n") } #[tokio::test] @@ -631,12 +997,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..d3e799f26 100644 --- a/crates/core/src/message/handlers/mod.rs +++ b/crates/core/src/message/handlers/mod.rs @@ -14,6 +14,7 @@ use super::MessagePayload; use crate::dht::ChordStorageRepair; use crate::dht::CorrectChord; use crate::dht::Did; +use crate::dht::LiveDid; use crate::dht::PeerRing; use crate::dht::PeerRingAction; use crate::error::Error; @@ -46,9 +47,29 @@ pub struct MessageHandler { swarm_callback: SharedSwarmCallback, } +#[derive(Clone)] +struct AdmittedPeer { + transport: Arc, + did: Did, +} + +impl From for Did { + fn from(peer: AdmittedPeer) -> Self { + peer.did + } +} + +#[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 AdmittedPeer { + async fn live(&self) -> bool { + self.transport.get_connection(self.did).is_some() + } +} + /// 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<()>; @@ -102,11 +123,16 @@ impl MessageHandler { pub(crate) async fn join_dht(&self, peer: Did) -> Result<()> { // Default HMCC/Zave join path: maps to the JoinThenSync operation in // the CorrectChord spec (see tests/default/test_dht_convergence.rs). - let conn = self - .transport - .get_connection(peer) - .ok_or(Error::SwarmMissDidInTable(peer))?; - let dht_ev = self.dht.join_then_sync(conn).await?; + if self.transport.get_connection(peer).is_none() { + return Err(Error::SwarmMissDidInTable(peer)); + } + let dht_ev = self + .dht + .join_then_sync(AdmittedPeer { + transport: Arc::clone(&self.transport), + did: peer, + }) + .await?; // The local join has completed. Follow-up convergence messages are // best-effort: a peer can churn before these sends complete, and that // must not suppress the application-level Connected event. @@ -117,26 +143,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 +217,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..3857d9f73 100644 --- a/crates/core/src/message/handlers/stabilization.rs +++ b/crates/core/src/message/handlers/stabilization.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::dht::Chord; use crate::dht::ChordStorageSync; +use crate::error::Error; use crate::error::Result; use crate::message::effects::ConnectionFunctor; use crate::message::effects::PayloadRelayFunctor; @@ -14,13 +15,20 @@ 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)?; + if ctx.should_forward_from(self.dht.did) { + return self + .run_effects([PayloadRelayFunctor::forward_payload(ctx, None).into()]) + .await; + } - if predecessor != ctx.relay.try_origin_sender()? { + let origin = self.admitted_notify_predecessor_origin(ctx, msg)?; + let predecessor = self.dht.notify(origin)?; + + if predecessor != origin { return self .run_effects([PayloadRelayFunctor::send_report_message( ctx, @@ -34,8 +42,30 @@ impl HandleMsg for MessageHandler { } } -#[cfg_attr(feature = "wasm", async_trait(?Send))] -#[cfg_attr(not(feature = "wasm"), async_trait)] +impl MessageHandler { + fn admitted_notify_predecessor_origin( + &self, + ctx: &MessagePayload, + msg: &NotifyPredecessorSend, + ) -> Result { + let origin = ctx.relay.try_origin_sender()?; + if msg.did != origin { + return Err(Error::InvalidMessage(format!( + "notify predecessor DID {} does not match relay origin {}", + msg.did, origin + ))); + } + if !self.transport.is_admitted_connection(origin) { + return Err(Error::InvalidMessage(format!( + "notify predecessor origin {origin} is not an admitted connection" + ))); + } + Ok(origin) + } +} + +#[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()]) @@ -45,7 +75,7 @@ impl HandleMsg for MessageHandler { .dht .sync_entries_with_successor(msg.did) .await? - .storage_sync_deliveries()?; + .coalesced_storage_sync_deliveries()?; let effects = deliveries.into_iter().map(|delivery| { let msg = SyncEntriesWithSuccessor::from_delivery(delivery); StorageSyncFunctor::send_storage_sync(msg).into() @@ -56,7 +86,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; @@ -90,11 +120,64 @@ mod test { impl SwarmCallback for NoopCallback {} + fn notify_context(origin: &SecretKey, destination: crate::dht::Did) -> Result { + let session = SessionSk::new_with_seckey(origin)?; + MessagePayload::new_send( + Message::custom(b"notify predecessor context")?, + &session, + destination, + destination, + ) + } + fn next_generated_key(keys: &mut impl Iterator) -> Result { keys.next() .ok_or_else(|| Error::InvalidMessage("expected generated key".to_string())) } + #[tokio::test] + async fn notify_predecessor_rejects_origin_mismatch_without_mutating_topology() -> Result<()> { + let node = prepare_node(SecretKey::random()).await; + let origin = SecretKey::random(); + let spoofed = SecretKey::random().address().into(); + let context = notify_context(&origin, node.did())?; + let handler = MessageHandler::new(node.swarm.transport.clone(), Arc::new(NoopCallback)); + + let result = handler + .handle(&context, &NotifyPredecessorSend { did: spoofed }) + .await; + + assert!(matches!( + result, + Err(Error::InvalidMessage(message)) + if message.contains("does not match relay origin") + )); + assert_eq!(*node.dht().lock_predecessor()?, None); + Ok(()) + } + + #[tokio::test] + async fn notify_predecessor_rejects_unadmitted_origin_without_mutating_topology() -> Result<()> + { + let node = prepare_node(SecretKey::random()).await; + let origin = SecretKey::random(); + let origin_did = origin.address().into(); + let context = notify_context(&origin, node.did())?; + let handler = MessageHandler::new(node.swarm.transport.clone(), Arc::new(NoopCallback)); + + let result = handler + .handle(&context, &NotifyPredecessorSend { did: origin_did }) + .await; + + assert!(matches!( + result, + Err(Error::InvalidMessage(message)) + if message.contains("not an admitted connection") + )); + assert_eq!(*node.dht().lock_predecessor()?, None); + Ok(()) + } + #[tokio::test] async fn test_triple_nodes_stabilization_1_2_3() -> Result<()> { let keys = gen_ordered_keys(3); @@ -282,12 +365,18 @@ mod test { assert_no_more_msg([&node1, &node2, &node3]).await; println!("=== Check state before stabilization ==="); - assert_eq!(node1.dht().successors().list()?, vec![node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![ + node2.did(), + node3.did() + ]); assert_eq!(node2.dht().successors().list()?, vec![ node3.did(), node1.did() ]); - assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + assert_eq!(node3.dht().successors().list()?, vec![ + node1.did(), + node2.did() + ]); assert!(node1.dht().lock_predecessor()?.is_none()); assert!(node2.dht().lock_predecessor()?.is_none()); assert!(node3.dht().lock_predecessor()?.is_none()); @@ -371,12 +460,18 @@ mod test { assert_no_more_msg([&node1, &node2, &node3]).await; println!("=== Check state before stabilization ==="); - assert_eq!(node1.dht().successors().list()?, vec![node2.did()]); + assert_eq!(node1.dht().successors().list()?, vec![ + node3.did(), + node2.did() + ]); assert_eq!(node2.dht().successors().list()?, vec![ node1.did(), node3.did() ]); - assert_eq!(node3.dht().successors().list()?, vec![node2.did()]); + assert_eq!(node3.dht().successors().list()?, vec![ + node2.did(), + node1.did() + ]); assert!(node1.dht().lock_predecessor()?.is_none()); assert!(node2.dht().lock_predecessor()?.is_none()); assert!(node3.dht().lock_predecessor()?.is_none()); diff --git a/crates/core/src/message/handlers/storage.rs b/crates/core/src/message/handlers/storage.rs index 33fcfb8df..881e11f31 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, @@ -208,7 +208,7 @@ async fn run_storage_repair_transport_effects( transport: Arc, act: PeerRingAction, ) -> Result<()> { - for delivery in act.storage_sync_deliveries()? { + for delivery in act.coalesced_storage_sync_deliveries()? { let msg = SyncEntriesWithSuccessor::from_delivery(delivery); transport.send_storage_sync(msg).await?; } @@ -221,7 +221,7 @@ async fn run_storage_repair_transport_effects( /// exactly; the transport interpreter chooses the storage route and records the /// ack capability at the effect boundary. pub(super) fn storage_sync_effects(act: PeerRingAction) -> Result>> { - act.storage_sync_deliveries()? + act.coalesced_storage_sync_deliveries()? .into_iter() .map(|delivery| { let msg = SyncEntriesWithSuccessor::from_delivery(delivery); @@ -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/storage/tests/test_sync_ack.rs b/crates/core/src/message/handlers/storage/tests/test_sync_ack.rs index 86308bdeb..3d666c75a 100644 --- a/crates/core/src/message/handlers/storage/tests/test_sync_ack.rs +++ b/crates/core/src/message/handlers/storage/tests/test_sync_ack.rs @@ -222,6 +222,28 @@ async fn additive_repair_sync_cannot_create_pending_cleanup_capability() -> Resu Ok(()) } +#[tokio::test] +async fn send_storage_sync_applies_local_destination_without_transport_send() -> Result<()> { + let node = prepare_node(SecretKey::random()).await; + let entry = Entry::new(Did::from(100u32), vec![], EntryKind::Data); + let placement_key = entry.did; + let stored_entry = entry.clone().try_into_storage_entry()?; + let sync_msg = SyncEntriesWithSuccessor { + purpose: StorageSyncPurpose::AdditiveRepair, + destination: StorageSyncDestination::PhysicalOwner(node.did()), + data: vec![PlacedEntry::new(placement_key, entry)], + }; + + node.swarm.transport.send_storage_sync(sync_msg).await?; + + assert_eq!( + node.dht().storage.get(&placement_key.to_string()).await?, + Some(stored_entry) + ); + assert_no_more_msg([&node]).await; + Ok(()) +} + #[tokio::test] async fn sync_entries_report_handler_rejects_wrong_physical_receiver() -> Result<()> { let sender = prepare_node(SecretKey::random()).await; 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/message/types.rs b/crates/core/src/message/types.rs index 545dfdf94..225b4287c 100644 --- a/crates/core/src/message/types.rs +++ b/crates/core/src/message/types.rs @@ -170,6 +170,29 @@ pub struct NotifyPredecessorReport { pub did: Did, } +/// Overlay liveness probe sent to an admitted peer. +#[derive(Debug, Deserialize, Serialize, Copy, Clone)] +pub struct PeerLivenessProbe { + /// Sender-local timestamp for logging and correlation. + pub sent_at_ms: i64, +} + +/// Overlay liveness report sent in response to [`PeerLivenessProbe`]. +#[derive(Debug, Deserialize, Serialize, Copy, Clone)] +pub struct PeerLivenessReport { + /// Sender-local timestamp copied from the probe. + pub sent_at_ms: i64, +} + +impl PeerLivenessProbe { + /// Build a response that proves the receiver processed this probe. + pub const fn resp(self) -> PeerLivenessReport { + PeerLivenessReport { + sent_at_ms: self.sent_at_ms, + } + } +} + /// The reason of query successor's TopoInfo #[derive(Debug, Deserialize, Serialize, Copy, Clone)] pub enum QueryFor { @@ -380,6 +403,10 @@ pub enum Message { NotifyPredecessorSend(NotifyPredecessorSend), /// Response of NotifyPredecessorSend NotifyPredecessorReport(NotifyPredecessorReport), + /// Overlay liveness probe. + PeerLivenessProbe(PeerLivenessProbe), + /// Overlay liveness probe response. + PeerLivenessReport(PeerLivenessReport), /// Remote message for searching an entry. SearchEntry(SearchEntry), /// Response when entries are found. diff --git a/crates/core/src/prelude.rs b/crates/core/src/prelude.rs index 81f67276e..4e556bf47 100644 --- a/crates/core/src/prelude.rs +++ b/crates/core/src/prelude.rs @@ -9,6 +9,8 @@ pub use url; pub use uuid; pub use crate::dht::entry; +pub use crate::lifecycle::StopSource; +pub use crate::lifecycle::StopToken; pub use crate::measure::PeerMeasurement; pub use crate::message; pub use crate::message::ChordStorageInterface; diff --git a/crates/core/src/storage/idb.rs b/crates/core/src/storage/idb.rs index 8dd5a3cd7..e38351a24 100644 --- a/crates/core/src/storage/idb.rs +++ b/crates/core/src/storage/idb.rs @@ -35,7 +35,7 @@ struct DataStruct { impl DataStruct { /// Create a `DataStruct` instance by key and data pub fn new(key: &str, data: T) -> Self { - let time_now = chrono::Utc::now().timestamp_millis(); + let time_now = crate::utils::get_epoch_ms_i64(); Self { key: key.to_owned(), last_visit_time: time_now, @@ -54,7 +54,7 @@ pub(crate) fn next_visit_time_after(previous: i64, now: i64) -> i64 { } fn next_visit_time(previous: i64) -> i64 { - next_visit_time_after(previous, chrono::Utc::now().timestamp_millis()) + next_visit_time_after(previous, crate::utils::get_epoch_ms_i64()) } /// StorageInstance struct 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..18c05a857 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,199 @@ 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 attempt.peer() != did { + tracing::warn!( + "ignoring data-channel open for {did}; pending attempt belongs to {}", + attempt.peer() + ); + self.transport.cancel_pending_connection(attempt).await?; + return Ok(false); + } + if !self.transport.promote_pending_connection(attempt)? { + return Ok(false); + } + + self.transport.record_peer_connected(did).await; + if !self.transport.is_admitted_connection_attempt(attempt) { + tracing::debug!( + "aborting data-channel admission for {did}; connection was retired while recording connect" + ); + return Ok(false); + } + self.message_handler.join_dht(did).await?; + if !self.transport.is_admitted_connection_attempt(attempt) { + tracing::debug!( + "undoing DHT admission for {did}; connection was retired while joining DHT" + ); + self.message_handler.leave_dht(did).await?; + return Ok(false); + } + for index in self.transport.take_pending_finger_updates(attempt)? { + self.transport.dht.apply_fixed_finger(index, did)?; + } + if !self.transport.is_admitted_connection_attempt(attempt) { + tracing::debug!( + "aborting connected event for {did}; connection was retired after DHT admission" + ); + self.message_handler.leave_dht(did).await?; + return Ok(false); + } + self.emit_connected_event_for_attempt(did, attempt).await + } + + async fn emit_connected_event_for_attempt( + &self, + did: Did, + attempt: PendingConnectionAttempt, + ) -> Result { + let delivery = self.transport.swarm_event_delivery_lock(did); + let result = async { + let _delivery = delivery.lock().await; + if !self.transport.is_admitted_connection_attempt(attempt) { + tracing::debug!("suppressing connected event for {did}; connection was retired before event delivery"); + return Ok(false); + } + self.emit_connection_state_change_unlocked(did, WebrtcConnectionState::Connected) + .await?; + Ok(true) + } + .await; + self.transport + .prune_swarm_event_delivery_lock(did, &delivery); + result + } + + async fn emit_connection_state_change( + &self, + did: Did, + state: WebrtcConnectionState, + ) -> Result<(), CallbackError> { + let delivery = self.transport.swarm_event_delivery_lock(did); + let result = async { + let _delivery = delivery.lock().await; + self.emit_connection_state_change_unlocked(did, state).await + } + .await; + self.transport + .prune_swarm_event_delivery_lock(did, &delivery); + result + } + + async fn emit_connection_state_change_unlocked( + &self, + did: Did, + state: WebrtcConnectionState, + ) -> Result<(), CallbackError> { + self.callback + .on_event(&SwarmEvent::ConnectionStateChange { peer: did, state }) + .await + } + + fn pending_disconnected_before_admission(&self, did: Did) -> bool { + let Some(attempt) = self.pending_attempt else { + return false; + }; + attempt.peer() == did && !self.transport.is_admitted_connection_attempt(attempt) + } + + fn is_local_did_event(&self, did: Did, operation: &str) -> bool { + if did != self.transport.dht.did { + return false; + } + tracing::warn!("ignoring {operation} for local DID {did}"); + true + } + + async fn cancel_mismatched_pending_connection( + &self, + did: Did, + operation: &str, + ) -> Result { + let Some(attempt) = self.pending_attempt else { + return Ok(false); + }; + if attempt.peer() == did { + return Ok(false); + } + tracing::warn!( + "ignoring {operation} for {did}; pending attempt belongs to {}", + attempt.peer() + ); + if self.transport.cancel_pending_connection(attempt).await? { + self.transport + .record_peer_disconnected(attempt.peer()) + .await; + } + Ok(true) + } + + async fn pending_connection_allows_message( + &self, + peer: Option, + ) -> Result { + let Some(attempt) = self.pending_attempt else { + return Ok(true); + }; + let Some(peer) = peer else { + tracing::warn!( + "ignoring message from unparsable peer; pending attempt belongs to {}", + attempt.peer() + ); + return Ok(false); + }; + if attempt.peer() != peer { + tracing::warn!( + "ignoring message from {peer}; pending attempt belongs to {}", + attempt.peer() + ); + self.transport.cancel_pending_connection(attempt).await?; + return Ok(false); + } + if !self.transport.is_admitted_connection_attempt(attempt) { + tracing::debug!("ignoring message from {peer}; pending connection is not admitted yet"); + return Ok(false); + } + Ok(true) + } + + async fn handle_pending_terminal_event( + &self, + did: Did, + operation: &str, + ) -> Result { + let Some(attempt) = self.pending_attempt else { + return Ok(false); + }; + if self.transport.cancel_pending_connection(attempt).await? { + self.transport.record_peer_disconnected(did).await; + return Ok(true); + } + if self.transport.is_admitted_connection_attempt(attempt) { + return Ok(false); + } + tracing::debug!( + "ignoring late {operation} for {did}; pending attempt belongs to generation already superseded" + ); + Ok(true) + } + async fn handle_payload( &self, cid: &str, @@ -100,6 +292,8 @@ impl InnerSwarmCallback { Message::NotifyPredecessorReport(ref msg) => { self.message_handler.handle(payload, msg).await } + Message::PeerLivenessProbe(ref msg) => self.message_handler.handle(payload, msg).await, + Message::PeerLivenessReport(ref msg) => self.message_handler.handle(payload, msg).await, Message::SearchEntry(ref msg) => self.message_handler.handle(payload, msg).await, Message::FoundEntry(ref msg) => self.message_handler.handle(payload, msg).await, Message::SyncEntriesWithSuccessor(ref msg) => { @@ -150,11 +344,14 @@ 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(); + if !self.pending_connection_allows_message(peer).await? { + return Ok(()); + } let payload = match MessagePayload::from_bincode(msg) { Ok(payload) => payload, Err(e) => { @@ -191,11 +388,33 @@ impl TransportCallback for InnerSwarmCallback { tracing::warn!("on_peer_connection_state_change parse did failed: {}", cid); return Ok(()); }; + if self + .cancel_mismatched_pending_connection(did, "connection state change") + .await? + { + return Ok(()); + } + if self.is_local_did_event(did, "connection state change") { + return Ok(()); + } 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 self + .handle_pending_terminal_event(did, "connection terminal state") + .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?; } @@ -206,22 +425,22 @@ impl TransportCallback for InnerSwarmCallback { // with no reconnect path. We leave it alone: it will either recover, // or degrade to `Failed`, which is handled above. WebrtcConnectionState::Disconnected => { + if self.pending_disconnected_before_admission(did) { + tracing::debug!( + "ignoring pre-admission disconnected state for pending connection {did}" + ); + return Ok(()); + } self.transport.record_peer_disconnected(did).await; tracing::info!("Connection to {did} is disconnected, waiting for recovery"); } _ => {} }; - // 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 { - peer: did, - state: s, - }) - .await? + self.emit_connection_state_change(did, s).await? } Ok(()) @@ -232,19 +451,21 @@ impl TransportCallback for InnerSwarmCallback { tracing::warn!("on_data_channel_open parse did failed: {}", cid); return Ok(()); }; + if self + .cancel_mismatched_pending_connection(did, "data-channel open") + .await? + { + return Ok(()); + } + if self.is_local_did_event(did, "data-channel open") { + 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> { @@ -252,12 +473,30 @@ impl TransportCallback for InnerSwarmCallback { tracing::warn!("on_data_channel_close parse did failed: {}", cid); return Ok(()); }; + if self + .cancel_mismatched_pending_connection(did, "data-channel close") + .await? + { + return Ok(()); + } + if self.is_local_did_event(did, "data-channel close") { + return Ok(()); + } // The data channel closing is a reliable signal that the peer is gone // (e.g. it closed the connection), so tear the connection down now // 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 self + .handle_pending_terminal_event(did, "data-channel close") + .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..ff3903c48 100644 --- a/crates/core/src/swarm/transport.rs +++ b/crates/core/src/swarm/transport.rs @@ -1,24 +1,28 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::str::FromStr; use std::sync::Arc; use std::sync::Mutex; 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; @@ -28,13 +32,11 @@ use rings_transport::delivery::DeliveryFuture; use rings_transport::webrtc_config::WebrtcUdpPortRange; use self::storage_sync::StorageSyncAckMap; -use crate::chunk::Chunk; use crate::chunk::ChunkList; use crate::chunk::Framing; use crate::chunk::ReassemblyLimits; use crate::chunk::WireReserves; use crate::consts::TRANSPORT_MAX_SIZE; -use crate::dht::entry::PlacementMiss; use crate::dht::Did; use crate::dht::LiveDid; use crate::dht::PeerRing; @@ -54,20 +56,68 @@ use crate::message::MessagePayload; use crate::message::PayloadSender; use crate::session::SessionSk; use crate::swarm::callback::InnerSwarmCallback; - +use crate::utils::get_epoch_ms_i64; + +mod connection; +mod delivery; +mod event_delivery; +mod liveness; +mod pending; +mod storage_lookup; mod storage_sync; -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; +use self::delivery::frame_chunk; +use self::delivery::record_measurement; +use self::delivery::send_data_with_timeout; +use self::delivery::spawn_chunked_send; +use self::delivery::spawn_delivery; +use self::delivery::ChunkSendPermit; +use self::event_delivery::SwarmEventDeliveryLock; +use self::event_delivery::SwarmEventDeliveryLocks; +use self::liveness::PeerLivenessMap; +pub(crate) use self::liveness::PEER_LIVENESS_IDLE_MS; +#[cfg(all(test, feature = "dummy", not(target_family = "wasm")))] +pub(crate) use self::liveness::PEER_LIVENESS_TIMEOUT_MS; +pub(crate) use self::pending::PendingConnectionAttempt; +use self::pending::PendingPeerPool; +pub(crate) use self::pending::DEFAULT_PENDING_CONNECTION_CAPACITY; +#[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] +use self::pending::PENDING_CONNECTION_TIMEOUT_MS; +use self::storage_lookup::StorageLookupObservationMap; +#[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] +pub(crate) use self::storage_lookup::STORAGE_LOOKUP_OBSERVATION_CAPACITY; + +fn message_kind(message: &Message) -> &'static str { + match message { + Message::ConnectNodeSend(_) => "ConnectNodeSend", + Message::ConnectNodeReport(_) => "ConnectNodeReport", + Message::FindSuccessorSend(_) => "FindSuccessorSend", + Message::FindSuccessorReport(_) => "FindSuccessorReport", + Message::NotifyPredecessorSend(_) => "NotifyPredecessorSend", + Message::NotifyPredecessorReport(_) => "NotifyPredecessorReport", + Message::PeerLivenessProbe(_) => "PeerLivenessProbe", + Message::PeerLivenessReport(_) => "PeerLivenessReport", + Message::SearchEntry(_) => "SearchEntry", + Message::FoundEntry(_) => "FoundEntry", + Message::OperateEntry(_) => "OperateEntry", + Message::SyncEntriesWithSuccessor(_) => "SyncEntriesWithSuccessor", + Message::SyncEntriesWithSuccessorReport(_) => "SyncEntriesWithSuccessorReport", + Message::CustomMessage(_) => "CustomMessage", + Message::E2eHandshakeRequest(_) => "E2eHandshakeRequest", + Message::E2eHandshakeResponse(_) => "E2eHandshakeResponse", + Message::E2eStreamFrame(_) => "E2eStreamFrame", + Message::QueryForTopoInfoSend(_) => "QueryForTopoInfoSend", + Message::QueryForTopoInfoReport(_) => "QueryForTopoInfoReport", + Message::Chunk(_) => "Chunk", + } +} -// Invariant: after every successful observation-buffer mutation, -// observations.len() <= STORAGE_LOOKUP_OBSERVATION_CAPACITY. -// Invariant: after evict_storage_lookup_observations(observations, now), every -// retained bucket satisfies -// now.saturating_sub(observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS. This -// is the freshness witness required before PlacementMiss.owner drives read-repair. -type StorageLookupObservationMap = BTreeMap; +fn payload_message_kind(payload: &MessagePayload) -> &'static str { + match payload.transaction.data::() { + Ok(message) => message_kind(&message), + Err(_) => "Unknown", + } +} pub struct SwarmTransport { pub(crate) network_id: u32, @@ -77,9 +127,15 @@ pub struct SwarmTransport { storage_redundancy: u16, dht_virtual_nodes: u16, reassembly_limits: ReassemblyLimits, + connection_lifecycle: Mutex<()>, + swarm_event_delivery: SwarmEventDeliveryLocks, + pending_peers: Mutex>, + active_peers: Mutex>, + pending_finger_updates: Mutex>>, + peer_liveness: Mutex, storage_lookup_observations: Mutex, pending_storage_sync_acks: Mutex, - measured_disconnects: Mutex>, + measured_disconnects: Mutex>, measure: Option, } @@ -132,202 +188,12 @@ impl SwarmWebrtcConfig { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] -struct StorageLookupObservationKey { - resource: Did, - redundancy: u16, -} - -struct StorageLookupObservation { - observed_at_ms: i64, - misses: BTreeSet, -} - -fn storage_lookup_observation_now_ms() -> i64 { - Utc::now().timestamp_millis() -} - -fn oldest_storage_lookup_observation_key( - observations: &StorageLookupObservationMap, -) -> Option { - observations - .iter() - .min_by_key(|(key, observation)| (observation.observed_at_ms, **key)) - .map(|(key, _)| *key) -} - -// Post: observations.len() <= STORAGE_LOOKUP_OBSERVATION_CAPACITY. -// Post: forall bucket in observations, -// now_ms.saturating_sub(bucket.observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS. -// Preservation: removing expired buckets and then oldest buckets cannot create -// a stale bucket or increase the number of buckets. -fn evict_storage_lookup_observations(observations: &mut StorageLookupObservationMap, now_ms: i64) { - observations.retain(|_, observation| { - now_ms.saturating_sub(observation.observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS - }); - - while observations.len() > STORAGE_LOOKUP_OBSERVATION_CAPACITY { - let Some(stale_key) = oldest_storage_lookup_observation_key(observations) else { - break; - }; - observations.remove(&stale_key); - } -} - -fn reserve_storage_lookup_observation_slot(observations: &mut StorageLookupObservationMap) { - while observations.len() >= STORAGE_LOOKUP_OBSERVATION_CAPACITY { - let Some(stale_key) = oldest_storage_lookup_observation_key(observations) else { - break; - }; - observations.remove(&stale_key); - } -} - #[derive(Clone)] pub struct SwarmConnection { peer: Did, pub connection: ConnectionRef, } -async fn record_measurement(measure: Option, did: Did, counter: MeasureCounter) { - if let Some(measure) = measure { - measure.incr(did, counter).await; - } -} - -/// Drive a message's [DeliveryFuture] to completion on the runtime, recording -/// 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")] -fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { - wasm_bindgen_futures::spawn_local(async move { - match fut.await { - Ok(()) => record_measurement(measure, did, MeasureCounter::Sent).await, - Err(e) => { - tracing::warn!("Message to {did} was not delivered: {e}"); - record_measurement(measure, did, MeasureCounter::FailedToSend).await; - } - } - }); -} - -/// Drive a message's [DeliveryFuture] to completion on the runtime, recording -/// the eventual peer-quality observation. -#[cfg(not(feature = "wasm"))] -fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { - tokio::spawn(async move { - match fut.await { - Ok(()) => record_measurement(measure, did, MeasureCounter::Sent).await, - Err(e) => { - tracing::warn!("Message to {did} was not delivered: {e}"); - record_measurement(measure, did, MeasureCounter::FailedToSend).await; - } - } - }); -} - -/// Frame one chunk into the bytes a data-channel send carries: wrap it in a `MessagePayload` -/// addressed to `did` and serialize it. Pure (the only failure is serialization). -fn frame_chunk(session_sk: &SessionSk, did: Did, chunk: Chunk) -> Result { - MessagePayload::new_send(Message::Chunk(chunk), session_sk, did, did)?.to_bincode() -} - -/// 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"))] -type ChunkTail = Box + Send>; -#[cfg(feature = "wasm")] -type ChunkTail = Box>; - -/// Drive the *tail* of a chunked send: the first chunk has already been accepted by the caller -/// (`do_send_payload`), so wait for it to flush (backpressure), then frame, send, and await each -/// remaining chunk in turn. One chunk is in flight at a time and no per-chunk task is spawned. A -/// later frame/send failure aborts the rest; the receiver TTL-expires the partial message (chunks -/// carry the message ttl), so no abort marker is needed. Fire-and-forget — the caller already -/// learned whether the *first* chunk was accepted, matching the whole-message contract. -async fn run_chunked_send( - conn: SwarmConnection, - tail: ChunkTail, - first_delivery: DeliveryFuture, - session_sk: SessionSk, - did: Did, - measure: Option, -) { - if let Err(e) = first_delivery.await { - tracing::warn!("Chunked send to {did} stopped before the first chunk flushed: {e}"); - record_measurement(measure, did, MeasureCounter::FailedToSend).await; - return; - } - for chunk in tail { - let bytes = match frame_chunk(&session_sk, did, chunk) { - Ok(bytes) => bytes, - Err(e) => { - tracing::warn!("Chunked send to {did} aborted while framing a chunk: {e}"); - return; - } - }; - match conn.send_data(bytes).await { - Ok(delivery) => { - if let Err(e) = delivery.await { - tracing::warn!("Chunked send to {did} stopped before flush: {e}"); - record_measurement(measure, did, MeasureCounter::FailedToSend).await; - return; - } - } - Err(e) => { - tracing::warn!("Chunked send to {did} stopped: {e}"); - record_measurement(measure, did, MeasureCounter::FailedToSend).await; - return; - } - } - } - record_measurement(measure, did, MeasureCounter::Sent).await; -} - -/// Drive the tail of a chunked send on the runtime (one bounded task per large message). See -/// [`run_chunked_send`]. -#[cfg(feature = "wasm")] -fn spawn_chunked_send( - conn: SwarmConnection, - tail: ChunkTail, - first_delivery: DeliveryFuture, - session_sk: SessionSk, - did: Did, - measure: Option, -) { - wasm_bindgen_futures::spawn_local(run_chunked_send( - conn, - tail, - first_delivery, - session_sk, - did, - measure, - )); -} - -/// Drive the tail of a chunked send on the runtime (one bounded task per large message). See -/// [`run_chunked_send`]. -#[cfg(not(feature = "wasm"))] -fn spawn_chunked_send( - conn: SwarmConnection, - tail: ChunkTail, - first_delivery: DeliveryFuture, - session_sk: SessionSk, - did: Did, - measure: Option, -) { - tokio::spawn(run_chunked_send( - conn, - tail, - first_delivery, - session_sk, - did, - measure, - )); -} - impl SwarmTransport { pub(crate) fn new( network_id: u32, @@ -349,9 +215,15 @@ impl SwarmTransport { storage_redundancy: settings.storage_redundancy, dht_virtual_nodes: settings.dht_virtual_nodes, reassembly_limits: settings.reassembly_limits, + connection_lifecycle: Mutex::new(()), + swarm_event_delivery: SwarmEventDeliveryLocks::new(), + pending_peers: Mutex::new(PendingPeerPool::new()), + active_peers: Mutex::new(BTreeMap::new()), + pending_finger_updates: Mutex::new(BTreeMap::new()), + peer_liveness: Mutex::new(PeerLivenessMap::new()), storage_lookup_observations: Mutex::new(BTreeMap::new()), pending_storage_sync_acks: Mutex::new(BTreeMap::new()), - measured_disconnects: Mutex::new(BTreeSet::new()), + measured_disconnects: Mutex::new(BTreeMap::new()), measure, } } @@ -393,8 +265,33 @@ impl SwarmTransport { record_measurement(self.measure.clone(), peer, counter).await; } + pub(crate) fn swarm_event_delivery_lock(&self, peer: Did) -> SwarmEventDeliveryLock { + self.swarm_event_delivery.lock(peer) + } + + pub(crate) fn prune_swarm_event_delivery_lock( + &self, + peer: Did, + delivery: &SwarmEventDeliveryLock, + ) { + self.swarm_event_delivery + .prune(peer, delivery, self.connection_epoch_exists(peer)); + } + + fn connection_epoch_exists(&self, peer: Did) -> bool { + self.active_peers() + .map(|active| active.contains_key(&peer)) + .unwrap_or(false) + || self + .pending_peers + .lock() + .map(|pending| pending.contains(peer)) + .unwrap_or(false) + } + /// Record that `peer` reached an open data channel. pub(crate) async fn record_peer_connected(&self, peer: Did) { + self.mark_peer_liveness_connected(peer); match self.measured_disconnects.lock() { Ok(mut measured) => { measured.remove(&peer); @@ -412,8 +309,9 @@ impl SwarmTransport { /// Invariant: for one connection epoch, at most one `Disconnected` counter is /// recorded. `record_peer_connected` starts a new epoch by clearing the marker. pub(crate) async fn record_peer_disconnected(&self, peer: Did) { + let now_ms = get_epoch_ms_i64(); let should_record = match self.measured_disconnects.lock() { - Ok(mut measured) => measured.insert(peer), + Ok(mut measured) => measured.insert(peer, now_ms).is_none(), Err(_) => { tracing::warn!("Failed to update disconnect epoch for disconnected peer {peer}"); true @@ -425,8 +323,17 @@ impl SwarmTransport { } } + /// Return the first time this peer left the usable connection epoch. + pub(crate) fn peer_disconnected_since_ms(&self, peer: Did) -> Option { + self.measured_disconnects + .lock() + .ok() + .and_then(|measured| measured.get(&peer).copied()) + } + /// Record that a payload from `peer` was accepted and verified by the swarm. pub(crate) async fn record_peer_message_received(&self, peer: Did) { + self.mark_peer_liveness_inbound(peer); self.record_peer_measurement(peer, MeasureCounter::Received) .await; } @@ -492,263 +399,79 @@ impl SwarmTransport { } } - fn storage_lookup_observation_key( - &self, - resource: Did, - redundancy: u16, - ) -> Result { - self.ensure_storage_redundancy_value(redundancy)?; - Ok(StorageLookupObservationKey { - resource, - redundancy, - }) - } - - /// Start a fresh lookup round for `resource`. - /// - /// This replaces any previous miss observations for the same resource and - /// redundancy with an empty local-authorized bucket. Inbound FoundEntry - /// messages may only add misses to an existing bucket, so remote peers cannot - /// create a new redundancy mode. - /// - /// Post: if capacity permits one active lookup, a bucket exists for - /// `(resource, redundancy)` and contains no misses. - /// Preservation: eviction establishes the capacity and freshness invariants - /// before replacing the lookup-round bucket. - pub(crate) fn start_storage_lookup(&self, resource: Did, redundancy: u16) -> Result<()> { - let key = self.storage_lookup_observation_key(resource, redundancy)?; - let mut observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - let now = storage_lookup_observation_now_ms(); - evict_storage_lookup_observations(&mut observations, now); - reserve_storage_lookup_observation_slot(&mut observations); - observations.insert(key, StorageLookupObservation { - observed_at_ms: now, - misses: BTreeSet::new(), - }); - Ok(()) - } - - /// Validate that a storage lookup response belongs to a local lookup round. - /// - /// Post: `Ok(())` proves a fresh bucket exists for `(resource, redundancy)`. - pub(crate) fn ensure_storage_lookup_active( - &self, - resource: Did, - redundancy: u16, - ) -> Result<()> { - let key = self.storage_lookup_observation_key(resource, redundancy)?; - let mut observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - let now = storage_lookup_observation_now_ms(); - evict_storage_lookup_observations(&mut observations, now); - if observations.contains_key(&key) { - Ok(()) - } else { - Err(Error::InvalidMessage( - "storage lookup response has no active local lookup".to_string(), - )) - } - } - - /// Buffer placement misses observed by an in-flight storage lookup. - /// - /// Post: retained observation buckets satisfy the capacity and freshness - /// invariants. - /// Post: the supplied misses are appended only to a bucket previously created - /// by [`Self::start_storage_lookup`]. - pub(crate) fn observe_storage_misses( - &self, - resource: Did, - redundancy: u16, - misses: impl IntoIterator, - ) -> Result<()> { - let key = self.storage_lookup_observation_key(resource, redundancy)?; - let mut misses = misses.into_iter().peekable(); - if misses.peek().is_none() { - return Ok(()); - } - let mut observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - let now = storage_lookup_observation_now_ms(); - evict_storage_lookup_observations(&mut observations, now); - let Some(observation) = observations.get_mut(&key) else { - return Err(Error::InvalidMessage( - "storage miss observation has no active local lookup".to_string(), - )); - }; - observation.observed_at_ms = now; - observation.misses.extend(misses); - evict_storage_lookup_observations(&mut observations, now); - Ok(()) - } - - /// Drain fresh miss observations for a found entry. - /// - /// Post: returned misses come only from a bucket that survived freshness - /// eviction at this call's observation time. - /// Post: the bucket remains active with no buffered misses until TTL or a new - /// lookup round removes it. - /// Preservation: eviction before drain prevents stale owners from driving - /// late read-repair. - pub(crate) fn take_storage_misses( - &self, - resource: Did, - redundancy: u16, - ) -> Result> { - let key = self.storage_lookup_observation_key(resource, redundancy)?; - let mut observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - let now = storage_lookup_observation_now_ms(); - evict_storage_lookup_observations(&mut observations, now); - let Some(observation) = observations.get_mut(&key) else { - return Err(Error::InvalidMessage( - "storage repair has no active local lookup".to_string(), - )); - }; - Ok(std::mem::take(&mut observation.misses) - .into_iter() - .collect()) - } - - #[cfg(all(test, not(feature = "wasm")))] - /// Test hook: make one observation bucket older than the freshness TTL. - pub(crate) fn expire_storage_lookup_observation( - &self, - resource: Did, - redundancy: u16, - ) -> Result<()> { - let key = self.storage_lookup_observation_key(resource, redundancy)?; - let mut observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - if let Some(observation) = observations.get_mut(&key) { - observation.observed_at_ms = storage_lookup_observation_now_ms() - .saturating_sub(STORAGE_LOOKUP_OBSERVATION_TTL_MS + 1); - } - Ok(()) - } - - #[cfg(all(test, not(feature = "wasm")))] - /// Test hook: count retained observation buckets. - pub(crate) fn storage_lookup_observation_count(&self) -> Result { - let observations = self - .storage_lookup_observations - .lock() - .map_err(|_| Error::DHTSyncLockError)?; - 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(()); - } - - let cid = peer.to_string(); - self.transport - .new_connection(&cid, Box::new(callback)) - .await - .map_err(Error::Transport) - } - - /// Get connection by did. - pub fn get_connection(&self, peer: Did) -> Option { - self.transport - .connection(&peer.to_string()) - .map(|conn| SwarmConnection { - peer, - connection: conn, - }) - .ok() - } - - /// Get all connections in transport. - pub fn get_connections(&self) -> Vec<(Did, SwarmConnection)> { - self.transport - .connections() - .into_iter() - .filter_map(|(k, v)| { - Did::from_str(&k).ok().map(|did| { - (did, SwarmConnection { - peer: did, - connection: v, - }) - }) - }) - .collect() - } - - /// Get dids of all connections in transport. - pub fn get_connection_ids(&self) -> Vec { - self.transport - .connection_ids() - .into_iter() - .filter_map(|k| Did::from_str(&k).ok()) - .collect() - } - - /// Disconnect a connection. There are three steps: - /// 1) remove from DHT; - /// 2) remove from Transport; - /// 3) close the connection; - pub async fn disconnect(&self, peer: Did) -> Result<()> { - tracing::info!("removing {peer} from DHT"); - self.dht.remove(peer)?; - self.transport - .close_connection(&peer.to_string()) - .await - .map_err(|e| e.into()) - } - /// 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) => { + if self.get_connection(peer).is_some() { + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + error = ?e, + "connection request satisfied by concurrent handshake" + ); + return Ok(()); + } self.record_peer_message_send_failed(peer).await; return Err(e); } }; - self.send_message(Message::ConnectNodeSend(offer_msg), peer) - .await?; - 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. - /// 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 { - let conn = self.get_connection(peer)?; - - if let Err(e) = conn.connection.webrtc_wait_for_data_channel_open().await { - tracing::warn!( - "[get_and_check_connection] connection {peer} data channel not open, will be dropped, reason: {e:?}" - ); - - if let Err(e) = self.disconnect(peer).await { - tracing::error!("Failed on close connection {peer}: {e:?}"); + let sdp_len = offer_msg.sdp.len(); + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + sdp_bytes = sdp_len, + "connection offer send start" + ); + match self + .send_message(Message::ConnectNodeSend(offer_msg), peer) + .await + { + Ok(tx_id) => { + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + tx_id = %tx_id, + "connection offer send complete" + ); } - - return None; - }; - - Some(conn) + Err(error) => { + tracing::warn!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + error = ?error, + "connection offer send failed" + ); + self.abandon_pending_connection(attempt, "sending connection offer") + .await; + if self.get_connection(peer).is_some() { + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + error = ?error, + "connection offer send failure satisfied by concurrent handshake" + ); + return Ok(()); + } + return Err(error); + } + } + Ok(()) } /// Create new connection and its offer. @@ -757,18 +480,66 @@ 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)?; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + state = ?conn.webrtc_connection_state(), + "connection offer create start" + ); + let offer = match conn.connection.webrtc_create_offer().await { + Ok(offer) => offer, + Err(error) => { + tracing::warn!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + error = ?error, + "connection offer create failed" + ); + self.abandon_pending_connection(attempt, "creating connection offer") + .await; + return Err(Error::Transport(error)); + } + }; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + sdp_bytes = offer.len(), + state = ?conn.webrtc_connection_state(), + "connection offer create complete" + ); + 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 +547,7 @@ impl SwarmTransport { dht_virtual_nodes: self.dht_virtual_nodes, }; - Ok(offer_msg) + Ok((attempt, offer_msg)) } /// Answer the offer of remote connection. @@ -792,9 +563,14 @@ impl SwarmTransport { )); } - let offer = serde_json::from_str(&offer_msg.sdp).map_err(Error::Deserialize)?; + let offer: String = serde_json::from_str(&offer_msg.sdp).map_err(Error::Deserialize)?; + + self.expire_pending_connections().await?; + if self.is_active_connection(peer) { + return Err(Error::AlreadyConnected); + } - if let Some(swarm_conn) = self.get_connection(peer) { + 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 +583,75 @@ 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)?; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + offer_sdp_bytes = offer.len(), + state = ?conn.webrtc_connection_state(), + "connection answer create start" + ); + let answer = match conn.connection.webrtc_answer_offer(offer).await { + Ok(answer) => answer, + Err(error) => { + tracing::warn!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + error = ?error, + "connection answer create failed" + ); + self.abandon_pending_connection(attempt, "creating connection answer") + .await; + return Err(Error::Transport(error)); + } + }; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + answer_sdp_bytes = answer.len(), + state = ?conn.webrtc_connection_state(), + "connection answer create complete" + ); + 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, @@ -850,15 +674,48 @@ impl SwarmTransport { )); } - let answer = serde_json::from_str(&answer_msg.sdp).map_err(Error::Deserialize)?; + let answer: String = 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))?; + let attempt = self.pending_attempt(peer)?; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = ?attempt.map(|attempt| attempt.generation), + answer_sdp_bytes = answer.len(), + state = ?conn.webrtc_connection_state(), + "connection answer accept start" + ); + 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; + } + tracing::warn!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + error = ?error, + "connection answer accept failed" + ); + return Err(Error::Transport(error)); + } + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = ?attempt.map(|attempt| attempt.generation), + state = ?conn.webrtc_connection_state(), + "connection answer accept complete" + ); Ok(()) } @@ -883,8 +740,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 @@ -907,36 +764,57 @@ impl PayloadSender for SwarmTransport { return Err(Error::SwarmMissDidInTable(did)); }; - tracing::debug!( - "Try send {:?}, to node {:?}", - payload.clone(), - payload.relay.next_hop, - ); - + let message_kind = payload_message_kind(&payload); + let tx_id = payload.transaction.tx_id; + let destination = payload.transaction.destination; + let relay_destination = payload.relay.destination; + let next_hop = payload.relay.next_hop; let data = payload.to_bincode()?; if data.len() > TRANSPORT_MAX_SIZE { - tracing::error!("Message is too large: {:?}", payload); + tracing::error!( + local = %self.dht.did, + next_hop = %next_hop, + destination = %destination, + relay_destination = %relay_destination, + tx_id = %tx_id, + message_kind, + bytes = data.len(), + max_bytes = TRANSPORT_MAX_SIZE, + "message payload is too large" + ); return Err(Error::MessageTooLarge(data.len())); } // The chunk-vs-whole decision is the pure `WireReserves::plan`, against this connection's // negotiated `max_message_size`; this block is only the effectful shell carrying it out. - // `None` means the peer's limit is too small to carry even one useful chunk — a real failure - // we surface (before sending anything) rather than fragmenting into a flood of near-empty - // chunks. Both arms are **fire-and-forget**: `send_message` returns once the bytes are - // accepted into the send buffer, not once they flush — a whole message hands its - // `DeliveryFuture` to the runtime, and a chunked message is driven by one bounded background - // task (one chunk in flight; see `run_chunked_send`), so a large payload never blocks the - // caller's path while keeping memory and the runtime task count bounded. + // `None` means the peer's limit is too small to carry even one useful chunk. Send admission is + // bounded because a WebRTC data-channel queue under backpressure can otherwise leave this + // future pending until the caller's outer timeout fires without a transport-level reason. let Some(plan) = WireReserves::PRODUCTION.plan(data.len(), conn.max_message_size()) else { self.record_peer_message_send_failed(did).await; return Err(Error::PeerMaxMessageSizeTooSmall(conn.max_message_size())); }; + tracing::debug!( + local = %self.dht.did, + next_hop = %next_hop, + destination = %destination, + relay_destination = %relay_destination, + tx_id = %tx_id, + message_kind, + bytes = data.len(), + max_message_size = conn.max_message_size(), + framing = ?plan, + "send payload start" + ); + let chunk_send_permit = ChunkSendPermit::for_payload(self.dht.clone(), did, &payload); match plan { - Framing::Whole => match conn.send_data(data).await { + Framing::Whole => match send_data_with_timeout(&conn, data, did, "whole_message").await + { Ok(delivery) => spawn_delivery(delivery, did, self.measure.clone()), Err(e) => { - self.record_peer_message_send_failed(did).await; + if e.records_peer_send_failure() { + self.record_peer_message_send_failed(did).await; + } return Err(e); } }, @@ -949,7 +827,7 @@ impl PayloadSender for SwarmTransport { let mut chunks = ChunkList::stream(data, chunk_size); if let Some(first) = chunks.next() { let first = frame_chunk(&self.session_sk, did, first)?; - match conn.send_data(first).await { + match send_data_with_timeout(&conn, first, did, "chunked_first").await { Ok(first_delivery) => { spawn_chunked_send( conn, @@ -957,11 +835,14 @@ impl PayloadSender for SwarmTransport { first_delivery, self.session_sk.clone(), did, + chunk_send_permit, self.measure.clone(), ); } Err(e) => { - self.record_peer_message_send_failed(did).await; + if e.records_peer_send_failure() { + self.record_peer_message_send_failed(did).await; + } return Err(e); } } @@ -970,20 +851,34 @@ impl PayloadSender for SwarmTransport { } tracing::debug!( - "Sent {:?}, to node {:?}", - payload.clone(), - payload.relay.next_hop, + local = %self.dht.did, + next_hop = %next_hop, + destination = %destination, + relay_destination = %relay_destination, + tx_id = %tx_id, + message_kind, + "send payload accepted" ); 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 LiveDid for SwarmConnection { async fn live(&self) -> bool { - self.webrtc_connection_state() == WebrtcConnectionState::Connected + match self.connection.data_channel_is_open() { + Ok(open) => open, + Err(error) => { + tracing::debug!( + peer = %self.peer, + error = ?error, + "failed to inspect data-channel liveness" + ); + false + } + } } } @@ -993,5 +888,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/connection.rs b/crates/core/src/swarm/transport/connection.rs new file mode 100644 index 000000000..8c4215426 --- /dev/null +++ b/crates/core/src/swarm/transport/connection.rs @@ -0,0 +1,185 @@ +use std::time::Duration; + +use futures::future::FutureExt; +use futures::pin_mut; +use futures::select; +use rings_transport::core::transport::ConnectionInterface; +use rings_transport::core::transport::TransportInterface; + +use super::SwarmConnection; +use super::SwarmTransport; +use crate::dht::Did; +use crate::error::Error; +use crate::error::Result; +use crate::utils::sleep; + +const DATA_CHANNEL_OPEN_TIMEOUT: Duration = Duration::from_secs(8); + +impl SwarmTransport { + /// 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(|peer| { + self.get_connection(peer) + .map(|connection| (peer, connection)) + }) + .collect() + } + + fn active_peer_ids(&self) -> Vec { + self.active_peers() + .map(|active| active.keys().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() + } + + /// Return admitted DIDs, even if their raw transport object has already gone away. + pub(crate) fn admitted_connection_ids(&self) -> Vec { + self.active_peer_ids() + } + + /// Get DIDs of active, routable connections. + pub fn get_connection_ids(&self) -> Vec { + self.get_connections() + .into_iter() + .map(|(peer, _)| peer) + .collect() + } + + /// 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.close_connection_for_disconnect(peer).await + } + + async fn close_connection_for_disconnect(&self, peer: Did) -> Result<()> { + match self.transport.close_connection(&peer.to_string()).await { + Ok(()) => Ok(()), + Err(rings_transport::error::Error::ConnectionNotFound(_)) => { + tracing::warn!( + peer = %peer, + "connection was already absent while disconnecting admitted peer" + ); + Ok(()) + } + Err(error) => Err(error.into()), + } + } + + /// 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 { + self.get_and_check_connection_with_timeout(peer, DATA_CHANNEL_OPEN_TIMEOUT) + .await + } + + pub(crate) async fn get_and_check_connection_with_timeout( + &self, + peer: Did, + wait_timeout: Duration, + ) -> Option { + let conn = self.get_connection(peer)?; + + let initial_state = conn.webrtc_connection_state(); + tracing::debug!( + target: "rings_core::transport::data_channel", + local = %self.dht.did, + peer = %peer, + state = ?initial_state, + timeout_ms = wait_timeout.as_millis(), + "waiting for active connection data channel" + ); + + let failure = { + let wait_for_open = conn.connection.webrtc_wait_for_data_channel_open().fuse(); + let timeout = sleep(wait_timeout).fuse(); + pin_mut!(wait_for_open, timeout); + + select! { + result = wait_for_open => result.err().map(|e| format!("transport_wait_failed: {e:?}")), + _ = timeout => Some("data_channel_open_wait_timeout".to_string()), + } + }; + + if let Some(reason) = failure { + let final_state = conn.webrtc_connection_state(); + tracing::warn!( + target: "rings_core::transport::data_channel", + local = %self.dht.did, + peer = %peer, + initial_state = ?initial_state, + final_state = ?final_state, + timeout_ms = wait_timeout.as_millis(), + reason = %reason, + "[get_and_check_connection] connection data channel not open, will be dropped" + ); + + if let Err(e) = self.disconnect(peer).await { + tracing::error!( + target: "rings_core::transport::data_channel", + local = %self.dht.did, + peer = %peer, + reason = %reason, + "failed to close connection after data-channel wait failure: {e:?}" + ); + } + + return None; + }; + + tracing::debug!( + target: "rings_core::transport::data_channel", + local = %self.dht.did, + peer = %peer, + state = ?conn.webrtc_connection_state(), + "active connection data channel is open" + ); + + Some(conn) + } +} diff --git a/crates/core/src/swarm/transport/delivery.rs b/crates/core/src/swarm/transport/delivery.rs new file mode 100644 index 000000000..73176ed2b --- /dev/null +++ b/crates/core/src/swarm/transport/delivery.rs @@ -0,0 +1,413 @@ +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use futures::future::FutureExt; +use futures::pin_mut; +use futures::select; +use rings_transport::core::transport::WebrtcConnectionState; +use rings_transport::delivery::DeliveryFuture; + +use super::SwarmConnection; +use crate::chunk::Chunk; +use crate::dht::Did; +use crate::dht::PeerRing; +use crate::dht::StorageSyncDestination; +use crate::error::Error; +use crate::error::Result; +use crate::measure::MeasureCounter; +use crate::measure::MeasureImpl; +use crate::message::Message; +use crate::message::MessagePayload; +use crate::session::SessionSk; +use crate::utils::sleep; + +#[cfg(test)] +pub(super) const DATA_CHANNEL_SEND_ACCEPT_TIMEOUT: Duration = Duration::from_millis(50); +#[cfg(not(test))] +pub(super) const DATA_CHANNEL_SEND_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5); + +#[cfg(test)] +const CHUNK_SEND_PERMIT_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(not(test))] +const CHUNK_SEND_PERMIT_POLL_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Debug)] +enum ChunkSendCancelReason { + TerminalConnectionState(WebrtcConnectionState), + RouteNoLongerPermitted, + RouteCheckFailed(Error), +} + +impl ChunkSendCancelReason { + const fn as_str(&self) -> &'static str { + match self { + Self::TerminalConnectionState(_) => "terminal_connection_state", + Self::RouteNoLongerPermitted => "route_no_longer_permitted", + Self::RouteCheckFailed(_) => "route_check_failed", + } + } + + const fn terminal_state(&self) -> Option { + match self { + Self::TerminalConnectionState(state) => Some(*state), + Self::RouteNoLongerPermitted | Self::RouteCheckFailed(_) => None, + } + } + + const fn route_check_error(&self) -> Option<&Error> { + match self { + Self::RouteCheckFailed(error) => Some(error), + Self::TerminalConnectionState(_) | Self::RouteNoLongerPermitted => None, + } + } + + const fn records_peer_failure(&self) -> bool { + matches!(self, Self::TerminalConnectionState(_)) + } +} + +enum ChunkSendProgress { + Ready(T), + Cancelled(ChunkSendCancelReason), +} + +#[derive(Clone)] +pub(super) enum ChunkSendPermit { + Always, + StorageSyncRoute { + dht: Arc, + destination: StorageSyncDestination, + next_hop: Did, + }, +} + +impl ChunkSendPermit { + pub(super) fn for_payload(dht: Arc, next_hop: Did, payload: &MessagePayload) -> Self { + match payload.transaction.data() { + Ok(Message::SyncEntriesWithSuccessor(msg)) => Self::StorageSyncRoute { + dht, + destination: msg.destination, + next_hop, + }, + Ok(_) => Self::Always, + Err(error) => { + tracing::debug!( + target: "rings_core::transport::chunked_send", + next_hop = %next_hop, + error = ?error, + "chunked send route permit fell back to connection-only mode" + ); + Self::Always + } + } + } + + fn check(&self) -> std::result::Result<(), ChunkSendCancelReason> { + match self { + Self::Always => Ok(()), + Self::StorageSyncRoute { + dht, + destination, + next_hop, + } => match dht.storage_sync_route_still_permits(*destination, *next_hop) { + Ok(true) => Ok(()), + Ok(false) => Err(ChunkSendCancelReason::RouteNoLongerPermitted), + Err(error) => Err(ChunkSendCancelReason::RouteCheckFailed(error)), + }, + } + } +} + +pub(super) async fn send_data_with_timeout( + conn: &SwarmConnection, + data: Bytes, + did: Did, + context: &'static str, +) -> Result { + let bytes = data.len(); + let send = conn.send_data(data).fuse(); + let timeout = sleep(DATA_CHANNEL_SEND_ACCEPT_TIMEOUT).fuse(); + pin_mut!(send, timeout); + + select! { + result = send => result, + _ = timeout => Err(Error::DataChannelSendQueueTimeout { + peer: did, + timeout_ms: DATA_CHANNEL_SEND_ACCEPT_TIMEOUT.as_millis(), + bytes, + context, + }), + } +} + +pub(super) async fn record_measurement( + measure: Option, + did: Did, + counter: MeasureCounter, +) { + if let Some(measure) = measure { + measure.incr(did, counter).await; + } +} + +/// Drive a message's [DeliveryFuture] to completion on the runtime, recording +/// 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(all(feature = "wasm", target_family = "wasm"))] +pub(super) fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { + wasm_bindgen_futures::spawn_local(async move { + match fut.await { + Ok(()) => record_measurement(measure, did, MeasureCounter::Sent).await, + Err(e) => { + tracing::warn!("Message to {did} was not delivered: {e}"); + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + } + } + }); +} + +/// Drive a message's [DeliveryFuture] to completion on the runtime, recording +/// the eventual peer-quality observation. +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] +pub(super) fn spawn_delivery(fut: DeliveryFuture, did: Did, measure: Option) { + tokio::spawn(async move { + match fut.await { + Ok(()) => record_measurement(measure, did, MeasureCounter::Sent).await, + Err(e) => { + tracing::warn!("Message to {did} was not delivered: {e}"); + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + } + } + }); +} + +/// Frame one chunk into the bytes a data-channel send carries: wrap it in a `MessagePayload` +/// addressed to `did` and serialize it. Pure (the only failure is serialization). +pub(super) fn frame_chunk(session_sk: &SessionSk, did: Did, chunk: Chunk) -> Result { + MessagePayload::new_send(Message::Chunk(chunk), session_sk, did, did)?.to_bincode() +} + +/// 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(all(feature = "wasm", target_family = "wasm")))] +type ChunkTail = Box + Send>; +#[cfg(all(feature = "wasm", target_family = "wasm"))] +type ChunkTail = Box>; + +fn chunk_send_cancel_reason( + conn: &SwarmConnection, + permit: &ChunkSendPermit, +) -> Option { + let state = conn.webrtc_connection_state(); + if matches!( + state, + WebrtcConnectionState::Failed | WebrtcConnectionState::Closed + ) { + return Some(ChunkSendCancelReason::TerminalConnectionState(state)); + } + + permit.check().err() +} + +fn log_chunk_send_cancel(did: Did, phase: &'static str, reason: &ChunkSendCancelReason) { + tracing::warn!( + target: "rings_core::transport::chunked_send", + peer = %did, + phase, + reason = reason.as_str(), + terminal_state = ?reason.terminal_state(), + route_check_error = ?reason.route_check_error(), + records_peer_failure = reason.records_peer_failure(), + "chunked send cancelled" + ); +} + +async fn record_cancel_measurement( + measure: Option, + did: Did, + reason: &ChunkSendCancelReason, +) { + if reason.records_peer_failure() { + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + } +} + +async fn await_delivery_or_cancel( + delivery: DeliveryFuture, + conn: &SwarmConnection, + permit: &ChunkSendPermit, + did: Did, + phase: &'static str, +) -> ChunkSendProgress> { + let delivery = delivery.fuse(); + pin_mut!(delivery); + + loop { + if let Some(reason) = chunk_send_cancel_reason(conn, permit) { + log_chunk_send_cancel(did, phase, &reason); + return ChunkSendProgress::Cancelled(reason); + } + + let poll = sleep(CHUNK_SEND_PERMIT_POLL_INTERVAL).fuse(); + pin_mut!(poll); + select! { + result = delivery => { + if result.is_err() { + if let Some(reason) = chunk_send_cancel_reason(conn, permit) { + log_chunk_send_cancel(did, phase, &reason); + return ChunkSendProgress::Cancelled(reason); + } + } + return ChunkSendProgress::Ready(result.map_err(Error::Transport)); + }, + _ = poll => {} + } + } +} + +async fn send_chunk_or_cancel( + conn: &SwarmConnection, + bytes: Bytes, + permit: &ChunkSendPermit, + did: Did, +) -> ChunkSendProgress> { + let send = send_data_with_timeout(conn, bytes, did, "chunked_tail").fuse(); + pin_mut!(send); + + loop { + if let Some(reason) = chunk_send_cancel_reason(conn, permit) { + log_chunk_send_cancel(did, "send_chunk", &reason); + return ChunkSendProgress::Cancelled(reason); + } + + let poll = sleep(CHUNK_SEND_PERMIT_POLL_INTERVAL).fuse(); + pin_mut!(poll); + select! { + result = send => { + if result.is_err() { + if let Some(reason) = chunk_send_cancel_reason(conn, permit) { + log_chunk_send_cancel(did, "send_chunk", &reason); + return ChunkSendProgress::Cancelled(reason); + } + } + return ChunkSendProgress::Ready(result); + }, + _ = poll => {} + } + } +} + +/// Drive the *tail* of a chunked send: the first chunk has already been accepted by the caller +/// (`do_send_payload`), so wait for it to flush (backpressure), then frame, send, and await each +/// remaining chunk in turn. One chunk is in flight at a time and no per-chunk task is spawned. A +/// later frame/send failure aborts the rest; the receiver TTL-expires the partial message (chunks +/// carry the message ttl), so no abort marker is needed. Fire-and-forget — the caller already +/// learned whether the *first* chunk was accepted, matching the whole-message contract. +async fn run_chunked_send( + conn: SwarmConnection, + tail: ChunkTail, + first_delivery: DeliveryFuture, + session_sk: SessionSk, + did: Did, + permit: ChunkSendPermit, + measure: Option, +) { + match await_delivery_or_cancel(first_delivery, &conn, &permit, did, "first_delivery").await { + ChunkSendProgress::Ready(Ok(())) => {} + ChunkSendProgress::Ready(Err(e)) => { + tracing::warn!("Chunked send to {did} stopped before the first chunk flushed: {e}"); + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + return; + } + ChunkSendProgress::Cancelled(reason) => { + record_cancel_measurement(measure, did, &reason).await; + return; + } + } + for chunk in tail { + let bytes = match frame_chunk(&session_sk, did, chunk) { + Ok(bytes) => bytes, + Err(e) => { + tracing::warn!("Chunked send to {did} aborted while framing a chunk: {e}"); + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + return; + } + }; + let delivery = match send_chunk_or_cancel(&conn, bytes, &permit, did).await { + ChunkSendProgress::Ready(Ok(delivery)) => delivery, + ChunkSendProgress::Ready(Err(e)) => { + tracing::warn!("Chunked send to {did} stopped: {e}"); + if e.records_peer_send_failure() { + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + } + return; + } + ChunkSendProgress::Cancelled(reason) => { + record_cancel_measurement(measure, did, &reason).await; + return; + } + }; + match await_delivery_or_cancel(delivery, &conn, &permit, did, "chunk_delivery").await { + ChunkSendProgress::Ready(Ok(())) => {} + ChunkSendProgress::Ready(Err(e)) => { + tracing::warn!("Chunked send to {did} stopped before flush: {e}"); + record_measurement(measure, did, MeasureCounter::FailedToSend).await; + return; + } + ChunkSendProgress::Cancelled(reason) => { + record_cancel_measurement(measure, did, &reason).await; + return; + } + } + } + record_measurement(measure, did, MeasureCounter::Sent).await; +} + +/// Drive the tail of a chunked send on the runtime (one bounded task per large message). See +/// [`run_chunked_send`]. +#[cfg(all(feature = "wasm", target_family = "wasm"))] +pub(super) fn spawn_chunked_send( + conn: SwarmConnection, + tail: ChunkTail, + first_delivery: DeliveryFuture, + session_sk: SessionSk, + did: Did, + permit: ChunkSendPermit, + measure: Option, +) { + wasm_bindgen_futures::spawn_local(run_chunked_send( + conn, + tail, + first_delivery, + session_sk, + did, + permit, + measure, + )); +} + +/// Drive the tail of a chunked send on the runtime (one bounded task per large message). See +/// [`run_chunked_send`]. +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] +pub(super) fn spawn_chunked_send( + conn: SwarmConnection, + tail: ChunkTail, + first_delivery: DeliveryFuture, + session_sk: SessionSk, + did: Did, + permit: ChunkSendPermit, + measure: Option, +) { + tokio::spawn(run_chunked_send( + conn, + tail, + first_delivery, + session_sk, + did, + permit, + measure, + )); +} diff --git a/crates/core/src/swarm/transport/event_delivery.rs b/crates/core/src/swarm/transport/event_delivery.rs new file mode 100644 index 000000000..d135f907f --- /dev/null +++ b/crates/core/src/swarm/transport/event_delivery.rs @@ -0,0 +1,53 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::Mutex; + +use async_lock::Mutex as AsyncMutex; + +use crate::dht::Did; + +pub(super) type SwarmEventDeliveryLock = Arc>; + +#[derive(Default)] +pub(super) struct SwarmEventDeliveryLocks { + locks: Mutex>, +} + +impl SwarmEventDeliveryLocks { + pub(super) fn new() -> Self { + Self::default() + } + + pub(super) fn lock(&self, peer: Did) -> SwarmEventDeliveryLock { + match self.locks.lock() { + Ok(mut locks) => locks + .entry(peer) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone(), + Err(_) => { + tracing::warn!("Failed to lock swarm event delivery map for peer {peer}"); + Arc::new(AsyncMutex::new(())) + } + } + } + + pub(super) fn prune( + &self, + peer: Did, + delivery: &SwarmEventDeliveryLock, + connection_epoch_exists: bool, + ) { + if connection_epoch_exists { + return; + } + let Ok(mut locks) = self.locks.lock() else { + tracing::warn!("Failed to prune swarm event delivery lock for peer {peer}"); + return; + }; + if locks.get(&peer).is_some_and(|current| { + Arc::ptr_eq(current, delivery) && Arc::strong_count(current) <= 2 + }) { + locks.remove(&peer); + } + } +} diff --git a/crates/core/src/swarm/transport/liveness.rs b/crates/core/src/swarm/transport/liveness.rs new file mode 100644 index 000000000..fcfc7e7be --- /dev/null +++ b/crates/core/src/swarm/transport/liveness.rs @@ -0,0 +1,288 @@ +use std::collections::BTreeMap; +use std::sync::MutexGuard; + +use crate::dht::Did; +use crate::error::Error; +use crate::error::Result; +use crate::swarm::transport::SwarmTransport; +use crate::utils::get_epoch_ms_i64; + +/// Idle interval after which an admitted peer needs an overlay liveness probe. +pub(crate) const PEER_LIVENESS_IDLE_MS: i64 = 15_000; +/// Maximum age of an unanswered liveness probe before the peer is evicted. +pub(crate) const PEER_LIVENESS_TIMEOUT_MS: i64 = 45_000; + +#[derive(Clone, Copy, Debug)] +struct PeerLiveness { + generation: u64, + connected_at_ms: i64, + last_inbound_ms: i64, + last_probe_ms: Option, + unanswered_probe_since_ms: Option, +} + +impl PeerLiveness { + fn new(generation: u64, now_ms: i64) -> Self { + Self { + generation, + connected_at_ms: now_ms, + last_inbound_ms: now_ms, + last_probe_ms: None, + unanswered_probe_since_ms: None, + } + } + + fn mark_inbound(&mut self, now_ms: i64) { + self.last_inbound_ms = now_ms; + self.unanswered_probe_since_ms = None; + } + + fn should_probe(&self, now_ms: i64) -> bool { + now_ms.saturating_sub(self.last_inbound_ms) >= PEER_LIVENESS_IDLE_MS + && self + .last_probe_ms + .map(|last_probe_ms| now_ms.saturating_sub(last_probe_ms) >= PEER_LIVENESS_IDLE_MS) + .unwrap_or(true) + } + + fn mark_probe_sent(&mut self, now_ms: i64) { + self.last_probe_ms = Some(now_ms); + self.unanswered_probe_since_ms.get_or_insert(now_ms); + } + + fn expiry(&self, now_ms: i64) -> Option { + let unanswered_since_ms = self.unanswered_probe_since_ms?; + let unanswered_for_ms = now_ms.saturating_sub(unanswered_since_ms); + (unanswered_for_ms >= PEER_LIVENESS_TIMEOUT_MS).then_some(PeerLivenessExpiry { + unanswered_for_ms, + timeout_ms: PEER_LIVENESS_TIMEOUT_MS, + }) + } + + fn connected_for_ms(&self, now_ms: i64) -> i64 { + now_ms.saturating_sub(self.connected_at_ms) + } +} + +/// Bounded-by-active-peers overlay liveness state. +/// +/// Invariant: for every `(did, state)` in this map, `state.generation` is the +/// active transport generation that admitted `did`. A new connection generation +/// resets liveness, so late observations from an old connection cannot prove the +/// new connection live. +pub(super) struct PeerLivenessMap { + peers: BTreeMap, +} + +impl PeerLivenessMap { + pub(super) fn new() -> Self { + Self { + peers: BTreeMap::new(), + } + } + + fn mark_connected(&mut self, peer: Did, generation: u64, now_ms: i64) { + self.peers + .insert(peer, PeerLiveness::new(generation, now_ms)); + } + + fn mark_inbound(&mut self, peer: Did, generation: u64, now_ms: i64) { + match self.peers.get_mut(&peer) { + Some(liveness) if liveness.generation == generation => { + liveness.mark_inbound(now_ms); + } + _ => self.mark_connected(peer, generation, now_ms), + } + } + + fn remove(&mut self, peer: Did) { + self.peers.remove(&peer); + } + + fn retain_active(&mut self, active: &BTreeMap) { + self.peers.retain(|peer, liveness| { + active + .get(peer) + .is_some_and(|generation| *generation == liveness.generation) + }); + } + + fn probe_candidates(&mut self, active: &BTreeMap, now_ms: i64) -> Vec { + self.retain_active(active); + for (peer, generation) in active { + self.peers + .entry(*peer) + .or_insert_with(|| PeerLiveness::new(*generation, now_ms)); + } + + self.peers + .iter() + .filter_map(|(peer, liveness)| liveness.should_probe(now_ms).then_some(*peer)) + .collect() + } + + fn mark_probe_sent(&mut self, peer: Did, generation: u64, now_ms: i64) { + match self.peers.get_mut(&peer) { + Some(liveness) if liveness.generation == generation => { + liveness.mark_probe_sent(now_ms); + } + _ => { + let mut liveness = PeerLiveness::new(generation, now_ms); + liveness.mark_probe_sent(now_ms); + self.peers.insert(peer, liveness); + } + } + } + + fn expiry(&self, peer: Did, generation: u64, now_ms: i64) -> Option { + let liveness = self.peers.get(&peer)?; + (liveness.generation == generation) + .then(|| liveness.expiry(now_ms)) + .flatten() + } + + fn connected_for_ms(&self, peer: Did, generation: u64, now_ms: i64) -> Option { + let liveness = self.peers.get(&peer)?; + (liveness.generation == generation).then(|| liveness.connected_for_ms(now_ms)) + } + + #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))] + fn force_probe_sent_at(&mut self, peer: Did, generation: u64, sent_at_ms: i64) { + let mut liveness = PeerLiveness::new(generation, sent_at_ms); + liveness.mark_probe_sent(sent_at_ms); + self.peers.insert(peer, liveness); + } + + #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))] + fn force_connected_at(&mut self, peer: Did, generation: u64, connected_at_ms: i64) { + self.peers + .insert(peer, PeerLiveness::new(generation, connected_at_ms)); + } +} + +/// Peer liveness expiry evidence. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PeerLivenessExpiry { + /// How long the probe has been unanswered. + pub(crate) unanswered_for_ms: i64, + /// Configured timeout for unanswered probes. + pub(crate) timeout_ms: i64, +} + +impl SwarmTransport { + fn peer_liveness(&self) -> Result> { + self.peer_liveness + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } + + fn active_generations(&self) -> Result> { + Ok(self.active_peers()?.clone()) + } + + fn active_generation(&self, peer: Did) -> Result> { + Ok(self.active_peers()?.get(&peer).copied()) + } + + pub(crate) fn mark_peer_liveness_connected(&self, peer: Did) { + let now_ms = get_epoch_ms_i64(); + let generation = match self.active_generation(peer) { + Ok(Some(generation)) => generation, + Ok(None) => return, + Err(error) => { + tracing::warn!( + "failed to read active generation for connected peer {peer}: {error}" + ); + return; + } + }; + if let Err(error) = self + .peer_liveness() + .map(|mut liveness| liveness.mark_connected(peer, generation, now_ms)) + { + tracing::warn!("failed to mark liveness for connected peer {peer}: {error}"); + } + } + + pub(crate) fn mark_peer_liveness_inbound(&self, peer: Did) { + let now_ms = get_epoch_ms_i64(); + let generation = match self.active_generation(peer) { + Ok(Some(generation)) => generation, + Ok(None) => return, + Err(error) => { + tracing::warn!("failed to read active generation for inbound peer {peer}: {error}"); + return; + } + }; + if let Err(error) = self + .peer_liveness() + .map(|mut liveness| liveness.mark_inbound(peer, generation, now_ms)) + { + tracing::warn!("failed to mark liveness for inbound peer {peer}: {error}"); + } + } + + pub(crate) fn remove_peer_liveness(&self, peer: Did) -> Result<()> { + self.peer_liveness()?.remove(peer); + Ok(()) + } + + pub(crate) fn liveness_probe_candidates(&self, now_ms: i64) -> Result> { + let active = self.active_generations()?; + Ok(self.peer_liveness()?.probe_candidates(&active, now_ms)) + } + + pub(crate) fn record_peer_liveness_probe_sent(&self, peer: Did, now_ms: i64) -> Result<()> { + let Some(generation) = self.active_generation(peer)? else { + return Ok(()); + }; + self.peer_liveness()? + .mark_probe_sent(peer, generation, now_ms); + Ok(()) + } + + pub(crate) fn peer_liveness_expiry( + &self, + peer: Did, + now_ms: i64, + ) -> Result> { + let Some(generation) = self.active_generation(peer)? else { + return Ok(None); + }; + Ok(self.peer_liveness()?.expiry(peer, generation, now_ms)) + } + + /// Return how long an admitted peer has owned its current active generation. + pub(crate) fn peer_connected_for_ms(&self, peer: Did, now_ms: i64) -> Result> { + let Some(generation) = self.active_generation(peer)? else { + return Ok(None); + }; + Ok(self + .peer_liveness()? + .connected_for_ms(peer, generation, now_ms)) + } + + #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))] + pub(crate) fn force_peer_liveness_probe_sent_at( + &self, + peer: Did, + sent_at_ms: i64, + ) -> Result<()> { + let Some(generation) = self.active_generation(peer)? else { + return Ok(()); + }; + self.peer_liveness()? + .force_probe_sent_at(peer, generation, sent_at_ms); + Ok(()) + } + + #[cfg(all(test, feature = "dummy", not(target_family = "wasm")))] + pub(crate) fn force_peer_connected_at(&self, peer: Did, connected_at_ms: i64) -> Result<()> { + let Some(generation) = self.active_generation(peer)? else { + return Ok(()); + }; + self.peer_liveness()? + .force_connected_at(peer, generation, connected_at_ms); + Ok(()) + } +} diff --git a/crates/core/src/swarm/transport/pending.rs b/crates/core/src/swarm/transport/pending.rs new file mode 100644 index 000000000..5e3ce9310 --- /dev/null +++ b/crates/core/src/swarm/transport/pending.rs @@ -0,0 +1,841 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use rings_transport::core::transport::TransportInterface; +use rings_transport::core::transport::WebrtcConnectionState; + +use super::SwarmConnection; +use super::SwarmTransport; +use crate::dht::Did; +use crate::error::Error; +use crate::error::Result; +use crate::swarm::callback::InnerSwarmCallback; +use crate::utils::get_epoch_ms_i64; + +/// Maximum number of peers that may be handshaking before a data channel opens. +pub(crate) const DEFAULT_PENDING_CONNECTION_CAPACITY: usize = 32; + +pub(super) const PENDING_CONNECTION_TIMEOUT_MS: i64 = 180_000; + +/// 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, Ord, PartialEq, PartialOrd)] +pub(crate) struct PendingConnectionAttempt { + pub(super) peer: Did, + pub(super) generation: u64, +} + +impl PendingConnectionAttempt { + pub(crate) fn peer(self) -> Did { + self.peer + } +} + +#[derive(Clone, Debug)] +pub(super) struct PendingPeer { + generation: u64, + admitted_at_ms: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ExpiredPendingPeer { + pub(super) attempt: PendingConnectionAttempt, + pub(super) age_ms: i64, +} + +/// 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. +#[derive(Clone, Debug)] +pub(super) struct PendingPeerPool { + next_generation: u64, + peers: BTreeMap, +} + +impl PendingPeerPool { + pub(super) fn new() -> Self { + Self { + next_generation: 0, + peers: BTreeMap::new(), + } + } + + pub(super) fn reserve(&mut self, peer: Did, now_ms: i64) -> 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 + .checked_add(1) + .ok_or(Error::PendingConnectionGenerationExhausted)?; + let attempt = PendingConnectionAttempt { + peer, + generation: self.next_generation, + }; + self.peers.insert(peer, PendingPeer { + generation: attempt.generation, + admitted_at_ms: now_ms, + }); + Ok(attempt) + } + + pub(super) fn contains(&self, peer: Did) -> bool { + self.peers.contains_key(&peer) + } + + pub(super) 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 + } + + #[cfg(test)] + fn set_next_generation_for_test(&mut self, next_generation: u64) { + self.next_generation = next_generation; + } + + pub(super) fn expire(&mut self, now_ms: i64) -> Vec { + let expired = self + .peers + .iter() + .filter_map(|(peer, pending)| { + let age_ms = now_ms.saturating_sub(pending.admitted_at_ms); + (age_ms >= PENDING_CONNECTION_TIMEOUT_MS).then_some(ExpiredPendingPeer { + attempt: PendingConnectionAttempt { + peer: *peer, + generation: pending.generation, + }, + age_ms, + }) + }) + .collect::>(); + for expired in &expired { + self.peers.remove(&expired.attempt.peer); + } + expired + } + + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] + pub(super) fn len(&self) -> usize { + self.peers.len() + } +} + +impl SwarmTransport { + fn connection_lifecycle(&self) -> Result> { + self.connection_lifecycle + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } + + fn pending_peers( + &self, + ) -> Result>> + { + self.pending_peers + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } + + pub(super) fn active_peers(&self) -> Result>> { + self.active_peers + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } + + fn pending_finger_updates( + &self, + ) -> Result>>> + { + self.pending_finger_updates + .lock() + .map_err(|_| Error::SwarmConnectionLifecycleLock) + } + + pub(super) fn get_raw_connection(&self, peer: Did) -> Option { + self.transport + .connection(&peer.to_string()) + .map(|conn| SwarmConnection { + peer, + connection: conn, + }) + .ok() + } + + pub(super) 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_key(&peer)) + .unwrap_or(false) + } + + /// Return whether `attempt` owns the current active slot for its peer. + /// + /// Invariant: a terminal callback may remove an active peer only when its + /// generation equals the generation admitted by data-channel open. + pub(crate) fn is_admitted_connection_attempt(&self, attempt: PendingConnectionAttempt) -> bool { + self.active_peers() + .map(|active| active.get(&attempt.peer).copied() == Some(attempt.generation)) + .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 + ) + }) + } + + pub(super) async fn reserve_pending_connection( + &self, + peer: Did, + ) -> Result { + self.expire_pending_connections().await?; + if peer == self.dht.did { + return Err(Error::ShouldNotConnectSelf); + } + let _lifecycle = self.connection_lifecycle()?; + // 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.active_peers()?.contains_key(&peer) { + return Err(Error::AlreadyConnected); + } + let attempt = self.pending_peers()?.reserve(peer, get_epoch_ms_i64())?; + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %peer, + generation = attempt.generation, + pending_timeout_ms = PENDING_CONNECTION_TIMEOUT_MS, + "pending connection reserved" + ); + Ok(attempt) + } + + pub(crate) fn pending_attempt(&self, peer: Did) -> Result> { + let _lifecycle = self.connection_lifecycle()?; + 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 { + self.promote_pending_connection_with_gap_observer(attempt, |_| {}) + } + + fn promote_pending_connection_with_gap_observer( + &self, + attempt: PendingConnectionAttempt, + observe_gap: impl FnOnce(&Self), + ) -> Result { + let _lifecycle = self.connection_lifecycle()?; + let mut pending = self.pending_peers()?; + if !pending.remove(attempt) { + return Ok(false); + } + // The lifecycle lock spans the pending->active transition. A concurrent + // reserve cannot observe this DID as absent between the two maps. + observe_gap(self); + self.active_peers()? + .insert(attempt.peer, attempt.generation); + drop(pending); + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %attempt.peer, + generation = attempt.generation, + "pending connection promoted" + ); + Ok(true) + } + + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] + pub(crate) fn promote_pending_connection_with_gap_observer_for_test( + &self, + attempt: PendingConnectionAttempt, + observe_gap: impl FnOnce(&Self), + ) -> Result { + self.promote_pending_connection_with_gap_observer(attempt, observe_gap) + } + + pub(super) fn retire_pending_connection( + &self, + attempt: PendingConnectionAttempt, + ) -> Result { + let _lifecycle = self.connection_lifecycle()?; + let removed = self.pending_peers()?.remove(attempt); + if removed { + self.pending_finger_updates()?.remove(&attempt); + } + Ok(removed) + } + + pub(super) fn retire_active_connection(&self, peer: Did) -> Result { + let _lifecycle = self.connection_lifecycle()?; + let removed = self.active_peers()?.remove(&peer).is_some(); + if removed { + self.pending_finger_updates()? + .retain(|attempt, _| attempt.peer != peer); + self.remove_peer_liveness(peer)?; + } + Ok(removed) + } + + /// Preserve a finger-table continuation until the matching pending attempt is admitted. + pub(crate) fn queue_pending_finger_update( + &self, + attempt: PendingConnectionAttempt, + index: usize, + ) -> Result<()> { + let _lifecycle = self.connection_lifecycle()?; + if self + .pending_peers()? + .peers + .get(&attempt.peer) + .is_some_and(|pending| pending.generation == attempt.generation) + { + self.pending_finger_updates()? + .entry(attempt) + .or_default() + .insert(index); + return Ok(()); + } + if self.active_peers()?.get(&attempt.peer).copied() == Some(attempt.generation) { + self.dht.apply_fixed_finger(index, attempt.peer)?; + } + Ok(()) + } + + /// Consume finger-table continuations owned by the admitted pending attempt. + pub(crate) fn take_pending_finger_updates( + &self, + attempt: PendingConnectionAttempt, + ) -> Result> { + Ok(self + .pending_finger_updates()? + .remove(&attempt) + .unwrap_or_default() + .into_iter() + .collect()) + } + + /// 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); + } + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %attempt.peer, + generation = attempt.generation, + "pending connection cancelled" + ); + self.transport + .close_connection(&attempt.peer.to_string()) + .await + .map_err(Error::Transport)?; + Ok(true) + } + + pub(super) 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 = { + let _lifecycle = self.connection_lifecycle()?; + let expired = self.pending_peers()?.expire(get_epoch_ms_i64()); + for expired in &expired { + self.pending_finger_updates()?.remove(&expired.attempt); + } + expired + }; + for expired in expired { + let attempt = expired.attempt; + let state = self + .get_raw_connection(attempt.peer) + .map(|conn| conn.webrtc_connection_state()); + tracing::warn!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %attempt.peer, + generation = attempt.generation, + age_ms = expired.age_ms, + timeout_ms = PENDING_CONNECTION_TIMEOUT_MS, + state = ?state, + "pending connection timed out before data-channel open" + ); + 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. + pub(super) async fn new_pending_connection( + &self, + attempt: PendingConnectionAttempt, + callback: InnerSwarmCallback, + ) -> Result<()> { + let cid = attempt.peer.to_string(); + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %attempt.peer, + generation = attempt.generation, + "creating pending transport connection" + ); + if let Err(error) = self + .transport + .new_connection(&cid, Box::new(callback)) + .await + { + let _ = self.retire_pending_connection(attempt); + return Err(Error::Transport(error)); + } + tracing::info!( + target: "rings_core::swarm::transport::handshake", + local = %self.dht.did, + peer = %attempt.peer, + generation = attempt.generation, + "pending transport connection created" + ); + Ok(()) + } + + #[cfg(all(test, not(all(feature = "wasm", target_family = "wasm"))))] + pub(crate) fn pending_connection_count(&self) -> Result { + Ok(self.pending_peers()?.len()) + } +} + +#[cfg(test)] +mod lifecycle_model { + use std::collections::BTreeMap; + + use super::*; + use crate::ecc::SecretKey; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum PeerLifecycle { + Absent, + Pending(u64), + Active(u64), + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum CallbackGeneration { + Current, + Previous, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum LifecycleAction { + Reserve, + Open(CallbackGeneration), + Close(CallbackGeneration), + Failed(CallbackGeneration), + Timeout, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct LifecycleView { + peer: PeerLifecycle, + dht_member: bool, + transport_slot: bool, + generation_exhausted: bool, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct LifecycleModel { + peer: PeerLifecycle, + next_generation: u64, + previous_generation: Option, + generation_exhausted: bool, + } + + impl Default for LifecycleModel { + fn default() -> Self { + Self { + peer: PeerLifecycle::Absent, + next_generation: 0, + previous_generation: None, + generation_exhausted: false, + } + } + } + + impl LifecycleModel { + fn apply(mut self, action: LifecycleAction) -> Self { + match action { + LifecycleAction::Reserve => self.reserve(), + LifecycleAction::Open(callback) => self.open(callback), + LifecycleAction::Close(callback) | LifecycleAction::Failed(callback) => { + self.terminal(callback) + } + LifecycleAction::Timeout => self.timeout(), + } + self + } + + fn reserve(&mut self) { + if !matches!(self.peer, PeerLifecycle::Absent) { + return; + } + let Some(next_generation) = self.next_generation.checked_add(1) else { + self.generation_exhausted = true; + return; + }; + self.next_generation = next_generation; + self.peer = PeerLifecycle::Pending(next_generation); + } + + fn open(&mut self, callback: CallbackGeneration) { + let Some(callback_generation) = self.callback_generation(callback) else { + return; + }; + if self.peer == PeerLifecycle::Pending(callback_generation) { + self.peer = PeerLifecycle::Active(callback_generation); + } + } + + fn terminal(&mut self, callback: CallbackGeneration) { + let Some(callback_generation) = self.callback_generation(callback) else { + return; + }; + if self.generation_matches_live_slot(callback_generation) { + self.previous_generation = Some(callback_generation); + self.peer = PeerLifecycle::Absent; + } + } + + fn timeout(&mut self) { + if let PeerLifecycle::Pending(generation) = self.peer { + self.previous_generation = Some(generation); + self.peer = PeerLifecycle::Absent; + } + } + + fn callback_generation(self, callback: CallbackGeneration) -> Option { + match callback { + CallbackGeneration::Current => self.current_generation(), + CallbackGeneration::Previous => self.previous_generation, + } + } + + fn current_generation(self) -> Option { + match self.peer { + PeerLifecycle::Absent => None, + PeerLifecycle::Pending(generation) | PeerLifecycle::Active(generation) => { + Some(generation) + } + } + } + + fn generation_matches_live_slot(self, generation: u64) -> bool { + matches!( + self.peer, + PeerLifecycle::Pending(current) | PeerLifecycle::Active(current) if current == generation + ) + } + + fn view(self) -> LifecycleView { + LifecycleView { + peer: self.peer, + dht_member: matches!(self.peer, PeerLifecycle::Active(_)), + transport_slot: matches!( + self.peer, + PeerLifecycle::Pending(_) | PeerLifecycle::Active(_) + ), + generation_exhausted: self.generation_exhausted, + } + } + } + + #[derive(Clone)] + struct LifecycleImplementation { + peer: Did, + pending: PendingPeerPool<1>, + active: BTreeMap, + previous_attempt: Option, + current_attempt: Option, + dht_member: bool, + transport_slot: bool, + generation_exhausted: bool, + now_ms: i64, + } + + impl LifecycleImplementation { + fn new(peer: Did) -> Self { + Self { + peer, + pending: PendingPeerPool::new(), + active: BTreeMap::new(), + previous_attempt: None, + current_attempt: None, + dht_member: false, + transport_slot: false, + generation_exhausted: false, + now_ms: 0, + } + } + + fn with_next_generation(peer: Did, next_generation: u64) -> Self { + let mut this = Self::new(peer); + this.pending.set_next_generation_for_test(next_generation); + this + } + + fn apply(mut self, action: LifecycleAction) -> Self { + match action { + LifecycleAction::Reserve => self.reserve(), + LifecycleAction::Open(callback) => self.open(callback), + LifecycleAction::Close(callback) | LifecycleAction::Failed(callback) => { + self.terminal(callback) + } + LifecycleAction::Timeout => self.timeout(), + } + self + } + + fn reserve(&mut self) { + if self.pending.contains(self.peer) || self.active.contains_key(&self.peer) { + return; + } + match self.pending.reserve(self.peer, self.now_ms) { + Ok(attempt) => { + self.current_attempt = Some(attempt); + self.transport_slot = true; + self.dht_member = false; + self.now_ms += 1; + } + Err(Error::PendingConnectionGenerationExhausted) => { + self.generation_exhausted = true; + } + Err(error) => panic!("unexpected pending reserve error: {error:?}"), + } + } + + fn open(&mut self, callback: CallbackGeneration) { + let Some(attempt) = self.callback_attempt(callback) else { + return; + }; + if !self.pending.remove(attempt) { + return; + } + self.active.insert(attempt.peer, attempt.generation); + self.dht_member = true; + self.transport_slot = true; + } + + fn terminal(&mut self, callback: CallbackGeneration) { + let Some(attempt) = self.callback_attempt(callback) else { + return; + }; + let removed_pending = self.pending.remove(attempt); + let removed_active = self + .active + .get(&attempt.peer) + .copied() + .is_some_and(|generation| generation == attempt.generation); + if !removed_pending && !removed_active { + return; + } + if removed_active { + self.active.remove(&attempt.peer); + } + self.previous_attempt = Some(attempt); + if self.current_attempt == Some(attempt) { + self.current_attempt = None; + } + self.dht_member = false; + self.transport_slot = false; + } + + fn timeout(&mut self) { + let expired = self + .pending + .expire(self.now_ms + PENDING_CONNECTION_TIMEOUT_MS); + let Some(expired) = expired.into_iter().next() else { + return; + }; + self.previous_attempt = Some(expired.attempt); + if self.current_attempt == Some(expired.attempt) { + self.current_attempt = None; + } + self.dht_member = false; + self.transport_slot = false; + } + + fn callback_attempt( + &self, + callback: CallbackGeneration, + ) -> Option { + match callback { + CallbackGeneration::Current => self.current_attempt, + CallbackGeneration::Previous => self.previous_attempt, + } + } + + fn view(&self) -> LifecycleView { + let peer = if let Some(generation) = self.active.get(&self.peer).copied() { + PeerLifecycle::Active(generation) + } else if let Some(pending) = self.pending.peers.get(&self.peer) { + PeerLifecycle::Pending(pending.generation) + } else { + PeerLifecycle::Absent + }; + LifecycleView { + peer, + dht_member: self.dht_member, + transport_slot: self.transport_slot, + generation_exhausted: self.generation_exhausted, + } + } + } + + impl LifecycleView { + fn assert_invariants(self) { + // Invariant: Pending(g) is a physical transport slot only; it is not a Chord member. + if matches!(self.peer, PeerLifecycle::Pending(_)) { + assert!( + !self.dht_member, + "pending peers must never be routable: {self:?}" + ); + } + assert_eq!( + self.dht_member, + matches!(self.peer, PeerLifecycle::Active(_)), + "only admitted active peers may appear in DHT membership: {self:?}" + ); + assert_eq!( + self.transport_slot, + matches!( + self.peer, + PeerLifecycle::Pending(_) | PeerLifecycle::Active(_) + ), + "terminal and expiry transitions must remove the transport slot: {self:?}" + ); + } + } + + #[test] + fn pending_admission_model_preserves_generation_and_routing_invariants() { + const MAX_DEPTH: usize = 5; + let actions = [ + LifecycleAction::Reserve, + LifecycleAction::Open(CallbackGeneration::Current), + LifecycleAction::Open(CallbackGeneration::Previous), + LifecycleAction::Close(CallbackGeneration::Current), + LifecycleAction::Close(CallbackGeneration::Previous), + LifecycleAction::Failed(CallbackGeneration::Current), + LifecycleAction::Failed(CallbackGeneration::Previous), + LifecycleAction::Timeout, + ]; + let peer = SecretKey::random().address().into(); + + explore( + LifecycleModel::default(), + LifecycleImplementation::new(peer), + &actions, + MAX_DEPTH, + ); + } + + #[test] + fn pending_admission_model_and_pool_reject_generation_exhaustion_without_reuse() { + let peer = SecretKey::random().address().into(); + let model = LifecycleModel { + next_generation: u64::MAX, + ..LifecycleModel::default() + } + .apply(LifecycleAction::Reserve); + let implementation = LifecycleImplementation::with_next_generation(peer, u64::MAX) + .apply(LifecycleAction::Reserve); + + assert_eq!(model.view(), implementation.view()); + assert_eq!(model.view().peer, PeerLifecycle::Absent); + assert!(model.view().generation_exhausted); + model.view().assert_invariants(); + } + + fn explore( + model: LifecycleModel, + implementation: LifecycleImplementation, + actions: &[LifecycleAction], + remaining_depth: usize, + ) { + assert_eq!(model.view(), implementation.view()); + model.view().assert_invariants(); + if remaining_depth == 0 { + return; + } + for action in actions.iter().copied() { + explore( + model.apply(action), + implementation.clone().apply(action), + actions, + remaining_depth - 1, + ); + } + } +} diff --git a/crates/core/src/swarm/transport/storage_lookup.rs b/crates/core/src/swarm/transport/storage_lookup.rs new file mode 100644 index 000000000..960c41b86 --- /dev/null +++ b/crates/core/src/swarm/transport/storage_lookup.rs @@ -0,0 +1,231 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use super::SwarmTransport; +use crate::dht::entry::PlacementMiss; +use crate::dht::Did; +use crate::error::Error; +use crate::error::Result; +use crate::utils::get_epoch_ms_i64; + +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; + +// Invariant: after every successful observation-buffer mutation, +// observations.len() <= STORAGE_LOOKUP_OBSERVATION_CAPACITY. +// Invariant: after evict_storage_lookup_observations(observations, now), every +// retained bucket satisfies +// now.saturating_sub(observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS. This +// is the freshness witness required before PlacementMiss.owner drives read-repair. +pub(super) type StorageLookupObservationMap = + BTreeMap; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(super) struct StorageLookupObservationKey { + resource: Did, + redundancy: u16, +} + +pub(super) struct StorageLookupObservation { + observed_at_ms: i64, + misses: BTreeSet, +} + +fn storage_lookup_observation_now_ms() -> i64 { + get_epoch_ms_i64() +} + +fn oldest_storage_lookup_observation_key( + observations: &StorageLookupObservationMap, +) -> Option { + observations + .iter() + .min_by_key(|(key, observation)| (observation.observed_at_ms, **key)) + .map(|(key, _)| *key) +} + +// Post: observations.len() <= STORAGE_LOOKUP_OBSERVATION_CAPACITY. +// Post: forall bucket in observations, +// now_ms.saturating_sub(bucket.observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS. +// Preservation: removing expired buckets and then oldest buckets cannot create +// a stale bucket or increase the number of buckets. +fn evict_storage_lookup_observations(observations: &mut StorageLookupObservationMap, now_ms: i64) { + observations.retain(|_, observation| { + now_ms.saturating_sub(observation.observed_at_ms) <= STORAGE_LOOKUP_OBSERVATION_TTL_MS + }); + + while observations.len() > STORAGE_LOOKUP_OBSERVATION_CAPACITY { + let Some(stale_key) = oldest_storage_lookup_observation_key(observations) else { + break; + }; + observations.remove(&stale_key); + } +} + +fn reserve_storage_lookup_observation_slot(observations: &mut StorageLookupObservationMap) { + while observations.len() >= STORAGE_LOOKUP_OBSERVATION_CAPACITY { + let Some(stale_key) = oldest_storage_lookup_observation_key(observations) else { + break; + }; + observations.remove(&stale_key); + } +} + +impl SwarmTransport { + fn storage_lookup_observation_key( + &self, + resource: Did, + redundancy: u16, + ) -> Result { + self.ensure_storage_redundancy_value(redundancy)?; + Ok(StorageLookupObservationKey { + resource, + redundancy, + }) + } + + /// Start a fresh lookup round for `resource`. + /// + /// This replaces any previous miss observations for the same resource and + /// redundancy with an empty local-authorized bucket. Inbound FoundEntry + /// messages may only add misses to an existing bucket, so remote peers cannot + /// create a new redundancy mode. + /// + /// Post: if capacity permits one active lookup, a bucket exists for + /// `(resource, redundancy)` and contains no misses. + /// Preservation: eviction establishes the capacity and freshness invariants + /// before replacing the lookup-round bucket. + pub(crate) fn start_storage_lookup(&self, resource: Did, redundancy: u16) -> Result<()> { + let key = self.storage_lookup_observation_key(resource, redundancy)?; + let mut observations = self + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + let now = storage_lookup_observation_now_ms(); + evict_storage_lookup_observations(&mut observations, now); + reserve_storage_lookup_observation_slot(&mut observations); + observations.insert(key, StorageLookupObservation { + observed_at_ms: now, + misses: BTreeSet::new(), + }); + Ok(()) + } + + /// Validate that a storage lookup response belongs to a local lookup round. + /// + /// Post: `Ok(())` proves a fresh bucket exists for `(resource, redundancy)`. + pub(crate) fn ensure_storage_lookup_active( + &self, + resource: Did, + redundancy: u16, + ) -> Result<()> { + let key = self.storage_lookup_observation_key(resource, redundancy)?; + let mut observations = self + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + let now = storage_lookup_observation_now_ms(); + evict_storage_lookup_observations(&mut observations, now); + if observations.contains_key(&key) { + Ok(()) + } else { + Err(Error::InvalidMessage( + "storage lookup response has no active local lookup".to_string(), + )) + } + } + + /// Buffer placement misses observed by an in-flight storage lookup. + /// + /// Post: retained observation buckets satisfy the capacity and freshness + /// invariants. + /// Post: the supplied misses are appended only to a bucket previously created + /// by [`Self::start_storage_lookup`]. + pub(crate) fn observe_storage_misses( + &self, + resource: Did, + redundancy: u16, + misses: impl IntoIterator, + ) -> Result<()> { + let key = self.storage_lookup_observation_key(resource, redundancy)?; + let mut misses = misses.into_iter().peekable(); + if misses.peek().is_none() { + return Ok(()); + } + let mut observations = self + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + let now = storage_lookup_observation_now_ms(); + evict_storage_lookup_observations(&mut observations, now); + let Some(observation) = observations.get_mut(&key) else { + return Err(Error::InvalidMessage( + "storage miss observation has no active local lookup".to_string(), + )); + }; + observation.observed_at_ms = now; + observation.misses.extend(misses); + evict_storage_lookup_observations(&mut observations, now); + Ok(()) + } + + /// Drain fresh miss observations for a found entry. + /// + /// Post: returned misses come only from a bucket that survived freshness + /// eviction at this call's observation time. + /// Post: the bucket remains active with no buffered misses until TTL or a new + /// lookup round removes it. + /// Preservation: eviction before drain prevents stale owners from driving + /// late read-repair. + pub(crate) fn take_storage_misses( + &self, + resource: Did, + redundancy: u16, + ) -> Result> { + let key = self.storage_lookup_observation_key(resource, redundancy)?; + let mut observations = self + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + let now = storage_lookup_observation_now_ms(); + evict_storage_lookup_observations(&mut observations, now); + let Some(observation) = observations.get_mut(&key) else { + return Err(Error::InvalidMessage( + "storage repair has no active local lookup".to_string(), + )); + }; + Ok(std::mem::take(&mut observation.misses) + .into_iter() + .collect()) + } + + #[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, + resource: Did, + redundancy: u16, + ) -> Result<()> { + let key = self.storage_lookup_observation_key(resource, redundancy)?; + let mut observations = self + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + if let Some(observation) = observations.get_mut(&key) { + observation.observed_at_ms = storage_lookup_observation_now_ms() + .saturating_sub(STORAGE_LOOKUP_OBSERVATION_TTL_MS + 1); + } + Ok(()) + } + + #[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 + .storage_lookup_observations + .lock() + .map_err(|_| Error::DHTSyncLockError)?; + Ok(observations.len()) + } +} diff --git a/crates/core/src/swarm/transport/storage_sync.rs b/crates/core/src/swarm/transport/storage_sync.rs index 0293b5377..9d18c872f 100644 --- a/crates/core/src/swarm/transport/storage_sync.rs +++ b/crates/core/src/swarm/transport/storage_sync.rs @@ -1,11 +1,11 @@ use std::collections::BTreeMap; -use chrono::Utc; - use super::SwarmTransport; use crate::dht::entry::PlacedEntry; use crate::dht::entry::SyncedEntryAck; use crate::dht::Did; +use crate::dht::PeerRingAction; +use crate::dht::PeerRingRemoteAction; use crate::dht::StorageSyncDestination; use crate::dht::StorageSyncPurpose; use crate::error::Error; @@ -15,6 +15,7 @@ use crate::message::MessagePayload; use crate::message::PayloadSender; use crate::message::SyncEntriesWithSuccessor; use crate::message::SyncEntriesWithSuccessorReport; +use crate::utils::get_epoch_ms_i64; const STORAGE_SYNC_ACK_CAPACITY: usize = 1024; @@ -54,7 +55,7 @@ impl StorageSyncReceiverProof { } fn storage_sync_ack_now_ms() -> i64 { - Utc::now().timestamp_millis() + get_epoch_ms_i64() } fn expected_sync_acks(data: &[PlacedEntry]) -> Result> { @@ -87,6 +88,16 @@ fn validate_report_acks( Ok(()) } +fn storage_sync_destination_accepts_placement( + destination: StorageSyncDestination, + placement: Did, +) -> bool { + match destination { + StorageSyncDestination::PhysicalOwner(_) => true, + StorageSyncDestination::PlacementKey(key) => key == placement, + } +} + // Post: pending.len() < STORAGE_SYNC_ACK_CAPACITY. // Preservation: evicting an old pending capability before inserting a new one // can only make that old report fail validation; it cannot make an unproven @@ -105,6 +116,26 @@ fn evict_storage_sync_acks(pending: &mut StorageSyncAckMap) { } impl SwarmTransport { + async fn apply_local_storage_sync(&self, msg: &SyncEntriesWithSuccessor) -> Result<()> { + for placed in msg.data.iter() { + if !storage_sync_destination_accepts_placement(msg.destination, placed.key) { + continue; + } + + match self.dht.find_storage_owner(placed.key)? { + PeerRingAction::Some(_) => { + placed.validate_placement(self.storage_redundancy())?; + self.dht + .join_storage_entry(placed.key, placed.entry.clone()) + .await?; + } + PeerRingAction::RemoteAction(_, PeerRingRemoteAction::FindSuccessor(_)) => {} + action => return Err(Error::unexpected_peer_ring_action(action)), + } + } + Ok(()) + } + /// Record the exact ack capability created by an outbound storage-sync payload. /// /// Pre: `tx_id` is the transaction id of the payload whose message data is @@ -211,15 +242,14 @@ impl SwarmTransport { msg: SyncEntriesWithSuccessor, ) -> Result { let destination = msg.destination.did(); - let next_hop = self - .dht - .next_hop_for_storage_sync(msg.destination)? - .ok_or_else(|| { - Error::InvalidMessage( - "storage sync destination resolves to local branch at send boundary" - .to_string(), - ) - })?; + let Some(next_hop) = self.dht.next_hop_for_storage_sync(msg.destination)? else { + self.apply_local_storage_sync(&msg).await?; + return Ok(uuid::Uuid::new_v4()); + }; + if next_hop == self.dht.did { + self.apply_local_storage_sync(&msg).await?; + return Ok(uuid::Uuid::new_v4()); + } let payload = MessagePayload::new_send( Message::SyncEntriesWithSuccessor(msg.clone()), self.session_sk(), diff --git a/crates/core/src/swarm/transport/tests.rs b/crates/core/src/swarm/transport/tests.rs index c186c8754..645ae73bc 100644 --- a/crates/core/src/swarm/transport/tests.rs +++ b/crates/core/src/swarm/transport/tests.rs @@ -1,10 +1,20 @@ use std::collections::BTreeMap; +#[cfg(feature = "dummy")] +use std::sync::atomic::AtomicBool; +#[cfg(feature = "dummy")] +use std::sync::atomic::AtomicUsize; +#[cfg(feature = "dummy")] +use std::sync::atomic::Ordering; use std::sync::Arc; use std::sync::Mutex; use async_trait::async_trait; +use rings_transport::core::callback::TransportCallback; +#[cfg(feature = "dummy")] +use tokio::sync::Notify; 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; @@ -12,7 +22,13 @@ use crate::dht::MAX_STORAGE_VIRTUAL_POSITIONS_PER_OWNER; use crate::ecc::SecretKey; use crate::measure::BehaviourJudgement; use crate::measure::Measure; +#[cfg(feature = "dummy")] +use crate::message::MessagePayload; use crate::storage::MemStorage; +use crate::swarm::callback::InnerSwarmCallback; +use crate::swarm::callback::SwarmCallback; +#[cfg(feature = "dummy")] +use crate::swarm::callback::SwarmEvent; use crate::swarm::SwarmBuilder; #[derive(Default)] @@ -48,8 +64,19 @@ impl Measure for RecordingMeasure { } } - async fn get_count(&self, _did: Did, _counter: MeasureCounter) -> u64 { - 0 + async fn get_count(&self, did: Did, counter: MeasureCounter) -> u64 { + match self.counters.lock() { + Ok(counters) => counters + .iter() + .filter(|(observed_did, observed_counter)| { + *observed_did == did && *observed_counter == counter + }) + .count() as u64, + Err(_) => { + tracing::error!("RecordingMeasure counters mutex is poisoned"); + 0 + } + } } } @@ -70,6 +97,202 @@ impl BehaviourJudgement for RecordingMeasure { } } +struct NoopSwarmCallback; + +#[async_trait] +impl SwarmCallback for NoopSwarmCallback {} + +#[cfg(feature = "dummy")] +#[derive(Default)] +struct CountingSwarmCallback { + validates: AtomicUsize, + inbounds: AtomicUsize, + events: Mutex>, +} + +#[cfg(feature = "dummy")] +impl CountingSwarmCallback { + fn validates(&self) -> usize { + self.validates.load(Ordering::SeqCst) + } + + fn inbounds(&self) -> usize { + self.inbounds.load(Ordering::SeqCst) + } + + fn events(&self) -> std::io::Result> { + self.events + .lock() + .map(|events| events.clone()) + .map_err(|_| std::io::Error::other("events poisoned")) + } + + fn clear_events(&self) -> std::io::Result<()> { + self.events + .lock() + .map(|mut events| events.clear()) + .map_err(|_| std::io::Error::other("events poisoned")) + } +} + +#[cfg(feature = "dummy")] +#[async_trait] +impl SwarmCallback for CountingSwarmCallback { + async fn on_validate( + &self, + _payload: &MessagePayload, + ) -> std::result::Result<(), Box> { + self.validates.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn on_inbound( + &self, + _payload: &MessagePayload, + ) -> std::result::Result<(), Box> { + self.inbounds.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn on_event( + &self, + event: &SwarmEvent, + ) -> std::result::Result<(), Box> { + let SwarmEvent::ConnectionStateChange { state, .. } = event; + match self.events.lock() { + Ok(mut events) => events.push(*state), + Err(_) => tracing::error!("CountingSwarmCallback events mutex is poisoned"), + } + Ok(()) + } +} + +#[cfg(feature = "dummy")] +#[derive(Default)] +struct BlockingConnectMeasure { + inner: RecordingMeasure, + connect_started: AtomicBool, + connect_started_notify: Notify, + release_connect: Notify, +} + +#[cfg(feature = "dummy")] +impl BlockingConnectMeasure { + async fn wait_for_connect_started(&self) { + while !self.connect_started.load(Ordering::SeqCst) { + self.connect_started_notify.notified().await; + } + } + + fn release_connect(&self) { + self.release_connect.notify_waiters(); + } +} + +#[cfg(feature = "dummy")] +#[async_trait] +impl Measure for BlockingConnectMeasure { + async fn incr(&self, did: Did, counter: MeasureCounter) { + if counter == MeasureCounter::Connect { + self.connect_started.store(true, Ordering::SeqCst); + self.connect_started_notify.notify_waiters(); + self.release_connect.notified().await; + } + self.inner.incr(did, counter).await; + } + + async fn get_count(&self, did: Did, counter: MeasureCounter) -> u64 { + self.inner.get_count(did, counter).await + } +} + +#[cfg(feature = "dummy")] +#[async_trait] +impl BehaviourJudgement for BlockingConnectMeasure { + async fn quality(&self, did: Did) -> PeerQuality { + self.inner.quality(did).await + } + + async fn good(&self, did: Did) -> bool { + self.inner.good(did).await + } +} + +#[cfg(feature = "dummy")] +#[derive(Default)] +struct BlockingEventSwarmCallback { + blocked_peer: Mutex>, + connected_started: AtomicBool, + connected_started_notify: Notify, + release_connected: Notify, + events: Mutex>, +} + +#[cfg(feature = "dummy")] +impl BlockingEventSwarmCallback { + fn blocking_peer(peer: Did) -> Self { + Self { + blocked_peer: Mutex::new(Some(peer)), + ..Self::default() + } + } + + async fn wait_for_connected_event_started(&self) { + while !self.connected_started.load(Ordering::SeqCst) { + self.connected_started_notify.notified().await; + } + } + + fn release_connected_event(&self) { + self.release_connected.notify_waiters(); + } + + fn events(&self) -> std::io::Result> { + self.events + .lock() + .map(|events| events.iter().map(|(_, state)| *state).collect()) + .map_err(|_| std::io::Error::other("events poisoned")) + } + + fn peer_events(&self) -> std::io::Result> { + self.events + .lock() + .map(|events| events.clone()) + .map_err(|_| std::io::Error::other("events poisoned")) + } + + fn blocks_connected_peer(&self, peer: Did) -> bool { + self.blocked_peer + .lock() + .map(|blocked| match *blocked { + Some(blocked) => blocked == peer, + None => true, + }) + .unwrap_or(true) + } +} + +#[cfg(feature = "dummy")] +#[async_trait] +impl SwarmCallback for BlockingEventSwarmCallback { + async fn on_event( + &self, + event: &SwarmEvent, + ) -> std::result::Result<(), Box> { + let SwarmEvent::ConnectionStateChange { peer, state } = event; + if *state == WebrtcConnectionState::Connected && self.blocks_connected_peer(*peer) { + self.connected_started.store(true, Ordering::SeqCst); + self.connected_started_notify.notify_waiters(); + self.release_connected.notified().await; + } + match self.events.lock() { + Ok(mut events) => events.push((*peer, *state)), + Err(_) => tracing::error!("BlockingEventSwarmCallback events mutex is poisoned"), + } + Ok(()) + } +} + fn transport_with_measure(measure: MeasureImpl) -> Result { let key = SecretKey::random(); let session_sk = SessionSk::new_with_seckey(&key)?; @@ -93,6 +316,28 @@ fn transport_with_measure(measure: MeasureImpl) -> Result { )) } +#[cfg(feature = "dummy")] +async fn open_dummy_data_channel_before_ice_connected( + transport: &SwarmTransport, + peer: Did, +) -> Result<()> { + let connection = transport + .get_raw_connection(peer) + .ok_or(Error::SwarmMissTransport(peer))?; + + connection + .connection + .webrtc_answer_offer("remote-dummy-connection".to_string()) + .await + .map_err(Error::Transport)?; + assert_eq!( + connection.webrtc_connection_state(), + WebrtcConnectionState::Connecting + ); + assert!(connection.connection.data_channel_is_open()?); + Ok(()) +} + #[test] fn swarm_builder_uses_chord_virtual_node_default() -> Result<()> { let key = SecretKey::random(); @@ -132,6 +377,539 @@ 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 = 1_000; + 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 = 1_000; + 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 = 1_000; + let peer = SecretKey::random().address().into(); + let attempt = pool.reserve(peer, now)?; + + let expired = pool.expire(now + PENDING_CONNECTION_TIMEOUT_MS); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].attempt, attempt); + assert_eq!(expired[0].age_ms, PENDING_CONNECTION_TIMEOUT_MS); + 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_promotion_gap_is_covered_by_lifecycle_lock() -> Result<()> { + let transport = transport_with_measure(Arc::new(RecordingMeasure::default()))?; + let peer = SecretKey::random().address().into(); + let attempt = transport.reserve_pending_connection(peer).await?; + let mut observed_gap = false; + + assert!( + transport.promote_pending_connection_with_gap_observer_for_test(attempt, |transport| { + observed_gap = true; + assert!(transport.connection_lifecycle.try_lock().is_err()); + },)? + ); + + assert!(observed_gap); + assert!(transport.is_admitted_connection_attempt(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(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn pending_finger_update_is_applied_when_attempt_is_admitted() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let peer = SecretKey::random().address().into(); + let finger_index = 3; + let callback = InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, callback) + .await?; + + transport.queue_pending_finger_update(attempt, finger_index)?; + assert_eq!(transport.dht.lock_finger()?.get(finger_index), None); + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + + let callback = InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)) + .with_pending_connection_attempt(attempt); + callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(transport.dht.lock_finger()?.get(finger_index), Some(peer)); + assert!(transport.is_admitted_connection(peer)); + + transport.disconnect(peer).await?; + Ok(()) +} + +#[tokio::test] +async fn pending_finger_update_applies_if_admission_wins_queue_race() -> Result<()> { + let transport = transport_with_measure(Arc::new(RecordingMeasure::default()))?; + let peer = SecretKey::random().address().into(); + let finger_index = 4; + let attempt = transport.reserve_pending_connection(peer).await?; + + assert!(transport.promote_pending_connection(attempt)?); + transport.queue_pending_finger_update(attempt, finger_index)?; + + assert_eq!(transport.dht.lock_finger()?.get(finger_index), Some(peer)); + assert!(transport.is_admitted_connection(peer)); + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn data_channel_open_admits_successor_before_ice_connected() -> 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 (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, callback) + .await?; + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + + let callback = InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)) + .with_pending_connection_attempt(attempt); + callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert!(transport.is_admitted_connection(peer)); + assert!( + transport.dht.successors().contains(&peer)?, + "opened data channel must promote the advertised peer into DHT successors" + ); + + transport.disconnect(peer).await?; + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn pending_callback_messages_do_not_dispatch_before_admission() -> Result<()> { + let measure = Arc::new(RecordingMeasure::default()); + let transport = Arc::new(transport_with_measure(measure.clone())?); + let peer_key = SecretKey::random(); + let peer = peer_key.address().into(); + let peer_session = SessionSk::new_with_seckey(&peer_key)?; + let app_callback = Arc::new(CountingSwarmCallback::default()); + let offer_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, offer_callback) + .await?; + let pending_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()) + .with_pending_connection_attempt(attempt); + let payload = MessagePayload::new_send( + Message::custom(b"message-before-admission")?, + &peer_session, + transport.dht.did, + transport.dht.did, + )?; + let bytes = payload.to_bincode()?; + + pending_callback + .on_message(&peer.to_string(), &bytes) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(app_callback.validates(), 0); + assert_eq!(app_callback.inbounds(), 0); + assert_eq!(measure.snapshot_counters()?, Vec::new()); + assert!(!transport.dht.successors().contains(&peer)?); + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + + pending_callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + pending_callback + .on_message(&peer.to_string(), &bytes) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(app_callback.validates(), 1); + assert_eq!(app_callback.inbounds(), 1); + let counters = measure.snapshot_counters()?; + assert!(counters.contains(&(peer, MeasureCounter::Connect))); + assert!(counters.contains(&(peer, MeasureCounter::Received))); + assert!(transport.is_admitted_connection(peer)); + + transport.disconnect(peer).await?; + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn pending_disconnected_before_data_channel_open_is_not_reported() -> Result<()> { + let measure = Arc::new(RecordingMeasure::default()); + let transport = Arc::new(transport_with_measure(measure.clone())?); + let peer = SecretKey::random().address().into(); + let app_callback = Arc::new(CountingSwarmCallback::default()); + let offer_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, offer_callback) + .await?; + let pending_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()) + .with_pending_connection_attempt(attempt); + + pending_callback + .on_peer_connection_state_change(&peer.to_string(), WebrtcConnectionState::Disconnected) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(transport.pending_connection_count()?, 1); + assert_eq!(app_callback.events()?, Vec::new()); + assert_eq!(measure.snapshot_counters()?, Vec::new()); + assert!(!transport.dht.successors().contains(&peer)?); + + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + pending_callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert!(transport.is_admitted_connection_attempt(attempt)); + assert_eq!(app_callback.events()?, vec![ + WebrtcConnectionState::Connected + ]); + assert!(transport.dht.successors().contains(&peer)?); + + transport.disconnect(peer).await?; + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn terminal_event_during_pending_admission_prevents_late_dht_join() -> Result<()> { + let measure = Arc::new(BlockingConnectMeasure::default()); + let transport = Arc::new(transport_with_measure(measure.clone())?); + let peer = SecretKey::random().address().into(); + let app_callback = Arc::new(CountingSwarmCallback::default()); + let offer_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, offer_callback) + .await?; + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + app_callback.clear_events()?; + + let opening_transport = Arc::clone(&transport); + let opening_callback = app_callback.clone(); + let opening = tokio::spawn(async move { + let callback = InnerSwarmCallback::new(opening_transport, opening_callback) + .with_pending_connection_attempt(attempt); + callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string())) + }); + + measure.wait_for_connect_started().await; + assert!(transport.is_admitted_connection_attempt(attempt)); + + let terminal_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()) + .with_pending_connection_attempt(attempt); + terminal_callback + .on_peer_connection_state_change(&peer.to_string(), WebrtcConnectionState::Closed) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + measure.release_connect(); + opening + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))??; + + assert!(!transport.is_admitted_connection_attempt(attempt)); + assert!(!transport.dht.successors().contains(&peer)?); + let events = app_callback.events()?; + assert!(events.contains(&WebrtcConnectionState::Closed)); + assert!(!events.contains(&WebrtcConnectionState::Connected)); + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn terminal_event_waits_for_started_connected_event_delivery() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let peer = SecretKey::random().address().into(); + let app_callback = Arc::new(BlockingEventSwarmCallback::default()); + let offer_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, offer_callback) + .await?; + open_dummy_data_channel_before_ice_connected(&transport, peer).await?; + + let opening_transport = Arc::clone(&transport); + let opening_callback = app_callback.clone(); + let opening = tokio::spawn(async move { + let callback = InnerSwarmCallback::new(opening_transport, opening_callback) + .with_pending_connection_attempt(attempt); + callback + .on_data_channel_open(&peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string())) + }); + + app_callback.wait_for_connected_event_started().await; + let terminal_transport = Arc::clone(&transport); + let terminal_callback = app_callback.clone(); + let terminal = tokio::spawn(async move { + let callback = InnerSwarmCallback::new(terminal_transport, terminal_callback) + .with_pending_connection_attempt(attempt); + callback + .on_peer_connection_state_change(&peer.to_string(), WebrtcConnectionState::Closed) + .await + .map_err(|error| Error::InvalidMessage(error.to_string())) + }); + + tokio::task::yield_now().await; + assert_eq!( + connected_and_closed_events(app_callback.events()?), + Vec::new() + ); + app_callback.release_connected_event(); + + opening + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))??; + terminal + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))??; + + assert_eq!(connected_and_closed_events(app_callback.events()?), vec![ + WebrtcConnectionState::Connected, + WebrtcConnectionState::Closed + ]); + assert!(!transport.is_admitted_connection_attempt(attempt)); + assert!(!transport.dht.successors().contains(&peer)?); + Ok(()) +} + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn slow_connected_event_for_one_peer_does_not_block_other_peer_events() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let blocked_peer: Did = SecretKey::random().address().into(); + let other_peer: Did = SecretKey::random().address().into(); + let app_callback = Arc::new(BlockingEventSwarmCallback::blocking_peer(blocked_peer)); + let offer_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + let (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(blocked_peer, offer_callback) + .await?; + open_dummy_data_channel_before_ice_connected(&transport, blocked_peer).await?; + + let opening_transport = Arc::clone(&transport); + let opening_callback = app_callback.clone(); + let opening = tokio::spawn(async move { + let callback = InnerSwarmCallback::new(opening_transport, opening_callback) + .with_pending_connection_attempt(attempt); + callback + .on_data_channel_open(&blocked_peer.to_string()) + .await + .map_err(|error| Error::InvalidMessage(error.to_string())) + }); + + app_callback.wait_for_connected_event_started().await; + let other_callback = InnerSwarmCallback::new(Arc::clone(&transport), app_callback.clone()); + tokio::time::timeout( + std::time::Duration::from_millis(100), + other_callback.on_peer_connection_state_change( + &other_peer.to_string(), + WebrtcConnectionState::Connecting, + ), + ) + .await + .map_err(|_| Error::InvalidMessage("unrelated peer event was blocked".to_string()))? + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert!(app_callback + .peer_events()? + .contains(&(other_peer, WebrtcConnectionState::Connecting))); + app_callback.release_connected_event(); + opening + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))??; + + assert!(app_callback + .peer_events()? + .contains(&(blocked_peer, WebrtcConnectionState::Connected))); + transport.disconnect(blocked_peer).await?; + Ok(()) +} + +#[cfg(feature = "dummy")] +fn connected_and_closed_events(events: Vec) -> Vec { + events + .into_iter() + .filter(|state| { + matches!( + state, + WebrtcConnectionState::Connected | WebrtcConnectionState::Closed + ) + }) + .collect() +} + +#[tokio::test] +async fn local_did_lifecycle_callbacks_are_ignored() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let callback = InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)); + let local_cid = transport.dht.did.to_string(); + + callback + .on_data_channel_open(&local_cid) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + callback + .on_peer_connection_state_change(&local_cid, WebrtcConnectionState::Closed) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + callback + .on_data_channel_close(&local_cid) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(transport.pending_connection_count()?, 0); + assert!(transport.get_connection(transport.dht.did).is_none()); + Ok(()) +} + +#[tokio::test] +async fn mismatched_pending_callback_cancels_attempt_without_admission() -> 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 (attempt, _offer) = transport + .prepare_connection_offer_with_attempt(peer, callback) + .await?; + let mismatched_callback = + InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)) + .with_pending_connection_attempt(attempt); + let local_cid = transport.dht.did.to_string(); + + mismatched_callback + .on_data_channel_open(&local_cid) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert_eq!(transport.pending_connection_count()?, 0); + assert!(transport.get_connection(peer).is_none()); + assert!(transport.get_connection(transport.dht.did).is_none()); + Ok(()) +} + +#[tokio::test] +async fn late_terminal_callback_cannot_remove_replacement_active_slot() -> Result<()> { + let transport = Arc::new(transport_with_measure(Arc::new( + RecordingMeasure::default(), + ))?); + let peer = SecretKey::random().address().into(); + let old_attempt = transport.reserve_pending_connection(peer).await?; + assert!(transport.retire_pending_connection(old_attempt)?); + let current_attempt = transport.reserve_pending_connection(peer).await?; + assert!(transport.promote_pending_connection(current_attempt)?); + let late_callback = + InnerSwarmCallback::new(Arc::clone(&transport), Arc::new(NoopSwarmCallback)) + .with_pending_connection_attempt(old_attempt); + + late_callback + .on_peer_connection_state_change(&peer.to_string(), WebrtcConnectionState::Closed) + .await + .map_err(|error| Error::InvalidMessage(error.to_string()))?; + + assert!(transport.is_admitted_connection_attempt(current_attempt)); + assert_eq!(transport.admitted_connection_ids(), vec![peer]); + Ok(()) +} + #[test] fn connection_offer_protocol_mode_includes_storage_redundancy() -> Result<()> { let transport = transport_with_measure(Arc::new(RecordingMeasure::default()))?; @@ -178,8 +956,11 @@ async fn disconnected_observation_is_once_per_connection_epoch() -> Result<()> { transport.record_peer_disconnected(peer).await; transport.record_peer_disconnected(peer).await; + assert!(transport.peer_disconnected_since_ms(peer).is_some()); transport.record_peer_connected(peer).await; + assert!(transport.peer_disconnected_since_ms(peer).is_none()); transport.record_peer_disconnected(peer).await; + assert!(transport.peer_disconnected_since_ms(peer).is_some()); assert_eq!(measure.snapshot_counters()?.as_slice(), &[ (peer, MeasureCounter::Disconnected), 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..56c295de7 100644 --- a/crates/core/src/tests/default/test_chunk_e2e.rs +++ b/crates/core/src/tests/default/test_chunk_e2e.rs @@ -3,13 +3,156 @@ //! receiver reassembles the original message — exercising stream → wrap → send → reassemble, not //! just the pure `WireReserves::plan` decision. +use std::sync::Arc; +use std::sync::Mutex; + +use async_trait::async_trait; use rings_transport::connections::dummy_controlled; +use rings_transport::core::transport::WebrtcConnectionState; +use tokio::time::sleep; +use tokio::time::Duration; +use crate::dht::entry::Entry; +use crate::dht::entry::EntryKind; +use crate::dht::entry::PlacedEntry; +use crate::dht::StorageSyncDestination; +use crate::dht::StorageSyncPurpose; use crate::ecc::SecretKey; +use crate::error::Result; +use crate::measure::BehaviourJudgement; +use crate::measure::Measure; +use crate::measure::MeasureCounter; +use crate::measure::MeasureImpl; +use crate::measure::PeerQuality; use crate::message::Message; +use crate::message::SyncEntriesWithSuccessor; +use crate::session::SessionSk; +use crate::storage::MemStorage; +use crate::swarm::SwarmBuilder; use crate::tests::default::prepare_node; +use crate::tests::default::wait_for_connection_state; +use crate::tests::default::wait_for_msgs; +use crate::tests::default::wait_for_successor; +use crate::tests::default::Node; use crate::tests::manually_establish_connection; +struct PendingSendGuard; + +impl PendingSendGuard { + fn new() -> Self { + dummy_controlled::set_send_message_pending(true); + Self + } +} + +impl Drop for PendingSendGuard { + fn drop(&mut self) { + dummy_controlled::set_send_message_pending(false); + } +} + +struct PendingAfterSentCountGuard; + +impl PendingAfterSentCountGuard { + fn new(threshold: usize) -> Self { + dummy_controlled::set_send_message_pending_after_sent_count(Some(threshold)); + Self + } +} + +impl Drop for PendingAfterSentCountGuard { + fn drop(&mut self) { + dummy_controlled::set_send_message_pending_after_sent_count(None); + } +} + +struct MaxMessageSizeGuard; + +impl MaxMessageSizeGuard { + fn new(size: usize) -> Self { + dummy_controlled::set_max_message_size(size); + Self + } +} + +impl Drop for MaxMessageSizeGuard { + fn drop(&mut self) { + dummy_controlled::set_max_message_size(0); + } +} + +#[derive(Default)] +struct CountingMeasure { + counters: Mutex>, +} + +impl CountingMeasure { + fn count(&self, did: crate::dht::Did, counter: MeasureCounter) -> u64 { + match self.counters.lock() { + Ok(counters) => counters + .iter() + .filter(|(observed_did, observed_counter)| { + *observed_did == did && *observed_counter == counter + }) + .count() as u64, + Err(_) => 0, + } + } +} + +#[async_trait] +impl Measure for CountingMeasure { + async fn incr(&self, did: crate::dht::Did, counter: MeasureCounter) { + if let Ok(mut counters) = self.counters.lock() { + counters.push((did, counter)); + } + } + + async fn get_count(&self, did: crate::dht::Did, counter: MeasureCounter) -> u64 { + self.count(did, counter) + } +} + +#[async_trait] +impl BehaviourJudgement for CountingMeasure { + async fn quality(&self, _did: crate::dht::Did) -> PeerQuality { + PeerQuality::Unknown + } + + async fn good(&self, _did: crate::dht::Did) -> bool { + true + } +} + +fn prepare_node_with_measure(key: SecretKey, measure: MeasureImpl) -> Result { + let session = SessionSk::new_with_seckey(&key)?; + let swarm = Arc::new( + SwarmBuilder::new( + 0, + "stun://stun.l.google.com:19302", + Box::new(MemStorage::new()), + session, + ) + .dht_finger_table_size(super::TEST_DHT_FINGER_TABLE_SIZE) + .dht_virtual_nodes(0) + .measure(measure) + .build(), + ); + Ok(Node::new(swarm)) +} + +fn large_storage_sync_entries() -> Result> { + let mut entries = Vec::new(); + for index in 0..48 { + let topic = format!("chunked storage sync cancellation {index}"); + let entry_did = Entry::gen_did(&topic)?; + let payload = format!("payload-{index}-{}", "x".repeat(512)); + let entry = Entry::new(entry_did, vec![payload.into()], EntryKind::Data); + entries.push(PlacedEntry::new(entry_did, entry)); + } + Ok(entries) +} + /// Read inbound messages on `node` until a `CustomMessage` arrives (skipping DHT bookkeeping), or /// give up after a bounded number of messages. async fn recv_custom(node: &crate::tests::default::Node) -> Option> { @@ -29,6 +172,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. @@ -53,6 +202,88 @@ async fn large_message_is_chunked_and_reassembled() { dummy_controlled::set_max_message_size(0); } +#[tokio::test] +async fn spawned_storage_sync_tail_cancelled_by_route_disappear_does_not_degrade_next_hop( +) -> Result<()> { + let measure = Arc::new(CountingMeasure::default()); + let measure_impl: MeasureImpl = measure.clone(); + let node1 = prepare_node_with_measure(SecretKey::random(), measure_impl)?; + let node2 = prepare_node(SecretKey::random()).await; + manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_connection_state(&node1, node2.did(), WebrtcConnectionState::Connected).await?; + wait_for_successor(&node1, node2.did()).await?; + wait_for_msgs([&node1, &node2]).await; + + let _max_size = MaxMessageSizeGuard::new(8192); + dummy_controlled::reset_sent_count(); + let _pending_after_first_chunk = PendingAfterSentCountGuard::new(1); + let msg = SyncEntriesWithSuccessor { + purpose: StorageSyncPurpose::AdditiveRepair, + destination: StorageSyncDestination::PhysicalOwner(node2.did()), + data: large_storage_sync_entries()?, + }; + + let failed_before = measure.count(node2.did(), MeasureCounter::FailedToSend); + node1.swarm.transport.send_storage_sync(msg).await?; + assert_eq!( + dummy_controlled::sent_count(), + 1, + "the first chunk is accepted before the route is withdrawn" + ); + + node1.dht().remove(node2.did())?; + sleep(Duration::from_millis(600)).await; + assert_eq!( + dummy_controlled::sent_count(), + 1, + "route cancellation must stop the spawned task before another chunk is dispatched" + ); + assert_eq!( + measure.count(node2.did(), MeasureCounter::FailedToSend), + failed_before, + "route cancellation is stale topology, not next-hop transport failure" + ); + Ok(()) +} + +#[tokio::test] +async fn send_queue_backpressure_returns_transport_timeout() { + let measure = Arc::new(CountingMeasure::default()); + let measure_impl: MeasureImpl = measure.clone(); + let key1 = SecretKey::random(); + let key2 = SecretKey::random(); + let node1 = prepare_node_with_measure(key1, measure_impl).unwrap(); + 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(); + + let failed_before = measure.count(node2.did(), MeasureCounter::FailedToSend); + let _guard = PendingSendGuard::new(); + let err = node1 + .swarm + .send_message(Message::custom(b"queue-blocked").unwrap(), node2.did()) + .await + .expect_err("send admission should time out when the backend never accepts bytes"); + + assert!( + matches!( + err, + crate::error::Error::DataChannelSendQueueTimeout { peer, .. } if peer == node2.did() + ), + "expected DataChannelSendQueueTimeout, got {err:?}" + ); + assert_eq!( + measure.count(node2.did(), MeasureCounter::FailedToSend), + failed_before, + "data-channel queue backpressure is local admission pressure, not peer failure" + ); +} + #[tokio::test] async fn negotiated_size_too_small_errors_without_partial_send() { let key1 = SecretKey::random(); @@ -60,6 +291,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..9d5b02261 100644 --- a/crates/core/src/tests/default/test_connection.rs +++ b/crates/core/src/tests/default/test_connection.rs @@ -1,5 +1,9 @@ +#[cfg(feature = "dummy")] +use rings_transport::connections::dummy_controlled; use rings_transport::core::transport::WebrtcConnectionState; +#[cfg(feature = "dummy")] +use crate::dht::successor::SuccessorReader; use crate::ecc::tests::gen_ordered_keys; use crate::ecc::SecretKey; use crate::tests::default::assert_no_more_msg; @@ -73,43 +77,27 @@ async fn test_handshake_on_both_sides(key1: SecretKey, key2: SecretKey, key3: Se WebrtcConnectionState::Connected ); + let direct_connection_already_synced = + node1.swarm.transport.get_connection(node2.did()).is_some() + && node2.swarm.transport.get_connection(node1.did()).is_some(); + // connect to each at same time // Node 1 -> Offer -> Node 2 // Node 2 -> Offer -> Node 1 _ = 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:?}", - ); + if direct_connection_already_synced { + assert_eq!(node1.swarm.transport.pending_connection_count().unwrap(), 0); + assert_eq!(node2.swarm.transport.pending_connection_count().unwrap(), 0); + } else { + // 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; @@ -138,3 +126,38 @@ async fn test_handshake_on_both_sides(key1: SecretKey, key2: SecretKey, key3: Se WebrtcConnectionState::Connected, ) } + +#[cfg(feature = "dummy")] +#[tokio::test] +async fn dummy_mismatched_data_channel_open_does_not_admit_peer() { + dummy_controlled::enable(true); + + let keys = gen_ordered_keys(2); + let node1 = prepare_node(keys[0]).await; + let node2 = prepare_node(keys[1]).await; + + let offer = node1.swarm.create_offer(node2.did()).await.unwrap(); + assert!(node1.swarm.transport.get_connection(node2.did()).is_none()); + assert_eq!(node1.swarm.transport.pending_connection_count().unwrap(), 1); + + let answer = node2.swarm.answer_offer(offer).await.unwrap(); + node1.swarm.accept_answer(answer).await.unwrap(); + + assert!( + dummy_controlled::deliver_next_data_channel_open_with_cid(node1.did().to_string()).await + ); + + assert_eq!(node1.swarm.transport.pending_connection_count().unwrap(), 0); + assert!(node1.swarm.transport.get_connection(node2.did()).is_none()); + assert!(node1.swarm.transport.get_connection(node1.did()).is_none()); + assert!(!node1 + .dht() + .successors() + .list() + .unwrap() + .contains(&node2.did())); + + _ = node1.swarm.disconnect(node2.did()).await; + _ = node2.swarm.disconnect(node1.did()).await; + dummy_controlled::enable(false); +} diff --git a/crates/core/src/tests/default/test_dht_stateright.rs b/crates/core/src/tests/default/test_dht_stateright.rs index 4e9278558..00b54ccbd 100644 --- a/crates/core/src/tests/default/test_dht_stateright.rs +++ b/crates/core/src/tests/default/test_dht_stateright.rs @@ -35,7 +35,6 @@ use std::borrow::Cow; use std::collections::BTreeSet; use std::hash::Hash; -use std::hash::Hasher; use num_bigint::BigUint; use stateright::actor::model_timeout; @@ -51,19 +50,11 @@ use stateright::Model; use super::test_dht_convergence::spec; use super::test_dht_convergence::K; -use crate::algebra::JoinSemilattice; -use crate::consts::ENTRY_DATA_MAX_LEN; -use crate::dht::entry::Entry; -use crate::dht::entry::EntryCrdt; -use crate::dht::entry::EntryDot; -use crate::dht::entry::EntryKind; -use crate::dht::entry::EntryVersion; use crate::dht::successor::SuccessorReader; use crate::dht::successor::SuccessorWriter; use crate::dht::Chord; use crate::dht::Did; use crate::dht::PeerRing; -use crate::message::Encoded; use crate::storage::MemStorage; /// A DID at `num/den` of the way round the ring — deterministic test positions. @@ -544,592 +535,11 @@ fn discovery_model(all: Vec, rounds: u8) -> ActorModel bool { - if from == to { - return false; - } - self.side(from) == self.side(to) - } - - fn side(self, node: usize) -> bool { - let shift = match u32::try_from(node) { - Ok(shift) => shift, - Err(_) => return false, - }; - let Some(bit) = 1u8.checked_shl(shift) else { - return false; - }; - self.0 & bit != 0 - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -enum StorageJoinCarrier { - DataBoundedTopN, - DataOverwriteReset, - RelayTombstone, -} - -#[derive(Clone, Debug)] -struct StorageJoinValue { - carrier: StorageJoinCarrier, - bits: u8, - entry: Entry, -} - -impl StorageJoinValue { - fn new(carrier: StorageJoinCarrier, bits: u8, entry: Entry) -> Self { - match entry.try_into_storage_entry() { - Ok(entry) => Self { - carrier, - bits, - entry, - }, - Err(error) => panic!("storage model entry must normalize: {error}"), - } - } - - fn bottom_like(&self) -> Self { - storage_value_from_bits(self.carrier, 0) - } -} - -impl PartialEq for StorageJoinValue { - fn eq(&self, other: &Self) -> bool { - self.carrier == other.carrier && self.bits == other.bits - } -} - -impl Eq for StorageJoinValue {} - -impl Hash for StorageJoinValue { - fn hash(&self, state: &mut H) { - self.carrier.hash(state); - self.bits.hash(state); - } -} - -impl Ord for StorageJoinValue { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.carrier - .cmp(&other.carrier) - .then_with(|| self.bits.cmp(&other.bits)) - } -} - -impl PartialOrd for StorageJoinValue { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl JoinSemilattice for StorageJoinValue { - fn join(self, other: Self) -> Self { - if self.carrier != other.carrier { - panic!("storage model joins only one carrier"); - } - let carrier = self.carrier; - let bits = self.bits | other.bits; - let joined = storage_join_entry(self.entry, other.entry); - Self::new(carrier, bits, joined) - } -} - -struct StorageJoinScenario { - name: &'static str, - initial: [StorageJoinValue; STORAGE_REPLICA_COUNT], -} - -impl StorageJoinScenario { - fn bottom(&self) -> StorageJoinValue { - self.initial[0].bottom_like() - } - - fn global_lub(&self) -> StorageJoinValue { - self.initial - .iter() - .cloned() - .fold(self.bottom(), JoinSemilattice::join) - } -} - -fn storage_model_did(offset: u32) -> Did { - Did::from(10_000u32.saturating_add(offset)) -} - -fn storage_version(time: u128, actor: u32, operation: u32) -> EntryVersion { - EntryVersion::new(time, Did::from(actor), Did::from(operation)) -} - -fn storage_index(index: usize) -> u32 { - match u32::try_from(index) { - Ok(index) => index, - Err(_) => panic!("storage model index must fit in u32"), - } -} - -fn storage_dot(version: EntryVersion, index: usize) -> EntryDot { - let index = storage_index(index); - EntryDot { version, index } -} - -fn storage_encoded(label: &str) -> Encoded { - Encoded::from(label) -} - -fn storage_join_entry(left: Entry, right: Entry) -> Entry { - let joined = match left.join(right) { - Ok(entry) => entry, - Err(error) => panic!("storage model joins only compatible entries: {error}"), - }; - match joined.try_into_storage_entry() { - Ok(entry) => entry, - Err(error) => panic!("storage model join result must normalize: {error}"), - } -} - -fn data_value_range(did: Did, label: &'static str, start_time: u128, count: usize) -> Entry { - let data = (0..count) - .map(|index| storage_encoded(&format!("{label}-{index}"))) - .collect::>(); - let dots = (0..count) - .map(|offset| { - let index = storage_index(offset); - let time = start_time.saturating_add(u128::from(index)); - storage_dot( - storage_version(time, 1, 1_000u32.saturating_add(index)), - offset, - ) - }) - .collect::>(); - Entry { - did, - data, - kind: EntryKind::Data, - crdt: EntryCrdt { - register: None, - dots, - tombstones: Vec::new(), - }, - } -} - -fn data_overwrite_value(did: Did, label: &'static str, version: EntryVersion) -> Entry { - Entry { - did, - data: vec![storage_encoded(label)], - kind: EntryKind::Data, - crdt: EntryCrdt { - register: Some(version), - dots: vec![storage_dot(version, 0)], - tombstones: Vec::new(), - }, - } -} - -fn relay_add_value(did: Did, label: &'static str, dot: EntryDot) -> Entry { - Entry { - did, - data: vec![storage_encoded(label)], - kind: EntryKind::RelayMessage, - crdt: EntryCrdt { - register: None, - dots: vec![dot], - tombstones: Vec::new(), - }, - } -} - -fn relay_remove_value(did: Did, dot: EntryDot) -> Entry { - Entry { - did, - data: Vec::new(), - kind: EntryKind::RelayMessage, - crdt: EntryCrdt { - register: None, - dots: Vec::new(), - tombstones: vec![dot], - }, - } -} - -fn storage_delta_entry(carrier: StorageJoinCarrier, bit: u8) -> Entry { - match carrier { - StorageJoinCarrier::DataBoundedTopN => data_value_range( - storage_model_did(1), - match bit { - 0b001 => "low", - 0b010 => "mid", - 0b100 => "high", - _ => panic!("storage model delta bit must be singleton"), - }, - match bit { - 0b001 => 1, - 0b010 => 1_000, - 0b100 => 2_000, - _ => panic!("storage model delta bit must be singleton"), - }, - ENTRY_DATA_MAX_LEN, - ), - StorageJoinCarrier::DataOverwriteReset => match bit { - 0b001 => data_value_range(storage_model_did(2), "stale-a", 1, 3), - 0b010 => { - data_overwrite_value(storage_model_did(2), "reset", storage_version(100, 2, 200)) - } - 0b100 => data_value_range(storage_model_did(2), "stale-c", 10, 3), - _ => panic!("storage model delta bit must be singleton"), - }, - StorageJoinCarrier::RelayTombstone => { - let relay_a_dot = storage_dot(storage_version(1, 1, 10), 0); - let relay_b_dot = storage_dot(storage_version(2, 2, 20), 0); - match bit { - 0b001 => relay_add_value(storage_model_did(3), "relay-a", relay_a_dot), - 0b010 => relay_add_value(storage_model_did(3), "relay-b", relay_b_dot), - 0b100 => relay_remove_value(storage_model_did(3), relay_a_dot), - _ => panic!("storage model delta bit must be singleton"), - } - } - } -} - -fn storage_bottom_entry(carrier: StorageJoinCarrier) -> Entry { - let (did, kind) = match carrier { - StorageJoinCarrier::DataBoundedTopN => (storage_model_did(1), EntryKind::Data), - StorageJoinCarrier::DataOverwriteReset => (storage_model_did(2), EntryKind::Data), - StorageJoinCarrier::RelayTombstone => (storage_model_did(3), EntryKind::RelayMessage), - }; - Entry::new(did, Vec::new(), kind) -} - -fn storage_value_from_bits(carrier: StorageJoinCarrier, bits: u8) -> StorageJoinValue { - let mut entry = storage_bottom_entry(carrier); - for bit in [0b001, 0b010, 0b100] { - if bits & bit != 0 { - entry = storage_join_entry(entry, storage_delta_entry(carrier, bit)); - } - } - StorageJoinValue::new(carrier, bits, entry) -} - -fn storage_join_scenarios() -> Vec { - vec![ - StorageJoinScenario { - name: "data bounded top-n", - initial: [ - storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b001), - storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b010), - storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b100), - ], - }, - StorageJoinScenario { - name: "data overwrite reset floor", - initial: [ - storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b001), - storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b010), - storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b100), - ], - }, - StorageJoinScenario { - name: "relay tombstone prevents resurrection", - initial: [ - storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b001), - storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b010), - storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b100), - ], - }, - ] -} - -fn storage_join_carriers() -> [StorageJoinCarrier; 3] { - [ - StorageJoinCarrier::DataBoundedTopN, - StorageJoinCarrier::DataOverwriteReset, - StorageJoinCarrier::RelayTombstone, - ] -} - -fn storage_value_by_bits(values: &[StorageJoinValue], bits: u8) -> &StorageJoinValue { - match values.get(usize::from(bits)) { - Some(value) => value, - None => panic!("storage model bitmask must be in the finite carrier"), - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -enum StorageJoinPhase { - Partitioned, - Merged, -} - -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -struct StorageJoinState { - partition: StoragePartition, - phase: StorageJoinPhase, - replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], -} - -impl StorageJoinState { - fn initial( - partition: StoragePartition, - replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], - ) -> Self { - Self { - partition, - phase: StorageJoinPhase::Partitioned, - replicas, - } - } - - fn topology_permits(&self, from: usize, to: usize) -> bool { - if from == to { - return false; - } - match self.phase { - StorageJoinPhase::Partitioned => self.partition.permits(from, to), - StorageJoinPhase::Merged => from < STORAGE_REPLICA_COUNT && to < STORAGE_REPLICA_COUNT, - } - } - - fn transfer_current(&self, from: usize, to: usize) -> Option { - if !self.topology_permits(from, to) { - return None; - } - let value = self.replicas.get(from).cloned()?; - let mut next = self.clone(); - let replica = next.replicas.get_mut(to)?; - *replica = replica.clone().join(value); - Some(next) - } - - fn merge_partition(&self) -> Option { - if self.phase == StorageJoinPhase::Merged { - return None; - } - Some(Self { - phase: StorageJoinPhase::Merged, - ..self.clone() - }) - } - - fn successors(&self) -> Vec { - let mut next = Vec::new(); - if let Some(merged) = self.merge_partition() { - next.push(merged); - } - for from in 0..STORAGE_REPLICA_COUNT { - for to in 0..STORAGE_REPLICA_COUNT { - if let Some(transferred) = self.transfer_current(from, to) { - next.push(transferred); - } - } - } - next - } - - fn is_quiescent_lub(&self, global_lub: &StorageJoinValue) -> bool { - self.replicas.iter().all(|value| value == global_lub) - } - - fn transfer_all_current(&self) -> Self { - let mut state = self.clone(); - for from in 0..STORAGE_REPLICA_COUNT { - for to in 0..STORAGE_REPLICA_COUNT { - if let Some(next) = state.transfer_current(from, to) { - state = next; - } - } - } - state - } - - fn drive_to_quiescent_lub(&self, global_lub: &StorageJoinValue) -> Self { - let mut state = self.clone(); - for _ in 0..STORAGE_REPLICA_COUNT * 8 { - if state.is_quiescent_lub(global_lub) { - return state; - } - let next = state.transfer_all_current(); - if next == state { - return next; - } - state = next; - } - state - } -} - -fn reachable_storage_join_states( - partition: StoragePartition, - replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], -) -> BTreeSet { - let mut seen = BTreeSet::new(); - let mut frontier = vec![StorageJoinState::initial(partition, replicas)]; - while let Some(state) = frontier.pop() { - if !seen.insert(state.clone()) { - continue; - } - for next in state.successors() { - if !seen.contains(&next) { - frontier.push(next); - } - } - } - seen -} - -// =================================================================== -// Stage 4: storage hand-off cleanup safety for one placement key. -// -// SCOPE: this is the #614 S2' cleanup model, not the storage convergence -// theorem. Convergence is Stage 3's join-semilattice fact. This stage abstracts -// exactly one placement key, the copy -> ack -> delete hand-off, and arbitrary -// local writes over a finite representative value domain while a copy or ack is -// in flight: -// -// local(v) --SendCopy(v)--> copy_in_flight(v) -// copy_in_flight(v) --DeliverCopy--> successor(v) + ack_in_flight(v) -// local(v) --LocalWrite(w)--> local(w) -// ack_in_flight(v) --DeliverAckDelete--> delete local only if local == v -// -// Property checked below: -// -// Always S2': local(k) is removed only if successor(k) contains the same -// value at the moment of removal. -// =================================================================== - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] -enum StorageSyncStep { - SendCopy, - DeliverCopy, - LocalWrite(StorageValue), - DeliverAckDelete, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] -enum StorageValue { - V0, - V1, - V2, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] -struct StorageSyncState { - local: Option, - successor: Option, - copy_in_flight: Option, - ack_in_flight: Option, -} - -impl StorageSyncState { - fn initial() -> Self { - Self { - local: Some(StorageValue::V0), - successor: None, - copy_in_flight: None, - ack_in_flight: None, - } - } - - fn step(self, step: StorageSyncStep) -> Option { - match step { - StorageSyncStep::SendCopy => Some(Self { - copy_in_flight: Some(self.local?), - ..self - }), - StorageSyncStep::DeliverCopy => { - let copied = self.copy_in_flight?; - Some(Self { - successor: Some(copied), - copy_in_flight: None, - ack_in_flight: Some(copied), - ..self - }) - } - StorageSyncStep::LocalWrite(value) - if self.copy_in_flight.is_some() || self.ack_in_flight.is_some() => - { - Some(Self { - local: Some(value), - ..self - }) - } - StorageSyncStep::DeliverAckDelete => { - let acked = self.ack_in_flight?; - let local = match self.local { - Some(current) if current == acked => None, - current => current, - }; - Some(Self { - local, - ack_in_flight: None, - ..self - }) - } - _ => None, - } - } - - fn removed_local_value(self, next: Self) -> Option { - match (self.local, next.local) { - (Some(removed), None) => Some(removed), - _ => None, - } - } -} +mod storage_model; #[cfg(test)] mod tests { + use super::storage_model::*; use super::*; /// Build a fully-converged DHT for `node` (the production join/notify path). diff --git a/crates/core/src/tests/default/test_dht_stateright/storage_model.rs b/crates/core/src/tests/default/test_dht_stateright/storage_model.rs new file mode 100644 index 000000000..92a567730 --- /dev/null +++ b/crates/core/src/tests/default/test_dht_stateright/storage_model.rs @@ -0,0 +1,597 @@ +use std::collections::BTreeSet; +use std::hash::Hash; +use std::hash::Hasher; + +use crate::algebra::JoinSemilattice; +use crate::consts::ENTRY_DATA_MAX_LEN; +use crate::dht::entry::Entry; +use crate::dht::entry::EntryCrdt; +use crate::dht::entry::EntryDot; +use crate::dht::entry::EntryKind; +use crate::dht::entry::EntryVersion; +use crate::dht::Did; +use crate::message::Encoded; + +// =================================================================== +// Stage 3: storage CRDT SEC topology model. +// +// State variables: +// phase in {Partitioned, Merged} +// replica in StorageJoinValue^3 +// +// Initial state: +// replicas start with independent local writes A, B, C. +// phase = Partitioned, where only same-side nodes may exchange state. +// +// Next-state relation: +// Transfer(from, to) applies replica[to] := replica[to] join replica[from] +// whenever the topology allows that edge. +// Merge changes phase from Partitioned to Merged, enabling every edge. +// +// Carrier safety: +// `storage_entry_join_satisfies_semilattice_laws` proves the real Entry +// carriers are join-semilattices over this finite domain. This topology +// model therefore does not duplicate the carrier <= LUB invariant; its +// distinct obligation is the liveness/closure step below. +// +// Liveness expectation under fair anti-entropy: +// from any reachable Merged state, repeated Transfer steps reach the single +// least upper bound at every replica. +// +// Refinement: +// An asynchronous send/deliver trace projects to this Transfer model because +// delivery is a pure join of a sender snapshot into the receiver. Message +// reordering and duplication are covered by the semilattice law checked +// below: join is commutative and idempotent. +// +// Quotient: +// `StorageJoinValue` hashes and compares only `(carrier, bits)` so BFS stays +// finite. The test `storage_entry_join_satisfies_semilattice_laws` is the +// refinement witness: for every finite carrier state, real Entry::join equals +// canonical(bits_a union bits_b). The topology model is therefore checked on +// the quotient, while carrier correctness is checked on the real entries. +// =================================================================== + +pub(super) const STORAGE_REPLICA_COUNT: usize = 3; +pub(super) const STORAGE_PARTITION_MASKS: [StoragePartition; STORAGE_REPLICA_COUNT] = [ + StoragePartition(0b001), + StoragePartition(0b010), + StoragePartition(0b011), +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) struct StoragePartition(u8); + +impl StoragePartition { + fn permits(self, from: usize, to: usize) -> bool { + if from == to { + return false; + } + self.side(from) == self.side(to) + } + + fn side(self, node: usize) -> bool { + let shift = match u32::try_from(node) { + Ok(shift) => shift, + Err(_) => return false, + }; + let Some(bit) = 1u8.checked_shl(shift) else { + return false; + }; + self.0 & bit != 0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) enum StorageJoinCarrier { + DataBoundedTopN, + DataOverwriteReset, + RelayTombstone, +} + +#[derive(Clone, Debug)] +pub(super) struct StorageJoinValue { + carrier: StorageJoinCarrier, + pub(super) bits: u8, + pub(super) entry: Entry, +} + +impl StorageJoinValue { + fn new(carrier: StorageJoinCarrier, bits: u8, entry: Entry) -> Self { + match entry.try_into_storage_entry() { + Ok(entry) => Self { + carrier, + bits, + entry, + }, + Err(error) => panic!("storage model entry must normalize: {error}"), + } + } + + fn bottom_like(&self) -> Self { + storage_value_from_bits(self.carrier, 0) + } +} + +impl PartialEq for StorageJoinValue { + fn eq(&self, other: &Self) -> bool { + self.carrier == other.carrier && self.bits == other.bits + } +} + +impl Eq for StorageJoinValue {} + +impl Hash for StorageJoinValue { + fn hash(&self, state: &mut H) { + self.carrier.hash(state); + self.bits.hash(state); + } +} + +impl Ord for StorageJoinValue { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.carrier + .cmp(&other.carrier) + .then_with(|| self.bits.cmp(&other.bits)) + } +} + +impl PartialOrd for StorageJoinValue { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl JoinSemilattice for StorageJoinValue { + fn join(self, other: Self) -> Self { + if self.carrier != other.carrier { + panic!("storage model joins only one carrier"); + } + let carrier = self.carrier; + let bits = self.bits | other.bits; + let joined = storage_join_entry(self.entry, other.entry); + Self::new(carrier, bits, joined) + } +} + +pub(super) struct StorageJoinScenario { + pub(super) name: &'static str, + pub(super) initial: [StorageJoinValue; STORAGE_REPLICA_COUNT], +} + +impl StorageJoinScenario { + fn bottom(&self) -> StorageJoinValue { + self.initial[0].bottom_like() + } + + pub(super) fn global_lub(&self) -> StorageJoinValue { + self.initial + .iter() + .cloned() + .fold(self.bottom(), JoinSemilattice::join) + } +} + +fn storage_model_did(offset: u32) -> Did { + Did::from(10_000u32.saturating_add(offset)) +} + +fn storage_version(time: u128, actor: u32, operation: u32) -> EntryVersion { + EntryVersion::new(time, Did::from(actor), Did::from(operation)) +} + +fn storage_index(index: usize) -> u32 { + match u32::try_from(index) { + Ok(index) => index, + Err(_) => panic!("storage model index must fit in u32"), + } +} + +fn storage_dot(version: EntryVersion, index: usize) -> EntryDot { + let index = storage_index(index); + EntryDot { version, index } +} + +fn storage_encoded(label: &str) -> Encoded { + Encoded::from(label) +} + +pub(super) fn storage_join_entry(left: Entry, right: Entry) -> Entry { + let joined = match left.join(right) { + Ok(entry) => entry, + Err(error) => panic!("storage model joins only compatible entries: {error}"), + }; + match joined.try_into_storage_entry() { + Ok(entry) => entry, + Err(error) => panic!("storage model join result must normalize: {error}"), + } +} + +fn data_value_range(did: Did, label: &'static str, start_time: u128, count: usize) -> Entry { + let data = (0..count) + .map(|index| storage_encoded(&format!("{label}-{index}"))) + .collect::>(); + let dots = (0..count) + .map(|offset| { + let index = storage_index(offset); + let time = start_time.saturating_add(u128::from(index)); + storage_dot( + storage_version(time, 1, 1_000u32.saturating_add(index)), + offset, + ) + }) + .collect::>(); + Entry { + did, + data, + kind: EntryKind::Data, + crdt: EntryCrdt { + register: None, + dots, + tombstones: Vec::new(), + }, + } +} + +fn data_overwrite_value(did: Did, label: &'static str, version: EntryVersion) -> Entry { + Entry { + did, + data: vec![storage_encoded(label)], + kind: EntryKind::Data, + crdt: EntryCrdt { + register: Some(version), + dots: vec![storage_dot(version, 0)], + tombstones: Vec::new(), + }, + } +} + +fn relay_add_value(did: Did, label: &'static str, dot: EntryDot) -> Entry { + Entry { + did, + data: vec![storage_encoded(label)], + kind: EntryKind::RelayMessage, + crdt: EntryCrdt { + register: None, + dots: vec![dot], + tombstones: Vec::new(), + }, + } +} + +fn relay_remove_value(did: Did, dot: EntryDot) -> Entry { + Entry { + did, + data: Vec::new(), + kind: EntryKind::RelayMessage, + crdt: EntryCrdt { + register: None, + dots: Vec::new(), + tombstones: vec![dot], + }, + } +} + +fn storage_delta_entry(carrier: StorageJoinCarrier, bit: u8) -> Entry { + match carrier { + StorageJoinCarrier::DataBoundedTopN => data_value_range( + storage_model_did(1), + match bit { + 0b001 => "low", + 0b010 => "mid", + 0b100 => "high", + _ => panic!("storage model delta bit must be singleton"), + }, + match bit { + 0b001 => 1, + 0b010 => 1_000, + 0b100 => 2_000, + _ => panic!("storage model delta bit must be singleton"), + }, + ENTRY_DATA_MAX_LEN, + ), + StorageJoinCarrier::DataOverwriteReset => match bit { + 0b001 => data_value_range(storage_model_did(2), "stale-a", 1, 3), + 0b010 => { + data_overwrite_value(storage_model_did(2), "reset", storage_version(100, 2, 200)) + } + 0b100 => data_value_range(storage_model_did(2), "stale-c", 10, 3), + _ => panic!("storage model delta bit must be singleton"), + }, + StorageJoinCarrier::RelayTombstone => { + let relay_a_dot = storage_dot(storage_version(1, 1, 10), 0); + let relay_b_dot = storage_dot(storage_version(2, 2, 20), 0); + match bit { + 0b001 => relay_add_value(storage_model_did(3), "relay-a", relay_a_dot), + 0b010 => relay_add_value(storage_model_did(3), "relay-b", relay_b_dot), + 0b100 => relay_remove_value(storage_model_did(3), relay_a_dot), + _ => panic!("storage model delta bit must be singleton"), + } + } + } +} + +fn storage_bottom_entry(carrier: StorageJoinCarrier) -> Entry { + let (did, kind) = match carrier { + StorageJoinCarrier::DataBoundedTopN => (storage_model_did(1), EntryKind::Data), + StorageJoinCarrier::DataOverwriteReset => (storage_model_did(2), EntryKind::Data), + StorageJoinCarrier::RelayTombstone => (storage_model_did(3), EntryKind::RelayMessage), + }; + Entry::new(did, Vec::new(), kind) +} + +pub(super) fn storage_value_from_bits(carrier: StorageJoinCarrier, bits: u8) -> StorageJoinValue { + let mut entry = storage_bottom_entry(carrier); + for bit in [0b001, 0b010, 0b100] { + if bits & bit != 0 { + entry = storage_join_entry(entry, storage_delta_entry(carrier, bit)); + } + } + StorageJoinValue::new(carrier, bits, entry) +} + +pub(super) fn storage_join_scenarios() -> Vec { + vec![ + StorageJoinScenario { + name: "data bounded top-n", + initial: [ + storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b001), + storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b010), + storage_value_from_bits(StorageJoinCarrier::DataBoundedTopN, 0b100), + ], + }, + StorageJoinScenario { + name: "data overwrite reset floor", + initial: [ + storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b001), + storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b010), + storage_value_from_bits(StorageJoinCarrier::DataOverwriteReset, 0b100), + ], + }, + StorageJoinScenario { + name: "relay tombstone prevents resurrection", + initial: [ + storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b001), + storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b010), + storage_value_from_bits(StorageJoinCarrier::RelayTombstone, 0b100), + ], + }, + ] +} + +pub(super) fn storage_join_carriers() -> [StorageJoinCarrier; 3] { + [ + StorageJoinCarrier::DataBoundedTopN, + StorageJoinCarrier::DataOverwriteReset, + StorageJoinCarrier::RelayTombstone, + ] +} + +pub(super) fn storage_value_by_bits(values: &[StorageJoinValue], bits: u8) -> &StorageJoinValue { + match values.get(usize::from(bits)) { + Some(value) => value, + None => panic!("storage model bitmask must be in the finite carrier"), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) enum StorageJoinPhase { + Partitioned, + Merged, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(super) struct StorageJoinState { + partition: StoragePartition, + pub(super) phase: StorageJoinPhase, + replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], +} + +impl StorageJoinState { + fn initial( + partition: StoragePartition, + replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], + ) -> Self { + Self { + partition, + phase: StorageJoinPhase::Partitioned, + replicas, + } + } + + fn topology_permits(&self, from: usize, to: usize) -> bool { + if from == to { + return false; + } + match self.phase { + StorageJoinPhase::Partitioned => self.partition.permits(from, to), + StorageJoinPhase::Merged => from < STORAGE_REPLICA_COUNT && to < STORAGE_REPLICA_COUNT, + } + } + + fn transfer_current(&self, from: usize, to: usize) -> Option { + if !self.topology_permits(from, to) { + return None; + } + let value = self.replicas.get(from).cloned()?; + let mut next = self.clone(); + let replica = next.replicas.get_mut(to)?; + *replica = replica.clone().join(value); + Some(next) + } + + fn merge_partition(&self) -> Option { + if self.phase == StorageJoinPhase::Merged { + return None; + } + Some(Self { + phase: StorageJoinPhase::Merged, + ..self.clone() + }) + } + + fn successors(&self) -> Vec { + let mut next = Vec::new(); + if let Some(merged) = self.merge_partition() { + next.push(merged); + } + for from in 0..STORAGE_REPLICA_COUNT { + for to in 0..STORAGE_REPLICA_COUNT { + if let Some(transferred) = self.transfer_current(from, to) { + next.push(transferred); + } + } + } + next + } + + pub(super) fn is_quiescent_lub(&self, global_lub: &StorageJoinValue) -> bool { + self.replicas.iter().all(|value| value == global_lub) + } + + fn transfer_all_current(&self) -> Self { + let mut state = self.clone(); + for from in 0..STORAGE_REPLICA_COUNT { + for to in 0..STORAGE_REPLICA_COUNT { + if let Some(next) = state.transfer_current(from, to) { + state = next; + } + } + } + state + } + + pub(super) fn drive_to_quiescent_lub(&self, global_lub: &StorageJoinValue) -> Self { + let mut state = self.clone(); + for _ in 0..STORAGE_REPLICA_COUNT * 8 { + if state.is_quiescent_lub(global_lub) { + return state; + } + let next = state.transfer_all_current(); + if next == state { + return next; + } + state = next; + } + state + } +} + +pub(super) fn reachable_storage_join_states( + partition: StoragePartition, + replicas: [StorageJoinValue; STORAGE_REPLICA_COUNT], +) -> BTreeSet { + let mut seen = BTreeSet::new(); + let mut frontier = vec![StorageJoinState::initial(partition, replicas)]; + while let Some(state) = frontier.pop() { + if !seen.insert(state.clone()) { + continue; + } + for next in state.successors() { + if !seen.contains(&next) { + frontier.push(next); + } + } + } + seen +} + +// =================================================================== +// Stage 4: storage hand-off cleanup safety for one placement key. +// +// SCOPE: this is the #614 S2' cleanup model, not the storage convergence +// theorem. Convergence is Stage 3's join-semilattice fact. This stage abstracts +// exactly one placement key, the copy -> ack -> delete hand-off, and arbitrary +// local writes over a finite representative value domain while a copy or ack is +// in flight: +// +// local(v) --SendCopy(v)--> copy_in_flight(v) +// copy_in_flight(v) --DeliverCopy--> successor(v) + ack_in_flight(v) +// local(v) --LocalWrite(w)--> local(w) +// ack_in_flight(v) --DeliverAckDelete--> delete local only if local == v +// +// Property checked below: +// +// Always S2': local(k) is removed only if successor(k) contains the same +// value at the moment of removal. +// =================================================================== + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(super) enum StorageSyncStep { + SendCopy, + DeliverCopy, + LocalWrite(StorageValue), + DeliverAckDelete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(super) enum StorageValue { + V0, + V1, + V2, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(super) struct StorageSyncState { + local: Option, + pub(super) successor: Option, + copy_in_flight: Option, + ack_in_flight: Option, +} + +impl StorageSyncState { + pub(super) fn initial() -> Self { + Self { + local: Some(StorageValue::V0), + successor: None, + copy_in_flight: None, + ack_in_flight: None, + } + } + + pub(super) fn step(self, step: StorageSyncStep) -> Option { + match step { + StorageSyncStep::SendCopy => Some(Self { + copy_in_flight: Some(self.local?), + ..self + }), + StorageSyncStep::DeliverCopy => { + let copied = self.copy_in_flight?; + Some(Self { + successor: Some(copied), + copy_in_flight: None, + ack_in_flight: Some(copied), + ..self + }) + } + StorageSyncStep::LocalWrite(value) + if self.copy_in_flight.is_some() || self.ack_in_flight.is_some() => + { + Some(Self { + local: Some(value), + ..self + }) + } + StorageSyncStep::DeliverAckDelete => { + let acked = self.ack_in_flight?; + let local = match self.local { + Some(current) if current == acked => None, + current => current, + }; + Some(Self { + local, + ack_in_flight: None, + ..self + }) + } + _ => None, + } + } + + pub(super) fn removed_local_value(self, next: Self) -> Option { + match (self.local, next.local) { + (Some(removed), None) => Some(removed), + _ => None, + } + } +} diff --git a/crates/core/src/tests/default/test_message_handler.rs b/crates/core/src/tests/default/test_message_handler.rs index 7e8c2fc2f..9924d65f1 100644 --- a/crates/core/src/tests/default/test_message_handler.rs +++ b/crates/core/src/tests/default/test_message_handler.rs @@ -19,6 +19,7 @@ use crate::dht::PeerRingAction; use crate::dht::PeerRingRemoteAction; use crate::ecc::tests::gen_ordered_keys; use crate::ecc::SecretKey; +use crate::error::Error; use crate::error::Result; use crate::message; use crate::message::Encoder; @@ -37,6 +38,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 +48,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 +92,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 +105,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 +129,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)); @@ -188,7 +214,10 @@ async fn test_handle_connect_node() -> Result<()> { // node1 may already have connected node3 while syncing successor-list // candidates. If not, ask DHT to connect it through node2. if node1.swarm.transport.get_connection(node3.did()).is_none() { - node1.swarm.connect(node3.did()).await.unwrap(); + match node1.swarm.connect(node3.did()).await { + Ok(()) | Err(Error::AlreadyConnected) => {} + Err(error) => return Err(error), + } } wait_for_connection_state(&node1, node3.did(), WebrtcConnectionState::Connected).await?; diff --git a/crates/core/src/tests/default/test_stabilization.rs b/crates/core/src/tests/default/test_stabilization.rs index 66ca02d34..d7f6c8745 100644 --- a/crates/core/src/tests/default/test_stabilization.rs +++ b/crates/core/src/tests/default/test_stabilization.rs @@ -1,17 +1,208 @@ use std::sync::Arc; +use std::sync::Mutex; + +use async_trait::async_trait; +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +use rings_transport::connections::dummy_controlled; +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +use tokio::time::timeout; +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +use tokio::time::Duration; use crate::dht::entry::Entry; use crate::dht::entry::EntryKind; +use crate::dht::successor::SuccessorReader; +use crate::dht::successor::SuccessorWriter; +use crate::dht::Did; +use crate::dht::PeerRingAction; use crate::ecc::SecretKey; +use crate::error::Error; use crate::error::Result; +use crate::measure::BehaviourJudgement; +use crate::measure::Measure; +use crate::measure::MeasureCounter; +use crate::measure::MeasureImpl; +use crate::measure::PeerQuality; +use crate::measure::PeerQualityEvidence; +use crate::measure::PeerQualityThresholds; use crate::session::SessionSk; use crate::storage::MemStorage; +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +use crate::swarm::transport::PEER_LIVENESS_IDLE_MS; +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +use crate::swarm::transport::PEER_LIVENESS_TIMEOUT_MS; use crate::swarm::SwarmBuilder; +use crate::tests::default::assert_no_more_msg; use crate::tests::default::prepare_node; +use crate::tests::default::wait_for_msgs; use crate::tests::default::wait_for_predecessor; use crate::tests::default::wait_for_successor; use crate::tests::default::Node; use crate::tests::manually_establish_connection; +use crate::utils::get_epoch_ms_i64; + +#[derive(Default)] +struct CountingMeasure { + counters: Mutex>, +} + +#[async_trait] +impl Measure for CountingMeasure { + async fn incr(&self, did: Did, counter: MeasureCounter) { + match self.counters.lock() { + Ok(mut counters) => counters.push((did, counter)), + Err(_) => tracing::error!("CountingMeasure counters mutex is poisoned"), + } + } + + async fn get_count(&self, did: Did, counter: MeasureCounter) -> u64 { + match self.counters.lock() { + Ok(counters) => counters + .iter() + .filter(|(observed_did, observed_counter)| { + *observed_did == did && *observed_counter == counter + }) + .count() as u64, + Err(_) => { + tracing::error!("CountingMeasure counters mutex is poisoned"); + 0 + } + } + } +} + +#[async_trait] +impl BehaviourJudgement for CountingMeasure { + async fn quality(&self, did: Did) -> PeerQuality { + PeerQualityEvidence::from_measure(self, did) + .await + .classify(PeerQualityThresholds::new(3, 10, 10)) + } + + async fn good(&self, did: Did) -> bool { + self.quality(did).await != PeerQuality::Degraded + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +struct PendingDataChannelWaitGuard; + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl PendingDataChannelWaitGuard { + fn new() -> Self { + dummy_controlled::set_wait_for_data_channel_open_pending(true); + Self + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl Drop for PendingDataChannelWaitGuard { + fn drop(&mut self) { + dummy_controlled::set_wait_for_data_channel_open_pending(false); + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +struct DropMessagesGuard; + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl DropMessagesGuard { + fn new() -> Self { + dummy_controlled::set_drop_messages(true); + Self + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl Drop for DropMessagesGuard { + fn drop(&mut self) { + dummy_controlled::set_drop_messages(false); + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +struct PendingSendGuard; + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl PendingSendGuard { + fn new() -> Self { + dummy_controlled::set_send_message_pending(true); + Self + } +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +impl Drop for PendingSendGuard { + fn drop(&mut self) { + dummy_controlled::set_send_message_pending(false); + } +} + +fn prepare_node_with_measure(key: SecretKey, measure: MeasureImpl) -> Result { + let session = SessionSk::new_with_seckey(&key)?; + let swarm = Arc::new( + SwarmBuilder::new( + 0, + "stun://stun.l.google.com:19302", + Box::new(MemStorage::new()), + session, + ) + .dht_finger_table_size(super::TEST_DHT_FINGER_TABLE_SIZE) + .dht_virtual_nodes(0) + .measure(measure) + .build(), + ); + Ok(Node::new(swarm)) +} + +fn prepare_repair_node(key: SecretKey) -> Result { + prepare_repair_node_with_optional_measure(key, None) +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +fn prepare_repair_node_with_measure(key: SecretKey, measure: MeasureImpl) -> Result { + prepare_repair_node_with_optional_measure(key, Some(measure)) +} + +fn prepare_repair_node_with_optional_measure( + key: SecretKey, + measure: Option, +) -> Result { + let session = SessionSk::new_with_seckey(&key)?; + let mut builder = SwarmBuilder::new( + 0, + "stun://stun.l.google.com:19302", + Box::new(MemStorage::new()), + session, + ) + .dht_finger_table_size(super::TEST_DHT_FINGER_TABLE_SIZE) + .dht_storage_redundancy(2) + .dht_virtual_nodes(0); + if let Some(measure) = measure { + builder = builder.measure(measure); + } + let swarm = Arc::new(builder.build()); + Ok(Node::new(swarm)) +} + +fn entry_with_remote_repair_placement(node: &Node) -> Result<(Entry, Did)> { + for attempt in 0..1024 { + let resource = Entry::gen_did(&format!("fresh repair candidate {attempt}"))?; + let entry = Entry::new(resource, vec![], EntryKind::Data); + for placement in entry.did.rotate_affine(2)? { + if matches!( + node.dht().find_storage_owner(placement)?, + PeerRingAction::RemoteAction(_, _) + ) { + return Ok((entry, placement)); + } + } + } + + Err(Error::InvalidMessage( + "could not sample remote repair placement".to_string(), + )) +} #[tokio::test] async fn test_stabilization_once() -> Result<()> { @@ -37,6 +228,180 @@ async fn test_stabilization_once() -> Result<()> { Ok(()) } +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +#[tokio::test] +async fn get_and_check_connection_times_out_wedged_data_channel_wait() -> Result<()> { + let key1 = SecretKey::random(); + let key2 = SecretKey::random(); + let node1 = prepare_node(key1).await; + let node2 = prepare_node(key2).await; + manually_establish_connection(&node1.swarm, &node2.swarm).await; + + wait_for_successor(&node1, node2.did()).await?; + + let _guard = PendingDataChannelWaitGuard::new(); + let conn = timeout( + Duration::from_secs(1), + node1 + .swarm + .transport + .get_and_check_connection_with_timeout(node2.did(), Duration::from_millis(20)), + ) + .await + .map_err(|_| Error::PromiseStateTimeout)?; + + assert!(conn.is_none()); + Ok(()) +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +#[tokio::test] +async fn liveness_probe_backpressure_does_not_degrade_peer() -> Result<()> { + let measure = Arc::new(CountingMeasure::default()); + let measure_impl: MeasureImpl = measure.clone(); + let node1 = prepare_node_with_measure(SecretKey::random(), measure_impl)?; + let node2 = prepare_node(SecretKey::random()).await; + + manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_successor(&node1, node2.did()).await?; + node1 + .swarm + .transport + .force_peer_connected_at(node2.did(), get_epoch_ms_i64() - PEER_LIVENESS_IDLE_MS - 1)?; + + let _pending_send = PendingSendGuard::new(); + node1 + .swarm + .stabilizer() + .stabilize_with_step_timeout(Duration::from_secs(1)) + .await?; + + assert_eq!( + measure + .get_count(node2.did(), MeasureCounter::FailedToSend) + .await, + 0 + ); + Ok(()) +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +#[tokio::test] +async fn clean_unavailable_connections_removes_silent_connected_peer() -> Result<()> { + let node1 = prepare_node(SecretKey::random()).await; + let node2 = prepare_node(SecretKey::random()).await; + + manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_successor(&node1, node2.did()).await?; + + node1.dht().successors().extend(&[node2.did()])?; + *node1.dht().lock_predecessor()? = Some(node2.did()); + { + let dht = node1.dht(); + let mut finger = dht.lock_finger()?; + finger.set(0, node2.did()); + finger.set(3, node2.did()); + } + + let stale_probe_sent_at = get_epoch_ms_i64() - PEER_LIVENESS_TIMEOUT_MS - 1; + node1 + .swarm + .transport + .force_peer_liveness_probe_sent_at(node2.did(), stale_probe_sent_at)?; + + let _drop_messages = DropMessagesGuard::new(); + node1 + .swarm + .stabilizer() + .clean_unavailable_connections() + .await?; + + assert!(node1.swarm.transport.get_connection(node2.did()).is_none()); + assert!(!node1.dht().successors().contains(&node2.did())?); + assert_eq!(*node1.dht().lock_predecessor()?, None); + assert!(!node1.dht().lock_finger()?.contains(Some(node2.did()))); + + Ok(()) +} + +#[tokio::test] +async fn clean_unavailable_connections_removes_stale_topology_peer() -> Result<()> { + let node = prepare_node(SecretKey::random()).await; + let stale = SecretKey::random().address().into(); + + node.dht().successors().extend(&[stale])?; + *node.dht().lock_predecessor()? = Some(stale); + { + let dht = node.dht(); + let mut finger = dht.lock_finger()?; + finger.set(0, stale); + finger.set(3, stale); + } + + assert!(node.dht().successors().contains(&stale)?); + assert_eq!(*node.dht().lock_predecessor()?, Some(stale)); + assert!(node.dht().lock_finger()?.contains(Some(stale))); + assert!(!node.swarm.transport.is_admitted_connection(stale)); + + node.swarm + .stabilizer() + .clean_unavailable_connections() + .await?; + + assert!(!node.dht().successors().contains(&stale)?); + assert_eq!(*node.dht().lock_predecessor()?, None); + assert!(!node.dht().lock_finger()?.contains(Some(stale))); + + Ok(()) +} + +#[tokio::test] +async fn clean_unavailable_connections_removes_degraded_admitted_peer() -> Result<()> { + let measure = Arc::new(CountingMeasure::default()); + let measure_impl: MeasureImpl = measure.clone(); + let node1 = prepare_node_with_measure(SecretKey::random(), measure_impl)?; + let node2 = prepare_node(SecretKey::random()).await; + + manually_establish_connection(&node1.swarm, &node2.swarm).await; + wait_for_successor(&node1, node2.did()).await?; + + node1.dht().successors().extend(&[node2.did()])?; + *node1.dht().lock_predecessor()? = Some(node2.did()); + { + let dht = node1.dht(); + let mut finger = dht.lock_finger()?; + finger.set(0, node2.did()); + finger.set(3, node2.did()); + } + + for _ in 0..10 { + node1 + .swarm + .transport + .record_peer_message_send_failed(node2.did()) + .await; + } + assert_eq!( + measure + .get_count(node2.did(), MeasureCounter::FailedToSend) + .await, + 10 + ); + + node1 + .swarm + .stabilizer() + .clean_unavailable_connections() + .await?; + + assert!(node1.swarm.transport.get_connection(node2.did()).is_none()); + assert!(!node1.dht().successors().contains(&node2.did())?); + assert_eq!(*node1.dht().lock_predecessor()?, None); + assert!(!node1.dht().lock_finger()?.contains(Some(node2.did()))); + + Ok(()) +} + #[tokio::test] async fn stabilize_republishes_local_entries_to_missing_affine_owners() -> Result<()> { let key = SecretKey::random(); @@ -72,6 +437,125 @@ async fn stabilize_republishes_local_entries_to_missing_affine_owners() -> Resul Ok(()) } +#[tokio::test] +async fn repair_storage_defers_sync_to_fresh_next_hop() -> Result<()> { + let mut key1 = SecretKey::random(); + let mut key2 = SecretKey::random(); + if key1.address() < key2.address() { + (key1, key2) = (key2, key1) + } + let node1 = prepare_repair_node(key1)?; + let node2 = prepare_repair_node(key2)?; + manually_establish_connection(&node1.swarm, &node2.swarm).await; + + wait_for_successor(&node1, node2.did()).await?; + wait_for_msgs([&node1, &node2]).await; + let connected_for_ms = node1 + .swarm + .transport + .peer_connected_for_ms(node2.did(), get_epoch_ms_i64())? + .ok_or_else(|| Error::InvalidMessage("missing peer admission age".to_string()))?; + assert!( + connected_for_ms < 30_000, + "test must exercise a fresh connection; observed age {connected_for_ms}ms" + ); + + let (entry, remote_placement) = entry_with_remote_repair_placement(&node1)?; + node1 + .dht() + .storage + .put(&entry.did.to_string(), &entry) + .await?; + + node1.swarm.stabilizer().repair_storage().await?; + + assert_no_more_msg([&node2]).await; + assert_eq!( + node2 + .dht() + .storage + .get(&remote_placement.to_string()) + .await?, + None + ); + Ok(()) +} + +#[cfg(all(feature = "dummy", not(target_family = "wasm")))] +#[tokio::test] +async fn repair_storage_backpressure_defers_without_degrading_or_removing_peer() -> Result<()> { + let measure = Arc::new(CountingMeasure::default()); + let measure_impl: MeasureImpl = measure.clone(); + let mut key1 = SecretKey::random(); + let mut key2 = SecretKey::random(); + if key1.address() < key2.address() { + (key1, key2) = (key2, key1) + } + let node1 = prepare_repair_node_with_measure(key1, measure_impl)?; + let node2 = prepare_repair_node(key2)?; + manually_establish_connection(&node1.swarm, &node2.swarm).await; + + wait_for_successor(&node1, node2.did()).await?; + wait_for_msgs([&node1, &node2]).await; + node1 + .swarm + .transport + .force_peer_connected_at(node2.did(), get_epoch_ms_i64() - 31_000)?; + + node1.dht().successors().extend(&[node2.did()])?; + *node1.dht().lock_predecessor()? = Some(node2.did()); + { + let dht = node1.dht(); + let mut finger = dht.lock_finger()?; + finger.set(0, node2.did()); + finger.set(3, node2.did()); + } + + let (entry, remote_placement) = entry_with_remote_repair_placement(&node1)?; + node1 + .dht() + .storage + .put(&entry.did.to_string(), &entry) + .await?; + + let _pending_send = PendingSendGuard::new(); + node1.swarm.stabilizer().repair_storage().await?; + + assert_no_more_msg([&node2]).await; + assert_eq!( + node2 + .dht() + .storage + .get(&remote_placement.to_string()) + .await?, + None + ); + assert_eq!( + measure + .get_count(node2.did(), MeasureCounter::FailedToSend) + .await, + 0 + ); + + node1 + .swarm + .stabilizer() + .clean_unavailable_connections() + .await?; + + assert!(node1.swarm.transport.get_connection(node2.did()).is_some()); + assert!(node1.dht().successors().contains(&node2.did())?); + assert_eq!(*node1.dht().lock_predecessor()?, Some(node2.did())); + assert!(node1.dht().lock_finger()?.contains(Some(node2.did()))); + assert_eq!( + measure + .get_count(node2.did(), MeasureCounter::FailedToSend) + .await, + 0 + ); + Ok(()) +} + #[tokio::test] async fn test_stabilization() -> Result<()> { let mut key1 = SecretKey::random(); 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..a23ded85d 100644 --- a/crates/core/src/utils.rs +++ b/crates/core/src/utils.rs @@ -1,11 +1,44 @@ //! Utils for ring-core +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] use chrono::Utc; + /// Get local utc timestamp (millisecond) +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] pub fn get_epoch_ms() -> u128 { Utc::now().timestamp_millis() as u128 } -#[cfg(feature = "wasm")] +/// Get local utc timestamp (millisecond) +#[cfg(all(feature = "wasm", target_family = "wasm"))] +pub fn get_epoch_ms() -> u128 { + let now = js_sys::Date::now(); + if now.is_finite() && now > 0.0 { + now as u128 + } else { + 0 + } +} + +pub(crate) fn get_epoch_ms_i64() -> i64 { + i64::try_from(get_epoch_ms()).unwrap_or(i64::MAX) +} + +/// Sleep for `duration` on the active runtime. +#[cfg(not(all(feature = "wasm", target_family = "wasm")))] +pub(crate) async fn sleep(duration: std::time::Duration) { + futures_timer::Delay::new(duration).await; +} + +/// Sleep for `duration` on the JavaScript event loop. +#[cfg(all(feature = "wasm", target_family = "wasm"))] +pub(crate) async fn sleep(duration: std::time::Duration) { + let millis = i32::try_from(duration.as_millis()).unwrap_or(i32::MAX); + if let Err(error) = js_utils::window_sleep(millis).await { + tracing::error!("failed to wait for timeout: {:?}", error); + } +} + +#[cfg(all(feature = "wasm", target_family = "wasm"))] /// Toolset for wasm pub mod js_value { use serde::de::DeserializeOwned; @@ -38,7 +71,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 +178,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/Cargo.toml b/crates/node/Cargo.toml index b91335835..427c50608 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -101,7 +101,7 @@ strum = "0.25.0" thiserror = "1" tracing = "0.1.37" tracing-log = "0.1.3" -tracing-subscriber = { version = "0.3.15", features = ["ansi"] } +tracing-subscriber = { version = "0.3.15", features = ["ansi", "env-filter"] } uuid = { version = "0.8.2" } # node diff --git a/crates/node/bin/rings.rs b/crates/node/bin/rings.rs index 876e82cde..1d8d6c823 100644 --- a/crates/node/bin/rings.rs +++ b/crates/node/bin/rings.rs @@ -223,7 +223,7 @@ struct RunCommand { #[arg( long, - help = "Stabilization interval in seconds. If not provided, use stabilize_interval in config file or 3", + help = "Stabilization interval in seconds. If not provided, use stabilize_interval in config file or 15", env )] pub stabilize_interval: Option, diff --git a/crates/node/src/error.rs b/crates/node/src/error.rs index 20280ee8f..4efac53b9 100644 --- a/crates/node/src/error.rs +++ b/crates/node/src/error.rs @@ -144,6 +144,17 @@ pub enum Error { /// The node configuration is structurally invalid. #[error("Invalid configuration: {0}")] InvalidConfig(String) = 812, + /// Opening browser IndexedDB-backed provider storage failed. + #[error("Open browser storage \"{name}\" failed: {source}")] + BrowserStorageOpen { + /// IndexedDB database and object-store name requested by the browser provider. + name: String, + /// Storage backend error returned while opening the database. + source: rings_core::error::Error, + } = 814, + /// A periodic registration task observed a cooperative stop request. + #[error("registration task stopped")] + RegistrationStopped = 815, /// Creating a file on disk failed. #[error("Create File Error: {0}")] CreateFileError(String) = 900, @@ -203,11 +214,11 @@ pub enum Error { #[error("Wrong field, should be {0}")] SNARKWrongField(String) = 1403, /// Converting a JavaScript bigint into a prime-field element was out of range. - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] #[error("range error when covering js_sys::BigInt to PrimeField: {0}")] SNARKFFRangeError(String) = 1404, /// Converting a JavaScript bigint produced an empty representation. - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] #[error("Failed to load bigint to repr string, it's empty")] SNARKBigIntValueEmpty() = 1405, /// Loading a string as a prime-field element failed. @@ -269,7 +280,7 @@ impl From for Error { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl From for wasm_bindgen::JsValue { fn from(err: Error) -> Self { wasm_bindgen::JsValue::from_str(&err.to_string()) diff --git a/crates/node/src/extension/ext/interpret.rs b/crates/node/src/extension/ext/interpret.rs index 958442fe7..bb3c439f3 100644 --- a/crates/node/src/extension/ext/interpret.rs +++ b/crates/node/src/extension/ext/interpret.rs @@ -17,8 +17,11 @@ use crate::error::Result; /// namespace nor a remote `from`. A hard failure is an `Err`. Defined per-effect outcomes (e.g. /// "addressed no live session") are the extension's own concern and surfaced however it likes /// (its own return shapes / tests), not a core enum. -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] pub trait Interpret { /// The effect algebra this shell interprets — the same as its protocol's `Effect`. type Effect; diff --git a/crates/node/src/extension/ext/mod.rs b/crates/node/src/extension/ext/mod.rs index e6c65409e..462c38278 100644 --- a/crates/node/src/extension/ext/mod.rs +++ b/crates/node/src/extension/ext/mod.rs @@ -55,12 +55,12 @@ pub use registry::Scope; /// /// Lets the pure-core types be written once; the `Send`-ness divergence (browser futures /// are not `Send`) is confined here. `∀ T` on browser; `Send + Sync` elsewhere. -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] pub trait MaybeSend: Send + Sync {} -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] impl MaybeSend for T {} /// Auto-trait bound that is `Send + Sync` on native and empty on browser. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub trait MaybeSend {} -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl MaybeSend for T {} diff --git a/crates/node/src/extension/ext/registry.rs b/crates/node/src/extension/ext/registry.rs index 92fa75e5e..cf7bd62ae 100644 --- a/crates/node/src/extension/ext/registry.rs +++ b/crates/node/src/extension/ext/registry.rs @@ -36,18 +36,21 @@ use crate::processor::Processor; const MAX_FIXPOINT_STEPS: u32 = 1024; /// Type-erased handler stored in the registry: native is `Send + Sync`, browser not. -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] pub(crate) type DynHandler = dyn Handler + Send + Sync; /// Type-erased handler stored in the registry. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) type DynHandler = dyn Handler; type HandlerMap = RwLock>>; /// Erased, runtime-facing handler — the router-internal ABI. Implemented once, generically, by /// `Runner`; protocol authors never name it (they write `Protocol` + `Interpret`). -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] pub(crate) trait Handler { /// Decode → step (pure, committed) → run the protocol's effects, returning re-injected /// messages. `handle : (from, payload) → IO [Inbound]`. @@ -188,8 +191,11 @@ struct Runner { state: Mutex, } -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl Handler for Runner where P: Protocol + MaybeSend + 'static, diff --git a/crates/node/src/extension/mod.rs b/crates/node/src/extension/mod.rs index fa8649a8c..24ec1c45c 100644 --- a/crates/node/src/extension/mod.rs +++ b/crates/node/src/extension/mod.rs @@ -38,8 +38,8 @@ impl Backend { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl SwarmCallback for Backend { async fn on_inbound(&self, payload: &MessagePayload) -> Result<(), Box> { let data: Message = payload.transaction.data()?; diff --git a/crates/node/src/extension/protocols/echo.rs b/crates/node/src/extension/protocols/echo.rs index 08a26c03c..41685d435 100644 --- a/crates/node/src/extension/protocols/echo.rs +++ b/crates/node/src/extension/protocols/echo.rs @@ -82,8 +82,11 @@ impl Protocol for Echo { #[derive(Default)] pub struct EchoShell; -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl Interpret for EchoShell { type Effect = EchoEffect; diff --git a/crates/node/src/extension/protocols/mod.rs b/crates/node/src/extension/protocols/mod.rs index cb57ec30e..39e02bd70 100644 --- a/crates/node/src/extension/protocols/mod.rs +++ b/crates/node/src/extension/protocols/mod.rs @@ -5,6 +5,6 @@ //! platform (`NativeRelay` / `WtRelay`). pub mod echo; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub mod js; pub mod relay; diff --git a/crates/node/src/extension/protocols/relay.rs b/crates/node/src/extension/protocols/relay.rs index 747e48706..5508749d8 100644 --- a/crates/node/src/extension/protocols/relay.rs +++ b/crates/node/src/extension/protocols/relay.rs @@ -478,12 +478,12 @@ impl Interpret for NativeRelay { // ── Browser interpreter (WebTransport) ──────────────────────────────────────────────── /// Browser relay interpreter: runs [`RelayEffect`]s over the WebTransport engine it owns. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) struct WtRelay { engine: Arc, } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl WtRelay { /// Build over a shared WebTransport engine. pub(crate) fn new(engine: Arc) -> Self { @@ -491,7 +491,7 @@ impl WtRelay { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] #[async_trait::async_trait(?Send)] impl Interpret for WtRelay { type Effect = RelayEffect; @@ -649,7 +649,7 @@ impl RelayHandle { /// Map a service `name` → `target` by self-injecting a `RegisterService` command into the /// scope's own namespace (provenance = self). -#[cfg(any(feature = "node", feature = "browser"))] +#[cfg(any(feature = "node", all(feature = "browser", target_family = "wasm")))] async fn register_service(scope: &Scope, name: String, target: T) -> crate::error::Result<()> where T: Serialize { let command = RelayCommand::RegisterService { name, target }; @@ -663,14 +663,14 @@ where T: Serialize { /// Holds the two per-namespace scoped capabilities (`tcp` / `udp`). The browser relay is /// server-side only (no local listener), so this handle just registers services. See the /// native [`RelayHandle`]. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] #[derive(Clone)] pub struct RelayHandle { tcp: Scope, udp: Scope, } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl RelayHandle { /// Install the browser relay into an extension registry: register the TCP and UDP /// interpreters over a fresh, relay-owned WebTransport engine and return the client handle. diff --git a/crates/node/src/extension/snark/mod.rs b/crates/node/src/extension/snark/mod.rs index 1d714316c..ba5417a6c 100644 --- a/crates/node/src/extension/snark/mod.rs +++ b/crates/node/src/extension/snark/mod.rs @@ -35,7 +35,7 @@ type TaskId = uuid::Uuid; /// Namespace under which SNARK proof/verify tasks travel. pub const NAMESPACE: &str = "snark"; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub mod browser; mod builder; mod protocol; diff --git a/crates/node/src/extension/snark/protocol.rs b/crates/node/src/extension/snark/protocol.rs index 0a07cea21..1d7dc73a3 100644 --- a/crates/node/src/extension/snark/protocol.rs +++ b/crates/node/src/extension/snark/protocol.rs @@ -178,8 +178,11 @@ impl SnarkShell { } } -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl Interpret for SnarkShell { type Effect = SnarkEffect; diff --git a/crates/node/src/extension/transport/mod.rs b/crates/node/src/extension/transport/mod.rs index f88e880df..093737033 100644 --- a/crates/node/src/extension/transport/mod.rs +++ b/crates/node/src/extension/transport/mod.rs @@ -56,7 +56,7 @@ // API. Reachable in-crate by the relay extension only. #[cfg(feature = "node")] pub(crate) mod engine; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) mod wt; use bytes::Bytes; diff --git a/crates/node/src/logging.rs b/crates/node/src/logging.rs index 4977f9647..63aec816c 100644 --- a/crates/node/src/logging.rs +++ b/crates/node/src/logging.rs @@ -10,7 +10,7 @@ use tracing_log::LogTracer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::Registry; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub use self::browser::init_logging; #[cfg(feature = "node")] pub use self::node::init_logging; @@ -142,6 +142,7 @@ pub fn set_panic_hook() { pub mod node { use tracing_subscriber::filter; use tracing_subscriber::fmt; + use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; use super::*; @@ -153,12 +154,24 @@ pub mod node { let subscriber = Registry::default(); let level_filter = filter::LevelFilter::from_level(level.into()); + let filter = match std::env::var("RINGS_LOG_FILTER") { + Ok(spec) if !spec.trim().is_empty() => { + EnvFilter::try_new(spec.trim()).unwrap_or_else(|err| { + eprintln!( + "invalid RINGS_LOG_FILTER '{}': {}; falling back to {}", + spec, err, level_filter + ); + EnvFilter::new(level_filter.to_string()) + }) + } + _ => EnvFilter::new(level_filter.to_string()), + }; // Stderr let subscriber = subscriber.with( fmt::layer() .with_writer(std::io::stderr) - .with_filter(level_filter), + .with_filter(filter), ); // Enable log compatible layer to convert log record to tracing span. // We will ignore any errors that returned by this functions. @@ -169,7 +182,7 @@ pub mod node { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] /// Browser console logging configuration. pub mod browser { use tracing_wasm::ConsoleConfig; diff --git a/crates/node/src/measure.rs b/crates/node/src/measure.rs index 3ef779509..f059648cd 100644 --- a/crates/node/src/measure.rs +++ b/crates/node/src/measure.rs @@ -35,12 +35,12 @@ pub(crate) const fn peer_quality_thresholds() -> PeerQualityThresholds { /// `MeasureStorage` is the type accepted by `PeriodicMeasure::new`. /// It's used to store counts in a storage media provided by user. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub type MeasureStorage = Box>; /// `MeasureStorage` is the type accepted by `PeriodicMeasure::new`. /// It's used to store counts in a storage media provided by user. -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] pub type MeasureStorage = Box + Sync + Send>; /// `PeriodicMeasure` is used to assess the reliability of peers by counting their behaviour. @@ -63,9 +63,22 @@ trait MeasureClock: Send + Sync { struct SystemMeasureClock; impl MeasureClock for SystemMeasureClock { + #[cfg(not(all(feature = "browser", target_family = "wasm")))] fn now(&self) -> DateTime { Utc::now() } + + #[cfg(all(feature = "browser", target_family = "wasm"))] + fn now(&self) -> DateTime { + let now = js_sys::Date::now(); + if !now.is_finite() { + return DateTime::UNIX_EPOCH; + } + match DateTime::from_timestamp_millis(now as i64) { + Some(value) => value, + None => DateTime::UNIX_EPOCH, + } + } } #[derive(Debug)] @@ -178,7 +191,7 @@ impl PeriodicMeasure { } #[cfg_attr(feature = "node", async_trait)] -#[cfg_attr(feature = "browser", async_trait(?Send))] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] impl Measure for PeriodicMeasure { /// `incr` increments the counter of the given peer. async fn incr(&self, did: Did, counter: MeasureCounter) { @@ -217,7 +230,7 @@ impl Measure for PeriodicMeasure { } #[cfg_attr(feature = "node", async_trait)] -#[cfg_attr(feature = "browser", async_trait(?Send))] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] impl measure::BehaviourJudgement for PeriodicMeasure { async fn quality(&self, did: Did) -> PeerQuality { let thresholds = peer_quality_thresholds(); diff --git a/crates/node/src/native/config.rs b/crates/node/src/native/config.rs index 1ab4a8746..110e8724e 100644 --- a/crates/node/src/native/config.rs +++ b/crates/node/src/native/config.rs @@ -43,7 +43,7 @@ pub const DEFAULT_ENDPOINT_URL: &str = "http://127.0.0.1:50000"; /// Default WebRTC ICE server list. pub const DEFAULT_ICE_SERVERS: &str = "stun://stun.l.google.com:19302"; /// Default Chord stabilization interval in seconds. -pub const DEFAULT_STABILIZE_INTERVAL: u64 = 3; +pub const DEFAULT_STABILIZE_INTERVAL: u64 = 15; /// Default storage capacity in bytes for native storage backends. pub const DEFAULT_STORAGE_CAPACITY: u32 = 200000000; @@ -327,7 +327,7 @@ internal_api_port: 50000 external_api_addr: 127.0.0.1:50001 endpoint_url: http://127.0.0.1:50000 ice_servers: stun://stun.l.google.com:19302 -stabilize_interval: 3 +stabilize_interval: 15 external_ip: null webrtc_udp_port_min: null webrtc_udp_port_max: null @@ -374,7 +374,7 @@ internal_api_port: 50000 external_api_addr: 127.0.0.1:50001 endpoint_url: http://127.0.0.1:50000 ice_servers: stun://stun.l.google.com:19302 -stabilize_interval: 3 +stabilize_interval: 15 dht_virtual_nodes: 0 external_ip: null webrtc_udp_port_min: null diff --git a/crates/node/src/onion/circuit/shell.rs b/crates/node/src/onion/circuit/shell.rs index 2d7d6925a..65d985cdf 100644 --- a/crates/node/src/onion/circuit/shell.rs +++ b/crates/node/src/onion/circuit/shell.rs @@ -107,8 +107,11 @@ impl OnionCircuitShell { } } -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl Interpret for OnionCircuitShell where H: OnionCircuitHandler + crate::extension::ext::MaybeSend + 'static { @@ -185,8 +188,11 @@ pub struct OnionCircuitExitFrame { } /// Runtime-specific circuit handling. -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] pub trait OnionCircuitHandler { /// Handle a frame that reached this node as the exit. async fn handle_exit(&self, scope: &Scope, frame: OnionCircuitExitFrame) -> Result<()>; diff --git a/crates/node/src/onion/circuit/tests/test_circuit_protocol.rs b/crates/node/src/onion/circuit/tests/test_circuit_protocol.rs index 2732576e4..f7494c1e6 100644 --- a/crates/node/src/onion/circuit/tests/test_circuit_protocol.rs +++ b/crates/node/src/onion/circuit/tests/test_circuit_protocol.rs @@ -140,8 +140,11 @@ impl RecordingHandler { } #[cfg(feature = "node")] -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl OnionCircuitHandler for RecordingHandler { async fn handle_exit( &self, diff --git a/crates/node/src/onion/directory.rs b/crates/node/src/onion/directory.rs index 65fc2648b..36590a094 100644 --- a/crates/node/src/onion/directory.rs +++ b/crates/node/src/onion/directory.rs @@ -21,8 +21,11 @@ use crate::onion::proxy::OnionProxyTarget; use crate::online::OnlineNodeDescriptor; /// Read-only directory effects required by onion route construction. -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] pub(crate) trait OnionDirectoryReader { /// Return the local DID that must not appear as a selected relay. fn local_did(&self) -> Did; diff --git a/crates/node/src/onion/https/mod.rs b/crates/node/src/onion/https/mod.rs index 14177d506..277df809d 100644 --- a/crates/node/src/onion/https/mod.rs +++ b/crates/node/src/onion/https/mod.rs @@ -1,4 +1,10 @@ -#![cfg_attr(all(feature = "node", not(feature = "browser")), allow(dead_code))] +#![cfg_attr( + all( + feature = "node", + not(all(feature = "browser", target_family = "wasm")) + ), + allow(dead_code) +)] //! HTTPS onion-exit request/response adapter. //! //! This protocol is intentionally application-layer HTTPS. Clients can send an HTTPS request @@ -9,10 +15,10 @@ //! headers, credentials policy, and extension host permissions still apply. A full arbitrary HTTPS //! exit must run in a browser-extension or native context that grants those fetch permissions. -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use std::cell::RefCell; use std::collections::HashMap; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use std::rc::Rc; use std::sync::Arc; use std::sync::Mutex; @@ -21,35 +27,35 @@ use std::time::Duration; use bytes::Bytes; use futures::channel::oneshot; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use futures::future::Either; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use futures::FutureExt; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use js_sys::Function; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use js_sys::Object; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use js_sys::Promise; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use js_sys::Reflect; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use js_sys::Uint8Array; use rings_core::dht::Did; use rings_core::session::SessionSk; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use rings_core::utils::js_utils; use serde::Deserialize; use serde::Serialize; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use wasm_bindgen::closure::Closure; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use wasm_bindgen::JsCast; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use wasm_bindgen::JsValue; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use wasm_bindgen_futures::JsFuture; -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] use web_sys::AbortController; use crate::error::Error; @@ -58,7 +64,7 @@ use crate::extension::ext::Scope; use crate::onion::circuit::send_backward; use crate::onion::circuit::OnionAuthenticatedPayload; use crate::onion::circuit::OnionCircuitExitFrame; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use crate::onion::circuit::OnionCircuitHandler; use crate::onion::circuit::OnionCircuitId; use crate::onion::circuit::OnionCircuitPayload; @@ -80,7 +86,7 @@ use crate::onion::OnionRouteError; const DEFAULT_HTTPS_RESPONSE_BODY_LIMIT_BYTES: u64 = 8 * 1024 * 1024; #[cfg(feature = "node")] const HTTPS_EXIT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] const HTTPS_EXIT_REQUEST_TIMEOUT_MS: i32 = 30_000; /// One HTTPS request executed by an HTTPS exit. @@ -476,13 +482,13 @@ fn url_path(suffix: &str) -> String { } /// Browser handler for HTTPS onion circuits. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) struct BrowserOnionCircuitHandler { https: Arc, session_sk: SessionSk, } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl BrowserOnionCircuitHandler { /// Create a browser circuit handler backed by the HTTPS runtime. pub(crate) fn new(https: Arc, session_sk: SessionSk) -> Self { @@ -490,7 +496,7 @@ impl BrowserOnionCircuitHandler { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] #[async_trait::async_trait(?Send)] impl OnionCircuitHandler for BrowserOnionCircuitHandler { async fn handle_exit(&self, scope: &Scope, frame: OnionCircuitExitFrame) -> Result<()> { @@ -607,7 +613,7 @@ async fn execute_https_request( native_fetch(url, request, max_body_bytes, runtime, policy).await } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] async fn execute_https_request( url: &str, request: &OnionHttpsRequest, @@ -704,7 +710,7 @@ async fn native_fetch_with_timeout( }) } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] async fn browser_fetch( url: &str, request: &OnionHttpsRequest, @@ -760,7 +766,7 @@ async fn browser_fetch( } } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] fn fetch_init(request: &OnionHttpsRequest, signal: &JsValue) -> Result { let init = Object::new(); Reflect::set( @@ -835,7 +841,7 @@ fn usize_to_u64(value: usize) -> Result { u64::try_from(value).map_err(|_| Error::InvalidData) } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] fn collect_headers(response: &JsValue) -> Result> { let headers = Reflect::get(response, JsValue::from_str("headers").as_ref()).map_err(js_error)?; @@ -878,7 +884,7 @@ fn reject_content_length_over_limit( Ok(()) } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] async fn response_body( response: &JsValue, max_body_bytes: u64, @@ -973,7 +979,7 @@ fn default_path() -> String { "/".to_string() } -#[cfg(all(not(feature = "node"), feature = "browser"))] +#[cfg(all(not(feature = "node"), feature = "browser", target_family = "wasm"))] fn js_error(error: JsValue) -> Error { Error::JsError(format!("{error:?}")) } diff --git a/crates/node/src/onion/mod.rs b/crates/node/src/onion/mod.rs index 12365149e..3ca4ddda0 100644 --- a/crates/node/src/onion/mod.rs +++ b/crates/node/src/onion/mod.rs @@ -42,7 +42,7 @@ pub mod circuit; pub(crate) mod directory; pub(crate) mod exit_accounting; mod failure; -#[cfg(any(feature = "browser", feature = "node"))] +#[cfg(any(feature = "node", all(feature = "browser", target_family = "wasm")))] pub mod https; pub mod proxy; pub(crate) mod replay; @@ -822,7 +822,13 @@ impl OnionExitRegistration { .iter() .map(|descriptor| descriptor.encode().map_err(Error::CoreError)) .collect::>>()?; - self.publisher.publish_many(context, encoded).await?; + self.publisher + .publish_many_replacing(context, encoded, |observed| { + observed + .decode::() + .is_ok_and(|descriptor| descriptor.did == context.did()) + }) + .await?; Ok(descriptors) } @@ -860,8 +866,8 @@ impl OnionExitRegistration { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl RegistrationTask for OnionExitRegistration { fn name(&self) -> &'static str { "onion-exit" diff --git a/crates/node/src/prelude.rs b/crates/node/src/prelude.rs index b0d0e9f54..60cf74104 100644 --- a/crates/node/src/prelude.rs +++ b/crates/node/src/prelude.rs @@ -6,6 +6,8 @@ pub use rings_derive::wasm_export; pub use self::rings_core::chunk; pub use self::rings_core::dht::PeerRing; pub use self::rings_core::ecc::SecretKey; +pub use self::rings_core::lifecycle::StopSource; +pub use self::rings_core::lifecycle::StopToken; pub use self::rings_core::measure::PeerMeasurement; pub use self::rings_core::message::CustomMessage; pub use self::rings_core::message::Message; diff --git a/crates/node/src/processor/builder.rs b/crates/node/src/processor/builder.rs index 864457ebd..5a15227be 100644 --- a/crates/node/src/processor/builder.rs +++ b/crates/node/src/processor/builder.rs @@ -215,7 +215,7 @@ impl ProcessorBuilder { session_sk, stabilize_interval: self.stabilize_interval, online_node_registration, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] advertise_onion_relay: self.advertise_onion_relay, registration_tasks, }) diff --git a/crates/node/src/processor/config.rs b/crates/node/src/processor/config.rs index 48292952c..dcea574b7 100644 --- a/crates/node/src/processor/config.rs +++ b/crates/node/src/processor/config.rs @@ -140,7 +140,7 @@ impl ProcessorConfig { } /// Return the HTTPS onion-exit policy when this config advertises that service. - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] pub fn onion_https_exit_policy(&self) -> Option { (self.advertise_onion_exit && self diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index d2b7eab80..5ba4337a0 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -11,6 +11,7 @@ use rings_core::dht::EntryStorage; use rings_core::dht::DEFAULT_FINGER_TABLE_SIZE; use rings_core::ecc::PublicKey; use rings_core::ecc::SecretKey; +use rings_core::lifecycle::StopToken; use rings_core::measure::MeasureImpl; use rings_core::measure::PeerMeasurement; use rings_core::measure::PeerQuality; @@ -50,7 +51,7 @@ use crate::onion::https_onion_exit_services; use crate::onion::proxy::OnionProxyConfig; use crate::onion::proxy::OnionProxyRoute; use crate::onion::proxy::OnionProxyTarget; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use crate::onion::proxy::ONION_PROXY_HTTPS_SERVICE; use crate::onion::validate_onion_exit_registration_timing; use crate::onion::OnionExitDescriptor; @@ -89,14 +90,15 @@ pub use config::ProcessorConfigSerialized; const DHT_LOOKUP_CACHE_POLL_INTERVAL: Duration = Duration::from_millis(50); const DHT_LOOKUP_CACHE_POLL_ATTEMPTS: usize = 40; +const REGISTRATION_STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] async fn sleep_dht_lookup_poll_interval(interval: Duration) -> Result<()> { futures_timer::Delay::new(interval).await; Ok(()) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] async fn sleep_dht_lookup_poll_interval(interval: Duration) -> Result<()> { let interval_ms = i32::try_from(interval.as_millis()).unwrap_or(i32::MAX); rings_core::utils::js_utils::window_sleep(interval_ms) @@ -105,6 +107,22 @@ async fn sleep_dht_lookup_poll_interval(interval: Duration) -> Result<()> { Ok(()) } +async fn sleep_registration_interval_with_stop( + interval: Duration, + stop: &StopToken, +) -> Result { + let mut remaining = interval; + while !remaining.is_zero() { + if stop.should_stop() { + return Ok(false); + } + let step = std::cmp::min(remaining, REGISTRATION_STOP_POLL_INTERVAL); + sleep_registration_interval(step).await?; + remaining = remaining.saturating_sub(step); + } + Ok(!stop.should_stop()) +} + /// Processor for rings-node rpc server. /// /// Cloning shares the same node handle; publishes from any clone are serialized @@ -117,7 +135,7 @@ pub struct Processor { session_sk: SessionSk, stabilize_interval: Duration, online_node_registration: OnlineNodeRegistration, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] advertise_onion_relay: bool, registration_tasks: Vec>, } @@ -132,7 +150,7 @@ impl Processor { &self.session_sk } - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) fn advertise_onion_relay(&self) -> bool { self.advertise_onion_relay } @@ -141,6 +159,10 @@ impl Processor { RegistrationContext::new(self) } + fn registration_context_with_stop(&self, stop: StopToken) -> RegistrationContext<'_> { + RegistrationContext::new_with_stop(self, stop) + } + #[cfg(all(test, feature = "node"))] fn online_node_descriptor_at(&self, now_ms: u128) -> Result { self.online_node_registration @@ -242,9 +264,24 @@ impl Processor { Ok(self.select_onion_exits_from_entry(&refreshed_entry, service, include_expired)) } - async fn fetch_storage_entry(&self, entry_key: Did) -> Result> { + pub(crate) async fn fetch_storage_entry(&self, entry_key: Did) -> Result> { + let stop = StopToken::never(); + self.fetch_storage_entry_with_stop(entry_key, &stop).await + } + + pub(crate) async fn fetch_storage_entry_with_stop( + &self, + entry_key: Did, + stop: &StopToken, + ) -> Result> { + if stop.should_stop() { + return Err(Error::RegistrationStopped); + } self.storage_fetch(entry_key).await?; for attempt in 0..DHT_LOOKUP_CACHE_POLL_ATTEMPTS { + if stop.should_stop() { + return Err(Error::RegistrationStopped); + } if let Some(entry) = self.storage_check_cache(entry_key).await { return Ok(Some(entry)); } @@ -323,26 +360,52 @@ impl Processor { directory::build_onion_proxy_route(self, proxy, target).await } - async fn registration_task_daemon(&self, task: &dyn RegistrationTask) { + async fn run_registration_once( + &self, + task: &dyn RegistrationTask, + stop: StopToken, + ) -> Result<()> { + let context = self.registration_context_with_stop(stop); + task.register_once(&context).await + } + + async fn registration_task_daemon_with(&self, task: &dyn RegistrationTask, stop: StopToken) { loop { - if let Err(error) = task.register_once(&self.registration_context()).await { + if stop.should_stop() { + return; + } + if let Err(error) = self.run_registration_once(task, stop.clone()).await { + if matches!(error, Error::RegistrationStopped) { + tracing::debug!( + "Stopping {} registration task after cooperative stop", + task.name() + ); + return; + } tracing::warn!("Failed to run {} registration task: {error:?}", task.name()); } - if let Err(error) = sleep_registration_interval(task.interval()).await { - tracing::warn!( - "Stopping {} registration task after timer error: {error:?}", - task.name() - ); + if stop.should_stop() { return; } + match sleep_registration_interval_with_stop(task.interval(), &stop).await { + Ok(true) => {} + Ok(false) => return, + Err(error) => { + tracing::warn!( + "Stopping {} registration task after timer error: {error:?}", + task.name() + ); + return; + } + } } } - async fn registration_daemons(&self) { + async fn registration_daemons_with(&self, stop: StopToken) { join_all( self.registration_tasks .iter() - .map(|task| self.registration_task_daemon(task.as_ref())), + .map(|task| self.registration_task_daemon_with(task.as_ref(), stop.clone())), ) .await; } @@ -351,14 +414,24 @@ impl Processor { /// /// This is a long-running task; do not await completion as a readiness signal. pub async fn listen(&self) { + self.listen_with(StopToken::never()).await; + } + + /// Run stabilization and node registration tasks until `stop` asks them to exit. + /// + /// The shutdown is cooperative: it waits for the current stabilization or + /// registration operation to finish before returning. This avoids dropping + /// browser IndexedDB request futures while their JavaScript callbacks are + /// still pending. + pub async fn listen_with(&self, stop: StopToken) { let stabilizer = self.swarm.stabilizer(); let stabilizer = Arc::new(stabilizer); if self.registration_tasks.is_empty() { - stabilizer.wait(self.stabilize_interval).await; + stabilizer.wait_with(self.stabilize_interval, stop).await; } else { let _ = futures::future::join( - stabilizer.wait(self.stabilize_interval), - self.registration_daemons(), + stabilizer.wait_with(self.stabilize_interval, stop.clone()), + self.registration_daemons_with(stop), ) .await; } @@ -369,9 +442,14 @@ impl Processor { /// 1. PeerA has a connection with PeerB. /// 2. PeerC has a connection with PeerB. /// 3. PeerC can connect PeerA with PeerA's web3 address. + /// + /// This operation is idempotent: if topology convergence already produced + /// the direct connection, the requested connection is satisfied. pub async fn connect_with_did(&self, did: Did) -> Result<()> { - self.swarm.connect(did).await.map_err(Error::ConnectError)?; - Ok(()) + match self.swarm.connect(did).await { + Ok(()) | Err(rings_core::error::Error::AlreadyConnected) => Ok(()), + Err(error) => Err(Error::ConnectError(error)), + } } /// Disconnect a peer with web3 did. @@ -394,6 +472,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 +621,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 @@ -625,8 +730,11 @@ impl Processor { } } -#[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait::async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait::async_trait(?Send))] +#[cfg_attr( + not(all(feature = "browser", target_family = "wasm")), + async_trait::async_trait +)] impl OnionDirectoryReader for Processor { fn local_did(&self) -> Did { self.did() diff --git a/crates/node/src/processor/tests/test_config.rs b/crates/node/src/processor/tests/test_config.rs index 5e483eeb5..24fd384f3 100644 --- a/crates/node/src/processor/tests/test_config.rs +++ b/crates/node/src/processor/tests/test_config.rs @@ -160,7 +160,7 @@ external_address: null webrtc_udp_port_min: null webrtc_udp_port_max: null session_sk: "{}" -stabilize_interval: 3 +stabilize_interval: 15 online_node_heartbeat_interval_secs: 30 online_node_ttl_secs: 60 online_node_type: Native diff --git a/crates/node/src/processor/tests/test_network.rs b/crates/node/src/processor/tests/test_network.rs index a1acb69b3..0ac7d5c63 100644 --- a/crates/node/src/processor/tests/test_network.rs +++ b/crates/node/src/processor/tests/test_network.rs @@ -1,6 +1,56 @@ use super::common::*; use super::*; +const LISTENER_START_YIELD: Duration = Duration::from_millis(100); +const LISTENER_STOP_TIMEOUT: Duration = Duration::from_secs(2); + +#[tokio::test] +async fn listen_with_pre_stopped_token_returns_before_first_tick() { + let processor = prepare_processor().await; + let stop = StopSource::new(); + stop.request_stop(); + + tokio::time::timeout( + Duration::from_millis(100), + processor.listen_with(stop.token()), + ) + .await + .expect("pre-stopped listen token should exit before the first stabilization tick"); +} + +#[tokio::test] +async fn provider_listen_with_pre_stopped_token_returns_before_first_tick() { + let processor = prepare_processor().await; + let provider = Provider::from_processor(Arc::new(processor)); + let stop = StopSource::new(); + stop.request_stop(); + + tokio::time::timeout( + Duration::from_millis(100), + provider.listen_with(stop.token()), + ) + .await + .expect("pre-stopped provider listen token should exit before the first stabilization tick"); +} + +#[tokio::test] +async fn provider_listen_with_started_token_returns_after_stop() { + let processor = prepare_processor().await; + let provider = Provider::from_processor(Arc::new(processor)); + let stop = StopSource::new(); + let listen = provider.listen_with(stop.token()); + let stopper = async { + tokio::time::sleep(LISTENER_START_YIELD).await; + stop.request_stop(); + }; + + tokio::time::timeout(LISTENER_STOP_TIMEOUT, async { + futures::join!(listen, stopper); + }) + .await + .expect("started provider listen token should exit after stop"); +} + #[tokio::test] async fn online_node_registry_lists_two_publishers_over_network() -> Result<()> { let _network_guard = network_test_guard().await; @@ -59,12 +109,26 @@ async fn online_node_type_is_configurable() { #[tokio::test] async fn test_processor_create_offer() { - let peer_did = SecretKey::random().address().into(); - let processor = prepare_processor().await; - processor.swarm.create_offer(peer_did).await.unwrap(); - let conn_dids = processor.swarm.peers(); + 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(); + + let offer = p1.swarm.create_offer(p2.did()).await.unwrap(); + assert!(p1.swarm.peers().is_empty()); + + let answer = p2.swarm.answer_offer(offer).await.unwrap(); + p1.swarm.accept_answer(answer).await.unwrap(); + wait_processors_connected(&p1, &p2, &callback1, &callback2).await; + + let conn_dids = p1.swarm.peers(); assert_eq!(conn_dids.len(), 1); - assert_eq!(conn_dids.first().unwrap().did, peer_did.to_string()); + assert_eq!(conn_dids.first().unwrap().did, p2.did().to_string()); + assert_eq!(conn_dids.first().unwrap().state, "Connected"); } #[tokio::test] @@ -83,15 +147,7 @@ async fn test_processor_handshake_msg() { let did2 = p2.did(); let offer = p1.swarm.create_offer(p2.did()).await.unwrap(); - assert_eq!( - p1.swarm - .peers() - .into_iter() - .find(|peer| peer.did == p2.did().to_string()) - .unwrap() - .state, - "New" - ); + assert!(p1.swarm.peers().is_empty()); let answer = p2.swarm.answer_offer(offer).await.unwrap(); p1.swarm.accept_answer(answer).await.unwrap(); @@ -118,6 +174,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; diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index 2d1e5e3ac..12d18954d 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -1,6 +1,36 @@ use super::common::*; use super::*; +struct StoppedRegistration; + +#[async_trait] +impl RegistrationTask for StoppedRegistration { + fn name(&self) -> &'static str { + "stopped-test" + } + + fn interval(&self) -> Duration { + Duration::from_millis(20) + } + + async fn register_once(&self, _context: &RegistrationContext<'_>) -> Result<()> { + Err(Error::RegistrationStopped) + } +} + +#[tokio::test] +async fn registration_daemon_treats_expected_stop_as_terminal() { + let processor = prepare_processor().await; + let task = StoppedRegistration; + + tokio::time::timeout( + Duration::from_millis(100), + processor.registration_task_daemon_with(&task, StopToken::never()), + ) + .await + .expect("RegistrationStopped should terminate the registration daemon"); +} + #[tokio::test] async fn custom_registration_task_publishes_through_shared_dht_sink() -> Result<()> { let topic = "custom_registration_task"; @@ -119,6 +149,125 @@ async fn online_node_concurrent_publish_keeps_one_self_record() -> Result<()> { Ok(()) } +#[tokio::test] +async fn online_node_publish_replaces_observed_self_records() -> Result<()> { + let processor = prepare_processor().await; + let other = prepare_processor().await; + let now_ms = get_epoch_ms(); + let stale_self = processor.online_node_descriptor_at(now_ms.saturating_sub(30_000))?; + let other_descriptor = other.online_node_descriptor_at(now_ms)?; + + processor + .storage_store(Processor::online_node_registry_entry(vec![ + stale_self, + other_descriptor.clone(), + ])?) + .await?; + + let published = processor.publish_online_node_descriptor().await?; + let entry_key = entry::Entry::gen_did(ONLINE_NODES_TOPIC)?; + processor.storage_fetch(entry_key).await?; + let entry = processor + .storage_check_cache(entry_key) + .await + .expect("online node registry entry should be cached after publish"); + let stored = Processor::online_node_descriptors_from_entry(&entry); + + assert_eq!(stored.len(), 2); + assert_eq!( + stored + .iter() + .filter(|descriptor| descriptor.did == processor.did()) + .count(), + 1 + ); + assert!(stored.iter().any(|descriptor| descriptor == &published)); + assert!(stored + .iter() + .any(|descriptor| descriptor == &other_descriptor)); + Ok(()) +} + +#[tokio::test] +async fn onion_exit_publish_replaces_observed_self_records() -> Result<()> { + let processor = prepare_processor().await; + let other = prepare_processor().await; + let now_ms = get_epoch_ms(); + let mut policy = onion_policy(&["example.com:443"], &[])?; + policy.max_circuits = 8; + policy.max_streams_per_circuit = 2; + policy.max_bytes_per_minute = 4096; + let stale_tcp = onion_exit_descriptor_for_processor_with_service( + &processor, + OnionExitService::tcp(), + now_ms.saturating_sub(30_000), + policy.clone(), + )?; + let stale_https = onion_exit_descriptor_for_processor_with_service( + &processor, + OnionExitService::https(), + now_ms.saturating_sub(20_000), + policy.clone(), + )?; + let stale_api = onion_exit_descriptor_for_processor_with_service( + &processor, + OnionExitService::new("api", OnionExitTransport::Tcp)?, + now_ms.saturating_sub(10_000), + policy.clone(), + )?; + let other_https = onion_exit_descriptor_for_processor_with_service( + &other, + OnionExitService::https(), + now_ms, + policy.clone(), + )?; + + processor + .storage_store(Processor::onion_exit_registry_entry(vec![ + stale_tcp, + stale_https, + stale_api, + other_https.clone(), + ])?) + .await?; + + let registration = OnionExitRegistration::new( + Duration::from_secs(30), + Duration::from_secs(90), + default_online_node_type(), + vec![OnionExitService::https()], + policy, + ); + let published = registration + .publish_descriptors(&processor.registration_context()) + .await?; + let published_descriptor = published + .into_iter() + .next() + .ok_or_else(|| Error::InvalidConfig("expected one onion-exit descriptor".to_string()))?; + let entry_key = entry::Entry::gen_did(ONION_EXITS_TOPIC)?; + processor.storage_fetch(entry_key).await?; + let entry = processor + .storage_check_cache(entry_key) + .await + .expect("onion exit registry entry should be cached after publish"); + let stored = Processor::onion_exit_descriptors_from_entry(&entry); + + assert_eq!(stored.len(), 2); + assert_eq!( + stored + .iter() + .filter(|descriptor| descriptor.did == processor.did()) + .count(), + 1 + ); + assert!(stored + .iter() + .any(|descriptor| descriptor == &published_descriptor)); + assert!(stored.iter().any(|descriptor| descriptor == &other_https)); + Ok(()) +} + #[tokio::test] async fn online_node_lookup_filters_expired_descriptors_by_default() -> Result<()> { let processor = prepare_processor().await; diff --git a/crates/node/src/provider/browser/provider.rs b/crates/node/src/provider/browser/provider.rs index 8496c223c..2e543ca1c 100644 --- a/crates/node/src/provider/browser/provider.rs +++ b/crates/node/src/provider/browser/provider.rs @@ -7,12 +7,15 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; +use futures::channel::oneshot; use futures::future::Either; use futures::FutureExt; use js_sys; use js_sys::Uint8Array; use rings_core::dht::Did; +use rings_core::dht::EntryStorage; use rings_core::ecc::PublicKey; +use rings_core::lifecycle::StopSource; use rings_core::measure::PeerQuality; use rings_core::message::DhtProtocolMode; use rings_core::prelude::entry; @@ -32,6 +35,7 @@ use wasm_bindgen_futures::JsFuture; use crate::error::Error; use crate::error::Result as NodeResult; use crate::measure::peer_quality_thresholds; +use crate::measure::MeasureStorage; use crate::onion::circuit::encode_initial_forward; use crate::onion::circuit::route_first_hop; use crate::onion::circuit::OnionCircuitCapabilities; @@ -83,6 +87,40 @@ impl ProviderRef { } } +/// Browser listener lifecycle handle returned by [`Provider::listen`]. +#[derive(Clone)] +#[wasm_export] +pub struct ProviderListener { + stop: StopSource, + started: js_sys::Promise, + task: js_sys::Promise, +} + +#[wasm_export] +impl ProviderListener { + /// Request cooperative shutdown for the listener task. + pub fn stop(&self) { + self.stop.request_stop(); + } + + /// Return whether shutdown was requested through this handle. + pub fn is_stopped(&self) -> bool { + self.stop.is_stop_requested() + } + + /// Return a promise that resolves once the listener task enters its run loop. + pub fn started(&self) -> js_sys::Promise { + self.started.clone() + } + + /// Return the underlying listener task promise. + /// + /// It resolves only after [`ProviderListener::stop`] requests cooperative shutdown. + pub fn task(&self) -> js_sys::Promise { + self.task.clone() + } +} + /// Browser-compatible onion proxy handle. /// /// The proxy is target-agnostic: callers create it once with route-selection options, then send @@ -126,7 +164,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 +374,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))); } @@ -387,6 +425,70 @@ fn wrapped_signer(signer: js_sys::Function) -> AsyncSigner { ) } +async fn open_browser_entry_storage(storage_name: &str) -> NodeResult { + IdbStorage::new_with_cap_and_name(50000, storage_name) + .await + .map(|storage| Box::new(storage) as EntryStorage) + .map_err(|source| Error::BrowserStorageOpen { + name: storage_name.to_string(), + source, + }) +} + +async fn open_browser_entry_storage_or_memory(storage_name: &str) -> Option { + match open_browser_entry_storage(storage_name).await { + Ok(storage) => Some(storage), + Err(error) => { + tracing::warn!( + storage_name = %storage_name, + error = %error, + "browser entry IndexedDB unavailable; falling back to in-memory entry storage" + ); + None + } + } +} + +async fn open_browser_measure_storage(storage_name: &str) -> Option { + match IdbStorage::new_with_cap_and_name(50000, storage_name).await { + Ok(storage) => Some(Box::new(storage) as MeasureStorage), + Err(source) => { + tracing::warn!( + storage_name = %storage_name, + error = %source, + "browser measurement IndexedDB unavailable; falling back to in-memory measurement storage" + ); + None + } + } +} + +impl Provider { + /// Create a browser provider backed by IndexedDB storage and install its default backend. + /// + /// This is the Rust-side constructor for browser frontends. It keeps provider ownership as the + /// lifecycle boundary while reusing the same storage, backend, and onion-protocol setup as the + /// wasm-exported constructors. + pub async fn new_browser_provider_with_storage( + config: ProcessorConfig, + storage_name: String, + ) -> NodeResult { + let onion_https_exit_policy = config.onion_https_exit_policy(); + let entry_storage = open_browser_entry_storage_or_memory(&storage_name).await; + let measure_storage = + open_browser_measure_storage(&format!("{storage_name}/measure")).await; + + let provider = + Self::new_provider_with_storage_internal(config, entry_storage, measure_storage) + .await?; + provider.set_backend()?; + if let Some(policy) = onion_https_exit_policy { + provider.install_onion_https_protocol(Some(policy))?; + } + Ok(provider) + } +} + #[wasm_export] impl Provider { /// make provider as an As arc ref @@ -419,17 +521,8 @@ impl Provider { future_to_promise(async move { let signer = wrapped_signer(signer); - let entry_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, "rings-node") - .await - .map_err(JsError::from)?, - ); - - let measure_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, "rings-node/measure") - .await - .map_err(JsError::from)?, - ); + let entry_storage = open_browser_entry_storage_or_memory("rings-node").await; + let measure_storage = open_browser_measure_storage("rings-node/measure").await; let provider = Provider::new_provider_internal( network_id, @@ -438,8 +531,8 @@ impl Provider { account, account_type, Signer::Async(Box::new(signer)), - Some(entry_storage), - Some(measure_storage), + entry_storage, + measure_storage, ) .await?; @@ -467,17 +560,8 @@ impl Provider { .map_err(JsError::from)?; policy.validate_targets().map_err(JsError::from)?; - let entry_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, "rings-node") - .await - .map_err(JsError::from)?, - ); - - let measure_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, "rings-node/measure") - .await - .map_err(JsError::from)?, - ); + let entry_storage = open_browser_entry_storage_or_memory("rings-node").await; + let measure_storage = open_browser_measure_storage("rings-node/measure").await; let config_policy = policy.clone(); let provider = Provider::new_provider_internal_with_config( @@ -487,8 +571,8 @@ impl Provider { account, account_type, Signer::Async(Box::new(signer)), - Some(entry_storage), - Some(measure_storage), + entry_storage, + measure_storage, move |config| { config .enable_https_onion_exit() @@ -506,6 +590,24 @@ impl Provider { }) } + /// Install the browser HTTPS onion-exit protocol handler with an explicit target policy. + /// + /// This updates the local exit handler used by incoming onion HTTPS requests. Discovery still + /// comes from the provider's processor configuration, so nodes that should be routeable exits + /// must also be constructed with HTTPS onion-exit advertisement enabled. + pub fn install_onion_https_exit( + &self, + allowed_targets: Vec, + denied_targets: Vec, + ) -> Result<(), JsError> { + let policy = OnionExitPolicy::from_target_strings(allowed_targets, denied_targets) + .map_err(JsError::from)?; + policy.validate_targets().map_err(JsError::from)?; + self.install_onion_https_protocol(Some(policy)) + .map(|_| ()) + .map_err(JsError::from) + } + /// Create new provider instance with serialized config (yaml/json) pub fn new_provider_with_serialized_config(config: String) -> js_sys::Promise { future_to_promise(async move { @@ -531,32 +633,9 @@ impl Provider { storage_name: String, ) -> js_sys::Promise { future_to_promise(async move { - let onion_https_exit_policy = config.onion_https_exit_policy(); - let entry_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, &storage_name) - .await - .map_err(JsError::from)?, - ); - - let measure_storage = Box::new( - IdbStorage::new_with_cap_and_name(50000, &format!("{storage_name}/measure")) - .await - .map_err(JsError::from)?, - ); - - let provider = Self::new_provider_with_storage_internal( - config, - Some(entry_storage), - Some(measure_storage), - ) - .await - .map_err(JsError::from)?; - provider.set_backend().map_err(JsError::from)?; - if let Some(policy) = onion_https_exit_policy { - provider - .install_onion_https_protocol(Some(policy)) - .map_err(JsError::from)?; - } + let provider = Self::new_browser_provider_with_storage(config, storage_name) + .await + .map_err(JsError::from)?; Ok(JsValue::from(provider)) }) } @@ -594,17 +673,31 @@ impl Provider { }) } - /// Start the long-running listener. - /// - /// The returned Promise is not a readiness barrier and does not resolve - /// during normal operation. - pub fn listen(&self) -> js_sys::Promise { + /// Start the long-running listener and return its lifecycle handle. + pub fn listen(&self) -> ProviderListener { let p = self.processor.clone(); + let stop = StopSource::new(); + let token = stop.token(); + let (started_sender, started_receiver) = oneshot::channel::<()>(); - future_to_promise(async move { - p.listen().await; + let started = future_to_promise(async move { + started_receiver + .await + .map_err(|_| JsError::new("provider listener exited before start"))?; Ok(JsValue::null()) - }) + }); + + let task = future_to_promise(async move { + let _sent = started_sender.send(()); + p.listen_with(token).await; + Ok(JsValue::null()) + }); + + ProviderListener { + stop, + started, + task, + } } /// connect peer with remote jsonrpc server url diff --git a/crates/node/src/provider/ffi.rs b/crates/node/src/provider/ffi.rs index 1bcc8ba06..bc645a978 100644 --- a/crates/node/src/provider/ffi.rs +++ b/crates/node/src/provider/ffi.rs @@ -102,8 +102,8 @@ impl FfiBackend { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl SwarmCallback for FfiBackend { async fn on_inbound( &self, diff --git a/crates/node/src/provider/mod.rs b/crates/node/src/provider/mod.rs index 3c1200a99..36b9df1c6 100644 --- a/crates/node/src/provider/mod.rs +++ b/crates/node/src/provider/mod.rs @@ -3,11 +3,13 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use std::sync::Mutex; use rings_core::dht::Did; use rings_core::dht::EntryStorage; +#[cfg(feature = "node")] +use rings_core::lifecycle::StopToken; use rings_core::measure::PeerMeasurement; use rings_core::session::SessionSkBuilder; use rings_core::storage::MemStorage; @@ -24,7 +26,7 @@ use crate::processor::Processor; use crate::processor::ProcessorBuilder; use crate::processor::ProcessorConfig; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub mod browser; #[cfg(feature = "ffi")] pub mod ffi; @@ -40,18 +42,18 @@ pub struct Provider { processor: Arc, handler: InternalRpcHandler, extensions: crate::extension::ext::Extensions, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_https_runtime: Arc>>>, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_directory_endpoint: Arc>>, } /// Async signer, without Send required -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub type AsyncSigner = Box Pin>>>>; /// Async signer, use for non-wasm envirement, Send is necessary -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] pub type AsyncSigner = Box Pin> + Send>>>; /// Signer can be async and sync @@ -72,9 +74,9 @@ impl Provider { processor, handler: InternalRpcHandler, extensions, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_https_runtime: Arc::new(Mutex::new(None)), - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_directory_endpoint: Arc::new(Mutex::new(None)), } } @@ -150,9 +152,9 @@ impl Provider { processor, handler: InternalRpcHandler, extensions, - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_https_runtime: Arc::new(Mutex::new(None)), - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] onion_directory_endpoint: Arc::new(Mutex::new(None)), }) } @@ -252,14 +254,14 @@ impl Provider { params: serde_json::Value, ) -> Result { tracing::debug!("request {}", method); - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] let onion_directory_endpoint = onion_directory_endpoint_from_rpc(method.as_str(), ¶ms); let result = self .handler .handle_request(self.processor.clone(), method, params) .await .map_err(Error::InternalRpcError)?; - #[cfg(feature = "browser")] + #[cfg(all(feature = "browser", target_family = "wasm"))] if let Some(endpoint) = onion_directory_endpoint { self.set_onion_directory_endpoint(Some(endpoint))?; } @@ -288,9 +290,16 @@ impl Provider { pub async fn listen(&self) { self.processor.listen().await; } + + /// Listen for messages until `stop` requests cooperative shutdown. + /// + /// This is a long-running task; do not await completion as a readiness signal. + pub async fn listen_with(&self, stop: StopToken) { + self.processor.listen_with(stop).await; + } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn onion_directory_endpoint_from_rpc(method: &str, params: &serde_json::Value) -> Option { if !matches!(method, "connectPeerViaHttp" | "ConnectPeerViaHttp") { return None; @@ -303,7 +312,7 @@ fn onion_directory_endpoint_from_rpc(method: &str, params: &serde_json::Value) - .map(ToOwned::to_owned) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] impl Provider { pub(crate) fn set_onion_directory_endpoint(&self, endpoint: Option) -> Result<()> { let mut slot = self diff --git a/crates/node/src/registration.rs b/crates/node/src/registration.rs index 3c5f17078..79adeee5a 100644 --- a/crates/node/src/registration.rs +++ b/crates/node/src/registration.rs @@ -12,8 +12,10 @@ use async_trait::async_trait; use futures::lock::Mutex as AsyncMutex; use rings_core::dht::Did; use rings_core::ecc::VerificationPublicKey; +use rings_core::lifecycle::StopToken; use rings_core::message::Encoded; use rings_core::message::Encoder; +use rings_core::prelude::entry; use rings_core::session::SessionSk; use rings_core::utils::get_epoch_ms; @@ -48,11 +50,14 @@ pub(crate) fn default_online_node_type() -> OnlineNodeType { { OnlineNodeType::Ffi } - #[cfg(all(not(feature = "ffi"), feature = "browser"))] + #[cfg(all(not(feature = "ffi"), feature = "browser", target_family = "wasm"))] { OnlineNodeType::Browser } - #[cfg(all(not(feature = "ffi"), not(feature = "browser")))] + #[cfg(all( + not(feature = "ffi"), + not(all(feature = "browser", target_family = "wasm")) + ))] { OnlineNodeType::Native } @@ -77,7 +82,7 @@ pub(crate) fn validate_online_node_registration_timing( Ok(()) } -#[cfg(not(feature = "browser"))] +#[cfg(not(all(feature = "browser", target_family = "wasm")))] pub(crate) async fn sleep_registration_interval(interval: Duration) -> Result<()> { // Native timers are infallible; the Result keeps the daemon shape shared // with the wasm arm, where browser timer setup can fail. @@ -85,7 +90,7 @@ pub(crate) async fn sleep_registration_interval(interval: Duration) -> Result<() Ok(()) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) async fn sleep_registration_interval(interval: Duration) -> Result<()> { let interval_ms = i32::try_from(interval.as_millis()).unwrap_or(i32::MAX); rings_core::utils::js_utils::window_sleep(interval_ms) @@ -100,11 +105,28 @@ pub(crate) async fn sleep_registration_interval(interval: Duration) -> Result<() /// registry needs. The task does not own the processor. pub struct RegistrationContext<'a> { processor: &'a Processor, + stop: StopToken, } impl<'a> RegistrationContext<'a> { - pub(crate) const fn new(processor: &'a Processor) -> Self { - Self { processor } + pub(crate) fn new(processor: &'a Processor) -> Self { + Self::new_with_stop(processor, StopToken::never()) + } + + pub(crate) const fn new_with_stop(processor: &'a Processor, stop: StopToken) -> Self { + Self { processor, stop } + } + + /// Return whether the owning registration daemon has requested shutdown. + pub fn should_stop(&self) -> bool { + self.stop.should_stop() + } + + pub(crate) fn ensure_running(&self) -> Result<()> { + if self.should_stop() { + return Err(Error::RegistrationStopped); + } + Ok(()) } /// Return the local node DID. @@ -139,6 +161,12 @@ impl<'a> RegistrationContext<'a> { pub fn session_sk(&self) -> &SessionSk { self.processor.session_sk() } + + pub(crate) async fn fetch_storage_entry(&self, entry_key: Did) -> Result> { + self.processor + .fetch_storage_entry_with_stop(entry_key, &self.stop) + .await + } } /// Common publisher for DHT-backed registries. @@ -176,36 +204,113 @@ impl DhtRegistrationPublisher { &self, context: &RegistrationContext<'_>, values: impl IntoIterator, + ) -> Result<()> { + self.publish_many_with_replacement(context, values, false, |_| false) + .await + } + + /// Publish the current value set, tombstoning older observed values with the same registry key. + /// + /// Invariant: registry topics are keyed presence sets, not append-only heartbeat logs. + /// Preservation: every observed value replaced by the current publish is tombstoned after the + /// replacement value has been touched, while unrelated publisher keys stay joinable. + pub async fn publish_many_replacing( + &self, + context: &RegistrationContext<'_>, + values: impl IntoIterator, + replaces_observed_value: impl Fn(&Encoded) -> bool, + ) -> Result<()> { + self.publish_many_with_replacement(context, values, true, replaces_observed_value) + .await + } + + async fn publish_many_with_replacement( + &self, + context: &RegistrationContext<'_>, + values: impl IntoIterator, + load_observed_values: bool, + replaces_observed_value: impl Fn(&Encoded) -> bool, ) -> Result<()> { let current_values = values.into_iter().collect::>(); let mut published_values = self.published_values.lock().await; - let stale_values = published_values - .iter() - .filter(|published| !current_values.contains(*published)) - .cloned() - .collect::>(); + context.ensure_running()?; + let observed_values = if load_observed_values { + self.observed_registry_values(context).await? + } else { + vec![] + }; + let stale_values = begin_registration_publish( + &mut published_values, + ¤t_values, + observed_values, + replaces_observed_value, + ); for value in ¤t_values { + context.ensure_running()?; context .processor .storage_touch_data(&self.topic, value.clone()) .await?; } for stale_value in stale_values { + context.ensure_running()?; context .processor .storage_tombstone_data(&self.topic, stale_value.clone()) .await?; published_values.remove(&stale_value); } - *published_values = current_values; + finish_registration_publish(&mut published_values, current_values); Ok(()) } + + async fn observed_registry_values( + &self, + context: &RegistrationContext<'_>, + ) -> Result> { + let entry_key = entry::Entry::gen_did(&self.topic)?; + let Some(entry) = context.fetch_storage_entry(entry_key).await? else { + return Ok(vec![]); + }; + Ok(entry.data) + } +} + +fn begin_registration_publish( + published_values: &mut BTreeSet, + current_values: &BTreeSet, + observed_values: Vec, + replaces_observed_value: impl Fn(&Encoded) -> bool, +) -> Vec { + let mut stale_values = published_values + .iter() + .filter(|published| !current_values.contains(*published)) + .cloned() + .collect::>(); + stale_values.extend( + observed_values + .into_iter() + .filter(|observed| !current_values.contains(observed)) + .filter(replaces_observed_value), + ); + // Invariant: every value whose touch may have reached storage is remembered + // before the first await. If the publish future is later cancelled by an + // attempt timeout, the next attempt can still tombstone the value. + published_values.extend(current_values.iter().cloned()); + stale_values.into_iter().collect() +} + +fn finish_registration_publish( + published_values: &mut BTreeSet, + current_values: BTreeSet, +) { + *published_values = current_values; } /// Periodic node-layer registration. -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] pub trait RegistrationTask: MaybeSend { /// Stable name used in logs. fn name(&self) -> &'static str; @@ -312,7 +417,13 @@ impl OnlineNodeRegistration { let now_ms = get_epoch_ms(); let descriptor = self.descriptor_at(context, now_ms)?; let encoded = descriptor.encode().map_err(Error::CoreError)?; - self.publisher.publish(context, encoded).await?; + self.publisher + .publish_many_replacing(context, std::iter::once(encoded), |observed| { + observed + .decode::() + .is_ok_and(|descriptor| descriptor.did == context.did()) + }) + .await?; Ok(descriptor) } @@ -328,8 +439,8 @@ impl OnlineNodeRegistration { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl RegistrationTask for OnlineNodeRegistration { fn name(&self) -> &'static str { "online-node" @@ -343,3 +454,108 @@ impl RegistrationTask for OnlineNodeRegistration { self.publish_descriptor(context).await.map(|_| ()) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use rings_core::message::Encoded; + + use super::*; + + fn encoded(value: &str) -> Encoded { + value.into() + } + + fn encoded_subset(mask: u8) -> BTreeSet { + ["a", "b", "c"] + .into_iter() + .enumerate() + .filter(|(bit, _value)| mask & (1 << bit) != 0) + .map(|(_bit, value)| encoded(value)) + .collect() + } + + #[test] + fn registration_publish_remembers_attempted_values_before_effects() { + let old = encoded("old"); + let attempted = encoded("attempted"); + let current = BTreeSet::from([attempted.clone()]); + let mut known = BTreeSet::from([old.clone()]); + + let stale = begin_registration_publish(&mut known, ¤t, vec![], |_| false); + + assert_eq!(stale, vec![old.clone()]); + assert_eq!(known, BTreeSet::from([old, attempted])); + } + + #[test] + fn registration_publish_retry_tombstones_values_from_cancelled_attempts() { + let old = encoded("old"); + let cancelled = encoded("cancelled"); + let replacement = encoded("replacement"); + let mut known = BTreeSet::from([old.clone()]); + + let cancelled_current = BTreeSet::from([cancelled.clone()]); + let _ = begin_registration_publish(&mut known, &cancelled_current, vec![], |_| false); + let replacement_current = BTreeSet::from([replacement.clone()]); + let stale = begin_registration_publish(&mut known, &replacement_current, vec![], |_| false); + + assert_eq!( + stale.into_iter().collect::>(), + BTreeSet::from([old, cancelled]) + ); + assert!(known.contains(&replacement)); + finish_registration_publish(&mut known, replacement_current.clone()); + assert_eq!(known, replacement_current); + } + + #[test] + fn registration_publish_begin_finish_preserve_known_set_law() { + for old_mask in 0..8 { + for current_mask in 0..8 { + for replacement_mask in 0..8 { + let old = encoded_subset(old_mask); + let current = encoded_subset(current_mask); + let replacement = encoded_subset(replacement_mask); + let mut known = old.clone(); + + let _ = begin_registration_publish(&mut known, ¤t, vec![], |_| false); + let attempted = old.union(¤t).cloned().collect::>(); + assert_eq!(known, attempted); + + let stale = + begin_registration_publish(&mut known, &replacement, vec![], |_| false) + .into_iter() + .collect::>(); + let expected_stale = attempted + .difference(&replacement) + .cloned() + .collect::>(); + assert_eq!(stale, expected_stale); + + finish_registration_publish(&mut known, replacement.clone()); + assert_eq!(known, replacement); + } + } + } + } + + #[test] + fn registration_publish_tombstones_matching_observed_values() { + let current = BTreeSet::from([encoded("self-new")]); + let observed_self_old = encoded("self-old"); + let observed_other = encoded("other"); + let mut known = BTreeSet::new(); + + let stale = begin_registration_publish( + &mut known, + ¤t, + vec![observed_self_old.clone(), observed_other], + |observed| observed == &observed_self_old, + ); + + assert_eq!(stale, vec![observed_self_old]); + assert_eq!(known, current); + } +} diff --git a/crates/node/src/rpc_dto.rs b/crates/node/src/rpc_dto.rs index 15318c33a..2f51e5211 100644 --- a/crates/node/src/rpc_dto.rs +++ b/crates/node/src/rpc_dto.rs @@ -1,17 +1,17 @@ //! Conversions from node/core domain values to RPC wire DTOs. -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use std::str::FromStr; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use rings_core::dht::Did; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use rings_core::ecc::PublicKey; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use rings_core::ecc::VerificationPublicKey; use rings_core::measure::PeerMeasurement; use rings_core::measure::PeerQualityEvidence; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use rings_core::message::MessageVerification; use rings_rpc::protos::rings_node::BuildOnionRouteResponse; use rings_rpc::protos::rings_node::OnionExitDescriptorInfo; @@ -22,7 +22,7 @@ use rings_rpc::protos::rings_node::OnlineNodeDescriptorInfo; use rings_rpc::protos::rings_node::OnlineNodeTypeInfo; use rings_rpc::protos::rings_node::PeerMeasurementCountersInfo; use rings_rpc::protos::rings_node::PeerMeasurementInfo; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; @@ -32,7 +32,7 @@ use crate::error::Result; use crate::onion::OnionExitDescriptor; use crate::onion::OnionExitPolicy; use crate::onion::OnionExitService; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] use crate::onion::OnionExitTarget; use crate::onion::OnionExitTransport; use crate::onion::OnionRoute; @@ -43,12 +43,12 @@ fn json_value(value: impl Serialize) -> Result { serde_json::to_value(value).map_err(Error::SerdeJsonError) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn from_json_value(value: Value) -> Result { serde_json::from_value(value).map_err(Error::SerdeJsonError) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn did_from_string(value: &str) -> Result { Did::from_str(value).map_err(Error::CoreError) } @@ -61,7 +61,7 @@ fn online_node_type_info(node_type: OnlineNodeType) -> OnlineNodeTypeInfo { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn online_node_type_from_info(node_type: OnlineNodeTypeInfo) -> OnlineNodeType { match node_type { OnlineNodeTypeInfo::Browser => OnlineNodeType::Browser, @@ -114,7 +114,7 @@ fn onion_exit_transport_info(transport: OnionExitTransport) -> OnionExitTranspor } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn onion_exit_transport_from_info(transport: OnionExitTransportInfo) -> OnionExitTransport { match transport { OnionExitTransportInfo::Tcp => OnionExitTransport::Tcp, @@ -132,7 +132,7 @@ fn onion_exit_service_info(service: OnionExitService) -> OnionExitServiceInfo { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn onion_exit_service_from_info(service: OnionExitServiceInfo) -> Result { OnionExitService::new( service.name.as_str(), @@ -158,7 +158,7 @@ fn onion_exit_policy_info(policy: OnionExitPolicy) -> OnionExitPolicyInfo { } } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] fn onion_exit_policy_from_info(policy: OnionExitPolicyInfo) -> Result { Ok(OnionExitPolicy { allowed_targets: policy @@ -205,7 +205,7 @@ pub(crate) fn onion_exit_descriptor_infos( .collect() } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) fn online_node_descriptor_from_info( descriptor: OnlineNodeDescriptorInfo, ) -> Result { @@ -227,7 +227,7 @@ pub(crate) fn online_node_descriptor_from_info( }) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) fn online_node_descriptors_from_infos( descriptors: impl IntoIterator, ) -> Vec { @@ -238,7 +238,7 @@ pub(crate) fn online_node_descriptors_from_infos( .collect() } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) fn onion_exit_descriptors_from_info( descriptor: OnionExitDescriptorInfo, ) -> Result> { @@ -277,7 +277,7 @@ pub(crate) fn onion_exit_descriptors_from_info( .collect()) } -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub(crate) fn onion_exit_descriptors_from_infos( descriptors: impl IntoIterator, ) -> Vec { diff --git a/crates/node/src/rpc_impl.rs b/crates/node/src/rpc_impl.rs index 7b862978d..70e63a288 100644 --- a/crates/node/src/rpc_impl.rs +++ b/crates/node/src/rpc_impl.rs @@ -27,8 +27,8 @@ use crate::error::Error as ServerError; use crate::processor::Processor; use crate::seed::Seed; -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc( &self, @@ -59,8 +59,8 @@ impl HandleRpc for Proces } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: ConnectWithDidRequest) -> Result { let did = s2d(&req.did)?; @@ -69,8 +69,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: ConnectWithSeedRequest) -> Result { let seed: Seed = Seed::try_from(req)?; @@ -100,8 +100,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, _req: ListPeersRequest) -> Result { let peers = self @@ -114,8 +114,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: CreateOfferRequest) -> Result { let did = s2d(&req.did)?; @@ -136,8 +136,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: AnswerOfferRequest) -> Result { if req.offer.is_empty() { @@ -166,8 +166,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: AcceptAnswerRequest) -> Result { if req.answer.is_empty() { @@ -188,8 +188,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: DisconnectRequest) -> Result { let did = s2d(&req.did)?; @@ -198,8 +198,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc( &self, @@ -215,8 +215,8 @@ impl HandleRpc for Proces } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: SendE2eHandshakeRequest) -> Result { let destination = s2d(&req.destination_did)?; @@ -227,8 +227,8 @@ impl HandleRpc for Processor } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: SendE2eMessageRequest) -> Result { let destination = s2d(&req.destination_did)?; @@ -250,8 +250,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc( &self, @@ -266,8 +266,8 @@ impl HandleRpc for } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc( &self, @@ -295,8 +295,8 @@ impl HandleRpc for Proces } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: RegisterServiceRequest) -> Result { self.register_service(&req.name).await?; @@ -304,8 +304,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: LookupServiceRequest) -> Result { let entry_key = Entry::gen_did(&req.name) @@ -329,8 +329,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: LookupOnlineNodesRequest) -> Result { let nodes = self @@ -343,8 +343,8 @@ impl HandleRpc for Processo } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: LookupOnionExitsRequest) -> Result { let exits = self @@ -357,8 +357,8 @@ impl HandleRpc for Processor } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: BuildOnionRouteRequest) -> Result { let route = self @@ -369,8 +369,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, _req: NodeInfoRequest) -> Result { self.get_node_info() @@ -379,8 +379,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, req: PeerMeasurementRequest) -> Result { let did = s2d(&req.did)?; @@ -392,8 +392,8 @@ impl HandleRpc for Processor { } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc( &self, @@ -405,8 +405,8 @@ impl HandleRpc for Pr } } -#[cfg_attr(feature = "browser", async_trait(?Send))] -#[cfg_attr(not(feature = "browser"), async_trait)] +#[cfg_attr(all(feature = "browser", target_family = "wasm"), async_trait(?Send))] +#[cfg_attr(not(all(feature = "browser", target_family = "wasm")), async_trait)] impl HandleRpc for Processor { async fn handle_rpc(&self, _req: NodeDidRequest) -> Result { let did = self.did(); diff --git a/crates/node/src/tests/mod.rs b/crates/node/src/tests/mod.rs index 899eb496b..af9444852 100644 --- a/crates/node/src/tests/mod.rs +++ b/crates/node/src/tests/mod.rs @@ -1,4 +1,4 @@ #[cfg(feature = "node")] pub mod native; -#[cfg(feature = "browser")] +#[cfg(all(feature = "browser", target_family = "wasm"))] pub mod wasm; diff --git a/crates/node/src/tests/wasm/test_browser.rs b/crates/node/src/tests/wasm/test_browser.rs index d915570bd..3d50884bc 100644 --- a/crates/node/src/tests/wasm/test_browser.rs +++ b/crates/node/src/tests/wasm/test_browser.rs @@ -42,6 +42,18 @@ async fn test_two_provider_connect_and_list() { assert_eq!(peers.len(), 0); } +#[wasm_bindgen_test] +async fn test_provider_listener_handle_requests_stop() { + let provider = new_provider().await; + + let listener = provider.listen(); + assert!(!listener.is_stopped()); + + listener.stop(); + assert!(listener.is_stopped()); + utils::js_utils::window_sleep(10).await.unwrap(); +} + #[wasm_bindgen_test] async fn test_send_backend_message() { let provider1 = new_provider().await; diff --git a/crates/node/src/tests/wasm/test_processor.rs b/crates/node/src/tests/wasm/test_processor.rs index 1a9e936d0..dd31084eb 100644 --- a/crates/node/src/tests/wasm/test_processor.rs +++ b/crates/node/src/tests/wasm/test_processor.rs @@ -50,16 +50,22 @@ impl SwarmCallback for SwarmCallbackStruct { /// itself and fail with `SwarmMissDidInTable`. async fn wait_for_dht_successor(p: &Processor, did: Did) { let did = did.to_string(); + let mut last_inspect = None; for _ in 0..100 { - let successors = p.swarm.inspect().await.dht.successors; - if successors.iter().any(|s| s == &did) { + let inspect = p.swarm.inspect().await; + if inspect.dht.successors.iter().any(|s| s == &did) { return; } + last_inspect = Some(inspect); fluvio_wasm_timer::Delay::new(Duration::from_millis(200)) .await .unwrap(); } - panic!("timeout waiting for {did} to appear in DHT successors"); + panic!( + "timeout waiting for {did} to appear in DHT successors; peers={:?}, dht={:?}", + last_inspect.as_ref().map(|inspect| &inspect.peers), + last_inspect.as_ref().map(|inspect| &inspect.dht), + ); } async fn create_connection(p1: &Processor, p2: &Processor) { diff --git a/crates/rpc/src/protos/rings_node_handler.rs b/crates/rpc/src/protos/rings_node_handler.rs index 5c8115266..7c2864d2b 100644 --- a/crates/rpc/src/protos/rings_node_handler.rs +++ b/crates/rpc/src/protos/rings_node_handler.rs @@ -9,8 +9,8 @@ use super::rings_node::*; use crate::method::Method; /// Used for processor to match rpc request and response. -#[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 HandleRpc { /// Handle rpc request and return response. async fn handle_rpc(&self, req: Req) -> Result; diff --git a/crates/transport/src/callback.rs b/crates/transport/src/callback.rs index 78e507391..e798d4215 100644 --- a/crates/transport/src/callback.rs +++ b/crates/transport/src/callback.rs @@ -31,16 +31,24 @@ impl InnerTransportCallback { /// Notify the data channel is open. pub async fn on_data_channel_open(&self) { + self.on_data_channel_open_with_cid(&self.cid).await; + } + + pub(crate) async fn on_data_channel_open_with_cid(&self, cid: &str) { self.data_channel_state_notifier.wake(); - if let Err(e) = self.callback.on_data_channel_open(&self.cid).await { + if let Err(e) = self.callback.on_data_channel_open(cid).await { tracing::error!("Callback on_data_channel_open failed: {e:?}"); } } /// Notify the data channel is close. pub async fn on_data_channel_close(&self) { + self.on_data_channel_close_with_cid(&self.cid).await; + } + + pub(crate) async fn on_data_channel_close_with_cid(&self, cid: &str) { self.data_channel_state_notifier.wake(); - if let Err(e) = self.callback.on_data_channel_close(&self.cid).await { + if let Err(e) = self.callback.on_data_channel_close(cid).await { tracing::error!("Callback on_data_channel_close failed: {e:?}"); } } @@ -57,11 +65,16 @@ impl InnerTransportCallback { /// This method is invoked when the state of connection has changed. pub async fn on_peer_connection_state_change(&self, s: WebrtcConnectionState) { - if let Err(e) = self - .callback - .on_peer_connection_state_change(&self.cid, s) - .await - { + self.on_peer_connection_state_change_with_cid(&self.cid, s) + .await; + } + + pub(crate) async fn on_peer_connection_state_change_with_cid( + &self, + cid: &str, + s: WebrtcConnectionState, + ) { + if let Err(e) = self.callback.on_peer_connection_state_change(cid, s).await { tracing::error!("Callback on_peer_connection_state_change failed: {e:?}"); } } diff --git a/crates/transport/src/connection_ref.rs b/crates/transport/src/connection_ref.rs index e613090de..ebafed973 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 @@ -69,6 +69,14 @@ where .unwrap_or(WebrtcConnectionState::Closed) } + fn data_channel_is_open(&self) -> Result { + match self.upgrade() { + Ok(c) => c.data_channel_is_open(), + Err(Error::ConnectionReleased(_)) => Ok(false), + Err(error) => Err(error), + } + } + // On a released reference this reports the interop default rather than an error, by deliberate // design: `ConnectionInterface::max_message_size` returns `usize` (it feeds the framing // planner), and threading a `Result` through it and every backend for this one edge would add @@ -110,7 +118,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 @@ -130,6 +138,14 @@ where .unwrap_or(WebrtcConnectionState::Closed) } + fn data_channel_is_open(&self) -> Result { + match self.upgrade() { + Ok(c) => c.data_channel_is_open(), + Err(Error::ConnectionReleased(_)) => Ok(false), + Err(error) => Err(error), + } + } + // On a released reference this reports the interop default rather than an error, by deliberate // design: `ConnectionInterface::max_message_size` returns `usize` (it feeds the framing // planner), and threading a `Result` through it and every backend for this one edge would add @@ -181,8 +197,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; @@ -197,6 +216,9 @@ mod tests { fn webrtc_connection_state(&self) -> WebrtcConnectionState { unreachable!() } + fn data_channel_is_open(&self) -> Result { + unreachable!() + } async fn get_stats(&self) -> Vec { unreachable!() } diff --git a/crates/transport/src/connections/dummy/mod.rs b/crates/transport/src/connections/dummy/mod.rs index 5063388e2..054bd9b0c 100644 --- a/crates/transport/src/connections/dummy/mod.rs +++ b/crates/transport/src/connections/dummy/mod.rs @@ -64,6 +64,21 @@ thread_local! { /// through `do_send_payload` and exercise real reassembly. Thread-local for the same isolation /// reason as the controlled queue. static MAX_MESSAGE_SIZE: Cell = const { Cell::new(0) }; + /// Test-only per-thread override for the next lifecycle callback's cid. This lets dummy tests + /// exercise malformed transport events without changing the production callback path. + static NEXT_CALLBACK_CID: RefCell> = const { RefCell::new(None) }; + /// Test-only per-thread switch that makes `webrtc_wait_for_data_channel_open` stay pending. + /// This models a lifecycle notifier/callback wedge after a connection was already admitted. + static WAIT_FOR_DATA_CHANNEL_OPEN_PENDING: Cell = const { Cell::new(false) }; + /// Test-only per-thread switch that makes `send_message` stay pending after + /// the data channel is already open. + static SEND_MESSAGE_PENDING: Cell = const { Cell::new(false) }; + /// Test-only per-thread threshold that makes `send_message` stay pending after + /// this many messages have already been dispatched. + static SEND_MESSAGE_PENDING_AFTER_SENT_COUNT: Cell> = const { Cell::new(None) }; + /// Test-only per-thread switch that makes `send_message` report local success + /// without dispatching the message to the remote callback. + static DROP_MESSAGES: Cell = const { Cell::new(false) }; } /// Test-only controlled delivery scheduler. When enabled (per thread), dummy @@ -75,7 +90,12 @@ pub mod controlled { use super::CONNS; use super::CONTROLLED; use super::DELIVERY; + use super::DROP_MESSAGES; use super::MAX_MESSAGE_SIZE; + use super::NEXT_CALLBACK_CID; + use super::SEND_MESSAGE_PENDING; + use super::SEND_MESSAGE_PENDING_AFTER_SENT_COUNT; + use super::WAIT_FOR_DATA_CHANNEL_OPEN_PENDING; /// Turn the controlled scheduler on/off for the current thread. Turning it /// off clears this thread's queue. @@ -83,6 +103,13 @@ pub mod controlled { CONTROLLED.with(|c| c.set(on)); if !on { DELIVERY.with(|q| q.borrow_mut().clear()); + NEXT_CALLBACK_CID.with(|next| { + *next.borrow_mut() = None; + }); + WAIT_FOR_DATA_CHANNEL_OPEN_PENDING.with(|pending| pending.set(false)); + SEND_MESSAGE_PENDING.with(|pending| pending.set(false)); + SEND_MESSAGE_PENDING_AFTER_SENT_COUNT.with(|threshold| threshold.set(None)); + DROP_MESSAGES.with(|drop| drop.set(false)); } } @@ -92,6 +119,39 @@ pub mod controlled { MAX_MESSAGE_SIZE.with(|m| m.set(n)); } + /// Test hook: rewrite the next queued lifecycle callback to use `cid`. + /// + /// This applies only to peer-state and data-channel events delivered through + /// [`deliver`]. Message events keep their real connection id. + pub fn set_next_callback_cid(cid: impl Into) { + NEXT_CALLBACK_CID.with(|next| { + *next.borrow_mut() = Some(cid.into()); + }); + } + + /// Test hook: force `webrtc_wait_for_data_channel_open` on this thread to never complete. + pub fn set_wait_for_data_channel_open_pending(on: bool) { + WAIT_FOR_DATA_CHANNEL_OPEN_PENDING.with(|pending| pending.set(on)); + } + + /// Test hook: force `send_message` to stay pending after the data channel is open. + pub fn set_send_message_pending(on: bool) { + SEND_MESSAGE_PENDING.with(|pending| pending.set(on)); + } + + /// Test hook: force `send_message` to stay pending once this thread has already dispatched + /// `threshold` messages. `None` disables the hook. + pub fn set_send_message_pending_after_sent_count(threshold: Option) { + SEND_MESSAGE_PENDING_AFTER_SENT_COUNT.with(|pending_after| pending_after.set(threshold)); + } + + /// Test hook: make dummy sends disappear while still returning a successful + /// local send. This models a silent remote failure where the local data + /// channel remains open and `Connected`. + pub fn set_drop_messages(on: bool) { + DROP_MESSAGES.with(|drop| drop.set(on)); + } + /// Test hook: number of data-channel messages `send_message` has dispatched on this thread. /// Paired with [`reset_sent_count`] to assert that a failed send enqueued nothing. pub fn sent_count() -> usize { @@ -113,24 +173,60 @@ pub mod controlled { /// index is out of range or the target connection is gone. pub async fn deliver(index: usize) -> bool { let entry = DELIVERY.with(|q| q.borrow_mut().remove(index)); - let Some((rand_id, event)) = entry else { + let Some((rand_id, mut event)) = entry else { return false; }; let Some(conn) = CONNS.get(&rand_id).map(|c| c.clone()) else { return false; }; + if event.is_lifecycle_event() { + if let Some(cid) = NEXT_CALLBACK_CID.with(|next| next.borrow_mut().take()) { + event.set_callback_cid(cid); + } + } conn.handle_event(event).await; true } + + /// Deliver the next queued data-channel-open event with a rewritten callback cid. + pub async fn deliver_next_data_channel_open_with_cid(cid: impl Into) -> bool { + let index = DELIVERY.with(|q| { + q.borrow() + .iter() + .position(|(_, event)| matches!(event, super::Event::DataChannelOpen(_))) + }); + let Some(index) = index else { + return false; + }; + set_next_callback_cid(cid); + deliver(index).await + } } enum Event { - PeerConnectionStateChange(WebrtcConnectionState), - DataChannelOpen, - DataChannelClose, + PeerConnectionStateChange(WebrtcConnectionState, Option), + DataChannelOpen(Option), + DataChannelClose(Option), Message(Bytes), } +impl Event { + fn is_lifecycle_event(&self) -> bool { + !matches!(self, Self::Message(_)) + } + + fn set_callback_cid(&mut self, cid: String) { + match self { + Self::PeerConnectionStateChange(_, callback_cid) + | Self::DataChannelOpen(callback_cid) + | Self::DataChannelClose(callback_cid) => { + *callback_cid = Some(cid); + } + Self::Message(_) => {} + } + } +} + /// A dummy connection for local testing. /// Implements the [ConnectionInterface] trait with no real network. pub struct DummyConnection { @@ -182,11 +278,29 @@ impl DummyConnection { async fn handle_event(&self, event: Event) { match event { - Event::PeerConnectionStateChange(state) => { - self.callback.on_peer_connection_state_change(state).await + Event::PeerConnectionStateChange(state, callback_cid) => { + if let Some(cid) = callback_cid { + self.callback + .on_peer_connection_state_change_with_cid(&cid, state) + .await; + } else { + self.callback.on_peer_connection_state_change(state).await; + } + } + Event::DataChannelOpen(callback_cid) => { + if let Some(cid) = callback_cid { + self.callback.on_data_channel_open_with_cid(&cid).await; + } else { + self.callback.on_data_channel_open().await; + } + } + Event::DataChannelClose(callback_cid) => { + if let Some(cid) = callback_cid { + self.callback.on_data_channel_close_with_cid(&cid).await; + } else { + self.callback.on_data_channel_close().await; + } } - Event::DataChannelOpen => self.callback.on_data_channel_open().await, - Event::DataChannelClose => self.callback.on_data_channel_close().await, Event::Message(data) => { if SEND_MESSAGE_DELAY && !CONTROLLED.with(|c| c.get()) { random_delay().await; @@ -241,17 +355,17 @@ impl DummyConnection { *webrtc_connection_state = state; } - self.dispatch(Event::PeerConnectionStateChange(state)); + self.dispatch(Event::PeerConnectionStateChange(state, None)); if state == WebrtcConnectionState::Connected { - self.dispatch(Event::DataChannelOpen); + self.dispatch(Event::DataChannelOpen(None)); } if matches!( state, WebrtcConnectionState::Closed | WebrtcConnectionState::Disconnected ) { - self.dispatch(Event::DataChannelClose); + self.dispatch(Event::DataChannelClose(None)); } } } @@ -276,9 +390,22 @@ impl ConnectionInterface for DummyConnection { async fn send_message(&self, msg: TransportMessage) -> Result { self.webrtc_wait_for_data_channel_open().await?; + if SEND_MESSAGE_PENDING.with(|pending| pending.get()) + || SEND_MESSAGE_PENDING_AFTER_SENT_COUNT.with(|threshold| { + threshold + .get() + .map(|count| SENT_COUNT.with(|sent| sent.get()) >= count) + .unwrap_or(false) + }) + { + std::future::pending::<()>().await; + } SENT_COUNT.with(|c| c.set(c.get() + 1)); let data = bincode::serialize(&msg).map(Bytes::from)?; + if DROP_MESSAGES.with(|drop| drop.get()) { + return Ok(Box::pin(async { Ok(()) })); + } // The remote connection may have been torn down between the data // channel check and here (the dummy analogue of sending on a channel // that just closed). Mimic a real transport: fail gracefully instead of @@ -301,6 +428,17 @@ impl ConnectionInterface for DummyConnection { *self.connection_state() } + fn data_channel_is_open(&self) -> Result { + // Dummy handshakes use `Connecting` for the interval after an offer is + // answered but before the opposite side has accepted the answer. Tests + // can deliver a data-channel-open callback in that interval to model + // browser timing where SCTP opens before ICE reaches `Connected`. + Ok(matches!( + self.webrtc_connection_state(), + WebrtcConnectionState::Connected | WebrtcConnectionState::Connecting + )) + } + fn max_message_size(&self) -> usize { match MAX_MESSAGE_SIZE.with(|m| m.get()) { 0 => MAX_DATA_CHANNEL_MESSAGE_SIZE, @@ -342,12 +480,10 @@ impl ConnectionInterface for DummyConnection { } async fn webrtc_wait_for_data_channel_open(&self) -> Result<()> { - // Will pass if the state is connecting to prevent release connection in the `test_handshake_on_both_sides` test. - // The connecting state means an offer is answered but not accepted by the other side. - if matches!( - self.webrtc_connection_state(), - WebrtcConnectionState::Connected | WebrtcConnectionState::Connecting - ) { + if WAIT_FOR_DATA_CHANNEL_OPEN_PENDING.with(|pending| pending.get()) { + std::future::pending::<()>().await; + } + if self.data_channel_is_open()? { Ok(()) } else { Err(Error::DataChannelOpen( 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/connections/native_webrtc/mod.rs b/crates/transport/src/connections/native_webrtc/mod.rs index 16fdda16b..7c672222b 100644 --- a/crates/transport/src/connections/native_webrtc/mod.rs +++ b/crates/transport/src/connections/native_webrtc/mod.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -6,6 +7,7 @@ use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; +use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use webrtc::data_channel::data_channel_message::DataChannelMessage; @@ -66,6 +68,111 @@ const DELIVERY_POLL_INTERVAL: Duration = Duration::from_millis(300); /// later message's bytes. type TrackedChannel = (Arc, Arc, Arc>); +fn sdp_candidate_count(sdp: &str) -> usize { + sdp.lines() + .filter(|line| line.starts_with("a=candidate:")) + .count() +} + +fn external_address_candidates(external_address: Option<&str>) -> Vec { + let Some(external_address) = external_address else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + for candidate in external_address.split(',') { + let candidate = candidate.trim(); + if candidate.is_empty() || candidates.iter().any(|seen| seen == candidate) { + continue; + } + candidates.push(candidate.to_string()); + } + candidates +} + +fn is_loopback_address(candidate: &str) -> bool { + candidate + .parse::() + .map(|addr| addr.is_loopback()) + .unwrap_or(false) +} + +fn nat_1to1_host_candidates(candidates: &[String]) -> Vec { + candidates + .iter() + .filter(|candidate| !is_loopback_address(candidate)) + .cloned() + .collect() +} + +fn sdp_extra_host_candidates(candidates: &[String]) -> Vec { + candidates + .iter() + .filter(|candidate| is_loopback_address(candidate)) + .cloned() + .collect() +} + +fn duplicate_host_candidate_line(line: &str, extra_addresses: &[String]) -> Vec { + if !line.starts_with("a=candidate:") { + return Vec::new(); + } + + let fields = line.split_whitespace().collect::>(); + let Some(address) = fields.get(4) else { + return Vec::new(); + }; + let Some(candidate_type_marker) = fields.get(6) else { + return Vec::new(); + }; + let Some(candidate_type) = fields.get(7) else { + return Vec::new(); + }; + if *candidate_type_marker != "typ" || *candidate_type != "host" { + return Vec::new(); + } + + extra_addresses + .iter() + .filter(|candidate| candidate.as_str() != *address) + .filter_map(|candidate| { + let mut duplicate = fields + .iter() + .map(|field| field.to_string()) + .collect::>(); + let address_slot = duplicate.get_mut(4)?; + *address_slot = candidate.clone(); + Some(duplicate.join(" ")) + }) + .collect() +} + +fn append_sdp_extra_host_candidates(sdp: String, extra_addresses: &[String]) -> String { + if extra_addresses.is_empty() { + return sdp; + } + + let mut output = String::with_capacity(sdp.len()); + for segment in sdp.split_inclusive('\n') { + output.push_str(segment); + + let line = segment.trim_end_matches(['\r', '\n']); + let line_ending = if segment.ends_with("\r\n") { + "\r\n" + } else if segment.ends_with('\n') { + "\n" + } else { + "" + }; + for duplicate in duplicate_host_candidate_line(line, extra_addresses) { + output.push_str(&duplicate); + output.push_str(line_ending); + } + } + + output +} + /// Build the future that resolves once the message ending at `end_offset` on /// this channel has been flushed to the wire, or errors if the channel closes /// first. It re-checks on a timer, driving its own wake-ups. @@ -131,6 +238,7 @@ pub struct WebrtcConnection { webrtc_data_channel: Arc>, webrtc_data_channel_state_notifier: Notifier, cancel_token: CancellationToken, + sdp_extra_host_candidates: Vec, /// Negotiated SCTP `max_message_size` (RFC 8841), parsed from the remote SDP at handshake. /// `0` means not yet negotiated. webrtc-rs exposes no getter, so we track it ourselves. remote_max_message_size: Arc, @@ -150,18 +258,22 @@ impl WebrtcConnection { webrtc_conn: RTCPeerConnection, webrtc_data_channel: Arc>, webrtc_data_channel_state_notifier: Notifier, + sdp_extra_host_candidates: Vec, ) -> Self { Self { webrtc_conn, webrtc_data_channel, webrtc_data_channel_state_notifier, cancel_token: CancellationToken::new(), + sdp_extra_host_candidates, remote_max_message_size: Arc::new(AtomicUsize::new(0)), } } - async fn webrtc_gather(&self) -> Result { - let mut gathering_complete_promise = self.webrtc_conn.gathering_complete_promise().await; + async fn webrtc_gather( + &self, + mut gathering_complete_promise: mpsc::Receiver<()>, + ) -> Result { let gathering_complete_promise_with_timeout = tokio::time::timeout( std::time::Duration::from_secs(WEBRTC_GATHER_TIMEOUT.into()), gathering_complete_promise.recv(), @@ -171,17 +283,40 @@ impl WebrtcConnection { _ = self.cancel_token.cancelled() => { return Err(Error::WebrtcLocalSdpGenerationError("Local connection closed".to_string())) } - _ = gathering_complete_promise_with_timeout => {} + result = gathering_complete_promise_with_timeout => { + if result.is_err() { + return Err(Error::WebrtcLocalSdpGenerationError(format!( + "Webrtc gathering is not completed in {WEBRTC_GATHER_TIMEOUT} seconds" + ))); + } + } } - Ok(self + let sdp = self .webrtc_conn .local_description() .await .ok_or(Error::WebrtcLocalSdpGenerationError( "Failed to get local description".to_string(), ))? - .sdp) + .sdp; + let sdp = append_sdp_extra_host_candidates(sdp, &self.sdp_extra_host_candidates); + + let candidate_count = sdp_candidate_count(&sdp); + if candidate_count == 0 { + tracing::warn!( + sdp_bytes = sdp.len(), + "WebRTC local SDP has no ICE candidates after gathering" + ); + } else { + tracing::debug!( + sdp_bytes = sdp.len(), + candidate_count, + "WebRTC ICE gathering complete" + ); + } + + Ok(sdp) } } @@ -247,6 +382,10 @@ impl ConnectionInterface for WebrtcConnection { self.webrtc_conn.connection_state().into() } + fn data_channel_is_open(&self) -> Result { + self.webrtc_data_channel.all_ready() + } + fn max_message_size(&self) -> usize { // The value negotiated from the remote SDP at handshake; `0` = not yet negotiated, so // fall back to the interop default. @@ -258,11 +397,12 @@ impl ConnectionInterface for WebrtcConnection { async fn webrtc_create_offer(&self) -> Result { let setting_offer = self.webrtc_conn.create_offer(None).await?; + let gathering_complete_promise = self.webrtc_conn.gathering_complete_promise().await; self.webrtc_conn .set_local_description(setting_offer.clone()) .await?; - self.webrtc_gather().await + self.webrtc_gather(gathering_complete_promise).await } async fn webrtc_answer_offer(&self, offer: Self::Sdp) -> Result { @@ -275,10 +415,11 @@ impl ConnectionInterface for WebrtcConnection { self.webrtc_conn.set_remote_description(offer).await?; let answer = self.webrtc_conn.create_answer(None).await?; + let gathering_complete_promise = self.webrtc_conn.gathering_complete_promise().await; self.webrtc_conn .set_local_description(answer.clone()) .await?; - let local_sdp = self.webrtc_gather().await?; + let local_sdp = self.webrtc_gather(gathering_complete_promise).await?; self.remote_max_message_size .store(negotiated_max_message_size, Ordering::SeqCst); @@ -307,7 +448,7 @@ impl ConnectionInterface for WebrtcConnection { return Err(Error::DataChannelOpen("Connection unavailable".to_string())); } - if self.webrtc_data_channel.all_ready()? { + if self.data_channel_is_open()? { return Ok(()); } @@ -315,7 +456,7 @@ impl ConnectionInterface for WebrtcConnection { .set_timeout(WEBRTC_WAIT_FOR_DATA_CHANNEL_OPEN_TIMEOUT); self.webrtc_data_channel_state_notifier.clone().await; - if self.webrtc_data_channel.all_ready()? { + if self.data_channel_is_open()? { return Ok(()); } else { return Err(Error::DataChannelOpen(format!( @@ -359,13 +500,25 @@ impl TransportInterface for WebrtcTransport { let mut setting = webrtc::api::setting_engine::SettingEngine::default(); set_udp_network_range(&mut setting, self.udp_port_range)?; - if let Some(ref addr) = self.external_address { - tracing::debug!("setting external ip {:?}", addr); - setting.set_nat_1to1_ips(vec![addr.to_string()], RTCIceCandidateType::Host); + let external_addresses = external_address_candidates(self.external_address.as_deref()); + let nat_host_candidates = nat_1to1_host_candidates(&external_addresses); + let sdp_extra_host_candidates = sdp_extra_host_candidates(&external_addresses); + if nat_host_candidates.is_empty() { setting.set_ice_multicast_dns_mode(MulticastDnsMode::Disabled); } else { + tracing::debug!( + external_addresses = ?nat_host_candidates, + "setting external WebRTC host candidates" + ); + setting.set_nat_1to1_ips(nat_host_candidates, RTCIceCandidateType::Host); setting.set_ice_multicast_dns_mode(MulticastDnsMode::Disabled); } + if !sdp_extra_host_candidates.is_empty() { + tracing::debug!( + extra_host_candidates = ?sdp_extra_host_candidates, + "will append SDP-only WebRTC host candidates" + ); + } let webrtc_api = webrtc::api::APIBuilder::new() .with_setting_engine(setting) @@ -392,16 +545,25 @@ impl TransportInterface for WebrtcTransport { let d_label = d.label(); let d_id = d.id(); tracing::debug!("New DataChannel {d_label} {d_id}"); - // Open/close are detected on the channels we create (the pool, wired - // below); a received channel only carries inbound messages. Wiring - // open/close here too would fire on_data_channel_open twice (created - // + received) and churn join_dht. + // Open is admitted on channels we create (the pool, wired below). + // Close must also be observed on received channels: the answerer may + // never see close on its locally-created channels when the initiator's + // channels are the ones carrying traffic. + let on_close_inner_cb = data_channel_inner_cb.clone(); + d.on_close(Box::new(move || { + let cb = on_close_inner_cb.clone(); + Box::pin(async move { + cb.on_data_channel_close().await; + }) + })); + let on_message_inner_cb = data_channel_inner_cb.clone(); d.on_message(Box::new(move |msg: DataChannelMessage| { tracing::debug!( - "Received DataChannelMessage from {}: {:?}", - on_message_inner_cb.cid, - msg + peer = %on_message_inner_cb.cid, + is_string = msg.is_string, + bytes = msg.data.len(), + "Received DataChannelMessage" ); let inner_cb = on_message_inner_cb.clone(); @@ -474,6 +636,7 @@ impl TransportInterface for WebrtcTransport { webrtc_conn, channel_pool, webrtc_data_channel_state_notifier, + sdp_extra_host_candidates, ); self.pool.safely_insert(cid, conn)?; @@ -561,4 +724,44 @@ mod tests { assert_eq!(transport.udp_port_range, Some(range)); } + + #[test] + fn external_address_candidates_split_trim_and_deduplicate() { + let candidates = external_address_candidates(Some(" 127.0.0.1, 192.168.215.2,127.0.0.1, ")); + + assert_eq!(candidates, vec![ + "127.0.0.1".to_string(), + "192.168.215.2".to_string() + ]); + } + + #[test] + fn external_address_candidates_ignore_blank_config() { + assert!(external_address_candidates(Some(" , ")).is_empty()); + assert!(external_address_candidates(None).is_empty()); + } + + #[test] + fn loopback_external_addresses_are_sdp_only_candidates() { + let candidates = vec!["127.0.0.1".to_string(), "192.168.215.2".to_string()]; + + assert_eq!(nat_1to1_host_candidates(&candidates), vec!["192.168.215.2"]); + assert_eq!(sdp_extra_host_candidates(&candidates), vec!["127.0.0.1"]); + } + + #[test] + fn append_sdp_extra_host_candidates_duplicates_host_candidates() { + let sdp = "v=0\r\n\ +a=candidate:1 1 udp 2130706431 192.168.215.2 49160 typ host\r\n\ +a=end-of-candidates\r\n" + .to_string(); + + let rewritten = append_sdp_extra_host_candidates(sdp, &["127.0.0.1".to_string()]); + + assert!(rewritten.contains( + "a=candidate:1 1 udp 2130706431 192.168.215.2 49160 typ host\r\n\ +a=candidate:1 1 udp 2130706431 127.0.0.1 49160 typ host\r\n" + )); + assert!(rewritten.ends_with("a=end-of-candidates\r\n")); + } } diff --git a/crates/transport/src/connections/web_sys_webrtc/mod.rs b/crates/transport/src/connections/web_sys_webrtc/mod.rs index c44efd9c5..626a3b77e 100644 --- a/crates/transport/src/connections/web_sys_webrtc/mod.rs +++ b/crates/transport/src/connections/web_sys_webrtc/mod.rs @@ -219,6 +219,10 @@ impl ConnectionInterface for WebSysWebrtcConnection { self.webrtc_conn.connection_state().into() } + fn data_channel_is_open(&self) -> Result { + self.webrtc_data_channel.all_ready() + } + fn max_message_size(&self) -> usize { // The value negotiated from the remote SDP at handshake; `0` = not yet negotiated, so // fall back to the interop default. Same parsing as native (consistent behaviour). @@ -316,7 +320,7 @@ impl ConnectionInterface for WebSysWebrtcConnection { return Err(Error::DataChannelOpen("Connection unavailable".to_string())); } - if self.webrtc_data_channel.all_ready()? { + if self.data_channel_is_open()? { return Ok(()); } @@ -324,7 +328,7 @@ impl ConnectionInterface for WebSysWebrtcConnection { .set_timeout(WEBRTC_WAIT_FOR_DATA_CHANNEL_OPEN_TIMEOUT); self.webrtc_data_channel_state_notifier.clone().await; - if self.webrtc_data_channel.all_ready()? { + if self.data_channel_is_open()? { return Ok(()); } else { return Err(Error::DataChannelOpen(format!( @@ -387,10 +391,21 @@ impl TransportInterface for WebSysWebrtcTransport { let d = ev.channel(); let d_label = d.label(); tracing::debug!("New DataChannel {d_label}"); - // Open/close are detected on the channels we create (the pool, wired - // below); a received channel only carries inbound messages. Wiring - // open/close here too would fire on_data_channel_open twice (created - // + received) and churn join_dht. + // Open is admitted on channels we create (the pool, wired below). + // Close must also be observed on received channels: the answerer may + // never see close on its locally-created channels when the initiator's + // channels are the ones carrying traffic. + let on_close_inner_cb = data_channel_inner_cb.clone(); + let on_close = Box::new(move || { + let cb = on_close_inner_cb.clone(); + spawn_local(async move { + cb.on_data_channel_close().await; + }); + }); + let c = Closure::wrap(on_close as Box); + d.set_onclose(Some(c.as_ref().unchecked_ref())); + c.forget(); + let on_message_inner_cb = data_channel_inner_cb.clone(); let on_message = Box::new(move |ev: MessageEvent| { let data = ev.data(); @@ -425,9 +440,9 @@ impl TransportInterface for WebSysWebrtcTransport { } tracing::debug!( - "Received DataChannelMessage from {}: {:?}", - inner_cb.cid, - data + peer = %inner_cb.cid, + bytes = msg.len(), + "Received DataChannelMessage" ); inner_cb.on_message(&msg.into()).await; 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..529865c6d 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; @@ -104,6 +107,14 @@ pub trait ConnectionInterface { /// Get current webrtc connection state. fn webrtc_connection_state(&self) -> WebrtcConnectionState; + /// Return whether every data channel used by this connection is currently open. + /// + /// This is the routability predicate. ICE may still report `Connecting` + /// when the SCTP data channels have already opened, so callers that need to + /// decide whether payload transport is usable should check this method + /// rather than requiring [`WebrtcConnectionState::Connected`]. + fn data_channel_is_open(&self) -> Result; + /// The maximum size, in bytes, of one message this connection can send — the channel's /// negotiated SCTP / data-channel `max_message_size`, capped at /// [`MAX_DATA_CHANNEL_MESSAGE_SIZE`] for cross-peer interop. A caller must keep every sent @@ -133,8 +144,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 +184,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/ice_server.rs b/crates/transport/src/ice_server.rs index 411614161..f0ebfb812 100644 --- a/crates/transport/src/ice_server.rs +++ b/crates/transport/src/ice_server.rs @@ -53,6 +53,11 @@ impl IceServer { feature = "web-sys-webrtc" ))] pub(crate) fn parse_ice_servers_or_warn(config: &str, transport: &str) -> Vec { + let config = config.trim(); + if config.is_empty() { + return Vec::new(); + } + match IceServer::vec_from_str(config) { Ok(ice_servers) => ice_servers, Err(error) => { @@ -112,7 +117,7 @@ impl FromStr for IceServer { mod test { use std::str::FromStr; - use super::IceServer; + use super::*; #[test] fn test_parsing() { @@ -151,4 +156,15 @@ mod test { let parsed = IceServer::from_str("stun:///missing-host"); assert!(parsed.is_err()); } + + #[cfg(any( + feature = "dummy", + feature = "native-webrtc", + feature = "web-sys-webrtc" + ))] + #[test] + fn blank_ice_server_config_means_no_servers() { + assert!(parse_ice_servers_or_warn("", "test").is_empty()); + assert!(parse_ice_servers_or_warn(" ", "test").is_empty()); + } } 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..c7b734546 --- /dev/null +++ b/crates/webview/Cargo.toml @@ -0,0 +1,37 @@ +[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 } +httpdate = "1" +js-sys = { workspace = true, optional = true } +lol_html = "3" +percent-encoding = "2" +psl = "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..9deeaf867 --- /dev/null +++ b/crates/webview/src/browser.rs @@ -0,0 +1,700 @@ +//! 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 controlledAssetPaths = new Set(["/assets/webview-overlay.js"]); + 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 controlledAssetPath(input) {{ + const text = String(input); + const origin = globalThis.location?.origin; + if (!origin) return undefined; + try {{ + const url = new URL(text, origin); + if (controlledAssetPaths.has(url.pathname)) {{ + return `${{url.pathname}}${{url.search}}${{url.hash}}`; + }} + }} catch (_error) {{}} + return undefined; + }} + function encodeTarget(input, base = resolveTargetBase()) {{ + const text = String(input); + if (isUnrewritableTarget(text)) return text; + const controlled = controlledAssetPath(text); + if (controlled) return controlled; + 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; + function loadLocalScript(path, markerAttribute) {{ + const script = globalThis.document?.createElement?.("script"); + if (!script) return false; + script.async = false; + if (nativeSetAttribute) {{ + nativeSetAttribute.call(script, "src", path); + if (markerAttribute) nativeSetAttribute.call(script, markerAttribute, ""); + }} else {{ + script.src = path; + if (markerAttribute) script.setAttribute?.(markerAttribute, ""); + }} + (globalThis.document?.head || globalThis.document?.documentElement)?.append?.(script); + return true; + }} + 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; + gatewayState.loadLocalScript = loadLocalScript; +}})();"#, + 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; diff --git a/crates/webview/src/browser/tests.rs b/crates/webview/src/browser/tests.rs new file mode 100644 index 000000000..ccfd08451 --- /dev/null +++ b/crates/webview/src/browser/tests.rs @@ -0,0 +1,335 @@ +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..824867166 --- /dev/null +++ b/crates/webview/src/cookie.rs @@ -0,0 +1,603 @@ +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use url::Host; +use url::Url; + +use crate::error::Result; +use crate::error::WebviewError; +use crate::types::GatewayRequest; +use crate::types::GatewayRequestKind; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StoredCookie { + name: String, + value: String, + domain: String, + host_only: bool, + path: String, + secure: bool, + expires_at: Option, + same_site: SameSite, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SameSite { + Strict, + Lax, + None, +} + +/// 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, + expires_at: None, + same_site: SameSite::Lax, + }; + let mut delete_cookie = false; + let mut saw_max_age = 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() + }; + } else if key.eq_ignore_ascii_case("max-age") { + saw_max_age = true; + if let Ok(seconds) = value.trim().parse::() { + if seconds <= 0 { + delete_cookie = true; + cookie.expires_at = None; + } else { + delete_cookie = false; + cookie.expires_at = + Some(current_time_millis().saturating_add(max_age_millis(seconds))); + } + } + } else if key.eq_ignore_ascii_case("expires") && !saw_max_age { + if let Ok(expires_at) = httpdate::parse_http_date(value.trim()) { + let expires_at = system_time_millis(expires_at); + if expires_at <= current_time_millis() { + delete_cookie = true; + cookie.expires_at = None; + } else { + cookie.expires_at = Some(expires_at); + } + } + } else if key.eq_ignore_ascii_case("samesite") { + match value.trim().to_ascii_lowercase().as_str() { + "strict" => cookie.same_site = SameSite::Strict, + "lax" => cookie.same_site = SameSite::Lax, + "none" => cookie.same_site = SameSite::None, + _ => {} + } + } + } + } + + if !delete_cookie && cookie.same_site == SameSite::None && !cookie.secure { + return Ok(()); + } + self.cookies.retain(|existing| { + !(existing.name == cookie.name + && existing.domain == cookie.domain + && existing.path == cookie.path) + }); + if delete_cookie { + return Ok(()); + } + 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 { + self.cookie_header_matching(target, None, "GET", true, GatewayRequestKind::Navigation) + } + + /// Build a `Cookie` request header for one normalized gateway request. + pub fn cookie_header_for_request(&self, request: &GatewayRequest) -> Option { + self.cookie_header_matching( + &request.target, + request.source_origin.as_ref(), + request.method.as_str(), + request.top_level_navigation, + request.kind, + ) + } + + fn cookie_header_matching( + &self, + target: &Url, + source: Option<&Url>, + method: &str, + top_level_navigation: bool, + kind: GatewayRequestKind, + ) -> Option { + let host = target.host_str()?.to_ascii_lowercase(); + let path = target.path(); + let secure_request = target.scheme() == "https"; + let now = current_time_millis(); + let pairs: Vec = self + .cookies + .iter() + .filter(|cookie| { + !cookie_expired(cookie, now) + && (!cookie.secure || secure_request) + && domain_matches(cookie, &host) + && path_matches(cookie.path.as_str(), path) + && same_site_allows_cookie( + cookie, + source, + target, + method, + top_level_navigation, + kind, + ) + }) + .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 { + let now = current_time_millis(); + self.cookies + .iter() + .filter(|cookie| !cookie_expired(cookie, now)) + .count() + } + + /// Return true when no cookies are stored. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +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 cookie_expired(cookie: &StoredCookie, now: i64) -> bool { + cookie + .expires_at + .is_some_and(|expires_at| expires_at <= now) +} + +fn same_site_allows_cookie( + cookie: &StoredCookie, + source: Option<&Url>, + target: &Url, + method: &str, + top_level_navigation: bool, + kind: GatewayRequestKind, +) -> bool { + if cookie.same_site == SameSite::None { + return true; + } + if let Some(source) = source { + if same_site(source, target) { + return true; + } + } else { + return kind == GatewayRequestKind::Navigation && top_level_navigation; + } + cookie.same_site == SameSite::Lax + && top_level_navigation + && kind == GatewayRequestKind::Navigation + && is_safe_navigation_method(method) +} + +fn same_site(source: &Url, target: &Url) -> bool { + source.scheme() == target.scheme() + && site_for_same_site(source) + .zip(site_for_same_site(target)) + .is_some_and(|(source, target)| source.eq_ignore_ascii_case(target.as_str())) +} + +fn site_for_same_site(url: &Url) -> Option { + match url.host()? { + Host::Domain(host) => domain_site_for_same_site(host), + Host::Ipv4(host) => Some(host.to_string()), + Host::Ipv6(host) => Some(host.to_string()), + } +} + +fn domain_site_for_same_site(host: &str) -> Option { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() { + return None; + } + let registrable_domain = psl::domain_str(host.as_str()).unwrap_or(host.as_str()); + Some( + registrable_domain + .trim_end_matches('.') + .to_ascii_lowercase(), + ) +} + +fn is_safe_navigation_method(method: &str) -> bool { + matches!(method.to_ascii_uppercase().as_str(), "GET" | "HEAD") +} + +fn max_age_millis(seconds: i64) -> i64 { + seconds.saturating_mul(1_000) +} + +#[cfg(all(target_family = "wasm", feature = "browser"))] +fn current_time_millis() -> i64 { + js_sys::Date::now() as i64 +} + +#[cfg(all(target_family = "wasm", not(feature = "browser")))] +fn current_time_millis() -> i64 { + 0 +} + +#[cfg(not(target_family = "wasm"))] +fn current_time_millis() -> i64 { + system_time_millis(SystemTime::now()) +} + +fn system_time_millis(time: SystemTime) -> i64 { + let Ok(duration) = time.duration_since(UNIX_EPOCH) else { + return 0; + }; + duration_millis(duration) +} + +fn duration_millis(duration: Duration) -> i64 { + let millis = duration.as_millis(); + if millis > i64::MAX as u128 { + i64::MAX + } else { + millis as i64 + } +} + +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(()) + } + + #[test] + fn cookie_max_age_zero_deletes_existing_cookie() -> 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")?; + jar.store_set_cookie(&origin, "sid=gone; Path=/app; Max-Age=0")?; + + let target = Url::parse("https://example.com/app/page")?; + assert_eq!(jar.cookie_header(&target), None); + assert!(jar.is_empty()); + Ok(()) + } + + #[test] + fn cookie_past_expires_deletes_existing_cookie() -> 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")?; + jar.store_set_cookie( + &origin, + "sid=gone; Path=/app; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + )?; + + let target = Url::parse("https://example.com/app/page")?; + assert_eq!(jar.cookie_header(&target), None); + assert!(jar.is_empty()); + Ok(()) + } + + #[test] + fn strict_cookie_is_not_sent_for_cross_site_subresource() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://auth.example.test/app/index.html")?; + jar.store_set_cookie(&origin, "sid=strict; Path=/; SameSite=Strict; Secure")?; + + let request = crate::types::GatewayRequest::subresource(Url::parse( + "https://auth.example.test/app/script.js", + )?) + .with_source_origin(Url::parse("https://shop.example.net/page")?); + + assert_eq!(jar.cookie_header_for_request(&request), None); + Ok(()) + } + + #[test] + fn strict_cookie_is_sent_for_same_site_subdomain_subresource() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://auth.example.com/app/index.html")?; + jar.store_set_cookie(&origin, "sid=strict; Path=/; SameSite=Strict; Secure")?; + + let request = crate::types::GatewayRequest::subresource(Url::parse( + "https://auth.example.com/app/script.js", + )?) + .with_source_origin(Url::parse("https://shop.example.com/page")?); + + assert_eq!( + jar.cookie_header_for_request(&request).as_deref(), + Some("sid=strict") + ); + Ok(()) + } + + #[test] + fn strict_cookie_is_not_sent_between_public_suffix_siblings() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://owner.github.io/app/index.html")?; + jar.store_set_cookie(&origin, "sid=strict; Path=/; SameSite=Strict; Secure")?; + + let request = crate::types::GatewayRequest::subresource(Url::parse( + "https://owner.github.io/app/script.js", + )?) + .with_source_origin(Url::parse("https://attacker.github.io/page")?); + + assert_eq!(jar.cookie_header_for_request(&request), None); + Ok(()) + } + + #[test] + fn strict_cookie_ip_sources_use_exact_host_site() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://127.0.0.1/app/index.html")?; + jar.store_set_cookie(&origin, "sid=strict; Path=/; SameSite=Strict; Secure")?; + let same_ip = crate::types::GatewayRequest::subresource(Url::parse( + "https://127.0.0.1/app/script.js", + )?) + .with_source_origin(Url::parse("https://127.0.0.1/page")?); + let cross_ip = crate::types::GatewayRequest::subresource(Url::parse( + "https://127.0.0.1/app/script.js", + )?) + .with_source_origin(Url::parse("https://127.0.0.2/page")?); + + assert_eq!( + jar.cookie_header_for_request(&same_ip).as_deref(), + Some("sid=strict") + ); + assert_eq!(jar.cookie_header_for_request(&cross_ip), None); + Ok(()) + } + + #[test] + fn strict_and_lax_cookies_are_not_sent_without_subresource_source_context() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://auth.example.test/app/index.html")?; + jar.store_set_cookie(&origin, "strict=one; Path=/; SameSite=Strict; Secure")?; + jar.store_set_cookie(&origin, "lax=one; Path=/; SameSite=Lax; Secure")?; + jar.store_set_cookie(&origin, "none=one; Path=/; SameSite=None; Secure")?; + + let request = crate::types::GatewayRequest::subresource(Url::parse( + "https://auth.example.test/app/script.js", + )?); + + assert_eq!( + jar.cookie_header_for_request(&request).as_deref(), + Some("none=one") + ); + Ok(()) + } + + #[test] + fn lax_cookie_is_only_sent_on_safe_cross_site_top_level_navigation() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://auth.example.test/app/index.html")?; + jar.store_set_cookie(&origin, "sid=lax; Path=/; SameSite=Lax; Secure")?; + + let source = Url::parse("https://shop.example.net/page")?; + let safe_top_level = crate::types::GatewayRequest::navigation(Url::parse( + "https://auth.example.test/app/page", + )?) + .with_source_origin(source.clone()) + .with_top_level_navigation(true); + let unsafe_top_level = crate::types::GatewayRequest::navigation(Url::parse( + "https://auth.example.test/app/delete", + )?) + .with_source_origin(source.clone()) + .with_top_level_navigation(true) + .with_body("delete") + .with_header(crate::types::GatewayHeader::new( + "Content-Type", + "text/plain", + )?); + let unsafe_top_level = crate::types::GatewayRequest { + method: "POST".to_string(), + ..unsafe_top_level + }; + let iframe_navigation = crate::types::GatewayRequest::navigation(Url::parse( + "https://auth.example.test/app/frame", + )?) + .with_source_origin(source) + .with_top_level_navigation(false); + + assert_eq!( + jar.cookie_header_for_request(&safe_top_level).as_deref(), + Some("sid=lax") + ); + assert_eq!(jar.cookie_header_for_request(&unsafe_top_level), None); + assert_eq!(jar.cookie_header_for_request(&iframe_navigation), None); + Ok(()) + } + + #[test] + fn samesite_none_requires_secure_and_allows_cross_site_subresource() -> Result<()> { + let mut jar = CookieJar::new(); + let origin = Url::parse("https://auth.example.test/app/index.html")?; + + jar.store_set_cookie(&origin, "keep=old; Path=/")?; + jar.store_set_cookie(&origin, "keep=new; Path=/; SameSite=None")?; + assert_eq!( + jar.cookie_header(&Url::parse("https://auth.example.test/app/page")?) + .as_deref(), + Some("keep=old") + ); + + jar.store_set_cookie(&origin, "bad=none; Path=/; SameSite=None")?; + assert_eq!(jar.len(), 1); + + jar.store_set_cookie(&origin, "sid=none; Path=/; SameSite=None; Secure")?; + let request = crate::types::GatewayRequest::subresource(Url::parse( + "https://auth.example.test/app/script.js", + )?) + .with_source_origin(Url::parse("https://shop.example.net/page")?); + + assert_eq!( + jar.cookie_header_for_request(&request).as_deref(), + Some("sid=none") + ); + Ok(()) + } +} diff --git a/crates/webview/src/cors.rs b/crates/webview/src/cors.rs new file mode 100644 index 000000000..772da1c7a --- /dev/null +++ b/crates/webview/src/cors.rs @@ -0,0 +1,396 @@ +//! 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; + +const SAFE_RESPONSE_HEADERS: &[&str] = &[ + "cache-control", + "content-language", + "content-length", + "content-type", + "expires", + "last-modified", + "pragma", +]; + +/// 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(()) +} + +/// Filter response headers visible to a cross-origin runtime caller. +/// +/// The Service Worker returns responses from the controlled origin, so the +/// browser's native CORS header visibility rules would otherwise be bypassed. +pub fn filter_exposed_response_headers( + request: &GatewayRequest, + mut response: GatewayResponse, +) -> GatewayResponse { + if !request.is_cross_origin_runtime_request() { + return response; + } + let exposed = exposed_response_headers(request, &response); + response.headers.retain(|header| { + let name = header.name.to_ascii_lowercase(); + !is_gateway_internal_response_header(name.as_str()) + && (is_safelisted_response_header(name.as_str()) || exposed.contains(name.as_str())) + }); + response +} + +/// 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); + let wildcard_allows_headers = + request.credentials != GatewayCredentials::Include && allowed_headers.contains("*"); + if !requested_headers.is_empty() + && !wildcard_allows_headers + && !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 is_safelisted_response_header(name: &str) -> bool { + SAFE_RESPONSE_HEADERS + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +fn exposed_response_headers( + request: &GatewayRequest, + response: &GatewayResponse, +) -> BTreeSet { + let exposes_all = request.credentials != GatewayCredentials::Include + && header_values(response, "access-control-expose-headers") + .flat_map(|value| value.split(',')) + .any(|value| value.trim() == "*"); + if exposes_all { + return response + .headers + .iter() + .map(|header| header.name.to_ascii_lowercase()) + .collect(); + } + header_values(response, "access-control-expose-headers") + .flat_map(|value| value.split(',')) + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn is_gateway_internal_response_header(name: &str) -> bool { + matches!(name, "content-security-policy") +} + +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 url::Url; + + use super::*; + use crate::types::GatewayRequestKind; + + 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) + } + + #[test] + fn credentialed_preflight_treats_allow_headers_wildcard_as_literal() -> 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")?, + GatewayHeader::new("Access-Control-Allow-Methods", "PATCH")?, + GatewayHeader::new("Access-Control-Allow-Headers", "*")?, + ], + Vec::new(), + )?; + + assert!(matches!( + validate_preflight_response(&request, &response), + Err(WebviewError::Cors(_)) + )); + Ok(()) + } + + #[test] + fn cross_origin_runtime_response_headers_are_filtered_to_cors_visibility() -> Result<()> { + let request = GatewayRequest::fetch(Url::parse("https://api.example.test/data")?, "GET") + .with_source_origin(Url::parse("https://app.example.test/page")?); + let response = GatewayResponse::new( + 200, + vec![ + GatewayHeader::new("Access-Control-Allow-Origin", "https://app.example.test")?, + GatewayHeader::new("Access-Control-Expose-Headers", "X-Visible")?, + GatewayHeader::new("Content-Type", "application/json")?, + GatewayHeader::new("Content-Security-Policy", "default-src 'self'")?, + GatewayHeader::new("X-Visible", "ok")?, + GatewayHeader::new("X-Secret", "hidden")?, + ], + Vec::new(), + )?; + + let filtered = filter_exposed_response_headers(&request, response); + let names = filtered + .headers + .iter() + .map(|header| header.name.to_ascii_lowercase()) + .collect::>(); + + assert!(names.contains("content-type")); + assert!(names.contains("x-visible")); + assert!(!names.contains("x-secret")); + assert!(!names.contains("access-control-allow-origin")); + assert!(!names.contains("access-control-expose-headers")); + assert!(!names.contains("content-security-policy")); + Ok(()) + } +} diff --git a/crates/webview/src/error.rs b/crates/webview/src/error.rs new file mode 100644 index 000000000..ee60427eb --- /dev/null +++ b/crates/webview/src/error.rs @@ -0,0 +1,108 @@ +use thiserror::Error; + +/// Result type used by the webview gateway. +pub type Result = std::result::Result; + +/// Stable browser-facing failure metadata for one gateway request. +/// +/// This is the typed boundary between a transport adapter and browser UI. The +/// gateway response can use `status`, `code`, and `summary` without parsing the +/// human-readable `detail` message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GatewayFailure { + status: u16, + code: String, + summary: String, + detail: String, +} + +impl GatewayFailure { + /// Build gateway failure metadata. + pub fn new( + status: u16, + code: impl Into, + summary: impl Into, + detail: impl Into, + ) -> Self { + Self { + status, + code: code.into(), + summary: summary.into(), + detail: detail.into(), + } + } + + /// HTTP status to return from the controlled gateway route. + pub fn status(&self) -> u16 { + self.status + } + + /// Stable machine-readable failure code. + pub fn code(&self) -> &str { + &self.code + } + + /// Short user-facing failure summary. + pub fn summary(&self) -> &str { + &self.summary + } + + /// Detailed diagnostic message for the debug console. + pub fn detail(&self) -> &str { + &self.detail + } +} + +impl std::fmt::Display for GatewayFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +/// 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), + /// A runtime fetch or XHR was built without trusted source context. + #[error("runtime gateway request requires a trusted source origin")] + MissingRuntimeSourceOrigin, + /// A transport adapter returned stable browser-facing failure metadata. + #[error("{0}")] + GatewayFailure(GatewayFailure), + /// 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..1f32a2896 --- /dev/null +++ b/crates/webview/src/header.rs @@ -0,0 +1,287 @@ +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())); + request.headers.push(GatewayHeader { + name: "Accept-Encoding".to_string(), + value: "identity".to_string(), + }); + if should_send_virtual_origin(&request) { + let Some(source_origin) = request.source_origin.as_ref() else { + return request; + }; + 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_send_virtual_origin(request: &GatewayRequest) -> bool { + request.is_cross_origin_runtime_request() +} + +fn should_strip_response_header(name: &str) -> bool { + const STRIPPED: &[&str] = &[ + "connection", + "content-encoding", + "content-length", + "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 response_policy_strips_headers_invalidated_by_gateway_rewrites() -> 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("Content-Encoding", "gzip")?, + GatewayHeader::new("Content-Length", "128")?, + GatewayHeader::new("Content-Type", "text/html")?, + GatewayHeader::new("X-Frame-Options", "DENY")?, + ], + Vec::new(), + )?; + + let normalized = policy.normalize_response(&target, response)?; + + assert!(normalized.headers.iter().all(|header| { + !matches!( + header.name.to_ascii_lowercase().as_str(), + "content-encoding" | "content-length" | "x-frame-options" + ) + })); + assert!(normalized + .headers + .iter() + .any(|header| header.name_eq("content-type") && header.value == "text/html")); + 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-Encoding", "gzip")?, + GatewayHeader::new("Accept", "text/html")?, + GatewayHeader::new("X-App-Trace", "kept")?, + ], + body: Vec::new(), + kind: GatewayRequestKind::Navigation, + source_origin: None, + source_target: None, + credentials: crate::types::GatewayCredentials::SameOrigin, + top_level_navigation: true, + }; + + 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("accept-encoding") && header.value.eq_ignore_ascii_case("identity") + })); + assert!(normalized + .headers + .iter() + .any(|header| header.name_eq("x-app-trace") && header.value == "kept")); + Ok(()) + } + + #[test] + fn request_policy_does_not_synthesize_origin_for_subresources() -> Result<()> { + let target = Url::parse("https://cdn.example.test/app.js")?; + let policy = HeaderPolicy::new(GatewayPrefix::new("/webview/")?); + let request = GatewayRequest::subresource(target) + .with_source_origin(Url::parse("https://app.example.test/page")?); + + let normalized = policy.normalize_request(request); + + assert!(normalized + .headers + .iter() + .all(|header| !header.name_eq("origin"))); + Ok(()) + } +} diff --git a/crates/webview/src/lib.rs b/crates/webview/src/lib.rs new file mode 100644 index 000000000..ca035748d --- /dev/null +++ b/crates/webview/src/lib.rs @@ -0,0 +1,61 @@ +#![deny(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; + +/// Browser runtime bootstrap helpers. +#[cfg(feature = "browser")] +pub mod browser; + +pub use cookie::CookieJar; +pub use cors::preflight_request; +pub use cors::validate_response; +pub use error::GatewayFailure; +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..bae320d9d --- /dev/null +++ b/crates/webview/src/render.rs @@ -0,0 +1,168 @@ +//! 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..fef79c63a --- /dev/null +++ b/crates/webview/src/transport.rs @@ -0,0 +1,782 @@ +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::cookie::CookieJar; +use crate::cors; +use crate::error::Result; +use crate::error::WebviewError; +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), + PerRequest(fn(&GatewayRequest) -> 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 request = request.normalize_source_origin(); + if request.lacks_runtime_source_origin() { + return Err(WebviewError::MissingRuntimeSourceOrigin); + } + let mut request = self.header_policy.normalize_request(request); + if request.allows_target_cookies() { + if let Some(cookie_header) = cookies.cookie_header_for_request(&request) { + 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, + request: &GatewayRequest, + response: GatewayResponse, + ) -> Result { + let mut response = self + .header_policy + .normalize_response(&request.target, response)?; + response.body = self.rewrite_body(request, &response)?; + Ok(cors::filter_exposed_response_headers(request, response)) + } + + fn rewrite_body( + &self, + request: &GatewayRequest, + 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(), request.target.clone()); + if let Some(script) = self.bootstrap_for(request) { + 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, request: &GatewayRequest) -> Option { + match &self.bootstrap_script { + Some(BootstrapScript::Static(script)) => Some(script.clone()), + Some(BootstrapScript::PerTarget(build)) => Some(build(&request.target)), + Some(BootstrapScript::PerRequest(build)) => Some(build(request)), + 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 + } + + /// Attach a runtime bootstrap factory evaluated against each rendered gateway request. + pub fn with_request_bootstrap(mut self, script: fn(&GatewayRequest) -> String) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::PerRequest(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(&cors_request, 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 | GatewayRequestKind::Xhr => { + return Err(WebviewError::MissingRuntimeSourceOrigin) + } + }) + } + + /// 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 + } + + /// Attach a runtime bootstrap factory evaluated against each rendered gateway request. + pub fn with_request_bootstrap(mut self, script: fn(&GatewayRequest) -> String) -> Self { + self.policy.bootstrap_script = Some(BootstrapScript::PerRequest(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(&cors_request, 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 | GatewayRequestKind::Xhr => { + return Err(WebviewError::MissingRuntimeSourceOrigin) + } + }) + } + + /// 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 url::Url; + + 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, + source_target: None, + credentials: GatewayCredentials::SameOrigin, + top_level_navigation: true, + }; + + 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()) + } + + fn request_bootstrap(request: &GatewayRequest) -> String { + format!( + "globalThis.__ringsTopLevelNavigation = {};", + request.top_level_navigation + ) + } + + #[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(()) + } + + #[test] + fn per_request_bootstrap_tracks_navigation_context() -> Result<()> { + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, StaticTransport) + .with_request_bootstrap(request_bootstrap); + let target = TargetUrl::parse("https://frame.example.test/nested")?.into_url(); + let request = GatewayRequest::navigation(target).with_top_level_navigation(false); + + 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("globalThis.__ringsTopLevelNavigation = false;")); + Ok(()) + } + + #[test] + fn source_free_runtime_gateway_requests_are_rejected() -> Result<()> { + let target = TargetUrl::parse("https://api.example.test/data")?.into_url(); + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, StaticTransport); + + let send = futures::executor::block_on(gateway.send(GatewayRequest::fetch(target, "GET"))); + assert!(matches!( + send, + Err(WebviewError::MissingRuntimeSourceOrigin) + )); + assert!(matches!( + gateway.request_from_gateway_path( + "/webview/https%3A%2F%2Fapi%2Eexample%2Etest%2Fdata", + GatewayRequestKind::Fetch, + ), + Err(WebviewError::MissingRuntimeSourceOrigin) + )); + 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_source_origin( + TargetUrl::parse("https://example.com/index.html")?.into_url(), + ) + .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_normalizes_direct_struct_source_origin_before_transport() -> Result<()> { + let transport = RecordingTransport::new(); + let request = GatewayRequest { + target: TargetUrl::parse("https://app.example.test:8443/data")?.into_url(), + method: "GET".to_string(), + headers: Vec::new(), + body: Vec::new(), + kind: GatewayRequestKind::Fetch, + source_origin: Some(Url::parse( + "https://user:pass@app.example.test:8443/page?q=1#section", + )?), + source_target: None, + credentials: GatewayCredentials::SameOrigin, + top_level_navigation: false, + }; + let mut gateway = WebviewGateway::new(GatewayPrefix::new("/webview/")?, transport); + + futures::executor::block_on(gateway.send(request))?; + + let requests = gateway.transport.requests.borrow(); + let first = requests + .first() + .ok_or_else(|| WebviewError::Transport("missing request".to_string()))?; + assert_eq!( + first.source_origin.as_ref().map(Url::as_str), + Some("https://app.example.test:8443/") + ); + 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..067dcf193 --- /dev/null +++ b/crates/webview/src/types.rs @@ -0,0 +1,293 @@ +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 origin for requests initiated by another target document. + /// + /// This URL is normalized to the origin root (`scheme://host[:port]/`). The gateway + /// serializes only that origin upstream and uses it to apply virtual CORS, SameSite, and + /// credential rules. It must never be populated from an untrusted page header. + pub source_origin: Option, + /// Trusted page target that initiated this request. + /// + /// Unlike `source_origin`, this preserves the concrete controlled page URL for diagnostics + /// such as scoped WebView network and onion-route logs. It must never be populated from an + /// untrusted page header. + pub source_target: Option, + /// Browser credential mode for this request. + pub credentials: GatewayCredentials, + /// Whether this request is a top-level document navigation rather than an iframe navigation. + pub top_level_navigation: bool, +} + +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, + source_target: None, + credentials: GatewayCredentials::SameOrigin, + top_level_navigation: kind == GatewayRequestKind::Navigation, + } + } + + /// 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 origin for a browser runtime request. + pub fn with_source_origin(mut self, source_origin: Url) -> Self { + self.source_origin = Some(normalize_origin_url(source_origin)); + self + } + + /// Attach the trusted concrete source target for diagnostics. + pub fn with_source_target(mut self, source_target: Url) -> Self { + self.source_target = Some(source_target); + self + } + + /// Normalize trusted source context after direct struct construction. + pub fn normalize_source_origin(mut self) -> Self { + if let Some(source_origin) = self.source_origin.take() { + self.source_origin = Some(normalize_origin_url(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 + } + + /// Mark whether this request is a top-level document navigation. + pub fn with_top_level_navigation(mut self, top_level_navigation: bool) -> Self { + self.top_level_navigation = top_level_navigation; + 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 true when runtime CORS/SameSite context is missing. + pub fn lacks_runtime_source_origin(&self) -> bool { + matches!( + self.kind, + GatewayRequestKind::Fetch | GatewayRequestKind::Xhr + ) && self.source_origin.is_none() + } + + /// 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) { + (None, _) => false, + (_, GatewayCredentials::Omit) => false, + (Some(_), GatewayCredentials::Include) => true, + (Some(source), GatewayCredentials::SameOrigin) => { + source.origin() == self.target.origin() + } + } + } + + /// 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 + } +} + +fn normalize_origin_url(mut url: Url) -> Url { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + url +} + +/// 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, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_origin_is_normalized_to_origin_root() -> Result<()> { + let request = GatewayRequest::fetch(Url::parse("https://api.example.test/data")?, "GET") + .with_source_origin(Url::parse( + "https://user:pass@app.example.test:8443/path/page?q=1#section", + )?); + + assert_eq!( + request.source_origin.as_ref().map(Url::as_str), + Some("https://app.example.test:8443/") + ); + Ok(()) + } + + #[test] + fn source_less_runtime_requests_do_not_allow_target_cookies() -> Result<()> { + let request = GatewayRequest::fetch(Url::parse("https://api.example.test/data")?, "GET") + .with_credentials(GatewayCredentials::Include); + + assert!(request.lacks_runtime_source_origin()); + assert!(!request.allows_target_cookies()); + Ok(()) + } +} diff --git a/crates/webview/src/url.rs b/crates/webview/src/url.rs new file mode 100644 index 000000000..f8ecad237 --- /dev/null +++ b/crates/webview/src/url.rs @@ -0,0 +1,181 @@ +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.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 gateway_rejects_network_path_prefixes() { + assert!(matches!( + GatewayPrefix::new("//attacker.example/"), + Err(WebviewError::InvalidGatewayPrefix(_)) + )); + } + + #[test] + fn gateway_url_stays_on_gateway_origin_after_join() -> Result<()> { + let origin = Url::parse("https://rings.local/")?; + let prefix = GatewayPrefix::new("/webview/")?; + let target = TargetUrl::parse("https://example.com/")?; + + let gateway_url = origin.join(&prefix.encode(target.as_url()))?; + + assert_eq!(gateway_url.origin(), origin.origin()); + assert_eq!(prefix.decode_path(gateway_url.path())?, target); + Ok(()) + } + + #[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..64d489503 --- /dev/null +++ b/crates/webview/tests/browser_gateway.rs @@ -0,0 +1,802 @@ +//! Browser fixture coverage for the webview gateway. +#![cfg(feature = "browser")] + +use std::process::Command; +use std::sync::Arc; +use std::sync::Mutex; + +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; + +#[path = "browser_gateway/fixture_server.rs"] +mod fixture_server; + +use fixture_server::BrowserFixtureServer; + +#[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 = format!( + "{}\n{}", + bootstrap_script(prefix.as_str(), target.as_url()), + fixture_overlay_loader() + ); + 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(()) +} + +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 overlayScript = document.querySelector("script[data-rings-webview-overlay-loader]"); + const backgroundImage = (selector) => {{ + const element = document.querySelector(selector); + return element ? getComputedStyle(element).backgroundImage : ""; + }}; + return {{ + overlayMounted: Boolean(document.querySelector("#rings-webview-debug-overlay")), + overlayScriptSrc: overlayScript?.src || "", + 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.overlayMounted || !result.overlayScriptSrc.endsWith("/assets/webview-overlay.js")) {{ + throw new Error(`webview overlay did not mount from local asset: ${{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 fixture_overlay_loader() -> &'static str { + r#" +(() => { + if (globalThis.__ringsWebviewGateway?.loadLocalScript?.("/assets/webview-overlay.js", "data-rings-webview-overlay-loader")) return; + const script = document.createElement("script"); + script.src = "/assets/webview-overlay.js"; + script.async = false; + script.dataset.ringsWebviewOverlayLoader = ""; + (document.head || document.documentElement).append(script); +})(); +"# +} + +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}" + ))) + } +} + +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/browser_gateway/fixture_server.rs b/crates/webview/tests/browser_gateway/fixture_server.rs new file mode 100644 index 000000000..fb4d1b219 --- /dev/null +++ b/crates/webview/tests/browser_gateway/fixture_server.rs @@ -0,0 +1,333 @@ +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use url::Url; + +use super::*; + +pub(super) struct BrowserFixtureServer { + addr: std::net::SocketAddr, + shutdown: Arc, + handle: Option>>, +} + +impl BrowserFixtureServer { + pub(super) 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), + }) + } + + pub(super) fn gateway_url(&self, gateway_path: &str) -> String { + format!("http://{}{}", self.addr, gateway_path) + } + + pub(super) 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 == "/assets/webview-overlay.js" { + return write_http_response( + stream, + &GatewayResponse::new( + 200, + vec![GatewayHeader::new( + "Content-Type", + "application/javascript; charset=utf-8", + )?], + br#" +globalThis.__ringsWebviewDebugOverlay = { installed: true }; +document.documentElement.appendChild(Object.assign(document.createElement("div"), { + id: "rings-webview-debug-overlay", +})); +"# + .to_vec(), + )?, + ); + } + 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_http(prefix, request.path.as_str(), kind, &request.headers)?; + 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 gateway_request_from_http( + prefix: &GatewayPrefix, + path: &str, + kind: GatewayRequestKind, + headers: &[GatewayHeader], +) -> Result { + let target = 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") + .with_source_origin(runtime_source_from_referer(prefix, headers)?), + GatewayRequestKind::Xhr => GatewayRequest::xhr(target, "GET") + .with_source_origin(runtime_source_from_referer(prefix, headers)?), + }) +} + +fn runtime_source_from_referer(prefix: &GatewayPrefix, headers: &[GatewayHeader]) -> Result { + let source = header_value(headers, "referer") + .or_else(|| header_value(headers, "ping-from")) + .ok_or(WebviewError::MissingRuntimeSourceOrigin)?; + let source_url = Url::parse(source)?; + Ok(prefix.decode_path(source_url.path())?.into_url()) +} + +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 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) +} diff --git a/crates/webview/tests/webview_flow.rs b/crates/webview/tests/webview_flow.rs new file mode 100644 index 000000000..59b57559e --- /dev/null +++ b/crates/webview/tests/webview_flow.rs @@ -0,0 +1,279 @@ +//! 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_target = prefix.decode_path(&fetch_path)?.into_url(); + let fetch_response = futures::executor::block_on(renderer.gateway_mut().send( + GatewayRequest::fetch(fetch_target, "GET").with_source_origin(target.as_url().clone()), + ))?; + 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 xhr_target = prefix.decode_path(&xhr_path)?.into_url(); + let xhr_request = GatewayRequest::xhr(xhr_target, "POST") + .with_source_origin(target.as_url().clone()) + .with_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..e8a613f0b 100644 --- a/docker/cluster/Dockerfile +++ b/docker/cluster/Dockerfile @@ -1,11 +1,12 @@ # syntax=docker/dockerfile:1.7 -FROM rust:1.94-trixie AS builder +FROM rust:1.97-trixie AS builder 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 @@ -30,14 +31,14 @@ ENV RINGS_ALLOW_RANDOM_KEYS=true \ RINGS_CLUSTER_DIR=/var/lib/rings-cluster \ RINGS_CONNECT_TOPOLOGY=ring \ RINGS_ICE_SERVERS=stun://stun.l.google.com:19302 \ - RINGS_LOG_LEVEL=info \ + RINGS_LOG_LEVEL=warn \ RINGS_NETWORK_ID=1 \ RINGS_NODE_COUNT=3 \ RINGS_READY_RETRIES=60 \ RINGS_READY_SLEEP_SECONDS=1 \ RINGS_RUNTIME=current-thread \ RINGS_SESSION_TTL_SECONDS=2592000 \ - RINGS_STABILIZE_INTERVAL=3 + RINGS_STABILIZE_INTERVAL=15 EXPOSE 51000-51018/tcp diff --git a/docker/cluster/README.md b/docker/cluster/README.md index 8f0e98a98..39dd75d38 100644 --- a/docker/cluster/README.md +++ b/docker/cluster/README.md @@ -18,6 +18,21 @@ docker run --rm \ rings-node-cluster ``` +Run a browser-reachable cluster with one published JSON-RPC ingress and a fixed +native WebRTC UDP range: + +```sh +docker run --rm \ + -e RINGS_NODE_COUNT=3 \ + -e RINGS_ALLOW_RANDOM_KEYS=true \ + -e RINGS_EXTERNAL_IP=127.0.0.1 \ + -e RINGS_WEBRTC_UDP_PORT_MIN=49160 \ + -e RINGS_WEBRTC_UDP_PORT_MAX=49220 \ + -p 51000:51000/tcp \ + -p 49160-49220:49160-49220/udp \ + rings-node-cluster +``` + Run with externally supplied keys: ```sh @@ -48,6 +63,10 @@ Useful environment variables: - `RINGS_CLUSTER_DIR`: config, logs, storage, and session key directory; default `/var/lib/rings-cluster` - `RINGS_ICE_SERVERS`: ICE server list passed to every node; set it to an empty string to run without ICE servers - `RINGS_RUNTIME`: Tokio runtime flavor for each node process; default `current-thread` +- `RINGS_LOG_LEVEL`: node log level; default `warn` +- `RINGS_EXTERNAL_IP`: optional native WebRTC NAT 1:1 IP advertised in ICE host candidates; default unset. For browser testing from the Docker host, use `127.0.0.1`. +- `RINGS_EXTERNAL_IP_APPEND_CONTAINER_IP`: when `RINGS_EXTERNAL_IP` is set, also advertise the container IP so nodes inside the same container can still connect to each other; default `true` +- `RINGS_WEBRTC_UDP_PORT_MIN` and `RINGS_WEBRTC_UDP_PORT_MAX`: optional fixed native WebRTC UDP port range; publish the same range with `/udp` when browser peers must establish data channels to non-ingress nodes - `RINGS_ADVERTISE_ONION_RELAY`: publish relay capability from every node; default `true` - `RINGS_ADVERTISE_ONION_EXIT`: publish exit descriptors from every node; default `true` - `RINGS_ONION_EXIT_SERVICES`: comma-separated `name:transport` services; default `tcp:tcp,https:tcp` diff --git a/docker/cluster/rings-cluster-entrypoint.sh b/docker/cluster/rings-cluster-entrypoint.sh index 45294b3b1..8ad1ee5be 100644 --- a/docker/cluster/rings-cluster-entrypoint.sh +++ b/docker/cluster/rings-cluster-entrypoint.sh @@ -15,12 +15,14 @@ if [[ -v RINGS_ICE_SERVERS ]]; then else ICE_SERVERS="stun://stun.l.google.com:19302" fi -STABILIZE_INTERVAL="${RINGS_STABILIZE_INTERVAL:-3}" +STABILIZE_INTERVAL="${RINGS_STABILIZE_INTERVAL:-15}" SESSION_TTL_SECONDS="${RINGS_SESSION_TTL_SECONDS:-2592000}" READY_RETRIES="${RINGS_READY_RETRIES:-60}" READY_SLEEP_SECONDS="${RINGS_READY_SLEEP_SECONDS:-1}" -LOG_LEVEL="${RINGS_LOG_LEVEL:-info}" +LOG_LEVEL="${RINGS_LOG_LEVEL:-warn}" RUNTIME="${RINGS_RUNTIME:-current-thread}" +EXTERNAL_IP="${RINGS_EXTERNAL_IP:-}" +APPEND_CONTAINER_EXTERNAL_IP="${RINGS_EXTERNAL_IP_APPEND_CONTAINER_IP:-true}" WEBRTC_UDP_PORT_MIN="${RINGS_WEBRTC_UDP_PORT_MIN:-}" WEBRTC_UDP_PORT_MAX="${RINGS_WEBRTC_UDP_PORT_MAX:-}" STORAGE_CAPACITY="${RINGS_STORAGE_CAPACITY:-200000000}" @@ -79,6 +81,63 @@ yaml_quote() { printf "'%s'" "$value" } +detect_container_ip() { + local ip="" + for ip in $(hostname -i 2>/dev/null || true); do + [[ -n "$ip" ]] || continue + [[ "$ip" != 127.* && "$ip" != "::1" ]] || continue + printf '%s' "$ip" + return 0 + done +} + +csv_contains_item() { + local csv="$1" + local needle="$2" + local item="" + local old_ifs="$IFS" + IFS=',' + for item in $csv; do + item="$(trim "$item")" + if [[ "$item" == "$needle" ]]; then + IFS="$old_ifs" + return 0 + fi + done + IFS="$old_ifs" + return 1 +} + +append_csv_unique() { + local csv="$1" + local item="$2" + [[ -n "$item" ]] || { + printf '%s' "$csv" + return 0 + } + if csv_contains_item "$csv" "$item"; then + printf '%s' "$csv" + elif [[ -n "$csv" ]]; then + printf '%s,%s' "$csv" "$item" + else + printf '%s' "$item" + fi +} + +external_ip_candidates() { + local candidates="" + local container_ip="" + candidates="$(trim "$EXTERNAL_IP")" + [[ -n "$candidates" ]] || return 0 + + if [[ "$APPEND_CONTAINER_EXTERNAL_IP" == "true" ]]; then + container_ip="$(detect_container_ip)" + candidates="$(append_csv_unique "$candidates" "$container_ip")" + fi + + printf '%s' "$candidates" +} + canonical_onion_transport() { local raw="${1,,}" case "$raw" in @@ -162,6 +221,7 @@ require_uint RINGS_READY_SLEEP_SECONDS "$READY_SLEEP_SECONDS" require_uint RINGS_STORAGE_CAPACITY "$STORAGE_CAPACITY" ADVERTISE_ONION_RELAY="$(bool_literal RINGS_ADVERTISE_ONION_RELAY "$ADVERTISE_ONION_RELAY")" ADVERTISE_ONION_EXIT="$(bool_literal RINGS_ADVERTISE_ONION_EXIT "$ADVERTISE_ONION_EXIT")" +APPEND_CONTAINER_EXTERNAL_IP="$(bool_literal RINGS_EXTERNAL_IP_APPEND_CONTAINER_IP "$APPEND_CONTAINER_EXTERNAL_IP")" if (( NODE_COUNT < 1 )); then die "RINGS_NODE_COUNT must be at least 1" @@ -288,6 +348,7 @@ write_config() { local storage_path="$6" local data_path="$storage_path/data" local measure_path="$storage_path/measure" + local external_ips="" { printf 'network_id: %s\n' "$NETWORK_ID" @@ -317,7 +378,12 @@ write_config() { printf ' max_streams_per_circuit: 0\n' printf ' max_bytes_per_minute: 0\n' fi - printf 'external_ip: null\n' + external_ips="$(external_ip_candidates)" + if [[ -n "$external_ips" ]]; then + printf 'external_ip: %s\n' "$(yaml_quote "$external_ips")" + else + printf 'external_ip: null\n' + fi if [[ -n "$WEBRTC_UDP_PORT_MIN" || -n "$WEBRTC_UDP_PORT_MAX" ]]; then [[ -n "$WEBRTC_UDP_PORT_MIN" && -n "$WEBRTC_UDP_PORT_MAX" ]] \ || die "RINGS_WEBRTC_UDP_PORT_MIN and RINGS_WEBRTC_UDP_PORT_MAX must be set together" diff --git a/frontend/Cargo.lock b/frontend/Cargo.lock index 525cb1ba1..9f967418c 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", ] @@ -2456,6 +2505,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -2754,7 +2809,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3026,6 +3081,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" @@ -3058,6 +3132,15 @@ dependencies = [ "zerocopy-derive", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.2" @@ -3101,6 +3184,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 +3328,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 +3567,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 +3620,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" @@ -3708,6 +3850,21 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "psl" +version = "2.1.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0dedad316e05de220cbf3fd8bfb69f3df8755dde2d7d479211827b595168a93" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.3.1" @@ -4128,7 +4285,7 @@ dependencies = [ [[package]] name = "rings-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ark-bls12-381", "ark-ec", @@ -4190,7 +4347,7 @@ dependencies = [ [[package]] name = "rings-derive" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proc-macro2", "quote", @@ -4200,16 +4357,19 @@ dependencies = [ [[package]] name = "rings-frontend" -version = "0.1.0" +version = "0.2.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", @@ -4219,7 +4379,7 @@ dependencies = [ [[package]] name = "rings-node" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "arrayref", @@ -4265,7 +4425,7 @@ dependencies = [ [[package]] name = "rings-rpc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "base64 0.13.1", @@ -4281,7 +4441,7 @@ dependencies = [ [[package]] name = "rings-snark" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bellpepper-core", "byteorder", @@ -4308,7 +4468,7 @@ dependencies = [ [[package]] name = "rings-transport" -version = "0.14.0" +version = "0.15.0" dependencies = [ "async-trait", "bincode", @@ -4328,6 +4488,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rings-webview" +version = "0.15.0" +dependencies = [ + "async-trait", + "httpdate", + "js-sys", + "lol_html", + "percent-encoding", + "psl", + "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 +4596,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4506,6 +4684,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 +4834,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 +4983,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 +5032,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] @@ -4948,7 +5154,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5275,10 +5481,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log 0.2.0", ] @@ -5854,7 +6064,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..dac6b94de 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rings-frontend" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "GPL-3.0" publish = false @@ -11,14 +11,17 @@ publish = false [workspace] [dependencies] +async-trait = "0.1" base58 = "0.2" base64 = "0.22" 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..304ef93aa --- /dev/null +++ b/frontend/assets/webview-host.js @@ -0,0 +1,342 @@ +(() => { + "use strict"; + + const gatewayPrefix = "/webview/"; + const workerUrl = "/rings-webview-service-worker.js?gateway-host-protocol=4"; + const debugCapabilityRequestType = "rings-webview-debug-capability-request"; + const debugCapabilityResponseType = "rings-webview-debug-capability-response"; + const gatewayHostCapability = createGatewayHostCapability(); + let ownsGatewayHost = false; + let registrationPromise; + let popupDebugCapabilityPromise; + const debugEntries = []; + + function createGatewayHostCapability() { + if (typeof globalThis.crypto?.getRandomValues !== "function") { + return ""; + } + const values = new Uint8Array(32); + globalThis.crypto.getRandomValues(values); + return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join(""); + } + + function recordDebug(scope, message, level = "info", resource = undefined, broadcast = true, onion = undefined) { + const entry = { + at: new Date().toISOString(), + scope, + message, + level, + }; + if (resource) { + entry.resource = resource; + } + if (onion) { + entry.onion = onion; + } + debugEntries.push(entry); + if (debugEntries.length > 200) { + debugEntries.splice(0, debugEntries.length - 200); + } + if (broadcast) { + broadcastDebugEntry(entry); + } + } + + function broadcastDebugEntry(entry) { + if (!ownsGatewayHost) { + return; + } + const worker = navigator.serviceWorker?.controller; + if (!worker) { + return; + } + worker.postMessage({ + type: "rings-webview-debug-entry", + capability: gatewayHostCapability, + entry, + }); + } + + 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; + } + + function currentOrigin() { + return globalThis.location?.origin || ""; + } + + function sameOriginUrl(value) { + try { + const parsed = new URL(value); + return parsed.origin === currentOrigin() ? parsed : undefined; + } catch (_error) { + return undefined; + } + } + + function isTrustedWebviewShellUrl(value) { + const parsed = sameOriginUrl(value); + return Boolean(parsed && !parsed.pathname.startsWith(gatewayPrefix) && parsed.hash.startsWith("#webview")); + } + + function isWebviewShell() { + return isTrustedWebviewShellUrl(globalThis.location?.href || ""); + } + + function isTrustedWebviewShellWindow(source) { + if (!source || source === globalThis) { + return false; + } + try { + return isTrustedWebviewShellUrl(source.location.href); + } catch (_error) { + return false; + } + } + + function clearPopupOpener() { + if (!isWebviewShell() || !globalThis.opener) { + return; + } + try { + globalThis.opener = null; + } catch (_error) {} + } + + function requestDebugCapabilityFromOpener() { + if (!isWebviewShell() || !globalThis.opener || typeof MessageChannel !== "function") { + return Promise.resolve(undefined); + } + if (popupDebugCapabilityPromise) { + return popupDebugCapabilityPromise; + } + popupDebugCapabilityPromise = new Promise((resolve) => { + const channel = new MessageChannel(); + const finish = (capability) => { + globalThis.clearTimeout(timeout); + channel.port1.close(); + clearPopupOpener(); + resolve(capability); + }; + const timeout = globalThis.setTimeout(() => finish(undefined), 500); + channel.port1.onmessage = (event) => { + const capability = event.data?.capability; + if (event.data?.type === debugCapabilityResponseType && typeof capability === "string" && capability.length >= 32) { + finish(capability); + return; + } + finish(undefined); + }; + try { + globalThis.opener.postMessage({ type: debugCapabilityRequestType }, currentOrigin(), [channel.port2]); + } catch (_error) { + finish(undefined); + } + }); + return popupDebugCapabilityPromise; + } + + async function debugCapability() { + return (await requestDebugCapabilityFromOpener()) || gatewayHostCapability; + } + + 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"); + } + const acknowledged = await postWorkerMessage(worker, { + type: "rings-webview-host-register", + capability: gatewayHostCapability, + }); + ownsGatewayHost = Boolean(acknowledged); + if (acknowledged) { + recordDebug("host", "Registered the local Rings node as gateway host"); + return true; + } + recordDebug("host", "Service Worker rejected gateway host registration", "warning"); + throw new Error("Service Worker rejected gateway host registration"); + } + + 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 capability = await debugCapability(); + const acknowledged = await postWorkerMessage(worker, { + type: "rings-webview-debug-register", + capability, + }); + 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); + } + + globalThis.addEventListener("message", (event) => { + const message = event.data; + if (message?.type !== debugCapabilityRequestType) { + return; + } + const reply = event.ports?.[0]; + if (event.origin !== currentOrigin() || !reply || !isTrustedWebviewShellWindow(event.source)) { + reply?.postMessage({ type: debugCapabilityResponseType, ok: false }); + return; + } + reply.postMessage({ + type: debugCapabilityResponseType, + ok: true, + capability: gatewayHostCapability, + }); + }); + + 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, + false, + message.onion, + ); + return; + } + if (message?.type === "rings-webview-gateway-host-query") { + const ready = typeof globalThis.RingsWebviewGateway?.handle === "function"; + event.ports?.[0]?.postMessage({ ready, capability: gatewayHostCapability }); + const worker = navigator.serviceWorker.controller; + if (ready && worker) { + void postWorkerMessage(worker, { + type: "rings-webview-host-register", + capability: gatewayHostCapability, + }).then((acknowledged) => { + ownsGatewayHost = Boolean(acknowledged); + if (acknowledged) { + recordDebug("host", "Restored the local Rings node gateway host"); + } else { + recordDebug("host", "Service Worker rejected restored gateway host registration", "warning"); + } + }); + } + 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, + errorCode: "local_gateway_unavailable", + errorSummary: "Local Rings node gateway is unavailable.", + 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, + errorCode: "gateway_transport_failed", + errorSummary: "Gateway transport failed.", + error: String(error), + }); + }); + }); + + globalThis.RingsWebviewHost = Object.freeze({ + ensureReady, + registerGatewayHost, + enableDebug, + recordDebugEntry: recordDebug, + takeDebugEntries, + clearDebugEntries, + }); +})(); diff --git a/frontend/assets/webview-overlay.js b/frontend/assets/webview-overlay.js new file mode 100644 index 000000000..9aa64ed8c --- /dev/null +++ b/frontend/assets/webview-overlay.js @@ -0,0 +1,626 @@ +(() => { + "use strict"; + + const gatewayPrefix = "/webview/"; + const marker = "__ringsWebviewDebugOverlay"; + const hostId = "rings-webview-debug-overlay"; + const defaultTarget = "https://example.com/"; + const maxEntries = 200; + + const state = { + entries: [], + log: undefined, + network: undefined, + onion: undefined, + panel: undefined, + addressForm: undefined, + loadingTrack: undefined, + view: "log", + levelFilter: "all", + textFilter: "", + loading: false, + originalBodyPaddingTop: undefined, + originalBodyPaddingBottom: undefined, + bodySpacingScheduled: false, + }; + + function isWebviewContext() { + return ( + location.pathname.startsWith(gatewayPrefix) + || location.hash.startsWith("#webview") + || Boolean(document.querySelector("[data-rings-webview-default-target],[data-rings-webview-failure]")) + ); + } + + function initialTargetText() { + const configured = document.querySelector("[data-rings-webview-default-target]")?.dataset.ringsWebviewDefaultTarget; + if (configured) return configured; + if (!location.pathname.startsWith(gatewayPrefix)) return defaultTarget; + const encoded = location.pathname.slice(gatewayPrefix.length); + if (!encoded) return defaultTarget; + try { + return decodeURIComponent(encoded); + } catch (_error) { + return defaultTarget; + } + } + + function resourceLine(resource) { + const status = resource.status == null ? "pending" : String(resource.status); + return `#${resource.requestId} ${status} ${resource.kind} ${resource.method} ${resource.phase} ${resource.target} ${resource.durationMs} ms`; + } + + function onionLine(onion) { + const hops = Array.isArray(onion.hops) ? onion.hops.join(" ") : ""; + return `${onion.target || ""} ${onion.service || ""} ${onion.phase || ""} ${onion.exit || ""} ${hops} ${onion.durationMs || ""}`; + } + + function entryText(entry) { + return `${entry.at || ""} ${entry.scope || "worker"} ${entry.level || "info"} ${entry.message || ""} ${entry.resource ? resourceLine(entry.resource) : ""} ${entry.onion ? onionLine(entry.onion) : ""}`.toLowerCase(); + } + + function textMatches(entry) { + const needle = state.textFilter.trim().toLowerCase(); + return !needle || entryText(entry).includes(needle); + } + + function levelMatches(entry) { + return state.levelFilter === "all" || (entry.level || "info") === state.levelFilter; + } + + function filteredLogEntries() { + return state.entries.filter((entry) => levelMatches(entry) && textMatches(entry)); + } + + function filteredNetworkEntries() { + return Array.from( + new Map( + state.entries + .filter((entry) => entry.resource && textMatches(entry)) + .map((entry) => [entry.resource.requestId, entry]), + ).values(), + ); + } + + function filteredOnionEntries() { + return state.entries.filter((entry) => entry.onion && textMatches(entry)); + } + + function appendEmpty(container) { + const empty = document.createElement("p"); + empty.textContent = state.entries.length === 0 ? "Waiting for gateway activity" : "No matching entries"; + container.append(empty); + } + + function appendLogRow(container, entry) { + const row = document.createElement("p"); + row.className = entry.level === "error" ? "error" : entry.level === "warning" ? "warning" : ""; + const time = String(entry.at || "").split("T")[1]?.replace("Z", "").slice(0, 12) || ""; + row.textContent = `${time} ${(entry.level || "info").toUpperCase()} [${entry.scope || "worker"}] ${entry.message || "unknown event"}`; + container.append(row); + } + + function appendNetworkHeader(container) { + const row = document.createElement("div"); + row.className = "network-row heading"; + for (const field of ["ID", "Status", "Kind", "Method", "Phase", "Time", "Target"]) { + const cell = document.createElement("span"); + cell.textContent = field; + row.append(cell); + } + container.append(row); + } + + function appendNetworkRow(container, entry) { + const resource = entry.resource; + if (!resource) return; + const row = document.createElement("div"); + row.className = `network-row ${entry.level === "error" ? "error" : entry.level === "warning" ? "warning" : ""}`; + for (const field of [ + `#${resource.requestId}`, + resource.status == null ? "pending" : String(resource.status), + resource.kind, + resource.method, + resource.phase, + `${resource.durationMs} ms`, + resource.target, + ]) { + const cell = document.createElement("span"); + cell.textContent = field; + row.append(cell); + } + container.append(row); + } + + function compactDid(value) { + const text = String(value || ""); + return text.length > 18 ? `${text.slice(0, 10)}...${text.slice(-6)}` : text; + } + + function appendOnionHeader(container) { + const row = document.createElement("div"); + row.className = "onion-row heading"; + for (const field of ["Phase", "Service", "Hops", "Exit", "Time", "Target"]) { + const cell = document.createElement("span"); + cell.textContent = field; + row.append(cell); + } + container.append(row); + } + + function appendOnionRow(container, entry) { + const onion = entry.onion; + if (!onion) return; + const hops = Array.isArray(onion.hops) ? onion.hops : []; + const row = document.createElement("div"); + row.className = `onion-row ${entry.level === "error" ? "error" : entry.level === "warning" ? "warning" : ""}`; + for (const field of [ + onion.phase || "route", + onion.service || "https", + hops.length ? hops.map(compactDid).join(" -> ") : onion.error || "none", + compactDid(onion.exit), + onion.durationMs == null ? "" : `${onion.durationMs} ms`, + onion.target || "", + ]) { + const cell = document.createElement("span"); + cell.textContent = field; + row.append(cell); + } + container.append(row); + } + + function render() { + const container = state.view === "network" ? state.network : state.view === "onion" ? state.onion : state.log; + if (!container) return; + container.replaceChildren(); + const entries = state.view === "network" ? filteredNetworkEntries() : state.view === "onion" ? filteredOnionEntries() : filteredLogEntries(); + if (entries.length === 0) { + appendEmpty(container); + return; + } + if (state.view === "network") appendNetworkHeader(container); + if (state.view === "onion") appendOnionHeader(container); + for (const entry of entries) { + if (state.view === "network") appendNetworkRow(container, entry); + else if (state.view === "onion") appendOnionRow(container, entry); + else appendLogRow(container, entry); + } + container.scrollTop = container.scrollHeight; + } + + function setLoading(loading) { + state.loading = Boolean(loading); + if (!state.addressForm) return; + state.addressForm.dataset.loading = String(state.loading); + state.addressForm.setAttribute("aria-busy", String(state.loading)); + if (state.loadingTrack) state.loadingTrack.hidden = !state.loading; + } + + function syncDocumentLoading() { + if (document.readyState === "complete") { + setLoading(false); + return; + } + setLoading(true); + globalThis.addEventListener("load", () => setLoading(false), { once: true }); + globalThis.addEventListener("pageshow", () => { + if (document.readyState === "complete") setLoading(false); + }, { once: true }); + } + + function record(scope, message, level = "info", resource = undefined, onion = undefined, at = undefined) { + const entry = { at: at || new Date().toISOString(), scope, message, level }; + if (resource) entry.resource = resource; + if (onion) entry.onion = onion; + state.entries.push(entry); + if (state.entries.length > maxEntries) state.entries.splice(0, state.entries.length - maxEntries); + render(); + } + + function selectView(view) { + state.view = view; + state.log.hidden = view !== "log"; + state.network.hidden = view !== "network"; + state.onion.hidden = view !== "onion"; + for (const tab of state.panel.querySelectorAll("[data-view]")) { + const active = tab.dataset.view === view; + tab.dataset.active = String(active); + tab.setAttribute("aria-selected", String(active)); + } + render(); + } + + function normalizedTarget(input) { + let text = String(input || "").trim(); + if (!text) throw new Error("Address is empty"); + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) text = `https://${text}`; + const url = new URL(text); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Only HTTP(S) addresses can be opened"); + } + return url.href; + } + + function gatewayPath(input) { + return `${gatewayPrefix}${encodeURIComponent(normalizedTarget(input))}`; + } + + function limitedText(value) { + const text = String(value); + return text.length > 1200 ? `${text.slice(0, 1200)}...` : text; + } + + function formatConsoleValue(value) { + if (value instanceof Error) return limitedText(value.stack || value.message); + if (typeof value === "string") return limitedText(value); + try { + const json = JSON.stringify(value); + if (json !== undefined) return limitedText(json); + } catch (_error) {} + return limitedText(value); + } + + function installConsoleCapture() { + const captureMarker = "__ringsWebviewConsoleCapture"; + if (globalThis[captureMarker]) return; + globalThis[captureMarker] = true; + for (const method of ["error", "warn", "info", "log"]) { + const original = console[method]?.bind(console); + if (!original) continue; + console[method] = (...args) => { + const level = method === "error" ? "error" : method === "warn" ? "warning" : "info"; + try { + record("console", args.map(formatConsoleValue).join(" "), level); + } catch (_error) {} + original(...args); + }; + } + globalThis.addEventListener("error", (event) => { + record("page", event.error?.stack || event.message || "Uncaught page error", "error"); + }); + globalThis.addEventListener("unhandledrejection", (event) => { + record("page", `Unhandled rejection: ${formatConsoleValue(event.reason)}`, "error"); + }); + } + + function applyBodySpacing() { + if (!document.body) { + scheduleBodySpacing(); + return; + } + if (state.originalBodyPaddingTop === undefined) { + state.originalBodyPaddingTop = getComputedStyle(document.body).paddingTop || "0px"; + state.originalBodyPaddingBottom = getComputedStyle(document.body).paddingBottom || "0px"; + document.body.dataset.ringsWebviewToolbarReserved = "true"; + document.body.style.paddingTop = `calc(${state.originalBodyPaddingTop} + 46px)`; + document.documentElement.style.scrollPaddingTop = "52px"; + } + if (state.panel && !state.panel.hidden) { + document.body.style.paddingBottom = `calc(${state.originalBodyPaddingBottom} + min(300px, 45vh))`; + } else { + document.body.style.paddingBottom = state.originalBodyPaddingBottom; + } + } + + function scheduleBodySpacing() { + if (state.bodySpacingScheduled || document.body) return; + state.bodySpacingScheduled = true; + const retry = () => { + state.bodySpacingScheduled = false; + applyBodySpacing(); + }; + if (typeof MutationObserver === "function" && document.documentElement) { + const observer = new MutationObserver(() => { + if (!document.body) return; + observer.disconnect(); + retry(); + }); + observer.observe(document.documentElement, { childList: true }); + document.addEventListener("DOMContentLoaded", () => { + observer.disconnect(); + retry(); + }, { once: true }); + return; + } + document.addEventListener("DOMContentLoaded", retry, { once: true }); + } + + async function prepareGatewayForNavigation() { + const host = globalThis.RingsWebviewHost; + if (!host || typeof host.ensureReady !== "function") return; + await host.ensureReady(); + if (typeof host.enableDebug === "function") { + await host.enableDebug(); + } + } + + async function openAddress(address) { + let path; + try { + path = gatewayPath(address); + } catch (error) { + record("overlay", String(error), "error"); + return; + } + record("overlay", "Connecting through the local Rings gateway"); + setLoading(true); + try { + await prepareGatewayForNavigation(); + record("overlay", "Request sent to the local Rings gateway"); + location.href = path; + } catch (error) { + setLoading(false); + record("overlay", `gateway unavailable: ${String(error)}`, "error"); + } + } + + function requestDebugRegistration() { + const host = globalThis.RingsWebviewHost; + if (host && typeof host.enableDebug === "function") { + void host.enableDebug().catch((error) => record("overlay", `Debug listener unavailable: ${String(error)}`, "error")); + return; + } + void navigator.serviceWorker?.ready + .then((registration) => { + const worker = navigator.serviceWorker.controller || registration.active; + worker?.postMessage({ type: "rings-webview-debug-register" }); + }) + .catch(() => record("overlay", "Service Worker debug listener unavailable", "error")); + } + + function isGatewayResponseDocument() { + return Boolean( + document.querySelector("[data-rings-webview-failure]") + || globalThis.__ringsWebviewGateway, + ); + } + + function maybeReloadColdGatewayFallback() { + if (!location.pathname.startsWith(gatewayPrefix) || isGatewayResponseDocument()) return; + const host = globalThis.RingsWebviewHost; + if (!host || typeof host.ensureReady !== "function") return; + const storageKey = `rings-webview-cold-reload:${location.href}`; + try { + if (sessionStorage.getItem(storageKey) === "true") { + record("overlay", "Gateway path loaded without a Service Worker response after retry", "warning"); + return; + } + sessionStorage.setItem(storageKey, "true"); + } catch (_error) {} + void host.ensureReady() + .then(() => { + if (isGatewayResponseDocument()) return; + record("overlay", "Reloading gateway path after Service Worker took control"); + location.reload(); + }) + .catch((error) => record("overlay", `Service Worker controller unavailable: ${String(error)}`, "error")); + } + + function setDebugOpen(open) { + if (!state.panel) return; + const toggle = state.panel.getRootNode().getElementById("toggle"); + state.panel.hidden = !open; + toggle?.setAttribute("aria-expanded", String(open)); + if (toggle) toggle.dataset.active = String(open); + applyBodySpacing(); + if (open) render(); + } + + function appendFailureEntry() { + const failure = document.querySelector("[data-rings-webview-failure]"); + if (!failure || failure.dataset.ringsWebviewFailureRecorded === "true") return; + failure.dataset.ringsWebviewFailureRecorded = "true"; + const status = Number(failure.dataset.ringsWebviewFailureStatus || 0) || undefined; + const code = failure.dataset.ringsWebviewFailureCode || "gateway_request_failed"; + const summary = failure.dataset.ringsWebviewFailureSummary || "Gateway request failed."; + const detail = failure.dataset.ringsWebviewFailureDetail || summary; + record( + "gateway", + `${code}: ${detail}`, + "error", + { + requestId: "failure", + target: initialTargetText(), + method: "GET", + kind: "navigation", + phase: "failed", + durationMs: 0, + status, + }, + ); + setDebugOpen(true); + } + + function mount() { + if (!isWebviewContext()) return; + if (document.getElementById(hostId)) { + appendFailureEntry(); + return; + } + const host = document.createElement("div"); + host.id = hostId; + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = ` + + + `; + const panel = root.getElementById("panel"); + const toggle = root.getElementById("toggle"); + const back = root.getElementById("back"); + const forward = root.getElementById("forward"); + const reload = root.getElementById("reload"); + const addressForm = root.getElementById("address-form"); + const address = root.getElementById("address"); + const loadingTrack = root.getElementById("loading-track"); + const log = root.getElementById("log"); + const network = root.getElementById("network"); + const onion = root.getElementById("onion"); + const levelFilter = root.getElementById("level-filter"); + const textFilter = root.getElementById("text-filter"); + const clear = root.getElementById("clear"); + if (!panel || !toggle || !back || !forward || !reload || !addressForm || !address || !loadingTrack || !log || !network || !onion || !levelFilter || !textFilter || !clear) return; + state.panel = panel; + state.addressForm = addressForm; + state.loadingTrack = loadingTrack; + state.log = log; + state.network = network; + state.onion = onion; + address.value = initialTargetText(); + toggle.addEventListener("click", () => setDebugOpen(panel.hidden)); + back.addEventListener("click", () => { + setLoading(true); + history.back(); + }); + forward.addEventListener("click", () => { + setLoading(true); + history.forward(); + }); + reload.addEventListener("click", () => { + setLoading(true); + location.reload(); + }); + addressForm.addEventListener("submit", (event) => { + event.preventDefault(); + void openAddress(address.value); + }); + for (const tab of root.querySelectorAll("[data-view]")) { + tab.addEventListener("click", () => selectView(tab.dataset.view)); + } + levelFilter.addEventListener("change", () => { + state.levelFilter = levelFilter.value || "all"; + render(); + }); + textFilter.addEventListener("input", () => { + state.textFilter = textFilter.value || ""; + render(); + }); + clear.addEventListener("click", () => { + state.entries.splice(0, state.entries.length); + render(); + }); + document.documentElement.append(host); + applyBodySpacing(); + installConsoleCapture(); + requestDebugRegistration(); + syncDocumentLoading(); + record("overlay", "Debug panel ready"); + appendFailureEntry(); + maybeReloadColdGatewayFallback(); + } + + function onContextMaybeChanged() { + if (!isWebviewContext()) return; + if (document.documentElement) { + mount(); + return; + } + document.addEventListener("DOMContentLoaded", mount, { once: true }); + } + + globalThis[marker] = { + installed: true, + mount: onContextMaybeChanged, + mounted: () => Boolean(document.getElementById(hostId)), + record, + }; + + navigator.serviceWorker?.addEventListener("message", (event) => { + const entry = event.data; + if (entry?.type === "rings-webview-debug") { + record( + entry.scope || "worker", + entry.message || "unknown event", + entry.level || "info", + entry.resource, + entry.onion, + entry.at, + ); + } + }); + globalThis.addEventListener("hashchange", onContextMaybeChanged); + onContextMaybeChanged(); +})(); diff --git a/frontend/index.html b/frontend/index.html index 974deb65f..985babfb5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,8 +5,12 @@ 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..b374ccd7d --- /dev/null +++ b/frontend/rings-webview-service-worker.js @@ -0,0 +1,947 @@ +"use strict"; + +const gatewayPrefix = "/webview/"; +const requestTimeoutMs = 30_000; +const webviewOverlayScriptPath = "/assets/webview-overlay.js"; +const webviewHistoryGuardMarker = "data-rings-webview-history-guard"; +const webviewHistoryGuardScriptTag = ``; +const webviewOverlayScriptTag = ``; +const gatewayContentSecurityPolicy = "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'"; +const minimumGatewayHostCapabilityLength = 32; +let gatewayHostClientId = null; +let gatewayHostCapability = null; +const trustedShellClientIds = new Set(); +const trustedHostClientIds = new Set(); +const trustedDebugClientIds = new Set(); +const clientSourceTargets = new Map(); +const debugClientScopes = new Map(); +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-debug-entry" && event.data.entry) { + const publish = acceptDebugEntry(clientId, event.data.capability).then(async (ok) => { + if (!ok) { + await emitDebug("worker", "Rejected untrusted debug entry", "warning"); + reply?.postMessage({ ok: false, error: "untrusted debug entry" }); + return; + } + const entry = event.data.entry; + await emitDebug( + entry.scope || "host", + entry.message || "unknown event", + entry.level || "info", + entry.resource, + entry.at, + entry.onion, + ); + reply?.postMessage({ ok: true }); + }); + event.waitUntil?.(publish); + return; + } + if (event.data?.type === "rings-webview-host-register" && typeof clientId === "string" && clientId) { + const registration = registerGatewayHostClient(clientId, event.data.capability).then(async (ok) => { + if (ok) { + await emitDebug("worker", "Updated local Rings node gateway host"); + reply?.postMessage({ ok: true }); + } else { + await emitDebug("worker", "Rejected untrusted Rings node gateway host registration", "warning"); + reply?.postMessage({ ok: false, error: "untrusted gateway host registration" }); + } + }); + event.waitUntil?.(registration); + return; + } + if (event.data?.type === "rings-webview-debug-register" && typeof clientId === "string" && clientId) { + const registration = registerDebugClient(clientId, event.data.capability).then(async (ok) => { + if (ok) { + await emitDebug("worker", "Registered WebView debug client"); + reply?.postMessage({ ok: true }); + } else { + await emitDebug("worker", "Rejected untrusted debug client registration", "warning"); + reply?.postMessage({ ok: false, error: "untrusted debug client registration" }); + } + }); + event.waitUntil?.(registration); + 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)) { + rememberShellNavigationClient(event, url); + return; + } + event.respondWith(handleGatewayFetch(event)); +}); + +async function handleGatewayFetch(event) { + const requestId = nextRequestId; + nextRequestId += 1; + const startedAt = performance.now(); + let request; + try { + request = await serializeRequest(event); + rememberNavigationClientTarget(event, request); + } catch (error) { + request = debugRequestForFailure(event.request); + await emitResourceDebug( + requestId, + request, + startedAt, + "failed", + `#${requestId} rejected malformed gateway request: ${errorMessage(error)}`, + "error", + 400, + ); + return gatewayFailure( + 400, + errorMessage(error), + "Malformed Rings WebView gateway request.", + "invalid_gateway_request", + ); + } + 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.", + "Local Rings node gateway is unavailable.", + "local_gateway_unavailable", + ); + } + await emitResourceDebug( + requestId, + request, + startedAt, + "dispatched", + `#${requestId} Request sent to the local Rings gateway`, + ); + 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", + response?.errorSummary, + response?.errorCode, + ); + } + 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, + ); + const body = responseMustNotHaveBody(response.status) + ? null + : controlledNavigationBody(request, response.status, headers, response.body || null); + return new Response(body, { + 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)}`, + "The local gateway returned an invalid response.", + "invalid_gateway_response", + ); + } +} + +function responseMustNotHaveBody(status) { + return status === 204 || status === 205 || status === 304; +} + +function controlledNavigationBody(request, status, headers, body) { + if (request.kind !== "navigation" || !body || status < 200 || status >= 300) { + return body; + } + const bytes = bodyBytes(body); + if (!bytes) { + return body; + } + const contentType = (headers.get("content-type") || "").toLowerCase(); + if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) { + return body; + } + prepareControlledNavigationHeaders(headers); + const text = decodeUtf8(bytes); + if (!text || !looksLikeHtml(text)) { + return body; + } + const injected = injectControlledNavigationScripts(text, request.topLevelNavigation !== false); + if (injected === text) { + return body; + } + return new TextEncoder().encode(injected); +} + +function prepareControlledNavigationHeaders(headers) { + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.delete("content-security-policy-report-only"); + headers.delete("x-frame-options"); + headers.set("content-security-policy", gatewayContentSecurityPolicy); +} + +function bodyBytes(body) { + if (body instanceof Uint8Array) { + return body; + } + if (body instanceof ArrayBuffer) { + return new Uint8Array(body); + } + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } + if (typeof body === "string") { + return new TextEncoder().encode(body); + } + return undefined; +} + +function decodeUtf8(bytes) { + try { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch (_error) { + return ""; + } +} + +function looksLikeHtml(text) { + return /^\uFEFF?\s*(?:\s*)*(?:/i.test(guarded)) { + return guarded.replace(/<\/head\s*>/i, `${webviewOverlayScriptTag}`); + } + if (/]*>/i.test(guarded)) { + return guarded.replace(/]*>/i, (bodyTag) => `${bodyTag}${webviewOverlayScriptTag}`); + } + return `${guarded}\n${webviewOverlayScriptTag}`; +} + +function injectControlledNavigationScripts(html, includeOverlay) { + if (includeOverlay) { + return injectWebviewOverlay(html); + } + return injectWebviewHistoryGuard(html); +} + +function injectWebviewHistoryGuard(html) { + const leading = /^\uFEFF?\s*(?:(?:)\s*)*(?:]*>\s*)?/i.exec(html); + const index = leading ? leading[0].length : 0; + return `${html.slice(0, index)}${webviewHistoryGuardScriptTag}${html.slice(index)}`; +} + +async function emitResourceDebug(requestId, request, startedAt, phase, message, level = "info", status = undefined) { + const resource = { + requestId, + target: requestedTarget(request.requested), + sourceTarget: request.sourceTarget, + 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, at = undefined, onion = undefined) { + const entry = { + type: "rings-webview-debug", + at: at || new Date().toISOString(), + scope, + message, + level, + }; + if (resource) { + entry.resource = resource; + } + if (onion) { + entry.onion = onion; + } + debugHistory.push(entry); + if (debugHistory.length > 200) { + debugHistory.splice(0, debugHistory.length - 200); + } + const clients = await debugClientsForEntry(entry); + await Promise.all( + clients.map((client) => client.postMessage(entry)), + ); +} + +async function debugClientsForEntry(entry) { + const clientsById = new Map(); + for (const [clientId, scope] of debugClientScopes) { + const client = await self.clients.get(clientId); + if (!client || !debugScopeStillValid(clientId, client, scope)) { + debugClientScopes.delete(clientId); + continue; + } + if (debugScopeAllowsEntry(scope, entry)) { + clientsById.set(client.id, client); + } + } + return [...clientsById.values()]; +} + +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() { + return registeredGatewayHostClient(); +} + +async function registeredGatewayHostClient() { + if (!gatewayHostClientId) { + return undefined; + } + const client = await self.clients.get(gatewayHostClientId); + if (!client || !isTrustedGatewayHostClient(gatewayHostClientId, client)) { + gatewayHostClientId = null; + gatewayHostCapability = null; + return undefined; + } + return client; +} + +async function registerGatewayHostClient(clientId, capability) { + if (!isValidGatewayHostCapability(capability)) { + return false; + } + const client = await self.clients.get(clientId); + if (!client || !isTrustedGatewayHostRegistrationClient(clientId, client)) { + return false; + } + if (gatewayHostCapability && gatewayHostCapability !== capability) { + return false; + } + trustedShellClientIds.add(clientId); + clientSourceTargets.delete(clientId); + trustedHostClientIds.add(clientId); + gatewayHostClientId = clientId; + gatewayHostCapability = capability; + return true; +} + +async function registerDebugClient(clientId, capability) { + const client = await self.clients.get(clientId); + if (!client) { + return false; + } + const scope = debugClientRegistrationScope(clientId, client, capability); + if (scope !== false) { + trustedDebugClientIds.add(clientId); + debugClientScopes.set(clientId, scope); + replayDebugHistory(client, scope); + return true; + } + return false; +} + +async function acceptDebugEntry(clientId, capability) { + if (!hasGatewayHostCapability(clientId, capability)) { + return false; + } + const host = await registeredGatewayHostClient(); + return host?.id === clientId; +} + +function hasGatewayHostCapability(clientId, capability) { + return typeof clientId === "string" && Boolean(clientId) && Boolean(gatewayHostCapability) && capability === gatewayHostCapability; +} + +function isValidGatewayHostCapability(capability) { + return typeof capability === "string" && capability.length >= minimumGatewayHostCapabilityLength; +} + +function isTrustedGatewayHostClient(clientId, client) { + return trustedHostClientIds.has(clientId) + && isTrustedShellClient(clientId) + && clientFramePermitsGatewayHostRegistration(client) + && isTrustedGatewayHostUrl(client.url); +} + +function isTrustedShellClient(clientId) { + return trustedShellClientIds.has(clientId) && !clientSourceTargets.has(clientId); +} + +function isTrustedGatewayHostRegistrationClient(clientId, client) { + return !clientSourceTargets.has(clientId) + && clientFramePermitsGatewayHostRegistration(client) + && isTrustedGatewayHostUrl(client.url); +} + +function clientFramePermitsGatewayHostRegistration(client) { + return client.frameType === "top-level"; +} + +function clientFramePermitsDebugRegistration(client) { + return client.frameType === "top-level" || client.frameType === "auxiliary"; +} + +function debugClientRegistrationScope(clientId, client, capability) { + if ( + hasGatewayHostCapability(clientId, capability) + && isTrustedShellClient(clientId) + && clientFramePermitsDebugRegistration(client) + && isTrustedDebugClientUrl(client.url) + ) { + return shellDebugScope(); + } + const target = targetFromGatewayUrl(client.url); + if ( + trustedDebugClientIds.has(clientId) + && target + && clientFramePermitsDebugRegistration(client) + ) { + return targetDebugScope(target); + } + return false; +} + +function debugScopeStillValid(clientId, client, scope) { + if (!trustedDebugClientIds.has(clientId) || !clientFramePermitsDebugRegistration(client)) { + return false; + } + if (scope?.kind === "shell") { + return isTrustedShellClient(clientId) && isTrustedDebugClientUrl(client.url); + } + if (scope?.kind === "target") { + const target = targetFromGatewayUrl(client.url); + return Boolean(target && sameTargetUrl(target, scope.target)); + } + return false; +} + +function debugScopeAllowsEntry(scope, entry) { + if (scope?.kind === "shell") { + return true; + } + if (scope?.kind === "target") { + return debugEntryMatchesTargetScope(entry, scope.target); + } + return false; +} + +function shellDebugScope() { + return { kind: "shell" }; +} + +function targetDebugScope(target) { + return { kind: "target", target }; +} + +function debugEntryMatchesTargetScope(entry, target) { + const payload = entry?.resource || entry?.onion; + if (!payload) { + return false; + } + if (payload.sourceTarget) { + return sameTargetUrl(payload.sourceTarget, target); + } + if (payload.target && payload.kind === "navigation") { + return sameTargetUrl(payload.target, target); + } + return false; +} + +function sameTargetUrl(left, right) { + try { + return new URL(left).href === new URL(right).href; + } catch (_error) { + return false; + } +} + +function replayDebugHistory(client, scope) { + for (const entry of debugHistory) { + if (debugScopeAllowsEntry(scope, entry)) { + client.postMessage(entry); + } + } +} + +function isTrustedGatewayHostUrl(url) { + return isTrustedShellUrl(url, "#node"); +} + +function isTrustedDebugClientUrl(url) { + return isTrustedShellUrl(url, "#webview"); +} + +function isTrustedShellUrl(url, hashPrefix) { + try { + const parsed = new URL(url); + return parsed.origin === self.location.origin + && !parsed.pathname.startsWith(gatewayPrefix) + && parsed.hash.startsWith(hashPrefix); + } catch (_error) { + return false; + } +} + +function resetGatewayHostForTest() { + gatewayHostClientId = null; + gatewayHostCapability = null; + trustedShellClientIds.clear(); + trustedHostClientIds.clear(); + trustedDebugClientIds.clear(); + clientSourceTargets.clear(); + debugClientScopes.clear(); + debugHistory.splice(0, debugHistory.length); +} + +async function serializeRequest(event) { + const request = event.request; + const sourceTarget = await sourceTargetForClient(event.clientId); + const kind = requestKind(request); + const body = request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.clone().arrayBuffer(); + return { + requested: request.url, + sourceTarget, + method: request.method, + credentials: request.credentials, + topLevelNavigation: isTopLevelNavigationRequest(request), + headers: [...request.headers] + .filter(([name]) => name.toLowerCase() !== "x-rings-webview-kind") + .map(([name, value]) => ({ name, value })), + body, + kind, + }; +} + +function debugRequestForFailure(request) { + return { + requested: request.url, + sourceTarget: undefined, + method: request.method || "GET", + credentials: request.credentials, + headers: [], + body: undefined, + kind: "invalid", + }; +} + +async function sourceTargetForClient(clientId) { + if (!clientId) { + return undefined; + } + const client = await self.clients.get(clientId); + if (!client) { + clientSourceTargets.delete(clientId); + debugClientScopes.delete(clientId); + trustedDebugClientIds.delete(clientId); + return undefined; + } + const currentTarget = targetFromGatewayUrl(client.url); + if (!currentTarget) { + clientSourceTargets.delete(clientId); + if (trustedDebugClientIds.has(clientId) && isTrustedDebugClientUrl(client.url)) { + debugClientScopes.set(clientId, shellDebugScope()); + } else { + debugClientScopes.delete(clientId); + trustedDebugClientIds.delete(clientId); + } + return undefined; + } + const previousTarget = clientSourceTargets.get(clientId); + if (previousTarget !== currentTarget) { + clientSourceTargets.set(clientId, currentTarget); + trustedShellClientIds.delete(clientId); + trustedHostClientIds.delete(clientId); + if (trustedDebugClientIds.has(clientId)) { + debugClientScopes.set(clientId, targetDebugScope(currentTarget)); + } else { + debugClientScopes.delete(clientId); + } + } + return currentTarget; +} + +function rememberNavigationClientTarget(event, request) { + if (request.kind !== "navigation") { + return false; + } + const sourceTarget = targetFromGatewayUrl(request.requested); + if (!sourceTarget) { + return false; + } + let remembered = false; + if (rememberClientSourceTarget(event.resultingClientId, sourceTarget, event.clientId)) { + remembered = true; + } + if (!remembered && request.topLevelNavigation) { + remembered = rememberClientSourceTarget(event.clientId, sourceTarget); + } + return remembered; +} + +function rememberShellNavigationClient(event, url) { + if (url.origin !== self.location.origin || url.pathname.startsWith(gatewayPrefix) || !isTopLevelNavigationRequest(event.request)) { + return false; + } + let remembered = false; + if (rememberTrustedShellClient(event.resultingClientId, event.clientId)) { + remembered = true; + } + if (!remembered) { + remembered = rememberTrustedShellClient(event.clientId); + } + return remembered; +} + +function rememberClientSourceTargetForTest(clientId, sourceTarget) { + if (typeof clientId !== "string" || !clientId || typeof sourceTarget !== "string" || !sourceTarget) { + return false; + } + return rememberClientSourceTarget(clientId, sourceTarget); +} + +function rememberClientSourceTarget(clientId, sourceTarget, debugTrustSourceClientId = clientId) { + if (typeof clientId !== "string" || !clientId || typeof sourceTarget !== "string" || !sourceTarget) { + return false; + } + clientSourceTargets.set(clientId, sourceTarget); + trustedShellClientIds.delete(clientId); + trustedHostClientIds.delete(clientId); + if (trustedDebugClientIds.has(clientId) || trustedDebugClientIds.has(debugTrustSourceClientId)) { + trustedDebugClientIds.add(clientId); + debugClientScopes.set(clientId, targetDebugScope(sourceTarget)); + } else { + debugClientScopes.delete(clientId); + } + return true; +} + +function rememberTrustedShellClientForTest(clientId) { + if (typeof clientId !== "string" || !clientId) { + return false; + } + return rememberTrustedShellClient(clientId); +} + +function rememberTrustedShellClient(clientId, debugTrustSourceClientId = clientId) { + if (typeof clientId !== "string" || !clientId) { + return false; + } + trustedShellClientIds.add(clientId); + clientSourceTargets.delete(clientId); + if (trustedDebugClientIds.has(clientId) || trustedDebugClientIds.has(debugTrustSourceClientId)) { + trustedDebugClientIds.add(clientId); + debugClientScopes.set(clientId, shellDebugScope()); + } + return true; +} + +function targetFromGatewayUrl(value) { + let url; + try { + url = new URL(value); + } catch (_error) { + return undefined; + } + 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 isTopLevelNavigationRequest(request) { + return request.mode === "navigate" && request.destination === "document"; +} + +function requestKind(request) { + if (isNavigationRequest(request)) { + return "navigation"; + } + const taggedKind = runtimeKindTag(request); + if (isRuntimeReadableRequest(request)) { + return taggedKind || "fetch"; + } + if (taggedKind) { + throw new Error(`X-Rings-Webview-Kind is only valid for runtime requests, got ${taggedKind}`); + } + return "subresource"; +} + +function isNavigationRequest(request) { + return ( + request.mode === "navigate" + || request.destination === "document" + || request.destination === "iframe" + ); +} + +function isRuntimeReadableRequest(request) { + return !request.destination; +} + +function runtimeKindTag(request) { + const rawKind = request.headers.get("x-rings-webview-kind"); + if (rawKind == null) { + return undefined; + } + const values = rawKind.split(",").map((value) => value.trim()).filter(Boolean); + if (values.length !== 1 || !isRuntimeKind(values[0])) { + throw new Error(`invalid X-Rings-Webview-Kind: ${rawKind}`); + } + return values[0]; +} + +function isRuntimeKind(kind) { + return kind === "fetch" || kind === "xhr"; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function requestGatewayResponse(host, request) { + return new Promise((resolve) => { + const channel = new MessageChannel(); + const timeout = globalThis.setTimeout(() => { + channel.port1.close(); + resolve({ + ok: false, + status: 504, + errorCode: "local_gateway_timeout", + errorSummary: "Local Rings node gateway timed out.", + 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, summary = undefined, code = undefined) { + return new Response(gatewayFailureDocument( + status, + gatewayFailureSummary(message), + gatewayFailureReason(status, message, summary), + code || gatewayFailureCode(status), + ), { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + }, + }); +} + +function gatewayFailureSummary(message) { + let text = String(message || "gateway request failed").trim(); + text = text.replace( + /^gateway transport:\s+gateway transport failed:/, + "gateway transport failed:", + ); + text = text.replace( + /JsValue\(Error: ([^\n)]*)[\s\S]*\)/, + "Error: $1", + ); + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return firstLine || "gateway request failed"; +} + +function gatewayFailureReason(status, message, summary) { + const cleanSummary = String(summary || "").trim(); + if (cleanSummary) return cleanSummary; + const detail = gatewayFailureSummary(message); + if (status === 503 && detail.includes("no live onion exit offers service \"https\"")) { + return "No live HTTPS onion exit is available."; + } + if (status === 503 && detail.includes("no live onion exit")) { + return "No live onion exit is available for this request."; + } + if (status === 503 && detail.includes("Start a local Rings node")) { + return "Local Rings node gateway is unavailable."; + } + if (status === 502) return "Gateway transport failed."; + if (status === 504) return "Gateway request timed out."; + return "Gateway request failed."; +} + +function gatewayFailureCode(status) { + if (status === 400) return "invalid_webview_request"; + if (status === 403) return "webview_request_rejected"; + if (status === 404) return "controlled_asset_not_found"; + if (status === 502) return "gateway_transport_failed"; + if (status === 503) return "gateway_unavailable"; + if (status === 504) return "gateway_timeout"; + return "gateway_request_failed"; +} + +function gatewayFailureDocument(status, message, reason, code) { + const detail = escapeHtml(message); + const summary = escapeHtml(reason); + const reasonCode = escapeHtml(code); + const statusText = escapeHtml(status); + return ` + + + +Rings gateway failure ${statusText} + + + + + +`; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/frontend/scripts/test-extension-wallet-bridge.mts b/frontend/scripts/test-extension-wallet-bridge.mts index d2e1d2518..800187b20 100644 --- a/frontend/scripts/test-extension-wallet-bridge.mts +++ b/frontend/scripts/test-extension-wallet-bridge.mts @@ -146,7 +146,7 @@ try { walletKind: "eip191", networkId: "1", iceServers: "stun://stun.l.google.com:19302", - stabilizeInterval: "3", + stabilizeInterval: "1", storageName: "rings-frontend-wallet-fixture", seedUrl: "", }), diff --git a/frontend/scripts/test-webview-overlay.mts b/frontend/scripts/test-webview-overlay.mts new file mode 100644 index 000000000..487b1042b --- /dev/null +++ b/frontend/scripts/test-webview-overlay.mts @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +/** + * Runs a Playwright fixture for the real WebView overlay asset. + */ + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { type Browser, chromium } from "playwright"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = frontendProjectRoot(scriptDir); +const overlayPath = resolve(projectRoot, "assets", "webview-overlay.js"); +const overlaySource = await readFile(overlayPath, "utf8"); +const serverState = await serveSlowWebviewDocument(overlaySource); + +let browser: Browser | undefined; +try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const target = encodeURIComponent("https://www.google.com/"); + await page.goto(`http://127.0.0.1:${serverState.port}/webview/${target}`, { + waitUntil: "commit", + }); + await page.waitForFunction( + () => { + const webviewGlobal = globalThis as typeof globalThis & { + __ringsWebviewDebugOverlay?: unknown; + }; + return Boolean( + webviewGlobal.__ringsWebviewDebugOverlay && document.getElementById("rings-webview-debug-overlay"), + ); + }, + undefined, + { timeout: 5000 }, + ); + + const earlyState = await page.evaluate(() => { + const loadingKey = "loading"; + const overlay = document.getElementById("rings-webview-debug-overlay"); + const addressForm = overlay?.shadowRoot?.getElementById("address-form"); + const loadingTrack = overlay?.shadowRoot?.getElementById("loading-track") as HTMLElement | null | undefined; + return { + bodyText: document.body?.textContent || "", + loading: { + busy: addressForm?.getAttribute("aria-busy"), + loading: addressForm?.dataset[loadingKey], + trackHidden: loadingTrack?.hidden, + }, + mounted: Boolean(overlay), + readyState: document.readyState, + }; + }); + assert.equal(earlyState.mounted, true); + assert.equal(earlyState.readyState, "loading"); + assert.equal(earlyState.bodyText.includes("late body"), false); + assert.deepEqual(earlyState.loading, { + busy: "true", + loading: "true", + trackHidden: false, + }); + + serverState.finish(); + await page.locator("#late-body").waitFor({ state: "attached", timeout: 2000 }); + await page.waitForFunction(() => { + const loadingKey = "loading"; + return ( + document.getElementById("rings-webview-debug-overlay")?.shadowRoot?.getElementById("address-form")?.dataset[ + loadingKey + ] === "false" + ); + }); + const bodyPadding = await page.evaluate(() => document.body?.style.paddingTop || ""); + const finalLoading = await page.evaluate(() => { + const loadingKey = "loading"; + const overlay = document.getElementById("rings-webview-debug-overlay"); + const addressForm = overlay?.shadowRoot?.getElementById("address-form"); + const loadingTrack = overlay?.shadowRoot?.getElementById("loading-track") as HTMLElement | null | undefined; + return { + busy: addressForm?.getAttribute("aria-busy"), + loading: addressForm?.dataset[loadingKey], + trackHidden: loadingTrack?.hidden, + }; + }); + assert.match(bodyPadding, /46px/); + assert.deepEqual(finalLoading, { + busy: "false", + loading: "false", + trackHidden: true, + }); +} finally { + serverState.finish(); + await browser?.close(); + await closeServer(serverState.server); +} + +/** + * Resolves the frontend project root from either source or generated script paths. + */ +function frontendProjectRoot(currentScriptDir: string): string { + const parentDir = dirname(currentScriptDir); + if (parentDir.endsWith("/.generated")) { + return resolve(parentDir, ".."); + } + return resolve(currentScriptDir, ".."); +} + +/** + * Starts a server that streams the document head first and delays the body. + */ +function serveSlowWebviewDocument(overlay: string): Promise<{ + readonly finish: () => void; + readonly port: number; + readonly server: Server; +}> { + let slowResponse: ServerResponse | undefined; + let finished = false; + const server = createServer((request, response) => { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1/"); + if (requestUrl.pathname === "/assets/webview-overlay.js") { + response.writeHead(200, { + "content-type": "application/javascript; charset=utf-8", + }); + response.end(overlay); + return; + } + if (requestUrl.pathname.startsWith("/webview/")) { + slowResponse = response; + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + }); + response.write(` + + + Slow WebView Target + +`); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end("not found"); + }); + + const finish = () => { + if (finished) return; + finished = true; + slowResponse?.end('
late body
'); + }; + + return new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectListen); + const address = server.address(); + assert(address && typeof address !== "string", "expected TCP listener address"); + resolveListen({ + finish, + port: (address as AddressInfo).port, + server, + }); + }); + }); +} + +/** + * Closes an HTTP server and resolves once all handles are released. + */ +function closeServer(server: Server): Promise { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }); +} diff --git a/frontend/scripts/test-webview-service-worker.mts b/frontend/scripts/test-webview-service-worker.mts new file mode 100644 index 000000000..9b2b992e2 --- /dev/null +++ b/frontend/scripts/test-webview-service-worker.mts @@ -0,0 +1,990 @@ +#!/usr/bin/env node + +/** + * Runs unit checks for the Rings WebView service-worker request classifier. + */ + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +/** + * Minimal request shape consumed by the service worker's request-kind classifier. + */ +type RequestKindFixture = { + readonly headers: Headers; + readonly mode: string; + readonly destination: string; +}; + +/** + * Options used to build one request-kind fixture. + */ +type RequestKindFixtureOptions = { + readonly headers?: HeadersInit; + readonly mode?: string; + readonly destination?: string; +}; + +/** + * Service-worker symbols exported only inside this test VM. + */ +type ServiceWorkerTestApi = { + readonly controlledNavigationBody: ( + request: { readonly kind: string; readonly topLevelNavigation?: boolean }, + status: number, + headers: Headers, + body: Uint8Array | null, + ) => Uint8Array | null; + readonly emitDebug: ( + scope: string, + message: string, + level?: string, + resource?: unknown, + at?: string, + onion?: unknown, + ) => Promise; + readonly gatewayFailureDocument: (status: number, message: string, reason: string, code: string) => string; + readonly gatewayHostClient: () => Promise; + readonly rememberNavigationClientTarget: ( + event: ServiceWorkerNavigationEventFixture, + request: ServiceWorkerNavigationRequestFixture, + ) => boolean; + readonly rememberClientSourceTargetForTest: (clientId: string, sourceTarget: string) => boolean; + readonly rememberTrustedShellClientForTest: (clientId: string) => boolean; + readonly registerDebugClient: (clientId: string, capability?: string) => Promise; + readonly registerGatewayHostClient: (clientId: string, capability: string) => Promise; + readonly resetGatewayHostForTest: () => void; + readonly requestKind: (request: RequestKindFixture) => string; + readonly sourceTargetForClient: (clientId: string | undefined) => Promise; +}; + +/** + * Minimal Client shape consumed by gateway host registration. + */ +type ServiceWorkerClientFixture = { + readonly id: string; + readonly url: string; + readonly frameType: "auxiliary" | "top-level" | "nested" | "none"; + readonly postMessage: (message: unknown) => void; +}; + +/** + * Minimal FetchEvent client identity shape used by navigation source-target tests. + */ +type ServiceWorkerNavigationEventFixture = { + readonly clientId?: string; + readonly resultingClientId?: string; +}; + +/** + * Minimal serialized navigation request shape consumed by source-target tracking. + */ +type ServiceWorkerNavigationRequestFixture = { + readonly kind: string; + readonly requested: string; + readonly topLevelNavigation?: boolean; +}; + +/** + * Minimal message event shape used to drive the service worker registration handlers. + */ +type ServiceWorkerMessageEventFixture = { + readonly source?: { readonly id?: string }; + readonly data?: unknown; + readonly ports?: Array<{ postMessage: (message: unknown) => void }>; + waitUntil?: (promise: Promise) => void; +}; + +/** + * VM global shape needed to load the service worker without a browser. + */ +type ServiceWorkerTestContext = Record & { + self: { + readonly location: URL; + addEventListener: (type: string, listener: (event: ServiceWorkerMessageEventFixture) => void) => void; + clients: { + get: (clientId: string) => Promise; + matchAll: () => Promise; + }; + }; + __ringsWebviewServiceWorkerTest?: ServiceWorkerTestApi; +}; + +/** + * Minimal host-asset message event shape used to validate opener handoff. + */ +type HostAssetMessageEventFixture = { + readonly data?: unknown; + readonly origin: string; + readonly source?: { + readonly location: { + readonly href: string; + }; + }; + readonly ports?: Array<{ postMessage: (message: unknown) => void }>; +}; + +/** + * VM global shape needed to load the host asset without a browser. + */ +type HostAssetTestContext = Record & { + readonly location: URL; + readonly navigator: { + readonly serviceWorker: { + readonly addEventListener: (type: string, listener: (event: unknown) => void) => void; + }; + }; + readonly crypto: { + readonly getRandomValues: (values: Uint8Array) => Uint8Array; + }; + readonly addEventListener: (type: string, listener: (event: HostAssetMessageEventFixture) => void) => void; +}; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = frontendProjectRoot(scriptDir); +const hostAssetPath = resolve(projectRoot, "assets", "webview-host.js"); +const serviceWorkerPath = resolve(projectRoot, "rings-webview-service-worker.js"); +const hostAssetSource = await readFile(hostAssetPath, "utf8"); +const serviceWorkerSource = await readFile(serviceWorkerPath, "utf8"); +const clientsById = new Map(); +const messageListeners: Array<(event: ServiceWorkerMessageEventFixture) => void> = []; +const context: ServiceWorkerTestContext = { + console, + Headers, + ArrayBuffer, + URL, + TextDecoder, + TextEncoder, + Response, + Uint8Array, + performance, + setTimeout, + clearTimeout, + self: { + location: new URL("http://127.0.0.1:8080/"), + addEventListener(type, listener) { + if (type === "message") { + messageListeners.push(listener); + } + }, + clients: { + get: async (clientId) => clientsById.get(clientId), + matchAll: async () => [...clientsById.values()], + }, + }, +}; +const globalThisKey = "globalThis"; +context[globalThisKey] = context; + +vm.runInNewContext( + `${serviceWorkerSource}\nglobalThis.__ringsWebviewServiceWorkerTest = { controlledNavigationBody, emitDebug, gatewayFailureDocument, gatewayHostClient, rememberNavigationClientTarget, rememberClientSourceTargetForTest, rememberTrustedShellClientForTest, registerDebugClient, registerGatewayHostClient, resetGatewayHostForTest, requestKind, sourceTargetForClient };`, + context, + { + filename: serviceWorkerPath, + }, +); + +const serviceWorkerApi = context.__ringsWebviewServiceWorkerTest; +assert(serviceWorkerApi, "service worker test API was not exported"); +const { + controlledNavigationBody, + emitDebug, + gatewayFailureDocument, + gatewayHostClient, + rememberNavigationClientTarget, + rememberClientSourceTargetForTest, + rememberTrustedShellClientForTest, + registerDebugClient, + registerGatewayHostClient, + resetGatewayHostForTest, + requestKind, + sourceTargetForClient, +} = serviceWorkerApi; + +/** + * Resolves the frontend project root from either source or generated script paths. + */ +function frontendProjectRoot(currentScriptDir: string): string { + const parentDir = dirname(currentScriptDir); + if (parentDir.endsWith("/.generated")) { + return resolve(parentDir, ".."); + } + return resolve(currentScriptDir, ".."); +} + +/** + * Builds the minimum request object needed by `requestKind`. + */ +function request(options: RequestKindFixtureOptions = {}): RequestKindFixture { + return { + headers: new Headers(options.headers), + mode: options.mode ?? "cors", + destination: options.destination ?? "", + }; +} + +/** + * Encodes one UTF-8 body for service-worker response mutation tests. + */ +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +/** + * Decodes one UTF-8 body produced by the service worker. + */ +function text(value: Uint8Array | null): string { + assert(value, "expected response body bytes"); + return new TextDecoder().decode(value); +} + +/** + * Compares service-worker responses after crossing the VM realm boundary. + */ +function assertJsonEqual(actual: unknown, expected: unknown): void { + assert.equal(JSON.stringify(actual), JSON.stringify(expected)); +} + +/** + * Runs the injected history guard in a small browser-like VM. + */ +function runHistoryGuard(html: string, locationHref: string): unknown[][] { + const script = html.match(/ok'; + const headers = new Headers({ + "content-length": "42", + "content-security-policy": "default-src 'none'", + "content-type": "text/html", + }); + const body = controlledNavigationBody({ kind: "navigation" }, 200, headers, bytes(html)); + const injected = text(body); + assert.match(injected, /data-rings-webview-history-guard/); + assert.match(injected, /'); + assert.ok(guardIndex >= 0); + assert.ok(attackerMarkerIndex >= 0); + assert.ok(overlayIndex > attackerMarkerIndex); + const historyCalls = runHistoryGuard( + html, + "http://127.0.0.1:8080/webview/https%3A%2F%2Ftrusted.example%2Fdocs%2Findex.html", + ); + assert.equal(historyCalls[0]?.[3], "/webview/https%3A%2F%2Ftrusted.example%2Fsearch%3Fq%3Dtest"); + assert.equal(headers.has("content-length"), false); +} + +{ + const headers = new Headers({ + "content-length": "42", + "content-security-policy": "default-src 'none'", + "content-type": "text/html", + }); + const body = controlledNavigationBody( + { kind: "navigation" }, + 200, + headers, + bytes("\uFEFFTargetok"), + ); + const html = text(body); + assert.match(html, /ok', + ), + ); + const html = text(body); + const guardIndex = html.indexOf("data-rings-webview-history-guard"); + const attackerIndex = html.indexOf("data-attacker"); + assert.ok(guardIndex >= 0); + assert.ok(attackerIndex >= 0); + assert.ok(guardIndex < attackerIndex); + const historyCalls = runHistoryGuard( + html, + "http://127.0.0.1:8080/webview/https%3A%2F%2Ftrusted.example%2Fdocs%2Findex.html", + ); + assert.equal(historyCalls[0]?.[0], "pushState"); + assert.equal(historyCalls[0]?.[3], "/webview/https%3A%2F%2Ftrusted.example%2Fsearch%3Fq%3Dtest"); + assert.equal(historyCalls[1]?.[0], "replaceState"); + assert.equal(historyCalls[1]?.[3], "/webview/https%3A%2F%2Ftrusted.example%2F%23node"); +} + +{ + const css = bytes("body { color: red; }"); + const body = controlledNavigationBody({ kind: "subresource" }, 200, new Headers({ "content-type": "text/css" }), css); + assert.equal(body, css); +} + +{ + const html = gatewayFailureDocument( + 503, + 'gateway transport failed: no live onion exit offers service "https"', + "No live HTTPS onion exit is available.", + "onion_exit_unavailable", + ); + assert.match(html, /= 2); + assert.ok(popupMessages.length > popupMessageCountBeforeNavigation); + assert.equal(postNavigationMessages.length, 0); + assert.match(JSON.stringify(popupMessages), /pre-registration secret/); + assert.match(JSON.stringify(popupMessages), /trusted-shell secret/); + assert.match(JSON.stringify(popupMessages), /trusted navigation/); + assert.match(JSON.stringify(popupMessages), /post-registration secret/); + assert.match(JSON.stringify(popupMessages), /trusted onion route/); + assert.doesNotMatch(JSON.stringify(popupMessages), /cross-target secret/); + assert.doesNotMatch(JSON.stringify(popupMessages), /other onion route/); + assert.doesNotMatch(JSON.stringify(popupMessages), /target-overlap secret/); + assert.doesNotMatch(JSON.stringify(popupMessages), /onion target-overlap route/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /trusted navigation/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /post-registration secret/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /trusted onion route/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /cross-target secret/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /other onion route/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /target-overlap secret/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /onion target-overlap route/); + assert.doesNotMatch(JSON.stringify(popupMessages), /secret\.test/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), /secret\.test/); + assert.doesNotMatch(JSON.stringify(postNavigationMessages), new RegExp(hostCapability)); +} + +{ + resetGatewayHostForTest(); + clientsById.clear(); + const sourceMessages: unknown[] = []; + const resultMessages: unknown[] = []; + const hostCapability = "h".repeat(32); + clientsById.set("host", { + id: "host", + url: "http://127.0.0.1:8080/#node", + frameType: "top-level", + postMessage() {}, + }); + clientsById.set("popup-source", { + id: "popup-source", + url: "http://127.0.0.1:8080/#webview", + frameType: "auxiliary", + postMessage(message) { + sourceMessages.push(message); + }, + }); + clientsById.set("popup-result", { + id: "popup-result", + url: "http://127.0.0.1:8080/webview/https%3A%2F%2Fresult.example%2F", + frameType: "auxiliary", + postMessage(message) { + resultMessages.push(message); + }, + }); + + assert.equal(rememberTrustedShellClientForTest("popup-source"), true); + assert.equal(await registerGatewayHostClient("host", hostCapability), true); + assert.equal(await registerDebugClient("popup-source", hostCapability), true); + await emitDebug("worker", "source shell only"); + assert.equal(await sourceTargetForClient("popup-source"), undefined); + assert.equal( + rememberNavigationClientTarget( + { + clientId: "popup-source", + resultingClientId: "popup-result", + }, + { + kind: "navigation", + requested: "http://127.0.0.1:8080/webview/https%3A%2F%2Fresult.example%2F", + topLevelNavigation: true, + }, + ), + true, + ); + assert.equal(await registerDebugClient("popup-result"), true); + await emitDebug("worker", "result navigation", "info", { + requestId: "result-navigation", + target: "https://result.example/", + method: "GET", + kind: "navigation", + phase: "completed", + durationMs: 5, + status: 200, + }); + await emitDebug("worker", "unrelated result", "info", { + requestId: "unrelated-result", + target: "https://other.example/", + method: "GET", + kind: "navigation", + phase: "completed", + durationMs: 5, + status: 200, + }); + + assert.match(JSON.stringify(sourceMessages), /source shell only/); + assert.match(JSON.stringify(resultMessages), /result navigation/); + assert.doesNotMatch(JSON.stringify(resultMessages), /source shell only/); + assert.doesNotMatch(JSON.stringify(resultMessages), /unrelated result/); +} + +{ + resetGatewayHostForTest(); + clientsById.clear(); + const spoofedMessages: unknown[] = []; + clientsById.set("spoofed-gateway-client", { + id: "spoofed-gateway-client", + url: "http://127.0.0.1:8080/#node", + frameType: "top-level", + postMessage(message) { + spoofedMessages.push(message); + }, + }); + assert.equal(rememberClientSourceTargetForTest("spoofed-gateway-client", "https://target.example/"), true); + + assertJsonEqual( + await dispatchMessage("spoofed-gateway-client", { + type: "rings-webview-host-register", + capability: "x".repeat(32), + }), + [{ ok: false, error: "untrusted gateway host registration" }], + ); + assert.equal(await gatewayHostClient(), undefined); + assert.equal(spoofedMessages.length, 0); +} + +{ + resetGatewayHostForTest(); + clientsById.clear(); + const shellMessages: unknown[] = []; + clientsById.set("cold-shell-host", { + id: "cold-shell-host", + url: "http://127.0.0.1:8080/#node", + frameType: "top-level", + postMessage(message) { + shellMessages.push(message); + }, + }); + + assertJsonEqual( + await dispatchMessage("cold-shell-host", { + type: "rings-webview-host-register", + capability: "c".repeat(32), + }), + [{ ok: true }], + ); + assert.equal((await gatewayHostClient())?.id, "cold-shell-host"); + assert.equal(shellMessages.length, 0); +} + +{ + resetGatewayHostForTest(); + clientsById.clear(); + const nestedMessages: unknown[] = []; + clientsById.set("nested-shell-host", { + id: "nested-shell-host", + url: "http://127.0.0.1:8080/#node", + frameType: "nested", + postMessage(message) { + nestedMessages.push(message); + }, + }); + + assertJsonEqual( + await dispatchMessage("nested-shell-host", { + type: "rings-webview-host-register", + capability: "n".repeat(32), + }), + [{ ok: false, error: "untrusted gateway host registration" }], + ); + assert.equal(await gatewayHostClient(), undefined); + assert.equal(nestedMessages.length, 0); +} + +{ + resetGatewayHostForTest(); + clientsById.clear(); + clientsById.set("parent", { + id: "parent", + url: "http://127.0.0.1:8080/webview/https%3A%2F%2Fparent.example%2F", + frameType: "top-level", + postMessage() {}, + }); + clientsById.set("iframe", { + id: "iframe", + url: "http://127.0.0.1:8080/webview/https%3A%2F%2Fframe.example%2F", + frameType: "nested", + postMessage() {}, + }); + assert.equal(rememberClientSourceTargetForTest("parent", "https://parent.example/"), true); + + const iframeNavigationRemembered = rememberNavigationClientTarget( + { + clientId: "parent", + resultingClientId: "iframe", + }, + { + kind: "navigation", + requested: "http://127.0.0.1:8080/webview/https%3A%2F%2Fframe.example%2F", + topLevelNavigation: false, + }, + ); + assert.equal(iframeNavigationRemembered, true); + assert.equal(await sourceTargetForClient("parent"), "https://parent.example/"); + assert.equal(await sourceTargetForClient("iframe"), "https://frame.example/"); + + const topLevelFallbackRemembered = rememberNavigationClientTarget( + { + clientId: "parent", + }, + { + kind: "navigation", + requested: "http://127.0.0.1:8080/webview/https%3A%2F%2Ftop.example%2F", + topLevelNavigation: true, + }, + ); + assert.equal(topLevelFallbackRemembered, true); + clientsById.set("parent", { + id: "parent", + url: "http://127.0.0.1:8080/webview/https%3A%2F%2Ftop.example%2F", + frameType: "top-level", + postMessage() {}, + }); + assert.equal(await sourceTargetForClient("parent"), "https://top.example/"); +} diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 17467c0fe..1a474ba96 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -34,10 +34,15 @@ 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; +const DEFAULT_STABILIZE_INTERVAL_SECONDS: &str = "15"; +const LEGACY_DEFAULT_STABILIZE_INTERVAL_SECONDS: &str = "3"; + #[derive(Clone, PartialEq)] struct SettingsSnapshot { wallet_kind: String, @@ -72,6 +77,7 @@ struct NodeState { storage_name: UseStateHandle, peers: UseStateHandle>, seed_url: UseStateHandle, + webview_ready: UseStateHandle, } struct LinkState { @@ -162,13 +168,7 @@ fn use_node_state() -> NodeState { "stun://stun.l.google.com:19302", ) }), - stabilize_interval: use_state(|| { - load_setting_or_default( - extension::SETTING_STABILIZE_INTERVAL, - extension::LEGACY_SETTING_STABILIZE_INTERVAL, - "3", - ) - }), + stabilize_interval: use_state(load_stabilize_interval_setting_or_default), storage_name: use_state(|| { load_setting_or_default( extension::SETTING_STORAGE_NAME, @@ -184,6 +184,7 @@ fn use_node_state() -> NodeState { ) .unwrap_or_default() }), + webview_ready: use_state(|| false), } } @@ -262,6 +263,23 @@ fn load_setting_or_default(key: &str, legacy_key: &str, default: &'static str) - extension::load_setting_with_legacy(key, legacy_key).unwrap_or_else(|| default.to_string()) } +fn load_stabilize_interval_setting_or_default() -> String { + match extension::load_setting_with_legacy( + extension::SETTING_STABILIZE_INTERVAL, + extension::LEGACY_SETTING_STABILIZE_INTERVAL, + ) { + Some(value) if value.trim() == LEGACY_DEFAULT_STABILIZE_INTERVAL_SECONDS => { + extension::save_setting( + extension::SETTING_STABILIZE_INTERVAL, + DEFAULT_STABILIZE_INTERVAL_SECONDS, + ); + DEFAULT_STABILIZE_INTERVAL_SECONDS.to_string() + } + Some(value) => value, + None => DEFAULT_STABILIZE_INTERVAL_SECONDS.to_string(), + } +} + /// Rings browser frontend app. #[function_component(App)] pub fn app() -> Html { @@ -488,6 +506,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,14 +574,29 @@ 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, - *ctx.shell.active_dialog, - dialog_actions, - ctx.shell.control_sidebar_collapsed.clone(), - ctx.extension_mode, + controls::ControlSidebarShell { + workbench_control, + webview_control, + active_dialog: *ctx.shell.active_dialog, + dialog_actions, + collapsed: ctx.shell.control_sidebar_collapsed.clone(), + extension_mode: ctx.extension_mode, + }, ); let shell_class = console_shell_class(ctx.extension_mode); html! { @@ -715,7 +751,23 @@ fn current_shell_route() -> ShellRoute { } fn routed_shell_route() -> Option { - let hash = web_sys::window()?.location().hash().ok()?; + let location = web_sys::window()?.location(); + let pathname = location.pathname().ok()?; + if is_webview_path(pathname.as_str()) { + return Some(ShellRoute { + page: ShellPage::Webview, + dialog: ActiveDialog::None, + }); + } + let hash = location.hash().ok()?; + route_for_hash(hash.as_str()) +} + +fn is_webview_path(pathname: &str) -> bool { + pathname == "/webview" || pathname.starts_with(webview::GATEWAY_PREFIX) +} + +fn route_for_hash(hash: &str) -> Option { match hash.trim_start_matches('#').trim_start_matches('/') { "" | "home" => Some(ShellRoute { page: ShellPage::Guide, @@ -725,6 +777,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 +879,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..dffdbc1b2 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())); @@ -289,9 +295,22 @@ impl StartAction { built.stop(); return; } - self.did.set(my_did); + self.did.set(my_did.clone()); 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 + } + }; + if !token.is_current() { + self.discard_stale_local_node(&built); + return; + } + self.webview_ready.set(webview_ready); super::clear_shell_dialog_route(); self.active_dialog.set(ActiveDialog::None); self.node_starting.set(false); @@ -299,6 +318,18 @@ impl StartAction { .await; } + fn discard_stale_local_node(&self, built: &DemoNode) { + built.stop(); + let mut node_ref = self.node_ref.borrow_mut(); + if node_ref + .as_ref() + .is_some_and(|node| node.same_provider_instance(built)) + { + *node_ref = None; + webview::clear_browser_gateway(); + } + } + fn register_local_protocols(&self, built: &DemoNode, my_did: &str) -> Result<(), String> { self.site.borrow_mut().insert( "/".to_string(), @@ -432,11 +463,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 +486,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..d7d1003ac 100644 --- a/frontend/src/browser_api.rs +++ b/frontend/src/browser_api.rs @@ -59,17 +59,67 @@ pub(crate) async fn copy_text_to_clipboard(value: String) -> Result<(), String> Ok(()) } -pub(crate) async fn open_debug_url(url: &str) -> Result<(), String> { - match open_debug_url_with_extension_tabs("browser", url).await { - Ok(()) => Ok(()), - Err(_) => match open_debug_url_with_extension_tabs("chrome", url).await { - Ok(()) => Ok(()), - Err(_) => open_debug_url_with_window(url), - }, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DebugUrlOpenResult { + Opened, + CopiedInternalUrl, + ManualInternalUrl, +} + +pub(crate) fn open_debug_url(url: &str) -> Result { + if open_debug_url_with_extension_tabs("browser", url).is_ok() + || open_debug_url_with_extension_tabs("chrome", url).is_ok() + { + return Ok(DebugUrlOpenResult::Opened); + } + + // Browser-internal pages (`chrome://`, `about:`) are intentionally blocked from ordinary + // webpages. Avoid calling `window.open` for them, otherwise the user only sees a blocked + // navigation and the console records a misleading local-resource failure. + if is_browser_internal_url(url) { + return Ok(if copy_text_to_clipboard_start(url).is_ok() { + DebugUrlOpenResult::CopiedInternalUrl + } else { + DebugUrlOpenResult::ManualInternalUrl + }); + } + + open_debug_url_with_window(url).map(|_| DebugUrlOpenResult::Opened) +} + +/// Open the application-owned WebView shell in a browser popup. +/// +/// The caller supplies no remote target here: remote addresses are entered only inside the +/// controlled shell and become `/webview/` paths before the top-level document 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"), + ) + .map_err(js_error_label)?; + if opened.is_null() || opened.is_undefined() { + return Err("browser blocked the WebView popup".to_string()); + } + // The trusted `#webview` shell uses opener only for a one-shot MessageChannel debug + // capability handoff, then `assets/webview-host.js` clears it before remote navigation. + Ok(()) } -async fn open_debug_url_with_extension_tabs(namespace: &str, url: &str) -> Result<(), String> { +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)?; if extension_api.is_null() || extension_api.is_undefined() { @@ -89,9 +139,7 @@ async fn open_debug_url_with_extension_tabs(namespace: &str, url: &str) -> Resul let opened = create .call1(&tabs, &options.into()) .map_err(js_error_label)?; - if let Ok(promise) = opened.dyn_into::() { - JsFuture::from(promise).await.map_err(js_error_label)?; - } + observe_promise_rejection(opened); Ok(()) } @@ -118,6 +166,39 @@ fn open_debug_url_with_window(url: &str) -> Result<(), String> { Ok(()) } +fn is_browser_internal_url(url: &str) -> bool { + url.starts_with("chrome://") || url.starts_with("about:") +} + +fn copy_text_to_clipboard_start(value: &str) -> Result<(), String> { + let navigator = + Reflect::get(&js_sys::global(), &JsValue::from_str("navigator")).map_err(js_error_label)?; + let clipboard = + Reflect::get(&navigator, &JsValue::from_str("clipboard")).map_err(js_error_label)?; + if clipboard.is_null() || clipboard.is_undefined() { + return Err("clipboard API unavailable".to_string()); + } + let write_text = js_method(&clipboard, "writeText")?; + let result = write_text + .call1(&clipboard, &JsValue::from_str(value)) + .map_err(js_error_label)?; + observe_promise_rejection(result); + Ok(()) +} + +fn observe_promise_rejection(value: JsValue) { + let Ok(promise) = value.dyn_into::() else { + return; + }; + let promise: JsValue = promise.into(); + let Ok(catch) = js_method(&promise, "catch") else { + return; + }; + let handler = + wasm_bindgen::closure::Closure::once_into_js(|_error: JsValue| JsValue::UNDEFINED); + let _ = catch.call1(&promise, &handler); +} + pub(crate) async fn await_js(value: JsValue) -> Result { JsFuture::from(Promise::from(value)) .await @@ -219,5 +300,79 @@ pub(crate) fn js_set(object: &Object, name: &str, value: &JsValue) -> Result<(), } pub(crate) fn js_error_label(error: JsValue) -> String { - error.as_string().unwrap_or_else(|| format!("{error:?}")) + if let Some(message) = js_error_message(&error) { + return message; + } + if let Some(text) = error.as_string() { + return compact_js_error_text(&text); + } + compact_js_error_text(&format!("{error:?}")) +} + +fn js_error_message(error: &JsValue) -> Option { + let message = string_property(error, "message")?; + let name = string_property(error, "name"); + match name.as_deref() { + Some(name) if !name.is_empty() && name != "Error" && !message.starts_with(name) => { + Some(format!("{name}: {message}")) + } + _ => Some(message), + } +} + +fn string_property(object: &JsValue, name: &str) -> Option { + Reflect::get(object, &JsValue::from_str(name)) + .ok() + .and_then(|value| value.as_string()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn compact_js_error_text(raw: &str) -> String { + let mut text = raw.trim(); + if let Some(inner) = text + .strip_prefix("JsValue(") + .and_then(|value| value.strip_suffix(')')) + { + text = inner.trim(); + } + text.lines() + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown JavaScript error") + .to_string() +} + +#[cfg(test)] +mod tests { + use js_sys::Error; + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[wasm_bindgen_test] + fn js_error_label_uses_error_message_without_stack() { + let error = Error::new("Onion route error: no live onion exit offers service \"https\""); + + assert_eq!( + js_error_label(error.into()), + "Onion route error: no live onion exit offers service \"https\"" + ); + } + + #[wasm_bindgen_test] + fn js_error_label_compacts_debug_fallback() { + assert_eq!( + compact_js_error_text("JsValue(Error: boom\n at wasm-function[1])"), + "Error: boom" + ); + } + + #[wasm_bindgen_test] + fn browser_internal_debug_urls_are_detected() { + assert!(is_browser_internal_url("chrome://webrtc-internals/")); + assert!(is_browser_internal_url("about:webrtc")); + assert!(!is_browser_internal_url("https://example.test/")); + } } diff --git a/frontend/src/controls.rs b/frontend/src/controls.rs index 62f64c6bd..f90208fe3 100644 --- a/frontend/src/controls.rs +++ b/frontend/src/controls.rs @@ -15,6 +15,7 @@ use crate::wallet::WalletKind; mod sidebar; pub(crate) use sidebar::control_sidebar; +pub(crate) use sidebar::ControlSidebarShell; const CHROME_WEBRTC_DEBUG_URL: &str = "chrome://webrtc-internals/"; const FIREFOX_WEBRTC_DEBUG_URL: &str = "about:webrtc"; @@ -25,6 +26,7 @@ const FIREFOX_EXTENSION_MANAGER_URL: &str = "about:debugging#/runtime/this-firef pub(crate) enum ShellPage { Guide, Console, + Webview, } impl ShellPage { @@ -32,6 +34,7 @@ impl ShellPage { match self { Self::Guide => "Home", Self::Console => "Node", + Self::Webview => "WebView", } } } @@ -71,6 +74,7 @@ enum UiIcon { Power, PowerOff, Terminal, + Globe, Sliders, PanelOpen, PanelClose, @@ -98,6 +102,14 @@ fn ui_icon(icon: UiIcon) -> Html { }, + UiIcon::Globe => html! { + <> + + + + + + }, UiIcon::Sliders => html! { <> @@ -174,6 +186,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, @@ -701,25 +738,38 @@ fn open_detected_debug_callback( status: UseStateHandle, ) -> Callback { Callback::from(move |_| { - let status = status.clone(); - wasm_bindgen_futures::spawn_local(async move { - let browser = detect_browser(); - let Some(url) = debug_url(browser, target) else { + let browser = detect_browser(); + let Some(url) = debug_url(browser, target) else { + status.set(format!( + "cannot detect supported browser for {}", + target.label() + )); + return; + }; + match extension::open_debug_url(url) { + Ok(extension::DebugUrlOpenResult::Opened) => { + status.set(format!("opened {} {}", browser.label(), target.label())); + } + Ok(extension::DebugUrlOpenResult::CopiedInternalUrl) => { status.set(format!( - "cannot detect supported browser for {}", - target.label() + "{} blocks direct {} links from webpages; URL copied, paste it into the address bar", + browser.label(), + target.label(), )); - return; - }; - match extension::open_debug_url(url).await { - Ok(()) => status.set(format!("opened {} {}", browser.label(), target.label())), - Err(error) => status.set(format!( - "open {} {} failed: {error}", + } + Ok(extension::DebugUrlOpenResult::ManualInternalUrl) => { + status.set(format!( + "{} blocks direct {} links from webpages; paste {url} into the address bar", browser.label(), - target.label() - )), + target.label(), + )); } - }); + Err(error) => status.set(format!( + "open {} {} failed: {error}", + browser.label(), + target.label() + )), + } }) } diff --git a/frontend/src/controls/sidebar.rs b/frontend/src/controls/sidebar.rs index 66318c5af..0bc347bcf 100644 --- a/frontend/src/controls/sidebar.rs +++ b/frontend/src/controls/sidebar.rs @@ -47,36 +47,48 @@ impl ControlSidebarDerived { } } +pub(crate) struct ControlSidebarShell { + pub(crate) workbench_control: Html, + pub(crate) webview_control: Option, + pub(crate) active_dialog: ActiveDialog, + pub(crate) dialog_actions: DialogActions, + pub(crate) collapsed: UseStateHandle, + pub(crate) extension_mode: bool, +} + pub(crate) fn control_sidebar( view: ControlView<'_>, actions: LaunchActions, - workbench_control: Html, - active_dialog: ActiveDialog, - dialog_actions: DialogActions, - collapsed: UseStateHandle, - extension_mode: bool, + shell: ControlSidebarShell, ) -> Html { let derived = ControlSidebarDerived::from_view(&view); let on_copy_did = copy_local_did_callback(view.did, view.status); let open_settings_dialog = { - let open_dialog = dialog_actions.open.clone(); + let open_dialog = shell.dialog_actions.open.clone(); Callback::from(move |_| open_dialog.emit(ActiveDialog::Settings)) }; - let close_settings_dialog = dialog_actions.close.clone(); + let close_settings_dialog = shell.dialog_actions.close.clone(); let toggle_sidebar = { - let collapsed = collapsed.clone(); + let collapsed = shell.collapsed.clone(); Callback::from(move |_| collapsed.set(!*collapsed)) }; - let sidebar_class = control_sidebar_class(extension_mode, *collapsed); + let sidebar_class = control_sidebar_class(shell.extension_mode, *shell.collapsed); html! { } } @@ -180,15 +192,17 @@ fn sidebar_content( derived: &ControlSidebarDerived, actions: &LaunchActions, workbench_control: Html, + webview_control: Option, open_settings_dialog: Callback, on_copy_did: Callback, ) -> Html { html! {