diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea0abf2..a19853ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- **Breaking: provider routing now uses independent object capabilities.** The + legacy broad `Routing` interface and `routing` graft are replaced by + provider discovery through `routing::Finder` (`routing-finder`) and host + PeerID announcement through `routing::Announcer` (`routing-announcer`). + Finder queries enforce the requested unique-provider count, deduplicate WAN + and LAN results, retain at most `min(count, 16)` selected results behind a + single-slot handoff, and use a per-request token to cancel after sink + callback failure, epoch expiry, or a 30-second deadline that includes + swarm-command admission. Announcer + registrations and republication now end with their final owning authority + epoch. Guest IPNS resolution/publication and persistent UnixFS mutation are + removed. Canonical CIDv1/raw/BLAKE3 routing-key derivation is now the + optional pure `wetware:routing/key@0.1.0` WIT import. - **Wetware now owns default IPNS signing and can follow an IPNS deployment Stem.** `ww run --ipns-stem ` uses locally verified raw records as the authoritative deployment source. Signed EOL revokes the current generation @@ -17,7 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `Gateway.ExposeRoutingAPI` and use the Gateway listener, which defaults to `http://localhost:8080` and can be set with `IPFS_ROUTING_API` or `--ipns-routing-url`. Wetware no longer creates or requires Kubo's `"ww"` - signing key. Guest `Routing.publish` is unchanged. `rust-ipns` is temporarily + signing key. The host publisher remains separate from guest provider routing. + `rust-ipns` is temporarily pinned to reviewed commit `02c5ae7bf3f9568c7dbbb1308ae9299cfc7ba2d9` pending upstream PR #503 or a release that contains its V2-only validation fix. diff --git a/Cargo.lock b/Cargo.lock index 8d8b8b75..56c2b382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1375,6 +1375,7 @@ dependencies = [ "anyhow", "arc-swap", "async-trait", + "blake3", "cap-std", "cid", "dirs", @@ -1461,6 +1462,7 @@ dependencies = [ "libp2p-stream", "log", "rand 0.9.4", + "routing-key", "serde_json", "shakmaty", "system", @@ -2232,6 +2234,7 @@ dependencies = [ "hex", "log", "rand 0.9.4", + "routing-key", "system", "tokio", "tokio-util", @@ -5303,6 +5306,13 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "routing-key" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.41.0", +] + [[package]] name = "rpc" version = "0.1.0" @@ -5311,7 +5321,6 @@ dependencies = [ "async-trait", "auth", "base58", - "blake3", "bytes", "capnp", "capnp-rpc", diff --git a/Cargo.toml b/Cargo.toml index 388e9f93..b3a47dc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/membrane", "crates/rpc", "crates/guest/auth", + "crates/guest/routing-key", "std/system", "examples/chess", "examples/discovery", @@ -40,6 +41,7 @@ libp2p-stream = "0.4.0-alpha" log = "0.4" rand = "0.9.2" reqwest = { version = "0.12.24", default-features = false } +routing-key = { path = "crates/guest/routing-key" } serde = "1.0.228" serde_json = "1.0.145" tempfile = "3.23.0" diff --git a/README.md b/README.md index 8f57e4ad..bcf26892 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ an image. A published guest requires an application-specific composition path. - [Capabilities](doc/capabilities.md): the capability model and Cap'n Proto schemas - [CLI reference](doc/cli.md): full command-line usage - [Image layout](doc/images.md): FHS convention, mounts, on-chain coordination -- [Routing](doc/routing.md): Kademlia DHT and peer discovery +- [Provider routing](doc/routing.md): independent discovery and host-PeerID announcement capabilities - [Keys & identity](doc/keys.md): Ed25519 identity management - [RPC transport](doc/rpc-transport.md): transport plumbing and scheduling model - [Guest runtime](doc/guest-runtime.md): async runtime for WASM guests diff --git a/TODOS.md b/TODOS.md index cf9f68bc..a256131c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -180,7 +180,7 @@ usable by trusted FHS configuration or a future Warrant/ICME adapter. ## mDNS for Kubo-less LAN peer discovery **What:** Add `libp2p::mdns::tokio::Behaviour` to `host::Behaviour` to discover LAN peers without Kubo. mDNS is a **peer discovery source** that feeds the LAN DHT routing table — not a routing primitive. It does not touch Cap'n Proto or the guest API. **Why:** The dual DHT bootstraps the LAN routing table from Kubo's swarm peers. Without Kubo (or in environments where Kubo has no private-address peers), the LAN DHT starts empty. mDNS enables zero-config LAN discovery. Note: mDNS does NOT work in cloud/container environments (no multicast). Kubo bootstrap is the fallback/primary for those environments. Dual DHT and mDNS are orthogonal — can be built and merged independently. -**Context:** mDNS adds ~25-40 lines (config, event handling, address reconciliation). CI consideration: GitHub Actions runners may not support mDNS multicast, so mDNS-dependent tests should be `#[ignore]` or gated behind an env check. All critical logic remains testable via `LocalRouting` and mock swarm channels. +**Context:** mDNS adds ~25-40 lines (config, event handling, address reconciliation). CI consideration: GitHub Actions runners may not support mDNS multicast, so mDNS-dependent tests should be `#[ignore]` or gated behind an env check. All critical logic remains testable via `LocalProviderRouting` and mock swarm channels. **Effort:** S (CC: ~30 min) **Priority:** P3 **Depends on:** Dual DHT (architecturally orthogonal but LAN DHT should exist first so mDNS has a routing table to feed) diff --git a/capnp/membrane.capnp b/capnp/membrane.capnp index 786a6f35..88da28c2 100644 --- a/capnp/membrane.capnp +++ b/capnp/membrane.capnp @@ -28,7 +28,8 @@ interface Membrane @0xdb52c25106bc2c5e { # Pure capability provisioning (ocap model). Having a Membrane reference IS # authorization — no signer needed. Wrap in Terminal(Membrane) to gate access. # - # Canonical names: "identity", "host", "runtime", "routing", "http-client", "ipfs". + # Canonical names: "identity", "host", "runtime", "routing-finder", + # "routing-announcer", "http-client", "ipfs". # Trusted pid0 may also receive explicitly configured extras. # # Listener/Dialer accessed via host.network(). diff --git a/capnp/routing.capnp b/capnp/routing.capnp index 545b5610..82441cc2 100644 --- a/capnp/routing.capnp +++ b/capnp/routing.capnp @@ -1,61 +1,36 @@ -# Content routing capability backed by the in-process Kademlia client. +# Narrow provider-routing capabilities backed by the in-process Kademlia client. # -# Mirrors Go's coreiface.RoutingAPI (provide/findProviders only). -# Data transfer flows through the WASI virtual filesystem, not a capability. -# DHT key-value store (putValue/getValue) is deferred. +# Finder and Announcer are independent object capabilities. Canonical routing-key +# derivation is a pure Component Model import, not a Cap'n Proto capability. # -# Epoch-scoped: the host wraps the implementation with an EpochGuard so all -# methods fail with stale-epoch once the epoch advances. +# Both capabilities are epoch-scoped. The host wraps each implementation with an +# EpochGuard so calls fail after the authority epoch advances. -@0xa7c3e8f1d4b29065; +@0xbb7178bb658e44b6; struct ProviderInfo { - peerId @0 :Data; # libp2p peer ID, serialized. - addrs @1 :List(Data); # Multiaddrs for this provider, each serialized. + peerId @0 :Data; # Serialized libp2p PeerID. + addrs @1 :List(Data); # Serialized multiaddrs for the provider. } interface ProviderSink { - provider @0 (info :ProviderInfo) -> stream; - # Called once per discovered provider. -> stream enables - # Cap'n Proto flow control (backpressure). + provider @0 (info :ProviderInfo) -> (); + # Called once per unique provider. Each response applies backpressure and + # reports sink closure before Finder requests another result. done @1 (); - # Signals that the search is complete. Errors from earlier - # provider() calls surface here. + # Signals that the finite lookup has completed. } -interface Routing { - provide @0 (key :Text) -> (); - # Announce this node as a provider for the given CID. - - findProviders @1 (key :Text, count :UInt32, sink :ProviderSink) -> (); - # Stream providers for a CID into the caller-supplied sink. - - hash @2 (data :Data) -> (key :Text); - # Compute a deterministic CID (v1, raw codec, sha256) from data. - # Local operation — does not touch the network or Kubo. - - resolve @3 (name :Text) -> (path :Text); - # Resolve an IPNS name to an IPFS path via Kubo. - # Returns e.g. "/ipfs/bafyrei..." - - mkdir @4 (baseCid :Text, path :Text, parents :Bool) -> (rootCid :Text); - # Build a new UnixFS directory root by creating `path` relative to - # `baseCid`. Returns the new root CID. No global mutable root is used. - - writeFile @5 (baseCid :Text, path :Text, data :Data, createParents :Bool) -> (rootCid :Text); - # Build a new UnixFS root by writing file bytes at `path` relative to - # `baseCid` (overwrite if present). Returns the new root CID. - - remove @6 (baseCid :Text, path :Text, recursive :Bool) -> (rootCid :Text); - # Build a new UnixFS root by removing `path` relative to `baseCid`. - # Returns the new root CID. +interface Finder @0xebb8ace9d47ae6a8 { + findProviders @0 (key :Text, count :UInt32, sink :ProviderSink) -> (); + # `count` is the requested maximum number of unique provider peers. + # A count of zero launches no network query and returns no peers. + # Wetware applies a host maximum of 16 provider results. +} - publish @7 (name :Text, cid :Text, expectedCurrent :Text) -> (publishedPath :Text); - # Publish `/ipfs/` under IPNS `name`. - # - # Conflict semantics: - # - if `expectedCurrent` is empty, publish unconditionally. - # - if set, `name` must currently resolve to `expectedCurrent` (or fail). - # This is a compare-and-set guard to avoid silent last-write-wins. +interface Announcer @0xf52674c78f631b2f { + provide @0 (key :Text) -> (); + # Announce the Wetware host PeerID as a provider for the CID. Local provider + # registration and republication stop after the owning authority epoch ends. } diff --git a/crates/atom/tests/common/mod.rs b/crates/atom/tests/common/mod.rs index 9490e4e0..95909e8c 100644 --- a/crates/atom/tests/common/mod.rs +++ b/crates/atom/tests/common/mod.rs @@ -86,7 +86,7 @@ impl GraftBuilder for StubSessionBuilder { } // --------------------------------------------------------------------------- -// Stub servers for all 5 graft capabilities (Identity, Host, Runtime, Routing, HttpClient) +// Stub servers for all 6 graft capabilities // --------------------------------------------------------------------------- /// Stub Identity: returns unimplemented for all methods. @@ -163,37 +163,35 @@ impl http_capnp::http_client::Server for StubHttpClient { } } -/// Stub Routing: returns unimplemented for all methods. -pub struct StubRouting; +/// Stub Finder: returns unimplemented for provider discovery. +pub struct StubFinder; #[allow(refining_impl_trait)] -impl routing_capnp::routing::Server for StubRouting { - fn provide( - self: capnp::capability::Rc, - _params: routing_capnp::routing::ProvideParams, - _results: routing_capnp::routing::ProvideResults, - ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented("stub routing".into())) - } - +impl routing_capnp::finder::Server for StubFinder { fn find_providers( self: capnp::capability::Rc, - _params: routing_capnp::routing::FindProvidersParams, - _results: routing_capnp::routing::FindProvidersResults, + _params: routing_capnp::finder::FindProvidersParams, + _results: routing_capnp::finder::FindProvidersResults, ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented("stub routing".into())) + Promise::err(capnp::Error::unimplemented("stub finder".into())) } +} + +/// Stub Announcer: returns unimplemented for provider announcement. +pub struct StubAnnouncer; - fn hash( +#[allow(refining_impl_trait)] +impl routing_capnp::announcer::Server for StubAnnouncer { + fn provide( self: capnp::capability::Rc, - _params: routing_capnp::routing::HashParams, - _results: routing_capnp::routing::HashResults, + _params: routing_capnp::announcer::ProvideParams, + _results: routing_capnp::announcer::ProvideResults, ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented("stub routing".into())) + Promise::err(capnp::Error::unimplemented("stub announcer".into())) } } -/// GraftBuilder that populates ALL 5 graft capabilities with stubs (Export list format). +/// GraftBuilder that populates all six graft capabilities with stubs. /// Used to verify that graft() returns every capability field. pub struct FullStubSessionBuilder; @@ -208,10 +206,11 @@ impl GraftBuilder for FullStubSessionBuilder { let runtime: system_capnp::runtime::Client = capnp_rpc::new_client(StubRuntime { guard: guard.clone(), }); - let routing: routing_capnp::routing::Client = capnp_rpc::new_client(StubRouting); + let finder: routing_capnp::finder::Client = capnp_rpc::new_client(StubFinder); + let announcer: routing_capnp::announcer::Client = capnp_rpc::new_client(StubAnnouncer); let http_client: http_capnp::http_client::Client = capnp_rpc::new_client(StubHttpClient); - let mut caps = builder.reborrow().init_caps(5); + let mut caps = builder.reborrow().init_caps(6); let mut e = caps.reborrow().get(0); e.set_name("identity"); @@ -226,10 +225,14 @@ impl GraftBuilder for FullStubSessionBuilder { e.init_cap().set_as_capability(runtime.client.hook); let mut e = caps.reborrow().get(3); - e.set_name("routing"); - e.init_cap().set_as_capability(routing.client.hook); + e.set_name("routing-finder"); + e.init_cap().set_as_capability(finder.client.hook); let mut e = caps.reborrow().get(4); + e.set_name("routing-announcer"); + e.init_cap().set_as_capability(announcer.client.hook); + + let mut e = caps.reborrow().get(5); e.set_name("http-client"); e.init_cap().set_as_capability(http_client.client.hook); diff --git a/crates/atom/tests/membrane_integration.rs b/crates/atom/tests/membrane_integration.rs index 987c242e..7d790803 100644 --- a/crates/atom/tests/membrane_integration.rs +++ b/crates/atom/tests/membrane_integration.rs @@ -366,14 +366,14 @@ async fn test_terminal_missing_signer_rejected() { assert!(result.get_session().is_err()); } -/// Helper: create a Membrane client with all 5 capabilities populated. +/// Helper: create a Membrane client with all six capabilities populated. fn full_stub_membrane(rx: watch::Receiver) -> membrane_capnp::membrane::Client { new_client(MembraneServer::new(rx, FullStubSessionBuilder)) } -/// Verify that graft() returns all 5 capabilities: identity, host, runtime, routing, http-client. +/// Verify that graft() returns both routing capabilities as independent refs. #[tokio::test] -async fn test_graft_returns_all_five_capabilities() { +async fn test_graft_returns_all_six_capabilities() { let epoch = Epoch { seq: 1, head: b"head".to_vec(), @@ -392,16 +392,17 @@ async fn test_graft_returns_all_five_capabilities() { let results = graft_resp.get().expect("graft results"); let caps = results.get_caps().expect("caps"); - // All 5 capabilities must be present by name. - assert_eq!(caps.len(), 5, "expected 5 capabilities"); + assert_eq!(caps.len(), 6, "expected 6 capabilities"); let _identity: auth_capnp::identity::Client = get_graft_cap(&caps, "identity").expect("identity capability should be present"); let _host: system_capnp::host::Client = get_graft_cap(&caps, "host").expect("host capability should be present"); let runtime: system_capnp::runtime::Client = get_graft_cap(&caps, "runtime").expect("runtime capability should be present"); - let _routing: routing_capnp::routing::Client = - get_graft_cap(&caps, "routing").expect("routing capability should be present"); + let _finder: routing_capnp::finder::Client = + get_graft_cap(&caps, "routing-finder").expect("Finder capability should be present"); + let _announcer: routing_capnp::announcer::Client = + get_graft_cap(&caps, "routing-announcer").expect("Announcer capability should be present"); let _http_client: http_capnp::http_client::Client = get_graft_cap(&caps, "http-client").expect("http-client capability should be present"); diff --git a/crates/authority/src/lib.rs b/crates/authority/src/lib.rs index 4eaf6cf2..a890f353 100644 --- a/crates/authority/src/lib.rs +++ b/crates/authority/src/lib.rs @@ -114,6 +114,22 @@ mod wire_type_id_tests { ::TYPE_ID, 0xdb52_c251_06bc_2c5e ); + assert_eq!( + ::TYPE_ID, + 0xebb8_ace9_d47a_e6a8 + ); + assert_eq!( + ::TYPE_ID, + 0xf526_74c7_8f63_1b2f + ); + assert_ne!( + ::TYPE_ID, + 0xa7c3_e8f1_d4b2_9065 + ); + assert_ne!( + ::TYPE_ID, + 0xa7c3_e8f1_d4b2_9065 + ); } } diff --git a/crates/cell/Cargo.toml b/crates/cell/Cargo.toml index 05b4fd1c..fd460874 100644 --- a/crates/cell/Cargo.toml +++ b/crates/cell/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +blake3 = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } arc-swap = "1" diff --git a/crates/cell/src/lib.rs b/crates/cell/src/lib.rs index 23529331..49f6d40b 100644 --- a/crates/cell/src/lib.rs +++ b/crates/cell/src/lib.rs @@ -12,6 +12,7 @@ pub mod image; pub mod loaders; pub mod mount; pub mod proc; +pub mod routing_key; pub mod sched; pub mod vfs; diff --git a/crates/cell/src/proc.rs b/crates/cell/src/proc.rs index ecc13a00..903ab53b 100644 --- a/crates/cell/src/proc.rs +++ b/crates/cell/src/proc.rs @@ -33,6 +33,13 @@ mod pid0_runtime { }); } +mod routing_key_runtime { + wasmtime::component::bindgen!({ + world: "key-client", + path: "../guest/routing-key/wit", + }); +} + // Import generated types - Connection is a Resource type alias use exports::wetware::streams::streams::Connection; @@ -224,6 +231,12 @@ impl pid0_runtime::wetware::kernel_runtime::readiness::Host for ComponentRunStat } } +impl routing_key_runtime::wetware::routing::key::Host for ComponentRunStates { + fn derive(&mut self, data: Vec) -> String { + crate::routing_key::derive(&data).to_string() + } +} + fn commit_kernel_ready( gate: &authority::KernelReadyGate, ) -> Result<(), pid0_runtime::wetware::kernel_runtime::readiness::ReadyError> { @@ -238,6 +251,14 @@ fn commit_kernel_ready( } } +fn add_routing_key_to_linker(linker: &mut Linker) -> Result<()> { + routing_key_runtime::KeyClient::add_to_linker::>( + linker, + |state| state, + )?; + Ok(()) +} + // Required for WASI IO to work. impl WasiView for ComponentRunStates { fn ctx(&mut self) -> WasiCtxView<'_> { @@ -558,6 +579,7 @@ impl Proc { }; let mut linker = Linker::new(&engine); add_to_linker_async(&mut linker)?; + add_routing_key_to_linker(&mut linker)?; if kernel_ready_gate.is_some() { pid0_runtime::Pid0::add_to_linker::>( &mut linker, @@ -870,9 +892,79 @@ fn add_streams_to_linker(linker: &mut Linker) -> Result<()> #[cfg(test)] mod tests { use super::*; + use std::path::{Path, PathBuf}; + use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::OnceLock; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + const ROUTING_KEY_PROBE_COMPONENT: &str = r#" + (component + (type $key-type + (instance + (type $derive-type + (func (param "data" (list u8)) (result string))) + (export "derive" (func (type $derive-type))))) + (import "wetware:routing/key@0.1.0" + (instance $key (type $key-type))) + (alias export $key "derive" (func $derive)) + + (core module $libc + (memory (export "memory") 1) + (global $last (mut i32) (i32.const 4096)) + (func $realloc (export "realloc") + (param $old-ptr i32) + (param $old-size i32) + (param $align i32) + (param $new-size i32) + (result i32) + (local $ret i32) + + local.get $old-ptr + if unreachable end + + (global.set $last + (i32.and + (i32.add + (global.get $last) + (i32.add (local.get $align) (i32.const -1))) + (i32.xor + (i32.add (local.get $align) (i32.const -1)) + (i32.const -1)))) + global.get $last + local.set $ret + (global.set $last + (i32.add (global.get $last) (local.get $new-size))) + local.get $ret)) + (core instance $libc (instantiate $libc)) + + (core func $derive-lowered + (canon lower (func $derive) + (memory $libc "memory") + (realloc (func $libc "realloc")))) + + (core module $probe + (import "libc" "memory" (memory 1)) + (import "" "derive" (func $derive (param i32 i32 i32))) + (data (i32.const 0) "ww.chess.v1") + (func (export "probe") (result i32) + i32.const 0 + i32.const 11 + i32.const 96 + call $derive + i32.const 96)) + (core instance $probe + (instantiate $probe + (with "libc" (instance $libc)) + (with "" (instance + (export "derive" (func $derive-lowered)))))) + + (func (export "probe") (result string) + (canon lift (core func $probe "probe") + (memory $libc "memory") + (realloc (func $libc "realloc"))))) + "#; + struct EpochTicker { stop: Arc, thread: Option>, @@ -904,6 +996,151 @@ mod tests { } } + fn component_test_state() -> ComponentRunStates { + ComponentRunStates { + wasi_ctx: WasiCtxBuilder::new().build(), + resource_table: ResourceTable::new(), + image_root: None, + scratch: tempfile::TempDir::new().expect("component test scratch"), + data_stream: None, + cache_mode: None, + cid_tree: None, + writable_fs_descriptors: std::collections::HashSet::new(), + fuel_estimator: FuelEstimator::new(INITIAL_FUEL), + kernel_ready_gate: None, + } + } + + fn routing_key_probe_wasm() -> &'static PathBuf { + static PROBE: OnceLock = OnceLock::new(); + PROBE.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let target = root.join("target/routing-key-probe"); + let status = Command::new(env!("CARGO")) + .current_dir(&root) + .env("CARGO_TARGET_DIR", &target) + .args([ + "build", + "--locked", + "--manifest-path", + "tests/fixtures/routing-key-probe/Cargo.toml", + "--target", + "wasm32-wasip2", + "--release", + ]) + .status() + .expect("launch cargo to build routing-key wrapper probe"); + assert!(status.success(), "routing-key wrapper probe build failed"); + let wasm = target.join("wasm32-wasip2/release/routing_key_probe.wasm"); + assert!( + wasm.is_file(), + "routing-key probe missing: {}", + wasm.display() + ); + wasm + }) + } + + #[tokio::test] + async fn routing_key_guest_wrapper_matches_golden_vector() { + let engine = crate::engine::wasm_engine().expect("component engine"); + let component = Component::from_file(&engine, routing_key_probe_wasm()) + .expect("routing-key wrapper probe component"); + let mut linker = Linker::new(&engine); + add_to_linker_async(&mut linker).expect("install WASI imports"); + add_routing_key_to_linker(&mut linker).expect("install routing-key import"); + let mut store = Store::new(&engine, component_test_state()); + store.set_fuel(INITIAL_FUEL).expect("routing-key test fuel"); + store.set_epoch_deadline(1); + let instance = linker + .instantiate_async(&mut store, &component) + .await + .expect("instantiate routing-key wrapper probe"); + let probe = instance + .get_typed_func::<(), (String,)>(&mut store, "probe") + .expect("typed routing-key wrapper probe export"); + + for _ in 0..2 { + let (key,) = probe + .call_async(&mut store, ()) + .await + .expect("call routing-key wrapper probe"); + assert_eq!( + key, + "bafkr4ifcoue3f52zpzpz2xei7dqhs3gajm326llyljbwisxkwea7hbowyy" + ); + } + } + + #[tokio::test] + async fn routing_key_wit_call_matches_golden_vector_and_is_deterministic() { + let engine = crate::engine::wasm_engine().expect("component engine"); + let component = Component::new(&engine, ROUTING_KEY_PROBE_COMPONENT) + .expect("routing-key probe component"); + let mut linker = Linker::new(&engine); + add_routing_key_to_linker(&mut linker).expect("install routing-key import"); + let mut store = Store::new(&engine, component_test_state()); + store.set_fuel(INITIAL_FUEL).expect("routing-key test fuel"); + store.set_epoch_deadline(1); + let instance = linker + .instantiate_async(&mut store, &component) + .await + .expect("instantiate routing-key probe"); + let probe = instance + .get_typed_func::<(), (String,)>(&mut store, "probe") + .expect("typed routing-key probe export"); + + for _ in 0..2 { + let (key,) = probe + .call_async(&mut store, ()) + .await + .expect("call routing-key probe"); + assert_eq!( + key, + "bafkr4ifcoue3f52zpzpz2xei7dqhs3gajm326llyljbwisxkwea7hbowyy" + ); + } + } + + #[test] + fn routing_key_registration_does_not_require_a_guest_import() { + let engine = crate::engine::wasm_engine().expect("component engine"); + let component = Component::new(&engine, "(component)").expect("empty component"); + assert!(component + .component_type() + .imports(&engine) + .all(|(name, _)| name != "wetware:routing/key@0.1.0")); + + let mut linker = Linker::new(&engine); + add_routing_key_to_linker(&mut linker).expect("install routing-key import"); + linker + .instantiate_pre(&component) + .expect("extra routing-key host support must not affect a non-importing component"); + } + + #[test] + fn routing_key_import_is_registered_for_ordinary_and_pid0_linkers() { + let engine = crate::engine::wasm_engine().expect("component engine"); + let component = Component::new(&engine, ROUTING_KEY_PROBE_COMPONENT) + .expect("routing-key probe component"); + + let mut ordinary = Linker::new(&engine); + add_routing_key_to_linker(&mut ordinary).expect("ordinary routing-key import"); + ordinary + .instantiate_pre(&component) + .expect("ordinary linker satisfies routing-key import"); + + let mut pid0 = Linker::new(&engine); + add_routing_key_to_linker(&mut pid0).expect("PID0 routing-key import"); + pid0_runtime::Pid0::add_to_linker::>( + &mut pid0, + |state| state, + ) + .expect("install private PID0 import"); + pid0.instantiate_pre(&component) + .expect("PID0 linker satisfies routing-key import"); + } + fn private_kernel_import_component() -> Vec { use wit_component::{ComponentEncoder, StringEncoding}; use wit_parser::{ManglingAndAbi, Resolve}; diff --git a/crates/cell/src/routing_key.rs b/crates/cell/src/routing_key.rs new file mode 100644 index 00000000..0dac0f60 --- /dev/null +++ b/crates/cell/src/routing_key.rs @@ -0,0 +1,43 @@ +//! Canonical pure routing-key derivation. + +use cid::Cid; + +const RAW_CODEC: u64 = 0x55; +const BLAKE3_256_MULTIHASH_CODE: u64 = 0x1e; + +/// Derive the canonical CIDv1/raw/BLAKE3-256 routing key for `data`. +#[must_use] +pub fn derive(data: &[u8]) -> Cid { + let digest = blake3::hash(data); + let multihash = + cid::multihash::Multihash::<64>::wrap(BLAKE3_256_MULTIHASH_CODE, digest.as_bytes()) + .expect("BLAKE3-256 digest fits the routing-key multihash"); + Cid::new_v1(RAW_CODEC, multihash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_exact_canonical_golden_vectors() { + let vectors: &[(&[u8], &str)] = &[ + ( + b"", + "bafkr4ifpcne3t5pzugtkaqcn5i3nzskjtpfslsnnyejlpte2spfoihzsmi", + ), + ( + b"ww.chess.v1", + "bafkr4ifcoue3f52zpzpz2xei7dqhs3gajm326llyljbwisxkwea7hbowyy", + ), + ( + b"wetware", + "bafkr4id4izs4xqbbbmlhwbc7gu5bl3ogbclvqyepzdqfxgjpyoovqmqtnm", + ), + ]; + + for (input, expected) in vectors { + assert_eq!(derive(input).to_string(), *expected); + } + } +} diff --git a/crates/cell/wit/README.md b/crates/cell/wit/README.md index 4583cd21..c3367efd 100644 --- a/crates/cell/wit/README.md +++ b/crates/cell/wit/README.md @@ -27,6 +27,27 @@ Defines a bidirectional data stream interface for host-guest communication, sepa - Guests can use these handles with standard WASI stream APIs (`wasi:io/streams`) - Streams are backed by in-memory tokio channels on the host side +### `routing-key/wit/key.wit` + +The canonical routing-key WIT source lives in the publishable +`crates/guest/routing-key` package. `crates/cell/src/proc.rs` reads the same +file for its host binding. + +**Package:** `wetware:routing@0.1.0` + +**Interface:** `key` + +**Function:** `derive: func(data: list) -> string` + +**World:** `key-client`, which imports `key` + +The host registers `key-client` in the common linker path for ordinary and +PID0 components. The import remains optional. A component that does not select +`key-client` has no routing-key binding or component metadata. + +`routing_key_runtime` generates the host `Host` trait. `ComponentRunStates` +implements the trait, and `KeyClient::add_to_linker` registers the import. + ## How bindgen! Works ### Configuration @@ -50,9 +71,12 @@ bindgen!({ - `Connection`: A resource type alias (`ResourceAny`) representing the `connection` resource - Helper methods: `Connection::try_from_resource()` for converting `Resource` to `Connection` -### Important Limitation +### Important Limitation for `streams-world` -**`bindgen!` generates guest-side bindings only.** For exported interfaces (host-implemented), you must manually implement host functions using `linker.root().func_wrap_async()`. There is no generated trait to implement. +The `streams-world` binding does not generate a host trait for its exported +interface. Register those functions with `linker.root().func_wrap_async()`. +An import world such as `key-client` generates a `Host` trait and an +`add_to_linker` helper. ## Host Function Implementation @@ -136,9 +160,9 @@ Ok((connection,)) ## Key Implementation Details -### Async Function Wrapping +### `streams-world` Async Function Wrapping -All host functions use `func_wrap_async`: +The manually registered `streams-world` host functions use `func_wrap_async`: - Closure must return `Box> + Send>` - Use `Box::new(async move { ... })` for async closures @@ -169,19 +193,17 @@ All host functions use `func_wrap_async`: **Solution:** Use `Connection::try_from_resource(resource, &mut store)?` provided by bindgen. -### Issue: bindgen! generates guest bindings, not host traits - -**Solution:** Implement host functions manually using `linker.root().func_wrap_async()`. There is no trait to implement. - ## Adding New Interfaces To add a new WIT interface: -1. **Create the WIT file** in this directory (e.g., `loader.wit`) +1. **Choose one canonical WIT location.** Put a published guest binding's WIT + inside that package. Otherwise, create the WIT file in this directory. 2. **Define the interface** with package, interface, and world -3. **Add `bindgen!` macro** in the host code (e.g., `src/cell/proc.rs`) -4. **Implement host functions** using `linker.root().func_wrap_async()` -5. **Add to linker** before component instantiation +3. **Add `bindgen!` macro** in the host code (`crates/cell/src/proc.rs`) +4. **Implement the generated `Host` trait** for an import world, or register + exported-world functions with `linker.root().func_wrap_async()` +5. **Add the interface to each applicable linker** before instantiation Example WIT structure: @@ -227,7 +249,7 @@ linker.root().func_wrap_async( - **WIT defines the interface contract** between host and guest - **`bindgen!` generates guest-side types** and resource conversion helpers -- **Host functions are implemented manually** using `linker.root().func_wrap_async()` +- **Import worlds generate host traits**; exported `streams-world` functions use manual linker registration - **Resources are managed via two tables**: component table for host resources, WASI table for WASI streams - **Function names must be fully qualified**: `{package}/{interface}#{name}` - **Resource parameters must be wrapped in tuples** to satisfy trait bounds diff --git a/crates/guest/routing-key/Cargo.toml b/crates/guest/routing-key/Cargo.toml new file mode 100644 index 00000000..60b76186 --- /dev/null +++ b/crates/guest/routing-key/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "routing-key" +version = "0.1.0" +edition = "2021" +description = "Optional guest binding for Wetware routing-key derivation" + +[dependencies] +wit-bindgen = "0.41.0" diff --git a/crates/guest/routing-key/src/lib.rs b/crates/guest/routing-key/src/lib.rs new file mode 100644 index 00000000..0ccd6c88 --- /dev/null +++ b/crates/guest/routing-key/src/lib.rs @@ -0,0 +1,13 @@ +//! Optional guest binding for canonical routing-key derivation. + +mod bindings { + wit_bindgen::generate!({ + path: "wit", + world: "key-client", + }); +} + +/// Derive the canonical CID text used as a provider-routing key. +pub fn derive(data: &[u8]) -> String { + bindings::wetware::routing::key::derive(data) +} diff --git a/crates/guest/routing-key/wit/key.wit b/crates/guest/routing-key/wit/key.wit new file mode 100644 index 00000000..5c6b148b --- /dev/null +++ b/crates/guest/routing-key/wit/key.wit @@ -0,0 +1,12 @@ +package wetware:routing@0.1.0; + +/// Pure canonical routing-key derivation. +interface key { + /// Return CIDv1/raw/BLAKE3-256 text for the supplied bytes. + derive: func(data: list) -> string; +} + +/// Optional guest world for components that derive routing keys. +world key-client { + import key; +} diff --git a/crates/ipfs/src/lib.rs b/crates/ipfs/src/lib.rs index d6cf9011..7e108753 100644 --- a/crates/ipfs/src/lib.rs +++ b/crates/ipfs/src/lib.rs @@ -659,39 +659,6 @@ impl HttpClient { .map(str::to_owned) .ok_or_else(|| anyhow::anyhow!("IPFS resolve response missing Path field")) } - - /// Publish a CID under this node's IPNS key. - /// - /// Calls `/api/v0/name/publish` on the IPFS node. The path should be an - /// IPFS path (e.g., "/ipfs/QmHash..."). - /// - /// The `key` parameter selects which IPNS key to publish under - /// (default: "self" for the node's identity key). - pub async fn name_publish(&self, path: &str, key: &str) -> anyhow::Result { - let url = format!( - "{}/api/v0/name/publish?arg={}&key={}", - self.base_url, path, key - ); - let response = self - .http_client - .post(&url) - .send() - .await - .context("IPNS name publish request failed")?; - let status = response.status(); - let body: serde_json::Value = response - .json() - .await - .context("Failed to parse name publish response")?; - if !status.is_success() { - let msg = body["Message"].as_str().unwrap_or("unknown error"); - anyhow::bail!("IPNS name publish failed ({}): {}", status, msg); - } - body["Name"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("name publish response missing Name field")) - } } #[cfg(test)] diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 336388e5..e2d4ae64 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" anyhow = { workspace = true } async-trait = { workspace = true } base58 = { workspace = true } -blake3 = { workspace = true } bytes = { workspace = true } capnp = { workspace = true } capnp-rpc = { workspace = true } diff --git a/crates/rpc/src/graft.rs b/crates/rpc/src/graft.rs index e9aab55e..a14e6f07 100644 --- a/crates/rpc/src/graft.rs +++ b/crates/rpc/src/graft.rs @@ -248,7 +248,8 @@ impl auth_capnp::signer::Server for EpochGuardedDomainSigner { // HostGraftBuilder — GraftBuilder for the concrete stem graft response // --------------------------------------------------------------------------- -/// Fills the graft response with epoch-guarded Host, Runtime, Routing, HttpClient, and node identity. +/// Fills the graft response with epoch-guarded Host, Runtime, provider routing, +/// HttpClient, and node identity. /// /// **Runtime singleton**: the builder holds a pre-created `runtime::Client` that /// points to a single `RuntimeImpl` backend. Every graft clones this client, so @@ -341,12 +342,15 @@ impl HostGraftBuilder { } let host: system_capnp::host::Client = capnp_rpc::new_client(host_impl); - let routing: routing_capnp::routing::Client = - capnp_rpc::new_client(super::routing::RoutingImpl::new( - self.swarm_cmd_tx.clone(), - guard.clone(), - self.ipfs_client.clone(), - )); + let finder: routing_capnp::finder::Client = capnp_rpc::new_client( + super::routing::FinderImpl::new(self.swarm_cmd_tx.clone(), guard.clone()), + ); + let mut announcer_impl = + super::routing::AnnouncerImpl::new(self.swarm_cmd_tx.clone(), guard.clone()); + if let Some(scope) = self.registration_scope.clone() { + announcer_impl = announcer_impl.with_registration_scope(scope); + } + let announcer: routing_capnp::announcer::Client = capnp_rpc::new_client(announcer_impl); // Collect all capabilities into a flat list of Export entries. let mut entries = Vec::new(); @@ -364,7 +368,8 @@ impl HostGraftBuilder { "runtime", self.runtime_client.clone().client, )?); - entries.push(NamedCapability::new("routing", routing.client)?); + entries.push(NamedCapability::new("routing-finder", finder.client)?); + entries.push(NamedCapability::new("routing-announcer", announcer.client)?); let authority: auth_capnp::authority::Client = capnp_rpc::new_client(authority::AuthorityServer::new(guard.clone())); entries.push(NamedCapability::new("authority", authority.client)?); @@ -801,7 +806,8 @@ mod tests { "identity", "host", "runtime", - "routing", + "routing-finder", + "routing-announcer", "authority", "ipfs", "http-client", @@ -814,7 +820,7 @@ mod tests { } #[test] - fn external_graft_names_remain_exactly_unchanged() { + fn external_graft_names_expose_split_provider_authority() { let (_epoch_tx, epoch_rx) = tokio::sync::watch::channel(test_epoch(1)); let guard = EpochGuard { issued_seq: 1, @@ -865,7 +871,8 @@ mod tests { "identity", "host", "runtime", - "routing", + "routing-finder", + "routing-announcer", "authority", "ipfs" ] @@ -1238,7 +1245,8 @@ mod tests { "identity", "host", "runtime", - "routing", + "routing-finder", + "routing-announcer", "authority", "ipfs", "http-client", @@ -1248,7 +1256,7 @@ mod tests { "pid0 RPC bootstrap lost {expected}: {names:?}" ); } - assert_eq!(names.len(), 7); + assert_eq!(names.len(), 8); readiness_gate .kernel_ready() .expect("commit current pid0 generation"); diff --git a/crates/rpc/src/initial_authority.rs b/crates/rpc/src/initial_authority.rs index d5b76761..881741ac 100644 --- a/crates/rpc/src/initial_authority.rs +++ b/crates/rpc/src/initial_authority.rs @@ -7,7 +7,7 @@ use crate::named_capability::{decode_exports, encode_exports, NamedCapabilities} /// The complete parent-delegated authority assigned to a child at birth. /// /// The record contains only validated named capability references. It has no -/// mutation API and no ambient host, runtime, routing, identity, storage, HTTP, +/// mutation API and no ambient host, runtime, provider routing, identity, storage, HTTP, /// policy, provenance, supervision, or observability state. /// /// ```compile_fail diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index b3bbd8b7..546ee8ef 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -44,7 +44,7 @@ use capnp_rpc::RpcSystem; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; #[cfg(test)] use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::sync::{mpsc, Mutex, Notify, RwLock}; +use tokio::sync::{mpsc, watch, Mutex, Notify, RwLock}; #[cfg(test)] use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -65,19 +65,40 @@ pub enum SwarmCommand { /// Announce this Wetware node as a provider for the given DHT key /// (multihash bytes of a CID) on the Amino Kademlia DHT. KadProvide { + owner: ProviderOwnerId, key: Vec, reply: oneshot::Sender>, }, + /// Release every provider registration owned by one Announcer lease. + /// + /// The swarm stops local WAN and LAN provision only when the released + /// owner was the final owner for a key. + KadReleaseProviderOwner { owner: ProviderOwnerId }, /// Find providers for the given DHT key (multihash bytes of a CID). /// - /// Providers are sent over the unbounded channel as they are discovered. - /// The channel is closed when the query completes. + /// Providers cross a single-slot channel. The swarm applies its small + /// Kademlia-related result cap and retains only selected results until the + /// channel accepts them. A per-request token cancels host work without + /// consuming capacity in this shared command channel. The result channel + /// closes when the query completes, reaches its effective limit, or is + /// canceled. KadFindProviders { + request: ProviderQueryId, key: Vec, - reply: mpsc::UnboundedSender, + count: u32, + reply: mpsc::Sender, + cancel: watch::Receiver, }, } +/// Host-process identifier for one epoch-owned Announcer lease. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ProviderOwnerId(pub u64); + +/// Host-process identifier for one logical Finder query. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ProviderQueryId(pub u64); + fn validate_service_protocol_name(protocol: &str) -> Result<(), capnp::Error> { if protocol.is_empty() { return Err(capnp::Error::failed( diff --git a/crates/rpc/src/routing.rs b/crates/rpc/src/routing.rs index 0d98b7ee..4c146d50 100644 --- a/crates/rpc/src/routing.rs +++ b/crates/rpc/src/routing.rs @@ -1,574 +1,496 @@ -//! Epoch-guarded Routing capability backed by the in-process Kademlia client. +//! Epoch-guarded provider discovery and announcement capabilities. //! -//! Implements `routing_capnp::routing::Server` by dispatching to the swarm -//! event loop via `SwarmCommand::KadProvide` / `SwarmCommand::KadFindProviders`. -//! All methods check the epoch guard before proceeding. -//! -//! Data-plane reads flow through the WASI virtual filesystem. -//! This capability provides routing plus explicit write/publish control ops. +//! [`FinderImpl`] and [`AnnouncerImpl`] are independent Cap'n Proto servers. +//! Both dispatch to the swarm event loop, but neither exposes routing-key +//! derivation, IPNS operations, or persistent content mutation. + +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; -use blake3; +use authority::{routing_capnp, EpochGuard}; use capnp::capability::Promise; use capnp_rpc::pry; use cid::Cid; -use std::path::{Component, Path}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; -use authority::EpochGuard; +use crate::{PeerInfo, ProviderOwnerId, ProviderQueryId, SwarmCommand}; -use crate::SwarmCommand; -use authority::routing_capnp; +/// Finder calls use the same finite budget as network dial operations. +pub const FIND_PROVIDERS_TIMEOUT: Duration = Duration::from_secs(30); -/// Convert a CID string to Kademlia record key bytes (multihash). -/// -/// Provider records in the Amino DHT are keyed by the multihash of the CID. -fn cid_to_kad_key(cid_str: &str) -> Result, capnp::Error> { - let cid: Cid = cid_str +static NEXT_PROVIDER_OWNER: AtomicU64 = AtomicU64::new(1); +static NEXT_PROVIDER_QUERY: AtomicU64 = AtomicU64::new(1); + +/// Convert canonical CID text to the multihash bytes used as a Kademlia key. +fn cid_to_kad_key(cid_text: &str) -> Result, capnp::Error> { + let cid: Cid = cid_text .parse() - .map_err(|e| capnp::Error::failed(format!("invalid CID '{cid_str}': {e}")))?; + .map_err(|error| capnp::Error::failed(format!("invalid CID '{cid_text}': {error}")))?; Ok(cid.hash().to_bytes()) } -fn parse_cid_text(cid_str: &str, field: &str) -> Result { - cid_str - .parse() - .map_err(|e| capnp::Error::failed(format!("invalid {field} CID '{cid_str}': {e}"))) +/// Deterministic provider table for integration tests and local embedders. +/// +/// The factory returns distinct Finder and Announcer servers over one table; +/// it is not itself a broad capability. +#[derive(Clone, Default)] +pub struct LocalProviderRouting { + providers: Arc>>>, } -fn normalize_rel_path(path: &str) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err(capnp::Error::failed("path must not be empty".into())); +impl LocalProviderRouting { + pub fn new() -> Self { + Self::default() } - let mut parts: Vec = Vec::new(); - for c in Path::new(trimmed).components() { - match c { - Component::Normal(seg) => { - let s = seg - .to_str() - .ok_or_else(|| capnp::Error::failed("path must be valid UTF-8".into()))?; - if s.is_empty() { - continue; - } - parts.push(s.to_string()); - } - Component::CurDir => {} - Component::RootDir | Component::Prefix(_) | Component::ParentDir => { - return Err(capnp::Error::failed(format!( - "path must be relative and must not contain '..': {path}" - ))); - } + pub fn finder(&self) -> LocalFinder { + LocalFinder { + providers: Arc::clone(&self.providers), } } - if parts.is_empty() { - return Err(capnp::Error::failed("path must not be empty".into())); - } - Ok(parts.join("/")) -} - -fn maybe_normalize_ipfs_path(path: &str) -> String { - let trimmed = path.trim(); - if trimmed.is_empty() { - return String::new(); - } - if trimmed.starts_with("/ipfs/") { - return trimmed.to_string(); - } - format!("/ipfs/{}", trimmed.trim_start_matches('/')) -} - -fn enforce_publish_expected( - name: &str, - expected_current: &str, - current_resolved: &str, -) -> Result<(), capnp::Error> { - let expected = maybe_normalize_ipfs_path(expected_current); - if expected.is_empty() { - return Ok(()); - } - let current = maybe_normalize_ipfs_path(current_resolved); - if current != expected { - return Err(capnp::Error::failed(format!( - "ipns compare-and-set failed for {name}: expected {expected}, current {current}" - ))); - } - Ok(()) -} - -static WORKSPACE_SEQ: AtomicU64 = AtomicU64::new(1); - -fn next_workspace_path() -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let seq = WORKSPACE_SEQ.fetch_add(1, Ordering::Relaxed); - format!("/ww/stage5/{now}-{seq}") -} - -/// In-memory routing table for deterministic integration tests. -/// -/// No DHT, no swarm, no epoch guard — just a `HashMap>`. -/// Multiple nodes can share the same `LocalRouting` (via `Arc>`) to -/// simulate provide/findProviders without network non-determinism. -pub struct LocalRouting { - providers: - std::sync::Arc>>>, -} - -impl Default for LocalRouting { - fn default() -> Self { - Self { - providers: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + pub fn announcer(&self) -> LocalAnnouncer { + LocalAnnouncer { + providers: Arc::clone(&self.providers), } } -} -impl LocalRouting { - pub fn new() -> Self { - Self::default() - } - - /// Create a second handle to the same provider table. - pub fn clone_table(&self) -> Self { - Self { - providers: self.providers.clone(), - } + /// Pre-seed one provider entry for deterministic discovery tests. + pub fn provide_as(&self, cid: &str, peer: PeerInfo) { + self.providers + .lock() + .expect("local provider table poisoned") + .entry(cid.to_string()) + .or_default() + .push(peer); } +} - /// Pre-seed a provider entry so `findProviders` returns it. - pub fn provide_as(&self, cid: &str, peer: crate::PeerInfo) { - let mut table = self.providers.lock().unwrap(); - table.entry(cid.to_string()).or_default().push(peer); - } +pub struct LocalFinder { + providers: Arc>>>, } #[allow(refining_impl_trait)] -impl routing_capnp::routing::Server for LocalRouting { - fn provide( - self: capnp::capability::Rc, - params: routing_capnp::routing::ProvideParams, - _results: routing_capnp::routing::ProvideResults, - ) -> Promise<(), capnp::Error> { - let key_str = pry!(pry!(params.get()).get_key()) - .to_string() - .unwrap_or_default(); - let _: Cid = pry!(key_str - .parse() - .map_err(|e| capnp::Error::failed(format!("invalid CID '{key_str}': {e}")))); - - let mut table = self.providers.lock().unwrap(); - table.entry(key_str).or_default(); - Promise::ok(()) - } - +impl routing_capnp::finder::Server for LocalFinder { fn find_providers( self: capnp::capability::Rc, - params: routing_capnp::routing::FindProvidersParams, - _results: routing_capnp::routing::FindProvidersResults, + params: routing_capnp::finder::FindProvidersParams, + _results: routing_capnp::finder::FindProvidersResults, ) -> Promise<(), capnp::Error> { let reader = pry!(params.get()); - let key_str = pry!(reader.get_key()).to_string().unwrap_or_default(); + let cid = pry!(reader.get_key()).to_string().unwrap_or_default(); + let count = reader.get_count() as usize; let sink = pry!(reader.get_sink()); - let providers = { - let table = self.providers.lock().unwrap(); - table.get(&key_str).cloned().unwrap_or_default() - }; + if count == 0 { + return Promise::from_future(async move { + sink.done_request().send().promise.await?; + Ok(()) + }); + } + + let providers = self + .providers + .lock() + .expect("local provider table poisoned") + .get(&cid) + .cloned() + .unwrap_or_default(); Promise::from_future(async move { - for peer_info in &providers { - let mut req = sink.provider_request(); - let mut info = req.get().get_info()?; - info.set_peer_id(&peer_info.peer_id); - let mut addr_list = info.init_addrs(peer_info.addrs.len() as u32); - for (j, addr) in peer_info.addrs.iter().enumerate() { - addr_list.set(j as u32, addr); - } - req.send().await?; + let mut seen = std::collections::HashSet::new(); + for peer in providers + .iter() + .filter(|peer| seen.insert(peer.peer_id.clone())) + .take(count) + { + send_provider(&sink, peer).await?; } sink.done_request().send().promise.await?; Ok(()) }) } +} - fn hash( - self: capnp::capability::Rc, - params: routing_capnp::routing::HashParams, - mut results: routing_capnp::routing::HashResults, - ) -> Promise<(), capnp::Error> { - let data = pry!(pry!(params.get()).get_data()); - let digest = blake3::hash(data); - let mh = pry!( - cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()) - .map_err(|e| capnp::Error::failed(format!("multihash wrap: {e}"))) - ); - let c = Cid::new_v1(0x55, mh); - results.get().set_key(c.to_string()); - Promise::ok(()) - } - - fn resolve( - self: capnp::capability::Rc, - _params: routing_capnp::routing::ResolveParams, - _results: routing_capnp::routing::ResolveResults, - ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented( - "LocalRouting: resolve is not implemented".into(), - )) - } +pub struct LocalAnnouncer { + providers: Arc>>>, +} - fn mkdir( +#[allow(refining_impl_trait)] +impl routing_capnp::announcer::Server for LocalAnnouncer { + fn provide( self: capnp::capability::Rc, - _params: routing_capnp::routing::MkdirParams, - _results: routing_capnp::routing::MkdirResults, + params: routing_capnp::announcer::ProvideParams, + _results: routing_capnp::announcer::ProvideResults, ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented( - "LocalRouting: mkdir is not implemented".into(), - )) + let cid = pry!(pry!(params.get()).get_key()) + .to_string() + .unwrap_or_default(); + let _: Cid = pry!(cid + .parse() + .map_err(|error| capnp::Error::failed(format!("invalid CID '{cid}': {error}")))); + self.providers + .lock() + .expect("local provider table poisoned") + .entry(cid) + .or_default(); + Promise::ok(()) } +} - fn write_file( - self: capnp::capability::Rc, - _params: routing_capnp::routing::WriteFileParams, - _results: routing_capnp::routing::WriteFileResults, - ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented( - "LocalRouting: writeFile is not implemented".into(), - )) - } +/// Observational provider-discovery authority for one epoch. +pub struct FinderImpl { + swarm_cmd_tx: mpsc::Sender, + guard: EpochGuard, + query_timeout: Duration, +} - fn remove( - self: capnp::capability::Rc, - _params: routing_capnp::routing::RemoveParams, - _results: routing_capnp::routing::RemoveResults, - ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented( - "LocalRouting: remove is not implemented".into(), - )) +impl FinderImpl { + pub fn new(swarm_cmd_tx: mpsc::Sender, guard: EpochGuard) -> Self { + Self { + swarm_cmd_tx, + guard, + query_timeout: FIND_PROVIDERS_TIMEOUT, + } } - fn publish( - self: capnp::capability::Rc, - _params: routing_capnp::routing::PublishParams, - _results: routing_capnp::routing::PublishResults, - ) -> Promise<(), capnp::Error> { - Promise::err(capnp::Error::unimplemented( - "LocalRouting: publish is not implemented".into(), - )) + #[cfg(test)] + fn with_query_timeout(mut self, query_timeout: Duration) -> Self { + self.query_timeout = query_timeout; + self } } -/// Routing capability served to guests via the Membrane graft. -pub struct RoutingImpl { +/// Host-PeerID provider-announcement authority for one epoch. +pub struct AnnouncerImpl { swarm_cmd_tx: mpsc::Sender, guard: EpochGuard, - ipfs_client: ipfs::HttpClient, + owner: ProviderOwnerId, + cleanup_started: Cell, + registration_scope: Option>, + active: Rc>, } -impl RoutingImpl { - pub fn new( - swarm_cmd_tx: mpsc::Sender, - guard: EpochGuard, - ipfs_client: ipfs::HttpClient, - ) -> Self { +impl AnnouncerImpl { + pub fn new(swarm_cmd_tx: mpsc::Sender, guard: EpochGuard) -> Self { Self { swarm_cmd_tx, guard, - ipfs_client, + owner: ProviderOwnerId(NEXT_PROVIDER_OWNER.fetch_add(1, Ordering::Relaxed)), + cleanup_started: Cell::new(false), + registration_scope: None, + active: Rc::new(Cell::new(true)), } } -} -#[allow(refining_impl_trait)] -impl routing_capnp::routing::Server for RoutingImpl { - fn provide( - self: capnp::capability::Rc, - params: routing_capnp::routing::ProvideParams, - _results: routing_capnp::routing::ProvideResults, - ) -> Promise<(), capnp::Error> { - pry!(self.guard.check()); - let key_str = pry!(pry!(params.get()).get_key()) - .to_string() - .unwrap_or_default(); - let key_bytes = pry!(cid_to_kad_key(&key_str)); - let swarm_cmd_tx = self.swarm_cmd_tx.clone(); - Promise::from_future(async move { - let (reply_tx, reply_rx) = oneshot::channel(); - swarm_cmd_tx - .send(SwarmCommand::KadProvide { - key: key_bytes, - reply: reply_tx, - }) - .await - .map_err(|_| capnp::Error::failed("swarm channel closed".into()))?; - reply_rx - .await - .map_err(|_| capnp::Error::failed("swarm reply dropped".into()))? - .map_err(|e| capnp::Error::failed(format!("kad provide failed: {e}")))?; - Ok(()) - }) + pub fn with_registration_scope(mut self, registration_scope: watch::Receiver<()>) -> Self { + self.registration_scope = Some(registration_scope); + self } - fn find_providers( - self: capnp::capability::Rc, - params: routing_capnp::routing::FindProvidersParams, - _results: routing_capnp::routing::FindProvidersResults, - ) -> Promise<(), capnp::Error> { - pry!(self.guard.check()); - let reader = pry!(params.get()); - let key_str = pry!(reader.get_key()).to_string().unwrap_or_default(); - let sink = pry!(reader.get_sink()); - let key_bytes = pry!(cid_to_kad_key(&key_str)); - let swarm_cmd_tx = self.swarm_cmd_tx.clone(); - Promise::from_future(async move { - let (provider_tx, mut provider_rx) = mpsc::unbounded_channel(); - swarm_cmd_tx - .send(SwarmCommand::KadFindProviders { - key: key_bytes, - reply: provider_tx, - }) - .await - .map_err(|_| capnp::Error::failed("swarm channel closed".into()))?; + fn start_epoch_cleanup(&self) { + if self.cleanup_started.replace(true) { + return; + } - // Stream each discovered provider into the caller's sink. - while let Some(peer_info) = provider_rx.recv().await { - let mut req = sink.provider_request(); - let mut info = req.get().get_info()?; - info.set_peer_id(&peer_info.peer_id); - let mut addr_list = info.init_addrs(peer_info.addrs.len() as u32); - for (j, addr) in peer_info.addrs.iter().enumerate() { - addr_list.set(j as u32, addr); + let mut epoch_rx = self.guard.receiver.clone(); + let issued_seq = self.guard.issued_seq; + let owner = self.owner; + let swarm_cmd_tx = self.swarm_cmd_tx.clone(); + let mut registration_scope = self.registration_scope.clone(); + let active = Rc::clone(&self.active); + tokio::task::spawn_local(async move { + loop { + if epoch_rx.borrow().seq != issued_seq { + break; + } + if let Some(scope) = registration_scope.as_mut() { + tokio::select! { + changed = epoch_rx.changed() => { + if changed.is_err() { + break; + } + } + _ = scope.changed() => { + break; + } + } + } else if epoch_rx.changed().await.is_err() { + break; } - // -> stream: awaits until flow control allows the next send. - req.send().await?; } + active.set(false); + let _ = swarm_cmd_tx + .send(SwarmCommand::KadReleaseProviderOwner { owner }) + .await; + }); + } +} - // Signal completion. - sink.done_request().send().promise.await?; - Ok(()) - }) +impl Drop for AnnouncerImpl { + fn drop(&mut self) { + self.active.set(false); + if self.cleanup_started.get() { + let _ = self + .swarm_cmd_tx + .try_send(SwarmCommand::KadReleaseProviderOwner { owner: self.owner }); + } } +} - fn hash( - self: capnp::capability::Rc, - params: routing_capnp::routing::HashParams, - mut results: routing_capnp::routing::HashResults, - ) -> Promise<(), capnp::Error> { - let data = pry!(pry!(params.get()).get_data()); - let digest = blake3::hash(data); - // multihash: varint(0x1e) ++ varint(32) ++ blake3(data) - let mh = pry!( - cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()) - .map_err(|e| capnp::Error::failed(format!("multihash wrap: {e}"))) - ); - let c = Cid::new_v1(0x55, mh); // 0x55 = raw codec - results.get().set_key(c.to_string()); - Promise::ok(()) +struct FindCancellation { + sender: Option>, +} + +impl FindCancellation { + fn new(sender: watch::Sender) -> Self { + Self { + sender: Some(sender), + } } - fn resolve( - self: capnp::capability::Rc, - params: routing_capnp::routing::ResolveParams, - mut results: routing_capnp::routing::ResolveResults, - ) -> Promise<(), capnp::Error> { - pry!(self.guard.check()); - let name = pry!(pry!(params.get()).get_name()) - .to_string() - .unwrap_or_default(); - let ipfs_client = self.ipfs_client.clone(); - Promise::from_future(async move { - let path = ipfs_client - .name_resolve(&name) - .await - .map_err(|e| capnp::Error::failed(format!("IPNS resolve failed: {e}")))?; - results.get().set_path(&path); - Ok(()) - }) + fn cancel(&mut self) { + if let Some(sender) = self.sender.take() { + sender.send_replace(true); + } } - fn mkdir( - self: capnp::capability::Rc, - params: routing_capnp::routing::MkdirParams, - mut results: routing_capnp::routing::MkdirResults, - ) -> Promise<(), capnp::Error> { - pry!(self.guard.check()); - let reader = pry!(params.get()); - let base_cid = pry!(reader.get_base_cid()).to_string().unwrap_or_default(); - let rel_path = pry!(reader.get_path()).to_string().unwrap_or_default(); - let parents = reader.get_parents(); - let ipfs_client = self.ipfs_client.clone(); - Promise::from_future(async move { - let rel = normalize_rel_path(&rel_path)?; - let _ = parse_cid_text(&base_cid, "base")?; - let workspace = next_workspace_path(); - let root = format!("{workspace}/root"); - let mfs = ipfs_client.mfs(); - mfs.files_mkdir(&workspace, true) - .await - .map_err(|e| capnp::Error::failed(format!("mfs mkdir workspace failed: {e}")))?; - mfs.files_cp(&format!("/ipfs/{base_cid}"), &root) - .await - .map_err(|e| capnp::Error::failed(format!("mfs seed root failed: {e}")))?; - let target = format!("{root}/{rel}"); - let mutate_res = mfs - .files_mkdir(&target, parents) - .await - .map_err(|e| capnp::Error::failed(format!("mkdir failed at {target}: {e}"))); - let stat_res = ipfs_client.mfs().files_stat(&root, true).await; - let _ = ipfs_client.mfs().files_rm(&workspace, true).await; - mutate_res?; - let stat = - stat_res.map_err(|e| capnp::Error::failed(format!("mfs stat root failed: {e}")))?; - let root_cid = stat.hash; - let _ = parse_cid_text(&root_cid, "result")?; - results.get().set_root_cid(&root_cid); - Ok(()) - }) + fn complete(&mut self) { + let _ = self.sender.take(); + } +} + +impl Drop for FindCancellation { + fn drop(&mut self) { + self.cancel(); } +} - fn write_file( +#[allow(refining_impl_trait)] +impl routing_capnp::finder::Server for FinderImpl { + fn find_providers( self: capnp::capability::Rc, - params: routing_capnp::routing::WriteFileParams, - mut results: routing_capnp::routing::WriteFileResults, + params: routing_capnp::finder::FindProvidersParams, + _results: routing_capnp::finder::FindProvidersResults, ) -> Promise<(), capnp::Error> { pry!(self.guard.check()); let reader = pry!(params.get()); - let base_cid = pry!(reader.get_base_cid()).to_string().unwrap_or_default(); - let rel_path = pry!(reader.get_path()).to_string().unwrap_or_default(); - let data = pry!(reader.get_data()).to_vec(); - let create_parents = reader.get_create_parents(); - let ipfs_client = self.ipfs_client.clone(); + let cid = pry!(reader.get_key()).to_string().unwrap_or_default(); + let count = reader.get_count(); + let sink = pry!(reader.get_sink()); + + if count == 0 { + let mut epoch_rx = self.guard.receiver.clone(); + let issued_seq = self.guard.issued_seq; + let timeout = self.query_timeout; + return Promise::from_future(async move { + let done = sink.done_request().send().promise; + tokio::pin!(done); + let deadline = tokio::time::sleep(timeout); + tokio::pin!(deadline); + loop { + tokio::select! { + result = &mut done => return result.map(|_| ()), + changed = epoch_rx.changed() => { + if changed.is_err() || epoch_rx.borrow().seq != issued_seq { + return Err(authority::stale_epoch_error( + "Finder authority epoch no longer current", + )); + } + } + () = &mut deadline => return Ok(()), + } + } + }); + } + + let key = pry!(cid_to_kad_key(&cid)); + let request = ProviderQueryId(NEXT_PROVIDER_QUERY.fetch_add(1, Ordering::Relaxed)); + let swarm_cmd_tx = self.swarm_cmd_tx.clone(); + let mut epoch_rx = self.guard.receiver.clone(); + let issued_seq = self.guard.issued_seq; + let timeout = self.query_timeout; + Promise::from_future(async move { - let rel = normalize_rel_path(&rel_path)?; - let _ = parse_cid_text(&base_cid, "base")?; - let leaf_cid = ipfs_client - .add_bytes(&data) - .await - .map_err(|e| capnp::Error::failed(format!("ipfs add bytes failed: {e}")))?; - let workspace = next_workspace_path(); - let root = format!("{workspace}/root"); - let mfs = ipfs_client.mfs(); - mfs.files_mkdir(&workspace, true) - .await - .map_err(|e| capnp::Error::failed(format!("mfs mkdir workspace failed: {e}")))?; - mfs.files_cp(&format!("/ipfs/{base_cid}"), &root) - .await - .map_err(|e| capnp::Error::failed(format!("mfs seed root failed: {e}")))?; - let target = format!("{root}/{rel}"); - let mutate_res = async { - if let Some(parent) = Path::new(&target).parent() { - if create_parents { - let parent_str = parent.to_string_lossy().to_string(); - mfs.files_mkdir(&parent_str, true).await.map_err(|e| { - capnp::Error::failed(format!( - "mkdir parents failed at {parent_str}: {e}" - )) - })?; + // Only one result crosses from the swarm into the Cap'n Proto + // sink at a time. The swarm retains at most `count` selected + // results while this handoff is occupied. + let (provider_tx, mut provider_rx) = mpsc::channel(1); + let (cancel_tx, cancel_rx) = watch::channel(false); + let deadline = tokio::time::sleep(timeout); + tokio::pin!(deadline); + let admission_tx = swarm_cmd_tx.clone(); + let command = admission_tx.send(SwarmCommand::KadFindProviders { + request, + key, + count, + reply: provider_tx, + cancel: cancel_rx, + }); + tokio::pin!(command); + loop { + tokio::select! { + result = &mut command => { + result.map_err(|_| capnp::Error::failed("swarm channel closed".into()))?; + break; + } + changed = epoch_rx.changed() => { + if changed.is_err() || epoch_rx.borrow().seq != issued_seq { + return Err(authority::stale_epoch_error( + "Finder authority epoch no longer current", + )); + } + } + () = &mut deadline => return Ok(()), + } + } + let mut cancellation = FindCancellation::new(cancel_tx); + let mut notify_done = true; + + 'providers: loop { + tokio::select! { + provider = provider_rx.recv() => { + let Some(provider) = provider else { + cancellation.complete(); + break; + }; + let send = send_provider(&sink, &provider); + tokio::pin!(send); + loop { + tokio::select! { + result = &mut send => { + if let Err(error) = result { + cancellation.cancel(); + return Err(error); + } + break; + } + changed = epoch_rx.changed() => { + if changed.is_err() || epoch_rx.borrow().seq != issued_seq { + cancellation.cancel(); + return Err(authority::stale_epoch_error( + "Finder authority epoch no longer current", + )); + } + } + () = &mut deadline => { + cancellation.cancel(); + notify_done = false; + break 'providers; + } + } + } + } + changed = epoch_rx.changed() => { + if changed.is_err() || epoch_rx.borrow().seq != issued_seq { + cancellation.cancel(); + return Err(authority::stale_epoch_error( + "Finder authority epoch no longer current", + )); + } + } + () = &mut deadline => { + cancellation.cancel(); + notify_done = false; + break; } } - let _ = mfs.files_rm(&target, false).await; - mfs.files_cp(&format!("/ipfs/{leaf_cid}"), &target) - .await - .map_err(|e| { - capnp::Error::failed(format!("write file failed at {target}: {e}")) - }) } - .await; - let stat_res = ipfs_client.mfs().files_stat(&root, true).await; - let _ = ipfs_client.mfs().files_rm(&workspace, true).await; - mutate_res?; - let stat = - stat_res.map_err(|e| capnp::Error::failed(format!("mfs stat root failed: {e}")))?; - let root_cid = stat.hash; - let _ = parse_cid_text(&root_cid, "result")?; - results.get().set_root_cid(&root_cid); - Ok(()) - }) - } - fn remove( - self: capnp::capability::Rc, - params: routing_capnp::routing::RemoveParams, - mut results: routing_capnp::routing::RemoveResults, - ) -> Promise<(), capnp::Error> { - pry!(self.guard.check()); - let reader = pry!(params.get()); - let base_cid = pry!(reader.get_base_cid()).to_string().unwrap_or_default(); - let rel_path = pry!(reader.get_path()).to_string().unwrap_or_default(); - let recursive = reader.get_recursive(); - let ipfs_client = self.ipfs_client.clone(); - Promise::from_future(async move { - let rel = normalize_rel_path(&rel_path)?; - let _ = parse_cid_text(&base_cid, "base")?; - let workspace = next_workspace_path(); - let root = format!("{workspace}/root"); - let mfs = ipfs_client.mfs(); - mfs.files_mkdir(&workspace, true) - .await - .map_err(|e| capnp::Error::failed(format!("mfs mkdir workspace failed: {e}")))?; - mfs.files_cp(&format!("/ipfs/{base_cid}"), &root) - .await - .map_err(|e| capnp::Error::failed(format!("mfs seed root failed: {e}")))?; - let target = format!("{root}/{rel}"); - let mutate_res = mfs - .files_rm(&target, recursive) - .await - .map_err(|e| capnp::Error::failed(format!("remove failed at {target}: {e}"))); - let stat_res = ipfs_client.mfs().files_stat(&root, true).await; - let _ = ipfs_client.mfs().files_rm(&workspace, true).await; - mutate_res?; - let stat = - stat_res.map_err(|e| capnp::Error::failed(format!("mfs stat root failed: {e}")))?; - let root_cid = stat.hash; - let _ = parse_cid_text(&root_cid, "result")?; - results.get().set_root_cid(&root_cid); - Ok(()) + if !notify_done { + return Ok(()); + } + + let done = sink.done_request().send().promise; + tokio::pin!(done); + loop { + tokio::select! { + result = &mut done => return result.map(|_| ()), + changed = epoch_rx.changed() => { + if changed.is_err() || epoch_rx.borrow().seq != issued_seq { + return Err(authority::stale_epoch_error( + "Finder authority epoch no longer current", + )); + } + } + () = &mut deadline => return Ok(()), + } + } }) } +} - fn publish( +#[allow(refining_impl_trait)] +impl routing_capnp::announcer::Server for AnnouncerImpl { + fn provide( self: capnp::capability::Rc, - params: routing_capnp::routing::PublishParams, - mut results: routing_capnp::routing::PublishResults, + params: routing_capnp::announcer::ProvideParams, + _results: routing_capnp::announcer::ProvideResults, ) -> Promise<(), capnp::Error> { pry!(self.guard.check()); - let reader = pry!(params.get()); - let name = pry!(reader.get_name()).to_string().unwrap_or_default(); - let cid = pry!(reader.get_cid()).to_string().unwrap_or_default(); - let expected_current = pry!(reader.get_expected_current()) + let cid = pry!(pry!(params.get()).get_key()) .to_string() .unwrap_or_default(); - let ipfs_client = self.ipfs_client.clone(); + let key = pry!(cid_to_kad_key(&cid)); + self.start_epoch_cleanup(); + + let owner = self.owner; + let swarm_cmd_tx = self.swarm_cmd_tx.clone(); + let guard = self.guard.clone(); + let active = Rc::clone(&self.active); Promise::from_future(async move { - let parsed = parse_cid_text(&cid, "publish")?; - let target = format!("/ipfs/{parsed}"); - if !expected_current.trim().is_empty() { - let current = ipfs_client - .name_resolve(&name) - .await - .map_err(|e| capnp::Error::failed(format!("ipns resolve failed: {e}")))?; - enforce_publish_expected(&name, &expected_current, ¤t)?; + guard.check()?; + if !active.get() { + return Err(capnp::Error::failed( + "Announcer owner scope already ended".into(), + )); } - ipfs_client - .name_publish(&target, &name) + let (reply_tx, reply_rx) = oneshot::channel(); + swarm_cmd_tx + .send(SwarmCommand::KadProvide { + owner, + key, + reply: reply_tx, + }) .await - .map_err(|e| capnp::Error::failed(format!("ipns publish failed: {e}")))?; - results.get().set_published_path(&target); + .map_err(|_| capnp::Error::failed("swarm channel closed".into()))?; + reply_rx + .await + .map_err(|_| capnp::Error::failed("swarm reply dropped".into()))? + .map_err(|error| capnp::Error::failed(format!("kad provide failed: {error}")))?; + guard.check()?; + if !active.get() { + return Err(capnp::Error::failed( + "Announcer owner scope ended during provide".into(), + )); + } Ok(()) }) } } +async fn send_provider( + sink: &routing_capnp::provider_sink::Client, + peer: &PeerInfo, +) -> Result<(), capnp::Error> { + let mut request = sink.provider_request(); + let mut info = request.get().get_info()?; + info.set_peer_id(&peer.peer_id); + let mut addrs = info.init_addrs(peer.addrs.len() as u32); + for (index, addr) in peer.addrs.iter().enumerate() { + addrs.set(index as u32, addr); + } + request.send().promise.await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::PeerInfo; - use crate::SwarmCommand; use authority::Epoch; use capnp_rpc::rpc_twoparty_capnp::Side; use capnp_rpc::twoparty::VatNetwork; @@ -580,48 +502,31 @@ mod tests { fn epoch(seq: u64) -> Epoch { Epoch { seq, - head: vec![], + head: Vec::new(), root: None, } } - /// Bootstrap a Routing client/server pair over in-memory duplex. - /// - /// Uses a fake swarm channel (receiver dropped); the epoch guard fires - /// before the channel is ever used in the stale-epoch tests. - fn setup_routing(guard: EpochGuard) -> routing_capnp::routing::Client { - let (_rx, client) = setup_routing_with_swarm(guard); - client + fn test_cid() -> &'static str { + "bafkr4ifcoue3f52zpzpz2xei7dqhs3gajm326llyljbwisxkwea7hbowyy" } - /// Bootstrap a Routing client/server pair, returning the swarm command - /// receiver so the caller can mock swarm responses. - fn setup_routing_with_swarm( - guard: EpochGuard, - ) -> (mpsc::Receiver, routing_capnp::routing::Client) { + fn bootstrap_client(server: capnp::capability::Client) -> C + where + C: capnp::capability::FromClientHook, + { let (client_stream, server_stream) = io::duplex(64 * 1024); let (client_read, client_write) = io::split(client_stream); let (server_read, server_write) = io::split(server_stream); - - let (swarm_tx, swarm_rx) = mpsc::channel(16); - let routing_impl = RoutingImpl::new( - swarm_tx, - guard, - ipfs::HttpClient::new("http://localhost:5001".into()), - ); - let routing_server: routing_capnp::routing::Client = capnp_rpc::new_client(routing_impl); - let server_network = VatNetwork::new( server_read.compat(), server_write.compat_write(), Side::Server, Default::default(), ); - let server_rpc = RpcSystem::new(Box::new(server_network), Some(routing_server.client)); tokio::task::spawn_local(async move { - let _ = server_rpc.await; + let _ = RpcSystem::new(Box::new(server_network), Some(server)).await; }); - let client_network = VatNetwork::new( client_read.compat(), client_write.compat_write(), @@ -629,580 +534,573 @@ mod tests { Default::default(), ); let mut client_rpc = RpcSystem::new(Box::new(client_network), None); - let client: routing_capnp::routing::Client = client_rpc.bootstrap(Side::Server); + let client = client_rpc.bootstrap(Side::Server); tokio::task::spawn_local(async move { let _ = client_rpc.await; }); - - (swarm_rx, client) + client } - // ------------------------------------------------------------------- - // Key derivation — both nodes must agree on the same Kad key - // ------------------------------------------------------------------- - - /// Build a CID the same way the `hash` RPC method does (CIDv1, raw, blake3). - fn hash_to_cid(data: &[u8]) -> String { - let digest = blake3::hash(data); - let mh = cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()).unwrap(); - Cid::new_v1(0x55, mh).to_string() + fn bootstrap_finder(server: T) -> routing_capnp::finder::Client + where + T: routing_capnp::finder::Server + 'static, + { + let server: routing_capnp::finder::Client = capnp_rpc::new_client(server); + bootstrap_client(server.client) } - #[test] - fn test_hash_is_deterministic() { - let a = hash_to_cid(b"ww.chess.v1"); - let b = hash_to_cid(b"ww.chess.v1"); - assert_eq!(a, b, "same input must produce same CID"); + fn bootstrap_announcer(server: T) -> routing_capnp::announcer::Client + where + T: routing_capnp::announcer::Server + 'static, + { + let server: routing_capnp::announcer::Client = capnp_rpc::new_client(server); + bootstrap_client(server.client) } - #[test] - fn test_cid_to_kad_key_deterministic() { - let cid = hash_to_cid(b"ww.chess.v1"); - let key_a = cid_to_kad_key(&cid).unwrap(); - let key_b = cid_to_kad_key(&cid).unwrap(); - assert_eq!(key_a, key_b, "same CID must produce same Kad key"); - assert!(!key_a.is_empty()); + struct Collector { + tx: mpsc::UnboundedSender, + reject: bool, } - #[test] - fn test_different_inputs_different_keys() { - let cid_a = hash_to_cid(b"ww.chess.v1"); - let cid_b = hash_to_cid(b"ww.chess.v2"); - let key_a = cid_to_kad_key(&cid_a).unwrap(); - let key_b = cid_to_kad_key(&cid_b).unwrap(); - assert_ne!(key_a, key_b); - } + struct BlockingCollector; - #[test] - fn test_cid_to_kad_key_rejects_invalid() { - assert!(cid_to_kad_key("not-a-cid").is_err()); - assert!(cid_to_kad_key("").is_err()); - } + impl routing_capnp::provider_sink::Server for Collector { + async fn provider( + self: capnp::capability::Rc, + params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, + ) -> Result<(), capnp::Error> { + if self.reject { + return Err(capnp::Error::failed("sink closed".into())); + } + let info = params.get()?.get_info()?; + let peer_id = info.get_peer_id()?.to_vec(); + let source = info.get_addrs()?; + let addrs = (0..source.len()) + .map(|index| source.get(index).map(|addr| addr.to_vec())) + .collect::, _>>()?; + let _ = self.tx.send(PeerInfo { peer_id, addrs }); + Ok(()) + } - #[test] - fn test_normalize_rel_path_accepts_clean_relative_path() { - let p = normalize_rel_path("apps/demo/main.wasm").unwrap(); - assert_eq!(p, "apps/demo/main.wasm"); + async fn done( + self: capnp::capability::Rc, + _params: routing_capnp::provider_sink::DoneParams, + _results: routing_capnp::provider_sink::DoneResults, + ) -> Result<(), capnp::Error> { + Ok(()) + } } - #[test] - fn test_normalize_rel_path_rejects_absolute_and_parent() { - assert!(normalize_rel_path("/etc/passwd").is_err()); - assert!(normalize_rel_path("../demo").is_err()); - assert!(normalize_rel_path("apps/../demo").is_err()); - } + impl routing_capnp::provider_sink::Server for BlockingCollector { + async fn provider( + self: capnp::capability::Rc, + _params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, + ) -> Result<(), capnp::Error> { + std::future::pending().await + } - #[test] - fn test_publish_compare_and_set_semantics() { - // Empty expected => unconditional publish. - assert!(enforce_publish_expected("ww", "", "/ipfs/bafyok").is_ok()); - // Exact match => ok. - assert!(enforce_publish_expected("ww", "/ipfs/bafyok", "/ipfs/bafyok").is_ok()); - // CID-only expected is normalized to /ipfs/. - assert!(enforce_publish_expected("ww", "bafyok", "/ipfs/bafyok").is_ok()); - // Mismatch => conflict. - assert!(enforce_publish_expected("ww", "/ipfs/bafyold", "/ipfs/bafynew").is_err()); + async fn done( + self: capnp::capability::Rc, + _params: routing_capnp::provider_sink::DoneParams, + _results: routing_capnp::provider_sink::DoneResults, + ) -> Result<(), capnp::Error> { + Ok(()) + } } - // ------------------------------------------------------------------- - // Epoch guard tests - // ------------------------------------------------------------------- + fn collector( + reject: bool, + ) -> ( + routing_capnp::provider_sink::Client, + mpsc::UnboundedReceiver, + ) { + let (tx, rx) = mpsc::unbounded_channel(); + (capnp_rpc::new_client(Collector { tx, reject }), rx) + } #[tokio::test] - async fn test_provide_rejects_stale_epoch() { - let local = tokio::task::LocalSet::new(); - local + async fn legacy_routing_interface_id_is_rejected_by_new_capabilities() { + const LEGACY_ROUTING_INTERFACE_ID: u64 = 0xa7c3_e8f1_d4b2_9065; + + tokio::task::LocalSet::new() .run_until(async { - let (tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, - }; - let client = setup_routing(guard); - - // Advance epoch → stale. - tx.send(epoch(2)).unwrap(); - - let mut req = client.provide_request(); - req.get().set_key("QmTest"); - match req.send().promise.await { - Err(e) => assert!( - e.to_string().contains("staleEpoch"), - "expected staleEpoch, got: {e}" - ), - Ok(_) => panic!("expected staleEpoch error"), + let routing = LocalProviderRouting::new(); + let finder = bootstrap_finder(routing.finder()); + let announcer = bootstrap_announcer(routing.announcer()); + + for client in [finder.client, announcer.client] { + let request = client + .new_call::( + LEGACY_ROUTING_INTERFACE_ID, + 0, + None, + ); + let error = match request.send().promise.await { + Ok(_) => panic!("legacy Routing interface ID resolved"), + Err(error) => error, + }; + assert_eq!(error.kind, capnp::ErrorKind::Unimplemented); } }) .await; } #[tokio::test] - async fn test_find_providers_rejects_stale_epoch() { - let local = tokio::task::LocalSet::new(); - local + async fn finder_count_zero_launches_no_query() { + tokio::task::LocalSet::new() .run_until(async { - let (tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, - }; - let client = setup_routing(guard); - - // Advance epoch → stale. - tx.send(epoch(2)).unwrap(); - - // findProviders needs a sink; we don't care about it since - // the epoch check fires before the sink is read. - let req = client.find_providers_request(); - match req.send().promise.await { - Err(e) => assert!( - e.to_string().contains("staleEpoch"), - "expected staleEpoch, got: {e}" - ), - Ok(_) => panic!("expected staleEpoch error"), - } + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(4); + let client = bootstrap_finder(FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + )); + let (sink, mut providers) = collector(false); + let mut request = client.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(0); + request.get().set_sink(sink); + request.send().promise.await.expect("zero-count find"); + assert!(swarm_rx.try_recv().is_err()); + assert!(providers.try_recv().is_err()); }) .await; } - // ------------------------------------------------------------------- - // RPC round-trip tests — happy path through Cap'n Proto serialization - // ------------------------------------------------------------------- - - /// RPC round-trip for `hash`: data → CIDv1 (raw, blake3). - /// - /// Exercises Cap'n Proto serialization of the Data param and Text result. #[tokio::test] - async fn test_hash_rpc_round_trip() { - let local = tokio::task::LocalSet::new(); - local + async fn finder_uses_single_slot_buffer_and_cancels_closed_sink() { + tokio::task::LocalSet::new() .run_until(async { - let (_tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let client = bootstrap_finder(FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + )); + let (sink, _providers) = collector(true); + let mut request = client.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(5); + request.get().set_sink(sink); + let call = request.send().promise; + + let (reply, cancel) = match swarm_rx.recv().await.expect("find command") { + SwarmCommand::KadFindProviders { + count, + reply, + cancel, + .. + } => { + assert_eq!(count, 5); + assert_eq!(reply.capacity(), 1); + (reply, cancel) + } + _ => panic!("expected find command"), }; - let client = setup_routing(guard); - - let data = b"ww.chess.v1"; - let mut req = client.hash_request(); - req.get().set_data(data); - let response = req.send().promise.await.expect("hash RPC"); - let key = response - .get() - .expect("get results") - .get_key() - .expect("get key") - .to_str() - .expect("key utf8"); - - // Must match local computation. - let expected = hash_to_cid(data); - assert_eq!(key, expected, "RPC hash must match local hash_to_cid"); - - // Must be a valid CIDv1. - let cid: Cid = key.parse().expect("result should be a valid CID"); - assert_eq!(cid.version(), cid::Version::V1); - assert_eq!(cid.codec(), 0x55, "codec should be raw"); + reply + .send(PeerInfo { + peer_id: b"peer".to_vec(), + addrs: Vec::new(), + }) + .await + .expect("send provider"); + assert!(call.await.is_err()); + assert!(*cancel.borrow()); + assert!(swarm_rx.try_recv().is_err()); }) .await; } - /// RPC round-trip for `provide`: server dispatches SwarmCommand::KadProvide, - /// mock swarm replies Ok. #[tokio::test] - async fn test_provide_rpc_round_trip() { - let local = tokio::task::LocalSet::new(); - local + async fn finder_deadline_cancels_finite_query() { + tokio::task::LocalSet::new() .run_until(async { - let (_tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let server = FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + ) + .with_query_timeout(Duration::from_millis(10)); + let client = bootstrap_finder(server); + let (sink, _providers) = collector(false); + let mut request = client.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(1); + request.get().set_sink(sink); + let call = request.send().promise; + let (held_reply, cancel) = match swarm_rx.recv().await.expect("find command") { + SwarmCommand::KadFindProviders { reply, cancel, .. } => (reply, cancel), + _ => panic!("expected find command"), }; - let (mut swarm_rx, client) = setup_routing_with_swarm(guard); - - let cid = hash_to_cid(b"ww.chess.v1"); - let expected_key = cid_to_kad_key(&cid).unwrap(); - - // Mock swarm: accept the provide command and reply Ok. - let cid_clone = cid.clone(); - tokio::task::spawn_local(async move { - match swarm_rx.recv().await { - Some(SwarmCommand::KadProvide { key, reply }) => { - let expected = cid_to_kad_key(&cid_clone).unwrap(); - assert_eq!(key, expected, "swarm should receive correct key"); - reply.send(Ok(())).ok(); - } - _ => panic!("expected KadProvide command"), - } - }); - - let mut req = client.provide_request(); - req.get().set_key(&cid); - req.send().promise.await.expect("provide should succeed"); - - // Verify the key bytes match what we expect. - assert!(!expected_key.is_empty()); + call.await.expect("deadline is normal completion"); + assert!(*cancel.borrow()); + assert!(swarm_rx.try_recv().is_err()); + drop(held_reply); }) .await; } - /// `provide` with an invalid CID should fail at the RPC level. #[tokio::test] - async fn test_provide_rejects_invalid_cid() { - let local = tokio::task::LocalSet::new(); - local + async fn finder_deadline_includes_swarm_command_admission() { + tokio::task::LocalSet::new() .run_until(async { - let (_tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, - }; - let (_swarm_rx, client) = setup_routing_with_swarm(guard); - - let mut req = client.provide_request(); - req.get().set_key("not-a-valid-cid"); - match req.send().promise.await { - Err(e) => assert!( - e.to_string().contains("invalid CID"), - "expected 'invalid CID' error, got: {e}" - ), - Ok(_) => panic!("expected error for invalid CID"), - } + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(1); + swarm_tx + .send(SwarmCommand::KadReleaseProviderOwner { + owner: ProviderOwnerId(0), + }) + .await + .expect("occupy swarm command channel"); + let finder = bootstrap_finder( + FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + ) + .with_query_timeout(Duration::from_millis(10)), + ); + let (sink, _providers) = collector(false); + let mut request = finder.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(1); + request.get().set_sink(sink); + + tokio::time::timeout(Duration::from_secs(1), request.send().promise) + .await + .expect("Finder call exceeded its admission deadline") + .expect("admission deadline is normal completion"); + assert!(matches!( + swarm_rx.recv().await, + Some(SwarmCommand::KadReleaseProviderOwner { + owner: ProviderOwnerId(0) + }) + )); + assert!(swarm_rx.try_recv().is_err()); }) .await; } - // -- ProviderSink implementation for testing -------------------------- - - /// Collects providers streamed via the ProviderSink protocol. - struct CollectorSink { - tx: mpsc::UnboundedSender, - } - - impl routing_capnp::provider_sink::Server for CollectorSink { - // `-> stream` method: takes only Params, returns Future (no Results). - async fn provider( - self: capnp::capability::Rc, - params: routing_capnp::provider_sink::ProviderParams, - ) -> Result<(), capnp::Error> { - let reader = params.get()?; - let info = reader.get_info()?; - let peer_id = info.get_peer_id()?.to_vec(); - let addrs_reader = info.get_addrs()?; - let addrs: Vec> = (0..addrs_reader.len()) - .map(|i| addrs_reader.get(i).expect("get addr").to_vec()) - .collect(); - let _ = self.tx.send(PeerInfo { peer_id, addrs }); - Ok(()) - } - - async fn done( - self: capnp::capability::Rc, - _params: routing_capnp::provider_sink::DoneParams, - _results: routing_capnp::provider_sink::DoneResults, - ) -> Result<(), capnp::Error> { - Ok(()) - } - } - - /// RPC round-trip for `find_providers`: mock swarm sends providers, - /// CollectorSink receives them through Cap'n Proto streaming. #[tokio::test] - async fn test_find_providers_rpc_round_trip() { - let local = tokio::task::LocalSet::new(); - local + async fn finder_cancellation_bypasses_a_full_command_channel() { + tokio::task::LocalSet::new() .run_until(async { - let (_tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(1); + let flood_tx = swarm_tx.clone(); + let finder = bootstrap_finder( + FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + ) + .with_query_timeout(Duration::from_millis(10)), + ); + let (sink, _providers) = collector(false); + let mut request = finder.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(1); + request.get().set_sink(sink); + let call = request.send().promise; + + let (held_reply, cancel) = match swarm_rx.recv().await.expect("find command") { + SwarmCommand::KadFindProviders { reply, cancel, .. } => (reply, cancel), + _ => panic!("expected find command"), }; - let (mut swarm_rx, client) = setup_routing_with_swarm(guard); + flood_tx + .send(SwarmCommand::KadReleaseProviderOwner { + owner: ProviderOwnerId(0), + }) + .await + .expect("fill command channel"); - let cid = hash_to_cid(b"ww.chess.v1"); + tokio::time::timeout(Duration::from_secs(1), call) + .await + .expect("Finder call exceeded its deadline") + .expect("deadline is normal completion"); + assert!(*cancel.borrow()); + assert!(matches!( + swarm_rx.recv().await, + Some(SwarmCommand::KadReleaseProviderOwner { + owner: ProviderOwnerId(0) + }) + )); + assert!(swarm_rx.try_recv().is_err()); + drop(held_reply); + }) + .await; + } - // Fake providers to stream. - let fake_providers = vec![ - PeerInfo { - peer_id: b"peer-a".to_vec(), - addrs: vec![b"/ip4/1.2.3.4/tcp/4001".to_vec()], - }, - PeerInfo { - peer_id: b"peer-b".to_vec(), - addrs: vec![ - b"/ip4/5.6.7.8/tcp/4001".to_vec(), - b"/ip4/9.10.11.12/udp/4001/quic-v1".to_vec(), - ], + #[tokio::test] + async fn finder_deadline_cancels_while_sink_is_unresponsive() { + tokio::task::LocalSet::new() + .run_until(async { + let (_epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let server = FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, }, - ]; - let providers_clone = fake_providers.clone(); - - // Mock swarm: send fake providers then drop the channel. - tokio::task::spawn_local(async move { - match swarm_rx.recv().await { - Some(SwarmCommand::KadFindProviders { key: _, reply }) => { - for p in providers_clone { - reply.send(p).ok(); - } - // Drop reply to signal end of stream. - } - _ => panic!("expected KadFindProviders command"), - } - }); - - // Collector sink to receive streamed providers. - let (collector_tx, mut collector_rx) = mpsc::unbounded_channel(); + ) + .with_query_timeout(Duration::from_millis(10)); + let client = bootstrap_finder(server); let sink: routing_capnp::provider_sink::Client = - capnp_rpc::new_client(CollectorSink { tx: collector_tx }); - - let mut req = client.find_providers_request(); - req.get().set_key(&cid); - req.get().set_count(10); - req.get().set_sink(sink); - req.send() - .promise + capnp_rpc::new_client(BlockingCollector); + let mut request = client.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(1); + request.get().set_sink(sink); + let call = request.send().promise; + let (reply, cancel) = match swarm_rx.recv().await.expect("find command") { + SwarmCommand::KadFindProviders { reply, cancel, .. } => (reply, cancel), + _ => panic!("expected find command"), + }; + reply + .send(PeerInfo { + peer_id: b"peer".to_vec(), + addrs: Vec::new(), + }) .await - .expect("findProviders should succeed"); - - // Collect all received providers. - let mut received = Vec::new(); - while let Ok(info) = collector_rx.try_recv() { - received.push(info); - } + .expect("send provider"); - assert_eq!( - received.len(), - fake_providers.len(), - "should receive all providers" - ); - assert_eq!(received[0].peer_id, b"peer-a"); - assert_eq!(received[1].peer_id, b"peer-b"); - assert_eq!( - received[1].addrs.len(), - 2, - "second provider should have 2 addrs" - ); + tokio::time::timeout(Duration::from_secs(1), call) + .await + .expect("Finder call exceeded its deadline") + .expect("deadline is normal completion"); + assert!(*cancel.borrow()); + assert!(swarm_rx.try_recv().is_err()); }) .await; } - // ------------------------------------------------------------------- - // LocalRouting tests — deterministic, no swarm - // ------------------------------------------------------------------- - - /// Bootstrap a LocalRouting client over in-memory duplex. - fn setup_local_routing(local: &LocalRouting) -> routing_capnp::routing::Client { - let (client_stream, server_stream) = io::duplex(64 * 1024); - let (client_read, client_write) = io::split(client_stream); - let (server_read, server_write) = io::split(server_stream); - - let routing_server: routing_capnp::routing::Client = - capnp_rpc::new_client(local.clone_table()); - - let server_network = VatNetwork::new( - server_read.compat(), - server_write.compat_write(), - Side::Server, - Default::default(), - ); - let server_rpc = RpcSystem::new(Box::new(server_network), Some(routing_server.client)); - tokio::task::spawn_local(async move { - let _ = server_rpc.await; - }); - - let client_network = VatNetwork::new( - client_read.compat(), - client_write.compat_write(), - Side::Client, - Default::default(), - ); - let mut client_rpc = RpcSystem::new(Box::new(client_network), None); - let client: routing_capnp::routing::Client = client_rpc.bootstrap(Side::Server); - tokio::task::spawn_local(async move { - let _ = client_rpc.await; - }); - - client - } - #[tokio::test] - async fn test_local_routing_hash_matches_real() { - let local = tokio::task::LocalSet::new(); - local + async fn stale_epoch_stops_finder_and_announcer() { + tokio::task::LocalSet::new() .run_until(async { - let routing = LocalRouting::new(); - let client = setup_local_routing(&routing); - - let data = b"ww.chess.v1"; - let mut req = client.hash_request(); - req.get().set_data(data); - let response = req.send().promise.await.expect("hash RPC"); - let key = response - .get() - .expect("get results") - .get_key() - .expect("get key") - .to_str() - .expect("key utf8"); - - assert_eq!(key, hash_to_cid(data)); + let (epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let finder = bootstrap_finder(FinderImpl::new( + swarm_tx.clone(), + EpochGuard { + issued_seq: 1, + receiver: epoch_rx.clone(), + }, + )); + let announcer = bootstrap_announcer(AnnouncerImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + )); + epoch_tx.send(epoch(2)).expect("advance epoch"); + + let finder_error = match finder.find_providers_request().send().promise.await { + Ok(_) => panic!("stale Finder call succeeded"), + Err(error) => error, + }; + assert!(finder_error.to_string().contains("staleEpoch")); + let mut provide = announcer.provide_request(); + provide.get().set_key(test_cid()); + let announcer_error = match provide.send().promise.await { + Ok(_) => panic!("stale Announcer call succeeded"), + Err(error) => error, + }; + assert!(announcer_error.to_string().contains("staleEpoch")); + assert!(swarm_rx.try_recv().is_err()); }) .await; } #[tokio::test] - async fn test_local_routing_provide_and_find() { - let local = tokio::task::LocalSet::new(); - local + async fn epoch_change_cancels_active_finder_query() { + tokio::task::LocalSet::new() .run_until(async { - let routing = LocalRouting::new(); - let cid = hash_to_cid(b"ww.chess.v1"); - - routing.provide_as( - &cid, - PeerInfo { - peer_id: b"peer-local".to_vec(), - addrs: vec![b"/ip4/127.0.0.1/tcp/9000".to_vec()], + let (epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let finder = bootstrap_finder(FinderImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, }, - ); - - let client = setup_local_routing(&routing); - - let (collector_tx, mut collector_rx) = mpsc::unbounded_channel(); - let sink: routing_capnp::provider_sink::Client = - capnp_rpc::new_client(CollectorSink { tx: collector_tx }); - - let mut req = client.find_providers_request(); - req.get().set_key(&cid); - req.get().set_count(10); - req.get().set_sink(sink); - req.send() - .promise - .await - .expect("findProviders should succeed"); + )); + let (sink, _providers) = collector(false); + let mut request = finder.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(4); + request.get().set_sink(sink); + let call = request.send().promise; + let (held_reply, cancel) = match swarm_rx.recv().await.expect("find command") { + SwarmCommand::KadFindProviders { reply, cancel, .. } => (reply, cancel), + _ => panic!("expected find command"), + }; - let mut received = Vec::new(); - while let Ok(info) = collector_rx.try_recv() { - received.push(info); - } - assert_eq!(received.len(), 1); - assert_eq!(received[0].peer_id, b"peer-local"); + epoch_tx.send(epoch(2)).expect("advance epoch"); + let error = match call.await { + Ok(_) => panic!("stale active Finder call succeeded"), + Err(error) => error, + }; + assert!(error.to_string().contains("staleEpoch")); + assert!(*cancel.borrow()); + assert!(swarm_rx.try_recv().is_err()); + drop(held_reply); }) .await; } #[tokio::test] - async fn test_local_routing_shared_table() { - let local = tokio::task::LocalSet::new(); - local + async fn announcer_releases_owner_when_epoch_ends() { + tokio::task::LocalSet::new() .run_until(async { - let routing = LocalRouting::new(); - let cid = hash_to_cid(b"ww.chess.v1"); - - routing.provide_as( - &cid, - PeerInfo { - peer_id: b"node-a".to_vec(), - addrs: vec![b"/ip4/127.0.0.1/tcp/9001".to_vec()], - }, - ); - - let client_b = setup_local_routing(&routing); - - let (collector_tx, mut collector_rx) = mpsc::unbounded_channel(); - let sink: routing_capnp::provider_sink::Client = - capnp_rpc::new_client(CollectorSink { tx: collector_tx }); - - let mut req = client_b.find_providers_request(); - req.get().set_key(&cid); - req.get().set_count(10); - req.get().set_sink(sink); - req.send() - .promise - .await - .expect("findProviders should succeed"); - - let mut received = Vec::new(); - while let Ok(info) = collector_rx.try_recv() { - received.push(info); - } - assert_eq!(received.len(), 1); - assert_eq!(received[0].peer_id, b"node-a"); + let (epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let client = bootstrap_announcer(AnnouncerImpl::new( + swarm_tx, + EpochGuard { issued_seq: 1, receiver: epoch_rx }, + )); + let mut request = client.provide_request(); + request.get().set_key(test_cid()); + let call = request.send().promise; + let (owner, reply) = match swarm_rx.recv().await.expect("provide command") { + SwarmCommand::KadProvide { owner, reply, .. } => (owner, reply), + _ => panic!("expected provide command"), + }; + reply.send(Ok(())).expect("provide reply"); + call.await.expect("provide succeeds"); + epoch_tx.send(epoch(2)).expect("advance epoch"); + assert!(matches!( + swarm_rx.recv().await, + Some(SwarmCommand::KadReleaseProviderOwner { owner: released }) if released == owner + )); }) .await; } #[tokio::test] - async fn test_local_routing_empty_find() { - let local = tokio::task::LocalSet::new(); - local + async fn announcer_releases_owner_when_epoch_source_closes() { + tokio::task::LocalSet::new() .run_until(async { - let routing = LocalRouting::new(); - let client = setup_local_routing(&routing); - let cid = hash_to_cid(b"nonexistent"); - - let (collector_tx, mut collector_rx) = mpsc::unbounded_channel(); - let sink: routing_capnp::provider_sink::Client = - capnp_rpc::new_client(CollectorSink { tx: collector_tx }); - - let mut req = client.find_providers_request(); - req.get().set_key(&cid); - req.get().set_count(10); - req.get().set_sink(sink); - req.send() - .promise - .await - .expect("findProviders (empty) should succeed"); - - assert!(collector_rx.try_recv().is_err()); + let (epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let client = bootstrap_announcer(AnnouncerImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + )); + let mut request = client.provide_request(); + request.get().set_key(test_cid()); + let call = request.send().promise; + let (owner, reply) = match swarm_rx.recv().await.expect("provide command") { + SwarmCommand::KadProvide { owner, reply, .. } => (owner, reply), + _ => panic!("expected provide command"), + }; + reply.send(Ok(())).expect("provide reply"); + call.await.expect("provide succeeds"); + + drop(epoch_tx); + assert!(matches!( + swarm_rx.recv().await, + Some(SwarmCommand::KadReleaseProviderOwner { owner: released }) if released == owner + )); }) .await; } - /// `find_providers` with zero providers: swarm drops channel immediately. #[tokio::test] - async fn test_find_providers_empty_result() { - let local = tokio::task::LocalSet::new(); - local + async fn epoch_change_during_provide_releases_owner_and_fails_call() { + tokio::task::LocalSet::new() .run_until(async { - let (_tx, rx) = watch::channel(epoch(1)); - let guard = EpochGuard { - issued_seq: 1, - receiver: rx, + let (epoch_tx, epoch_rx) = watch::channel(epoch(1)); + let (swarm_tx, mut swarm_rx) = mpsc::channel(8); + let announcer = bootstrap_announcer(AnnouncerImpl::new( + swarm_tx, + EpochGuard { + issued_seq: 1, + receiver: epoch_rx, + }, + )); + let mut request = announcer.provide_request(); + request.get().set_key(test_cid()); + let call = request.send().promise; + let (owner, reply) = match swarm_rx.recv().await.expect("provide command") { + SwarmCommand::KadProvide { owner, reply, .. } => (owner, reply), + _ => panic!("expected provide command"), }; - let (mut swarm_rx, client) = setup_routing_with_swarm(guard); - - let cid = hash_to_cid(b"nonexistent"); - // Mock swarm: drop channel immediately (no providers). - tokio::task::spawn_local(async move { - match swarm_rx.recv().await { - Some(SwarmCommand::KadFindProviders { key: _, reply }) => { - drop(reply); // no providers - } - _ => panic!("expected KadFindProviders command"), - } - }); - - let (collector_tx, mut collector_rx) = mpsc::unbounded_channel(); - let sink: routing_capnp::provider_sink::Client = - capnp_rpc::new_client(CollectorSink { tx: collector_tx }); - - let mut req = client.find_providers_request(); - req.get().set_key(&cid); - req.get().set_count(10); - req.get().set_sink(sink); - req.send() - .promise - .await - .expect("findProviders (empty) should succeed"); + epoch_tx.send(epoch(2)).expect("advance epoch"); + assert!(matches!( + swarm_rx.recv().await, + Some(SwarmCommand::KadReleaseProviderOwner { owner: released }) if released == owner + )); + reply.send(Ok(())).expect("late provide result"); + let error = match call.await { + Ok(_) => panic!("provide succeeded after its epoch ended"), + Err(error) => error, + }; + assert!(error.to_string().contains("staleEpoch")); + }) + .await; + } - assert!( - collector_rx.try_recv().is_err(), - "should receive zero providers" - ); + #[tokio::test] + async fn local_finder_deduplicates_and_obeys_count() { + tokio::task::LocalSet::new() + .run_until(async { + let routing = LocalProviderRouting::new(); + for peer in [ + b"a".as_slice(), + b"a".as_slice(), + b"b".as_slice(), + b"c".as_slice(), + ] { + routing.provide_as( + test_cid(), + PeerInfo { + peer_id: peer.to_vec(), + addrs: Vec::new(), + }, + ); + } + let finder = bootstrap_finder(routing.finder()); + let (sink, mut providers) = collector(false); + let mut request = finder.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(2); + request.get().set_sink(sink); + request.send().promise.await.expect("local find"); + assert_eq!(providers.try_recv().expect("one provider").peer_id, b"a"); + assert_eq!(providers.try_recv().expect("second provider").peer_id, b"b"); + assert!(providers.try_recv().is_err()); + + let (sink, mut providers) = collector(false); + let mut request = finder.find_providers_request(); + request.get().set_key(test_cid()); + request.get().set_count(1); + request.get().set_sink(sink); + request.send().promise.await.expect("count-one find"); + assert_eq!(providers.try_recv().expect("one provider").peer_id, b"a"); + assert!(providers.try_recv().is_err()); }) .await; } diff --git a/diagrams/architecture-map.json b/diagrams/architecture-map.json index 1c81b21c..088e4250 100644 --- a/diagrams/architecture-map.json +++ b/diagrams/architecture-map.json @@ -47,7 +47,7 @@ "subtitle": "Owns the node service threads.", "files": ["src/services.rs", "src/host.rs", "src/ipns.rs"], "what": "The `Host` supervisor starts and stops the swarm, WAGI service, compiler, admin service, executor pool, and host-owned IPNS republisher. Long-running services own dedicated threads; deployment coordination runs in the main host runtime.", - "built": "`src/services.rs` defines `Service` and `Host`. `src/host.rs` owns libp2p behavior and swarm events. `src/ipns.rs` validates, persists, signs, transports, and republishes raw IPNS records. The executor pool uses local runtimes because Wasmtime stores are not `Send`." + "built": "`src/services.rs` defines `Service` and `Host`. `src/host.rs` owns libp2p behavior, swarm events, bounded provider queries, and epoch-owned provider registrations. `src/ipns.rs` validates, persists, signs, transports, and republishes raw IPNS records. The executor pool uses local runtimes because Wasmtime stores are not `Send`." }, { "key": "epoch", @@ -141,9 +141,9 @@ "group": "THE AUTHORITY PATH", "title": "Capability membrane", "subtitle": "Attenuates a reference and preserves the restriction.", - "files": ["crates/membrane/src/lib.rs"], + "files": ["crates/membrane/src/lib.rs", "crates/rpc/src/routing.rs", "capnp/routing.capnp"], "what": "The membrane enforces an allowlist at the Cap’n Proto hook level. It also re-wraps returned, pipelined, and resolved capabilities so an allowed call cannot escape the policy.", - "built": "`Policy` checks every call through the wrapped `ClientHook`. `crates/rpc` provides the host capability servers, initial grants, routing, streams, and HTTP listeners." + "built": "`Policy` checks every call through the wrapped `ClientHook`. `crates/rpc` provides independent provider `Finder` and `Announcer` servers, initial grants, streams, and HTTP listeners. Finder and Announcer remain separately graftable and delegable." }, { "key": "cell", @@ -157,9 +157,9 @@ "group": "THE CELL AND NETWORK", "title": "Isolated WASM cell", "subtitle": "A process with no ambient node authority.", - "files": ["crates/cell/src/lib.rs"], + "files": ["crates/cell/src/lib.rs", "crates/cell/src/routing_key.rs", "crates/guest/routing-key/wit/key.wit"], "what": "A cell has local computation, spawn-selected arguments and environment, stdio, clocks, randomness, and a private writable `/tmp`. Application authority arrives only through explicit grants.", - "built": "`crates/cell` contains Wasmtime process execution, image mounting, stream plumbing, filesystem interception, fuel scheduling, and virtual filesystems. `WW_CELL_MODE` selects vat, raw, HTTP, or pid0 stdio behavior." + "built": "`crates/cell` contains Wasmtime process execution, image mounting, stream plumbing, filesystem interception, fuel scheduling, and virtual filesystems. Components can optionally import pure CIDv1/raw/BLAKE3 routing-key derivation. `WW_CELL_MODE` selects vat, raw, HTTP, or pid0 stdio behavior." }, { "key": "rpc", @@ -173,9 +173,9 @@ "group": "THE CELL AND NETWORK", "title": "Cap’n Proto RPC", "subtitle": "Carries typed capability calls.", - "files": ["crates/rpc/src/lib.rs"], + "files": ["crates/rpc/src/lib.rs", "crates/rpc/src/routing.rs"], "what": "RPC connects cells to host-provided capabilities through in-memory duplex streams. It also carries vat service calls over libp2p streams between peers.", - "built": "`crates/rpc` implements grafts, initial authority records, named capabilities, stream dialing and listening, routing, WAGI, and connection budgets." + "built": "`crates/rpc` implements grafts, initial authority records, named capabilities, independent provider discovery and announcement, stream dialing and listening, WAGI, and connection budgets." }, { "key": "swarm", @@ -188,10 +188,10 @@ "z": 36, "group": "THE CELL AND NETWORK", "title": "libp2p swarm", - "subtitle": "Provides peer identity, routing, and streams.", + "subtitle": "Provides peer identity, provider routing, and streams.", "files": ["src/host.rs"], - "what": "The swarm runs Wetware’s peer-to-peer network. It manages Kademlia routing, identity, streams, relays, reachability, peer discovery, and service transports.", - "built": "`src/host.rs` builds the libp2p behavior. The runtime distinguishes WAN and LAN DHT requests and routes commands between capability implementations and the swarm loop." + "what": "The swarm runs Wetware’s peer-to-peer network. It manages Kademlia provider routing, identity, streams, relays, reachability, peer discovery, and service transports.", + "built": "`src/host.rs` builds separate WAN and LAN Kademlia behaviors. Finder calls deduplicate and bound dual-DHT results. Announcer owner leases retain local provision until final-owner cleanup stops republication." }, { "key": "wagi", diff --git a/doc/ai-context.md b/doc/ai-context.md index c836b802..e97d902a 100644 --- a/doc/ai-context.md +++ b/doc/ai-context.md @@ -66,15 +66,19 @@ granted entries): |------------|---------| | Host | Peer identity, addresses, peer management | | Runtime | Load WASM binaries, obtain scoped Executors | -| Routing | Kademlia DHT (provide, findProviders) | +| Finder (`routing-finder`) | Bounded, deduplicated Kademlia provider discovery | +| Announcer (`routing-announcer`) | Announce the Wetware host PeerID for an owner epoch | | Identity | Host-side signing (private key never enters WASM) | | HttpClient | Outbound HTTP requests | | StreamListener / StreamDialer | P2P byte streams for raw cells | | VatListener / VatClient | Cap'n Proto RPC for capnp cells | -Grant authoring must prefer an image-bound Executor over Runtime, scoped Signer over Identity, - attenuated methods over broad Host/Routing, and a capability protocol over - bearer tokens in args/env. +Canonical CIDv1/raw/BLAKE3 routing-key derivation is the optional pure +`wetware:routing/key@0.1.0` WIT import. It is not a capability reference. + +Grant authoring must prefer an image-bound Executor over Runtime, scoped Signer +over Identity, Finder without Announcer when observation is sufficient, and a +capability protocol over bearer tokens in args/env. Quick start: ``` diff --git a/doc/api/wasm-guest.md b/doc/api/wasm-guest.md index d3526c6d..92603bf0 100644 --- a/doc/api/wasm-guest.md +++ b/doc/api/wasm-guest.md @@ -87,8 +87,9 @@ is mounted. The host preopens the merged FHS image directory at `/` with When IPFS caching is active, filesystem operations are intercepted by `fs_intercept` to resolve content from IPFS transparently. -**Constraint**: Guests cannot write to the filesystem. All writes must go -through capabilities (IPFS, ByteStream, etc.). +**Constraint**: Image and `/ipfs` content is read-only. Each process has a +private ephemeral writable `/tmp`. No current guest capability commits `/tmp` +or performs persistent UnixFS mutation. ## Custom Interfaces @@ -117,6 +118,20 @@ semantics on the RPC channel. (`Builder::with_data_streams()`). Guests spawned without data streams (e.g., byte-pump handlers) will get an error on `create-connection`. +### wetware:routing/key@0.1.0 (optional) + +This pure host import derives the canonical provider-routing CID for caller +supplied bytes. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `derive` | `(data: list) -> string` | Return canonical CIDv1/raw/BLAKE3-256 text. | + +The algorithm uses BLAKE3-256, multihash code `0x1e`, and raw codec `0x55`. +The import grants no object-capability authority. A component must declare the +`wetware:routing/key@0.1.0` import to receive its bindings. Components that do +not declare it instantiate normally. The Rust wrapper crate is `routing-key`. + ### wetware:kernel-runtime/readiness@1.0.0 (private PID0 ABI) This interface is installed only when the host instantiates the trusted PID0 @@ -174,7 +189,8 @@ child through `Executor.spawn` or a listener's registration-time grant list. | `identity` | `auth_capnp::identity` | Host-side signing. | | `host` | `system_capnp::host` | Node identity and network interfaces. | | `runtime` | `system_capnp::runtime` | Load WASM binaries and obtain Executors. | -| `routing` | `routing_capnp::routing` | DHT operations such as providing and finding providers. | +| `routing-finder` | `routing_capnp::finder` | Find a bounded number of unique DHT providers. | +| `routing-announcer` | `routing_capnp::announcer` | Announce the Wetware host PeerID as a DHT provider. | | `authority` | `auth_capnp::authority` | Construct a policy-bound `Terminal` over an explicit capability. | | `ipfs` | `system_capnp::ipfs` | Read `/ipfs`, `/ipns`, or `/ipld` content through a `ByteStream`. | | `http-client` | `http_capnp::http_client` | Make outbound HTTP requests to the configured host allowlist. | @@ -194,6 +210,10 @@ host advances its epoch. Repeated `InitialGrants.get()` calls return the same recorded references; fresh authority requires explicit ancestor re-delegation or child respawn. Non-host grants keep their own normal lifetime semantics. +`routing-finder` and `routing-announcer` are independently delegable. The +optional routing-key WIT import is not part of `Membrane.graft()` or +`InitialGrants`. + ## Cap'n Proto RPC (system.capnp) Full interface reference for the capabilities available to guests. @@ -207,6 +227,17 @@ Full interface reference for the capabilities available to guests. | `peers` | `() -> (peers: List(PeerInfo))` | Currently connected peers. | | `network` | `() -> (streamListener, streamDialer, vatListener, vatClient, httpListener)` | Get network interfaces (byte-stream + RPC + HTTP modes). | +### Provider routing (`routing.capnp`) + +| Interface | Method | Signature | Description | +|-----------|--------|-----------|-------------| +| `Finder` | `findProviders` | `(key: Text, count: UInt32, sink: ProviderSink) -> ()` | Deliver at most `count` unique WAN/LAN provider PeerIDs through a single-slot handoff. The swarm selects and retains at most `min(count, 16)` results. `count == 0` starts no query. A per-request token stops remaining work after sink failure, epoch expiry, or the 30-second deadline. The deadline also includes command admission. | +| `Announcer` | `provide` | `(key: Text) -> ()` | Announce the Wetware host PeerID on WAN and LAN. Local registration and republication stop after the final owner epoch ends. | + +The removed broad `Routing` interface is not available. Guests have no +provider-routing methods for IPNS resolution or publication, persistent +UnixFS mutation, or CID derivation. + ### Runtime | Method | Signature | Description | diff --git a/doc/architecture.md b/doc/architecture.md index 6155ff5f..42660ff7 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -43,8 +43,9 @@ HOST PROCESS ``` The PID0 graft contains the host-provided capabilities appropriate for the -node configuration, including identity, host, runtime, routing, authority, -IPFS, and optional HTTP client. Local PID0 grafting has no `AuthPolicy`. +node configuration, including identity, host, runtime, independent +`routing-finder` and `routing-announcer` references, authority, IPFS, and +optional HTTP client. Local PID0 grafting has no `AuthPolicy`. Authenticated vat publication remains separate: each inbound stream receives a fresh `Terminal` that verifies login before returning policy-selected service authority. @@ -86,7 +87,7 @@ Consequently an ordinary child cannot reacquire host authority through its bootstrap, lexical capture, runtime propagation, arbitrary-name resolution, or the known fallback paths. -## Routing and interposition +## Capability routing, provider routing, and interposition Grants are opaque Cap'n Proto references. The grants-only bootstrap forwards neither calls nor authority: it returns the selected references as-is. @@ -102,6 +103,29 @@ is an intentional interposition point; it restricts a granted reference rather than converting the child bootstrap into a forwarding membrane. Cross-node three-phase handoff remains an upstream limitation. +Provider routing uses two narrow Cap'n Proto capabilities. `routing::Finder` +observes provider records through bounded WAN and LAN queries. +`routing::Announcer` asserts that the Wetware host PeerID provides a CID. A +parent can delegate one reference without delegating the other. + +Finder deduplicates PeerIDs across both DHTs, limits delivery to the caller's +`count`, and uses a single-slot transfer to the streaming sink. The swarm +retains at most `min(count, 16)` selected results while that slot is occupied. +Sink failure, epoch expiry, and a 30-second deadline cancel the remaining +provider and peer-routing queries through a per-request cancellation token. The +deadline includes swarm-command admission. + +Each Announcer has an owner lease. Duplicate provision by one lease is +idempotent. Multiple leases can own the same CID. The final owner release +removes the local WAN and LAN provider records, which stops future +republication. Remote records already propagated through Kademlia expire +naturally. + +Canonical routing-key derivation is not an RPC capability. Components can +optionally import `wetware:routing/key@0.1.0`, which computes +CIDv1/raw/BLAKE3-256 text without a graft, membrane, or epoch guard. Components +that omit the import do not receive its bindings. + ## Epoch and PID0 lifecycle An **epoch** is a host-local authority generation. Every host process assigns @@ -216,6 +240,10 @@ a 48-hour lifetime, five-minute TTL, four-hour refresh interval, one-minute initial delay, and five-minute retry delay. A followed third-party IPNS Stem never creates a publisher. +Guest provider routing does not expose IPNS resolution, publication, or host +signing authority. Removing the legacy guest `Routing.publish` method does not +change this host-owned HTTP Routing V1 lifecycle. + ## Fixed execution substrate Every child has local computation, args and environment selected at spawn, @@ -227,10 +255,10 @@ byte-loaded `Executor` gets a private empty read-only root. Every child has a private writable `/tmp`, cleaned up with its lifecycle. Optional host CAS wiring can make known-CID content readable. It does not -provide enumeration, mutation, MFS/IPNS, pin management, publishing, routing, -or arbitrary dialing. A read may nevertheless use node network, disk, cache, -and eviction resources. Known CIDs are copyable bearer locators, not -confidential object references. +provide enumeration, mutation, MFS/IPNS, pin management, publishing, provider +discovery, provider announcement, or arbitrary dialing. A read may nevertheless +use node network, disk, cache, and eviction resources. Known CIDs are copyable +bearer locators, not confidential object references. ## Security claim and boundary diff --git a/doc/capabilities.md b/doc/capabilities.md index 3dbf9855..bdb3a93b 100644 --- a/doc/capabilities.md +++ b/doc/capabilities.md @@ -95,11 +95,17 @@ allowlist. | **authority** | Construct a policy-bound `Terminal` over one explicit capability | | **host** | Peer identity, listen addresses, connected peers, network access | | **runtime** | Load WASM binaries and obtain scoped Executors (with compilation caching) | -| **routing** | Kademlia DHT: provide and find content/services | +| **routing-finder** | Find Kademlia providers through the observational `routing::Finder` interface | +| **routing-announcer** | Announce the Wetware host PeerID through `routing::Announcer` | | **ipfs** | Read `/ipfs`, `/ipns`, or `/ipld` content through a `ByteStream` | | **http-client** | Outbound HTTP requests, gated by `--http-dial` allowlist | Application-specific entries use their parent-chosen grant-map keys. +`routing-finder` and `routing-announcer` are separate references. Delegating +one does not delegate the other. Canonical routing-key derivation is the +optional pure `wetware:routing/key@0.1.0` WIT import, not an RPC capability or +graft export. + The wire-side `StreamListener` / `StreamDialer` / `VatListener` / `VatClient` interfaces are reached via `host.network()` rather than appearing as separate initial grants. @@ -139,20 +145,19 @@ node's network, pin and materialize blocks on disk, occupy cache budget, affect cache timing, and cause eviction/unpin work. Those bounded cache effects are intentional substrate effects, not node-control authority. -### Content mutation (explicit capability API) - -Writes go through the explicit `Routing` capability, not plain filesystem -reads. +### Provider routing and content control -- `Routing.mkdir(baseCid, path, parents)` returns a new root CID. -- `Routing.writeFile(baseCid, path, data, createParents)` returns a new root CID. -- `Routing.remove(baseCid, path, recursive)` returns a new root CID. -- `Routing.publish(name, cid, expectedCurrent)` returns the published IPFS path. +`routing::Finder` authorizes bounded provider discovery. `routing::Announcer` +authorizes provider assertions under the Wetware host PeerID. Announcer +registrations remain local only while at least one owner lease is active. The +final owner release stops WAN and LAN registration and future republication; +records already distributed to other peers expire naturally. -Semantics: -- Mutations are **CID-transform operations**: input root CID + operation -> output root CID. -- No hidden mutable global root is kept in the daemon. -- IPNS publish supports compare-and-set conflict checks via `expected-current`. +No current guest capability provides persistent content mutation or IPNS +publication. The removed broad `Routing` interface formerly contained +`mkdir`, `writeFile`, `remove`, `resolve`, and `publish`. WASI `/tmp` remains +private and ephemeral. Read-only image and `/ipfs` access do not acquire the +removed CID-transform semantics. ## Local overrides @@ -210,7 +215,7 @@ Schema definitions live in `capnp/`: - **`stem.capnp`** — Epoch and provenance metadata - **`auth.capnp`** — Terminal, Signer, Identity, Authority policy constructor - **`membrane.capnp`** — trusted-root Membrane, child InitialGrants, Export -- **`routing.capnp`** — Kademlia DHT (provide, findProviders, hash) +- **`routing.capnp`** — independent Kademlia provider `Finder` and `Announcer` - **`http.capnp`** — HttpClient Build scripts generate typed Rust bindings. Exported capabilities cross diff --git a/doc/deployment.md b/doc/deployment.md index eba5a495..4c3a6143 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -147,8 +147,9 @@ not publish that name. The default publisher no longer creates or uses Kubo's `"ww"` signing key. An old `"ww"` key can remain in the Kubo keystore, but Wetware ignores it. -Kubo never needs `~/.ww/identity`. Guest `Routing.publish` remains a separate -Kubo-key-backed capability and is unchanged. +Kubo never needs `~/.ww/identity`. The legacy guest `Routing.publish` method is +removed. Provider `Finder` and `Announcer` capabilities do not resolve or +publish IPNS records and do not receive host signing authority. Kubo 0.33 requires this one-time operator configuration: diff --git a/doc/routing.md b/doc/routing.md index dfde4de8..c77e812b 100644 --- a/doc/routing.md +++ b/doc/routing.md @@ -1,83 +1,163 @@ -# Routing & Service Discovery +# Provider Routing and Service Discovery ## Overview -Wetware uses Kademlia DHT for **content routing** — announcing and discovering -service providers on the peer-to-peer network. The DHT is untrusted discovery -(like DNS); authentication happens post-connection via Terminal challenge-response. +Wetware uses Kademlia provider records to announce and discover services on the +peer-to-peer network. Provider discovery is untrusted, like DNS. Applications +authenticate a provider after connection through a protocol such as Terminal +challenge-response. -## Capabilities +Provider routing has three independent surfaces: -The `Routing` capability (explicitly delegated to an ordinary child and read -via `initial_grants.get()`) provides: +| Surface | Form | Authority | +|---------|------|-----------| +| `routing::Finder` | Cap'n Proto capability | Observe providers and consume bounded host/network resources | +| `routing::Announcer` | Cap'n Proto capability | Assert that the Wetware host PeerID provides a CID | +| `wetware:routing/key@0.1.0` | Optional WIT host import | Pure canonical CID derivation; no object-capability authority | -| Method | Parameters | Description | -|--------|------------|-------------| -| `provide` | `key` | Announce this node as a provider for a CID key | -| `findProviders` | `key`, `count`, `sink` | Stream provider records to `ProviderSink` | -| `hash` | `data` | Compute a CIDv1 raw SHA-256 key from bytes | -| `resolve` | `name` | Resolve an IPNS name to `/ipfs/` | -| `mkdir` | `baseCid`, `path`, `parents` | Create a directory in a derived UnixFS root | -| `writeFile` | `baseCid`, `path`, `data`, `createParents` | Write a file in a derived UnixFS root | -| `remove` | `baseCid`, `path`, `recursive` | Remove a path from a derived UnixFS root | -| `publish` | `name`, `cid`, `expectedCurrent` | Publish a CID to IPNS with an optional compare-and-set guard | - -All methods are epoch-guarded. They fail with `staleEpoch` when the on-chain -head advances. The Host terminates the old PID0 and starts a fresh PID0 for the -new generation. Ordinary children cannot refresh their grants. +The host graft exports `Finder` as `routing-finder` and `Announcer` as +`routing-announcer`. A parent can delegate either reference without delegating +the other. The old broad `Routing` capability and the `routing` graft no longer +exist. ## Service discovery pattern ```text -key = Routing.hash("price-oracle") +key = wetware:routing/key.derive("price-oracle") -Node A: Routing.provide(key) -Node B: Routing.findProviders(key, count, providerSink) +Provider: Announcer.provide(key) +Consumer: Finder.findProviders(key, count, providerSink) ``` -Application service names are plain strings. Call `Routing.hash` to derive the -CID key used by `provide` and `findProviders`. +Both Cap'n Proto methods accept canonical CID text. They use the CID multihash +as the Kademlia provider-record key. They do not hash application names +implicitly. + +## `routing::Finder` + +`Finder.findProviders(key, count, sink)` searches the WAN and LAN Kademlia +DHTs. `count` is the maximum number of unique provider PeerIDs delivered to +the sink. + +- `count == 0` calls `sink.done()` without starting a network query. +- WAN and LAN results are deduplicated by PeerID. +- The host stops after it delivers `min(count, 16)` unique providers. +- One provider crosses the single-slot handoff to the Cap'n Proto sink at a + time. The swarm retains selected observations while the slot is occupied. + The pending set cannot exceed `min(count, 16)`. The 16-result host cap + matches the configured Kademlia replication factor. +- A failed `sink.provider` callback cancels the WAN, LAN, and associated + peer-routing queries. If the sink closes between callbacks, the next callback + detects the closure. The deadline still bounds the query lifetime. +- Each call has a 30-second deadline that starts before swarm-command + admission. Each admitted query uses a per-request cancellation token that + does not consume command-channel capacity. Deadline expiry cancels remaining + work and ends the call with the results already delivered. +- Epoch expiry cancels remaining work and fails the call with `staleEpoch`. + +The DHT can return the same PeerID with different observations. The current +contract delivers a PeerID at most once, with the addresses available when the +host selects the provider for delivery. + +## `routing::Announcer` + +`Announcer.provide(key)` announces the Wetware host PeerID on both the WAN and +LAN Kademlia DHTs. The guest does not select another provider identity. Holding +`Finder` does not grant this authority. + +Each `Announcer` server has one host-local owner lease. Repeated provision of +the same CID through the same lease is idempotent. If multiple leases own one +CID, Wetware keeps the local registration until the final owner ends. + +Wetware releases an owner's registrations when its authority epoch ends. The +trusted PID0 generation scope also ends its associated lease. Server drop +requests the same cleanup. Final-owner release calls `stop_providing` on both +DHT behaviors. This removes the local provider record that drives future +republication. + +Provider records already stored by other peers are not revoked immediately. +Those records expire under the remote DHT's TTL policy. With the current +`libp2p-kad` defaults, local records are republished every 12 hours and remote +provider records have a 48-hour TTL. + +Provision succeeds after either WAN or LAN publication succeeds. Wetware keeps +the same owner for both local registrations, including when one initial network +query fails. If both queries fail, Wetware removes the failed ownership claim +and stops local provision when no other owner remains. + +## Canonical routing keys + +Components can opt into this WIT import: + +```wit +package wetware:routing@0.1.0; + +interface key { + derive: func(data: list) -> string; +} + +world key-client { + import key; +} +``` -## Trust model +`derive` applies this exact algorithm: -``` -DHT discovery (untrusted) Vat transport and Terminal auth -───────────────────────── ────────────────────── -Routing.findProviders(key, ...) → VatClient.dial(peer, protocol) - returns peer addresses receive a fresh Terminal - Terminal.login(signer) - receive policy-selected service cap +```text +input bytes + -> BLAKE3-256 + -> multihash code 0x1e + -> CIDv1 with raw codec 0x55 + -> canonical CID text ``` -The DHT is a **public bulletin board** — any node can announce as a provider for -any name. Discovery tells you *who claims to offer a service*. Terminal -challenge-response tells you *whether you trust them*. +The host registers the import for ordinary and PID0 component linkers. A +component receives bindings only when it declares the import. Components that +omit the import instantiate normally and remain unaware of the interface. -## Key format +The Rust guest wrapper is the `routing-key` crate. The canonical host helper is +`cell::routing_key::derive`. For example: -Provider keys are CIDv1 hashes. `Routing.hash` computes a raw-codec SHA-256 CID -from application bytes. `provide` and `findProviders` accept the resulting key; -they do not hash service names implicitly. +```text +derive("ww.chess.v1") + = bafkr4ifcoue3f52zpzpz2xei7dqhs3gajm326llyljbwisxkwea7hbowyy +``` -## Mutation semantics +Routing-key derivation is deterministic computation. It has no `Membrane`, +`EpochGuard`, named graft, or delegable capability reference. -Write operations are **CID-transform** operations: +## Trust model -1. Input: base root CID -2. Apply one mutation (`mkdir`, `write-file`, or `remove`) -3. Output: new root CID +```text +DHT discovery (untrusted) Vat transport and Terminal authentication +------------------------- ------------------------------------------ +Finder.findProviders(...) -> VatClient.dial(peer, protocol) +returns provider addresses receive a fresh Terminal + Terminal.login(signer) + receive policy-selected service authority +``` + +Any node can claim to provide a key. `Finder` reports the claim. Terminal or +another application protocol establishes whether the caller trusts the peer. + +## Removed guest operations -No hidden mutable global root is kept in the daemon. +The breaking replacement removed these methods with the broad `Routing` +interface: -For IPNS updates, `publish` supports compare-and-set semantics: -if `expected-current` is provided and does not match the currently -resolved head, the call fails instead of silently overwriting. +- `hash`: use the optional pure WIT import. +- `resolve`: no guest IPNS resolver is currently exposed. +- `publish`: no guest IPNS publisher is currently exposed. +- `mkdir`, `writeFile`, and `remove`: persistent UnixFS/MFS authoring is not a + guest capability. -## Host IPNS records and guest `Routing.publish` +The WASI filesystem remains read-only for deployment/image content and +`/ipfs`, with a private ephemeral writable `/tmp`. These semantics do not +replace the removed persistent CID-transform operations. -The default host publisher and an IPNS Stem do not use the guest `Routing` -capability. They use `src/ipns.rs` to sign or validate raw records locally and -use Kubo only for HTTP Routing V1 transport: +Host-owned IPNS publication and IPNS Stem following are separate host +lifecycles. They continue to sign or validate raw records locally and use Kubo +HTTP Routing V1 only for transport: ```text GET /routing/v1/ipns/{canonical-base36-name} @@ -87,26 +167,13 @@ PUT /routing/v1/ipns/{canonical-base36-name} Content-Type: application/vnd.ipfs.ipns-record ``` -The default signer is `~/.ww/identity`. Kubo does not receive that private key. -Follower and publisher records remain private under `~/.ww/ipns/` and outside -the guest image root. - -Guest-visible `Routing.publish` is unchanged in this phase. It still calls -Kubo `name/resolve` for its optional compare-and-set check and Kubo -`name/publish` with the supplied key name. It does not use the host-owned -publisher state or grant access to `~/.ww/identity`. Redesign of that capability -remains deferred to ARCH-13. - -## Limitations - -- **Content routing only.** No key-value store (`putValue`/`getValue`) — deferred. -- **No DHT hardening.** Namespace collision protection and CID-based verification - deferred to when TEE attestation lands. -- **Provider records expire.** Kademlia provider records have a TTL (default 24h in - libp2p). Long-running services should re-provide periodically. +The default signer is `~/.ww/identity`. Kubo and guest provider-routing +capabilities do not receive that private key. ## See also -- [`capnp/routing.capnp`](../capnp/routing.capnp) — Schema definition -- [`doc/architecture.md`](architecture.md) — Capability flow and epoch lifecycle -- [`doc/keys.md`](keys.md) — Key management and identity +- [`capnp/routing.capnp`](../capnp/routing.capnp) — `Finder`, `Announcer`, and `ProviderSink` +- [`crates/guest/routing-key/wit/key.wit`](../crates/guest/routing-key/wit/key.wit) — optional routing-key import +- [`doc/capabilities.md`](capabilities.md) — authority and delegation model +- [`doc/architecture.md`](architecture.md) — capability flow and epoch lifecycle +- [`doc/keys.md`](keys.md) — key management and identity diff --git a/doc/rpc-transport.md b/doc/rpc-transport.md index 2e8ed8c3..8d520579 100644 --- a/doc/rpc-transport.md +++ b/doc/rpc-transport.md @@ -52,7 +52,8 @@ exports are: - `identity`, when a signing key is configured; - `host`; - `runtime`; -- `routing`; +- `routing-finder`; +- `routing-announcer`; - `authority`; - `ipfs`; - `http-client`, when an outbound HTTP allowlist is configured. diff --git a/examples/chess/Cargo.toml b/examples/chess/Cargo.toml index 1e0c9dad..34204500 100644 --- a/examples/chess/Cargo.toml +++ b/examples/chess/Cargo.toml @@ -12,6 +12,7 @@ wasip2 = { workspace = true } shakmaty = "0.27" rand = { workspace = true } hex = { workspace = true } +routing-key = { workspace = true } system = { path = "../../std/system" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/examples/chess/README.md b/examples/chess/README.md index a163c8cf..d57b856f 100644 --- a/examples/chess/README.md +++ b/examples/chess/README.md @@ -7,7 +7,8 @@ Two-node chess over libp2p RPC capabilities. - Cap'n Proto vat RPC with `WW_CELL_MODE=vat` - authenticated `VatListener` publication - typed `VatClient` calls -- service-name DHT discovery +- pure service-name routing-key derivation +- independent DHT Announcer and Finder grants - distinct Reader and Player method authority over one game ## Build diff --git a/examples/chess/build.rs b/examples/chess/build.rs index 09afedf1..2d0f4849 100644 --- a/examples/chess/build.rs +++ b/examples/chess/build.rs @@ -28,7 +28,7 @@ fn main() { // ── Pass 1: shared schemas ────────────────────────────────────── // Compile the system-level .capnp files that every guest needs: - // Host, Executor, IPFS, Routing, etc. These produce Rust modules + // Host, Executor, Finder, Announcer, etc. These produce Rust modules // like `system_capnp::executor::Client`. capnpc::CompilerCommand::new() .src_prefix(&capnp_dir) diff --git a/examples/chess/src/lib.rs b/examples/chess/src/lib.rs index 5a38ed39..3144350c 100644 --- a/examples/chess/src/lib.rs +++ b/examples/chess/src/lib.rs @@ -8,7 +8,7 @@ //! `system::serve()`. //! //! **`serve`**: provides on the DHT, discovers peers via -//! `routing.find_providers()`, dials them via `VatClient` to get typed +//! `Finder.findProviders()`, dials them via `VatClient` to get typed //! ChessEngine capabilities, and plays random games logging replays. use std::cell::RefCell; @@ -107,22 +107,6 @@ fn short_id(peer_id: &[u8]) -> String { } } -async fn routing_key( - routing: &routing_capnp::routing::Client, - service: &str, -) -> Result { - let mut req = routing.hash_request(); - req.get().set_data(service.as_bytes()); - let resp = req.send().promise.await?; - let key = resp - .get()? - .get_key()? - .to_str() - .map_err(|e| capnp::Error::failed(e.to_string()))? - .to_string(); - Ok(key) -} - // --------------------------------------------------------------------------- // Logging (WASI stderr, same pattern as kernel) // --------------------------------------------------------------------------- @@ -311,6 +295,7 @@ impl routing_capnp::provider_sink::Server for RpcDialingSink { fn provider( self: Rc, params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, ) -> Promise<(), capnp::Error> { let peer_id = pry!(pry!(pry!(params.get()).get_info()).get_peer_id()).to_vec(); @@ -521,7 +506,9 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> let results = grants_resp.get()?; let caps = results.get_caps()?; let host: system_capnp::host::Client = get_initial_grant(&caps, "host")?; - let routing: routing_capnp::routing::Client = get_initial_grant(&caps, "routing")?; + let announcer: routing_capnp::announcer::Client = + get_initial_grant(&caps, "routing-announcer")?; + let finder: routing_capnp::finder::Client = get_initial_grant(&caps, "routing-finder")?; // Get network capabilities — vat_client for typed capability dialing. let network_resp = host.network_request().send().promise.await?; @@ -536,9 +523,14 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> log::info!("service: looking for opponent..."); - let service_key = routing_key(&routing, CHESS_SERVICE).await?; + let service_key = routing_key::derive(CHESS_SERVICE.as_bytes()); log::info!("service: routing key {service_key}"); + // The host owns registration and republication until this authority epoch ends. + let mut provide_req = announcer.provide_request(); + provide_req.get().set_key(&service_key); + provide_req.send().promise.await?; + // Discovery loop with exponential backoff + jitter. let seen = Rc::new(RefCell::new(HashSet::>::new())); let mut cooldown_ms: u64 = 2_000; @@ -548,18 +540,13 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> loop { let prev_seen = seen.borrow().len(); - // Re-provide (DHT records expire). - let mut provide_req = routing.provide_request(); - provide_req.get().set_key(&service_key); - provide_req.send().promise.await?; - // Search for peers; RpcDialingSink dials new ones via RPC. let sink: routing_capnp::provider_sink::Client = capnp_rpc::new_client(RpcDialingSink { vat_client: vat_client.clone(), self_id: self_id.clone(), seen: seen.clone(), }); - let mut fp_req = routing.find_providers_request(); + let mut fp_req = finder.find_providers_request(); { let mut b = fp_req.get(); b.set_key(&service_key); diff --git a/examples/discovery/Cargo.toml b/examples/discovery/Cargo.toml index db44d584..7e4c8b2d 100644 --- a/examples/discovery/Cargo.toml +++ b/examples/discovery/Cargo.toml @@ -11,6 +11,7 @@ log = { workspace = true } wasip2 = { workspace = true } hex = { workspace = true } rand = { workspace = true } +routing-key = { workspace = true } system = { path = "../../std/system" } [lib] diff --git a/examples/discovery/README.md b/examples/discovery/README.md index 8a3fac38..55622f3b 100644 --- a/examples/discovery/README.md +++ b/examples/discovery/README.md @@ -7,7 +7,8 @@ A Greeter guest for named vat RPC and service-name DHT discovery. - Cap'n Proto vat RPC with `WW_CELL_MODE=vat` - explicit ungated `VatListener.serveRaw` publication for this fixture - typed `VatClient` dialing -- `routing.provide()` and `findProviders()` +- pure service-name routing-key derivation +- independent `routing-announcer` and `routing-finder` grants - service and consumer execution modes ## Build diff --git a/examples/discovery/src/lib.rs b/examples/discovery/src/lib.rs index 9fff1fa8..d5e26202 100644 --- a/examples/discovery/src/lib.rs +++ b/examples/discovery/src/lib.rs @@ -10,7 +10,7 @@ //! `system::serve()`. //! //! **`serve`**: provides on the DHT, discovers peers via -//! `routing.find_providers()`, dials them via `VatClient`, calls `greet()`. +//! `Finder.findProviders()`, dials them via `VatClient`, calls `greet()`. use std::cell::RefCell; use std::collections::HashSet; @@ -99,22 +99,6 @@ fn short_id(peer_id: &[u8]) -> String { } } -async fn routing_key( - routing: &routing_capnp::routing::Client, - service: &str, -) -> Result { - let mut req = routing.hash_request(); - req.get().set_data(service.as_bytes()); - let resp = req.send().promise.await?; - let key = resp - .get()? - .get_key()? - .to_str() - .map_err(|e| capnp::Error::failed(e.to_string()))? - .to_string(); - Ok(key) -} - // --------------------------------------------------------------------------- // Logging (WASI stderr) // --------------------------------------------------------------------------- @@ -205,6 +189,7 @@ impl routing_capnp::provider_sink::Server for GreetingSink { fn provider( self: Rc, params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, ) -> Promise<(), capnp::Error> { let peer_id = pry!(pry!(pry!(params.get()).get_info()).get_peer_id()).to_vec(); @@ -274,7 +259,9 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> let results = grants_resp.get()?; let caps = results.get_caps()?; let host: system_capnp::host::Client = get_initial_grant(&caps, "host")?; - let routing: routing_capnp::routing::Client = get_initial_grant(&caps, "routing")?; + let announcer: routing_capnp::announcer::Client = + get_initial_grant(&caps, "routing-announcer")?; + let finder: routing_capnp::finder::Client = get_initial_grant(&caps, "routing-finder")?; let network_resp = host.network_request().send().promise.await?; let network = network_resp.get()?; @@ -286,9 +273,14 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> log::info!("service: name {GREETER_SERVICE}"); log::info!("service: looking for peers..."); - let service_key = routing_key(&routing, GREETER_SERVICE).await?; + let service_key = routing_key::derive(GREETER_SERVICE.as_bytes()); log::info!("service: routing key {service_key}"); + // The host owns registration and republication until this authority epoch ends. + let mut provide_req = announcer.provide_request(); + provide_req.get().set_key(&service_key); + provide_req.send().promise.await?; + let seen = Rc::new(RefCell::new(HashSet::>::new())); let mut cooldown_ms: u64 = 2_000; const BASE_MS: u64 = 2_000; @@ -297,18 +289,13 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> loop { let prev_seen = seen.borrow().len(); - // Re-provide (DHT records expire). - let mut provide_req = routing.provide_request(); - provide_req.get().set_key(&service_key); - provide_req.send().promise.await?; - // Search for peers; GreetingSink dials new ones via RPC. let sink: routing_capnp::provider_sink::Client = capnp_rpc::new_client(GreetingSink { vat_client: vat_client.clone(), self_id: self_id.clone(), seen: seen.clone(), }); - let mut fp_req = routing.find_providers_request(); + let mut fp_req = finder.find_providers_request(); { let mut b = fp_req.get(); b.set_key(&service_key); diff --git a/examples/oracle/Cargo.lock b/examples/oracle/Cargo.lock index ab3a7628..b22a7953 100644 --- a/examples/oracle/Cargo.lock +++ b/examples/oracle/Cargo.lock @@ -451,6 +451,7 @@ dependencies = [ "hex", "log", "rand", + "routing-key", "serde", "serde_json", "system", @@ -553,6 +554,13 @@ dependencies = [ "getrandom", ] +[[package]] +name = "routing-key" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.41.0", +] + [[package]] name = "semver" version = "1.0.27" diff --git a/examples/oracle/Cargo.toml b/examples/oracle/Cargo.toml index 9341f8c8..dace4631 100644 --- a/examples/oracle/Cargo.toml +++ b/examples/oracle/Cargo.toml @@ -13,6 +13,7 @@ log = "0.4" wasip2 = "1.0.2" hex = "0.4" rand = "0.9" +routing-key = { path = "../../crates/guest/routing-key" } serde = { version = "1", features = ["derive"] } serde_json = "1" system = { path = "../../std/system" } diff --git a/examples/oracle/README.md b/examples/oracle/README.md index 0331eeb7..b354d6e4 100644 --- a/examples/oracle/README.md +++ b/examples/oracle/README.md @@ -7,7 +7,8 @@ A gas-price guest with typed vat RPC and HTTP/WAGI execution modes. - Cap'n Proto vat RPC with `WW_CELL_MODE=vat` - WAGI with `WW_CELL_MODE=http` - outbound requests through an explicit `HttpClient` grant -- DHT provider and consumer modes +- pure service-name routing-key derivation +- Announcer-only provider mode and Finder-only consumer mode - cache and JSON response behavior ## Build diff --git a/examples/oracle/src/lib.rs b/examples/oracle/src/lib.rs index e557dfc3..6fde189f 100644 --- a/examples/oracle/src/lib.rs +++ b/examples/oracle/src/lib.rs @@ -3,7 +3,7 @@ //! Demonstrates: //! - HttpClient capability for outbound HTTP (domain-scoped) //! - Subcommand dispatch (cell / serve / consume) -//! - DHT discovery via routing.provide()/findProviders() +//! - DHT discovery via independent Announcer and Finder capabilities //! //! Three modes, selected by subcommand: //! @@ -71,7 +71,10 @@ fn get_initial_grant( ) -> Result { for i in 0..caps.len() { let entry = caps.get(i); - let n = entry.get_name()?.to_str().map_err(|e| capnp::Error::failed(e.to_string()))?; + let n = entry + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))?; if n == name { return entry.get_cap().get_as_capability::(); } @@ -90,22 +93,6 @@ fn short_id(peer_id: &[u8]) -> String { } } -async fn routing_key( - routing: &routing_capnp::routing::Client, - service: &str, -) -> Result { - let mut req = routing.hash_request(); - req.get().set_data(service.as_bytes()); - let resp = req.send().promise.await?; - let key = resp - .get()? - .get_key()? - .to_str() - .map_err(|e| capnp::Error::failed(e.to_string()))? - .to_string(); - Ok(key) -} - // --------------------------------------------------------------------------- // Logging (WASI stderr) // --------------------------------------------------------------------------- @@ -312,8 +299,7 @@ fn run_cell() { system::serve(client.client, |initial_grants: InitialGrants| async move { let grants_resp = initial_grants.get_request().send().promise.await?; let caps = grants_resp.get()?.get_caps()?; - let http: http_capnp::http_client::Client = - get_initial_grant(&caps, "http-client")?; + let http: http_capnp::http_client::Client = get_initial_grant(&caps, "http-client")?; if let Err(e) = fetch_prices(&http, &cache).await { log::warn!("cell: initial price fetch failed: {e}"); @@ -343,31 +329,26 @@ async fn run_service(initial_grants: InitialGrants) -> Result<(), capnp::Error> let results = grants_resp.get()?; let caps = results.get_caps()?; let host: system_capnp::host::Client = get_initial_grant(&caps, "host")?; - let routing: routing_capnp::routing::Client = get_initial_grant(&caps, "routing")?; + let announcer: routing_capnp::announcer::Client = + get_initial_grant(&caps, "routing-announcer")?; let id_resp = host.id_request().send().promise.await?; let self_id = id_resp.get()?.get_peer_id()?.to_vec(); log::info!("oracle: peer {}", short_id(&self_id)); log::info!("oracle: service name {ORACLE_SERVICE}"); - let service_key = routing_key(&routing, ORACLE_SERVICE).await?; + let service_key = routing_key::derive(ORACLE_SERVICE.as_bytes()); log::info!("oracle: routing key {service_key}"); // Provide service-name routing key on DHT for discovery. - let mut provide_req = routing.provide_request(); + let mut provide_req = announcer.provide_request(); provide_req.get().set_key(&service_key); provide_req.send().promise.await?; log::info!("oracle: provided on DHT"); - // Keep running: re-provide on DHT (records expire). - let mut cooldown_ms: u64 = 30_000; + // Keep the epoch alive. The host owns registration and republication. loop { - let mut provide_req = routing.provide_request(); - provide_req.get().set_key(&service_key); - let _ = provide_req.send().promise.await; - - let pause = wasip2::clocks::monotonic_clock::subscribe_duration(cooldown_ms * 1_000_000); + let pause = wasip2::clocks::monotonic_clock::subscribe_duration(60_000_000_000); pause.block(); - cooldown_ms = cooldown_ms.min(60_000); } } @@ -386,6 +367,7 @@ impl routing_capnp::provider_sink::Server for OracleSink { fn provider( self: Rc, params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, ) -> Promise<(), capnp::Error> { let peer_id = pry!(pry!(pry!(params.get()).get_info()).get_peer_id()).to_vec(); @@ -425,8 +407,7 @@ async fn query_oracle( req.get().set_protocol(ORACLE_SERVICE); let resp = req.send().promise.await?; let dialed = resp.get()?.get_cap(); - let oracle: oracle_capnp::price_oracle::Client = - dialed.get_as_capability()?; + let oracle: oracle_capnp::price_oracle::Client = dialed.get_as_capability()?; // Query available pairs. let pairs_resp = oracle.get_pairs_request().send().promise.await?; @@ -460,7 +441,7 @@ async fn run_consumer(initial_grants: InitialGrants) -> Result<(), capnp::Error> let results = grants_resp.get()?; let caps = results.get_caps()?; let host: system_capnp::host::Client = get_initial_grant(&caps, "host")?; - let routing: routing_capnp::routing::Client = get_initial_grant(&caps, "routing")?; + let finder: routing_capnp::finder::Client = get_initial_grant(&caps, "routing-finder")?; let network_resp = host.network_request().send().promise.await?; let network = network_resp.get()?; @@ -470,7 +451,7 @@ async fn run_consumer(initial_grants: InitialGrants) -> Result<(), capnp::Error> let self_id = id_resp.get()?.get_peer_id()?.to_vec(); log::info!("consumer: peer {}", short_id(&self_id)); log::info!("consumer: looking for oracle providers..."); - let service_key = routing_key(&routing, ORACLE_SERVICE).await?; + let service_key = routing_key::derive(ORACLE_SERVICE.as_bytes()); log::info!("consumer: routing key {service_key}"); let seen = Rc::new(RefCell::new(std::collections::HashSet::>::new())); @@ -486,7 +467,7 @@ async fn run_consumer(initial_grants: InitialGrants) -> Result<(), capnp::Error> self_id: self_id.clone(), seen: seen.clone(), }); - let mut fp_req = routing.find_providers_request(); + let mut fp_req = finder.find_providers_request(); { let mut b = fp_req.get(); b.set_key(&service_key); @@ -523,8 +504,7 @@ fn run_http() -> Result<(), ()> { system::run(|initial_grants: InitialGrants| async move { let grants_resp = initial_grants.get_request().send().promise.await?; let grants = grants_resp.get()?.get_caps()?; - let http: http_capnp::http_client::Client = - get_initial_grant(&grants, "http-client")?; + let http: http_capnp::http_client::Client = get_initial_grant(&grants, "http-client")?; let cache = init_cache(); if let Err(e) = fetch_prices(&http, &cache).await { @@ -575,15 +555,13 @@ fn build_json_response(cache: &PriceCache, query: &str) -> String { "confidence".into(), serde_json::Value::from(entry.confidence), ); - obj.insert( - "timestamp".into(), - serde_json::Value::from(entry.timestamp), - ); + obj.insert("timestamp".into(), serde_json::Value::from(entry.timestamp)); pairs.insert(name.clone(), serde_json::Value::Object(obj)); } let root = serde_json::json!({ "pairs": pairs }); - serde_json::to_string_pretty(&root).unwrap_or_else(|_| r#"{"error":"json serialization"}"#.into()) + serde_json::to_string_pretty(&root) + .unwrap_or_else(|_| r#"{"error":"json serialization"}"#.into()) } // --------------------------------------------------------------------------- diff --git a/src/cli/main.rs b/src/cli/main.rs index 875637ac..368a535a 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -1032,8 +1032,8 @@ edition = "2021" [workspace] # standalone — not part of the host workspace [dependencies] -capnp = "0.23.2" -capnp-rpc = "0.23.0" +capnp = "0.25.3" +capnp-rpc = "0.25.0" log = "0.4" wasip2 = "1.0.2" system = {{ path = "../../std/system" }} @@ -1042,7 +1042,10 @@ system = {{ path = "../../std/system" }} crate-type = ["cdylib"] [build-dependencies] -capnpc = "0.23.3" +capnpc = "0.25.3" + +[patch.crates-io] +capnp-rpc = {{ git = "https://github.com/wetware/capnproto-rust", branch = "ww/import-fix-0.25-consume" }} "# ); std::fs::write(target_dir.join("Cargo.toml"), cargo_toml)?; diff --git a/src/discovery.rs b/src/discovery.rs index 55f7d27d..b10f8209 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -10,12 +10,8 @@ use std::sync::LazyLock; /// /// Computed as `CIDv1(raw, BLAKE3(b"wetware"))`. Any peer providing /// this key is advertising itself as a wetware host. -pub static DISCOVERY_CID: LazyLock = LazyLock::new(|| { - let digest = blake3::hash(b"wetware"); - let mh = cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()) - .expect("blake3 digest always fits in 64-byte multihash"); - cid::Cid::new_v1(0x55, mh) -}); +pub static DISCOVERY_CID: LazyLock = + LazyLock::new(|| cell::routing_key::derive(b"wetware")); /// The discovery CID as a Kad record key (raw CID bytes). pub fn discovery_record_key() -> libp2p::kad::RecordKey { diff --git a/src/host.rs b/src/host.rs index eec4a7e9..c5fda2b4 100644 --- a/src/host.rs +++ b/src/host.rs @@ -1,19 +1,21 @@ //! Wetware host runtime: libp2p host + Wasmtime host. #![cfg(not(target_arch = "wasm32"))] -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::IpAddr; use std::num::{NonZeroU8, NonZeroUsize}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; +use futures::stream::FuturesUnordered; use futures::StreamExt; use libp2p::core::connection::ConnectedPoint; use libp2p::kad; +use libp2p::kad::store::RecordStore; use libp2p::swarm::dial_opts::DialOpts; use libp2p::swarm::SwarmEvent; use libp2p::{Multiaddr, PeerId, SwarmBuilder}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use rpc::{NatReachability, NetworkState, PeerInfo}; @@ -24,6 +26,16 @@ use rpc::{NatReachability, NetworkState, PeerInfo}; /// Maximum number of concurrent relay reservations to maintain. const MAX_RELAY_RESERVATIONS: usize = 2; +/// Each DHT query contacts this many peers. The same bound caps selected +/// provider results so an untrusted `count` cannot scale host memory or +/// address-resolution fan-out without limit. +const KAD_REPLICATION_FACTOR: usize = 16; +const MAX_FIND_PROVIDER_RESULTS: u32 = KAD_REPLICATION_FACTOR as u32; + +fn find_provider_limit(requested: u32) -> u32 { + requested.min(MAX_FIND_PROVIDER_RESULTS) +} + /// The relay v2 hop protocol advertised by peers that can serve as relays. const RELAY_HOP_PROTOCOL: &str = "/libp2p/circuit/relay/0.2.0/hop"; @@ -138,36 +150,173 @@ fn actions_for_nat_transition(transition: NatTransition) -> NatTransitionActions /// Shared state for a logical `find_providers` request dispatched to both DHTs. /// -/// Both WAN and LAN queries feed providers into the same `sender`. The `seen` -/// set deduplicates across DHTs. `remaining` tracks how many DHT queries are -/// still active; the channel closes when it reaches 0. +/// Both WAN and LAN queries feed providers into the same `sender`. `seen` +/// deduplicates discovery results. `queued_peers` also deduplicates the two +/// address-resolution queries for an addressless provider. `pending` retains +/// selected results until the single-slot sender accepts them. Its length plus +/// `delivered` cannot exceed the caller's `limit`. `remaining` tracks how many +/// provider queries are still active. struct FindRequest { - sender: mpsc::UnboundedSender, + sender: mpsc::Sender, + cancellation: watch::Receiver, seen: HashSet, + queued_peers: HashSet, + pending: VecDeque, remaining: u8, + limit: u32, + delivered: u32, +} + +#[derive(Clone)] +struct PendingProvider { + peer_id: PeerId, + info: PeerInfo, +} + +type PendingFindDelivery = (u64, mpsc::Sender, PendingProvider); +type PendingFindCancellation = (u64, watch::Receiver); + +fn pending_find_deliveries(requests: &HashMap) -> Vec { + requests + .iter() + .filter_map(|(&request_id, request)| { + request + .pending + .front() + .cloned() + .map(|provider| (request_id, request.sender.clone(), provider)) + }) + .collect() +} + +async fn send_next_find_provider( + candidates: Vec, +) -> Option<(u64, PeerId, bool)> { + if candidates.is_empty() { + return std::future::pending().await; + } + + let mut deliveries = FuturesUnordered::new(); + for (request_id, sender, provider) in candidates { + deliveries.push(async move { + let peer_id = provider.peer_id; + let sent = sender.send(provider.info).await.is_ok(); + (request_id, peer_id, sent) + }); + } + deliveries.next().await +} + +fn pending_find_cancellations( + requests: &HashMap, +) -> Vec { + requests + .iter() + .map(|(&request_id, request)| (request_id, request.cancellation.clone())) + .collect() +} + +async fn wait_for_next_find_cancellation(candidates: Vec) -> Option { + if candidates.is_empty() { + return std::future::pending().await; + } + + let mut cancellations = FuturesUnordered::new(); + for (request_id, mut cancellation) in candidates { + cancellations.push(async move { + let already_canceled = *cancellation.borrow(); + if !already_canceled { + let _ = cancellation.changed().await; + } + request_id + }); + } + cancellations.next().await +} + +impl FindRequest { + fn can_select_provider(&self) -> bool { + self.seen.len() < usize::try_from(self.limit).unwrap_or(usize::MAX) + } + + fn select_provider(&mut self, peer_id: PeerId) -> bool { + self.can_select_provider() && self.seen.insert(peer_id) + } + + fn selection_complete(&self) -> bool { + !self.can_select_provider() + } + + fn queue_resolved_provider(&mut self, peer_id: PeerId, addrs: &[Multiaddr]) { + if self.queued_peers.insert(peer_id) { + self.pending.push_back(PendingProvider { + peer_id, + info: PeerInfo { + peer_id: peer_id.to_bytes(), + addrs: addrs.iter().map(|addr| addr.to_vec()).collect(), + }, + }); + } + } } /// Shared state for a logical `provide` request dispatched to both DHTs. /// -/// WAN is the source of truth. We reply on first success. If both fail, -/// reply with the WAN error. +/// The first WAN or LAN success activates the registration and replies to all +/// owners. If both DHTs fail, the request reports the WAN error when available. struct ProvideRequest { - reply: Option>>, + key: Vec, + owners: HashSet, + replies: Vec<(rpc::ProviderOwnerId, oneshot::Sender>)>, wan_done: bool, lan_done: bool, wan_err: Option, + succeeded: bool, } impl ProvideRequest { - fn new(reply: oneshot::Sender>) -> Self { + fn new( + owner: rpc::ProviderOwnerId, + key: Vec, + reply: oneshot::Sender>, + ) -> Self { Self { - reply: Some(reply), + key, + owners: HashSet::from([owner]), + replies: vec![(owner, reply)], wan_done: false, lan_done: false, wan_err: None, + succeeded: false, + } + } + + fn add_owner( + &mut self, + owner: rpc::ProviderOwnerId, + reply: oneshot::Sender>, + ) { + self.owners.insert(owner); + if self.succeeded { + let _ = reply.send(Ok(())); + } else { + self.replies.push((owner, reply)); } } + fn release_owner(&mut self, owner: rpc::ProviderOwnerId, reason: &str) { + self.owners.remove(&owner); + let mut retained = Vec::with_capacity(self.replies.len()); + for (reply_owner, reply) in self.replies.drain(..) { + if reply_owner == owner { + let _ = reply.send(Err(reason.to_string())); + } else { + retained.push((reply_owner, reply)); + } + } + self.replies = retained; + } + /// Record a DHT result. Returns true if the request is fully resolved. fn record(&mut self, source: DhtSource, result: Result<(), String>) -> bool { match source { @@ -176,8 +325,10 @@ impl ProvideRequest { } match result { Ok(()) => { - // First success wins — reply immediately. - if let Some(reply) = self.reply.take() { + self.succeeded = true; + // First success wins. All duplicate callers observe the same + // registration result. + for (_, reply) in self.replies.drain(..) { let _ = reply.send(Ok(())); } } @@ -191,16 +342,92 @@ impl ProvideRequest { } /// Finalize: if nobody got a success, send the WAN error. - fn finalize(mut self) { - if let Some(reply) = self.reply.take() { + fn finalize(mut self) -> ProvideOutcome { + if !self.succeeded { let err = self .wan_err .unwrap_or_else(|| "both DHTs failed".to_string()); - let _ = reply.send(Err(err)); + for (_, reply) in self.replies.drain(..) { + let _ = reply.send(Err(err.clone())); + } + ProvideOutcome::Failed { + owners: self.owners.into_iter().collect(), + key: self.key, + } + } else { + ProvideOutcome::Active } } } +enum ProvideOutcome { + Active, + Failed { + owners: Vec, + key: Vec, + }, +} + +/// Reference ownership for local provider registration and republication. +#[derive(Default)] +struct ProviderOwnership { + by_key: HashMap, HashSet>, + by_owner: HashMap>>, +} + +impl ProviderOwnership { + /// Claim one key. Returns true only for the first local owner. + fn claim(&mut self, owner: rpc::ProviderOwnerId, key: Vec) -> bool { + let owners = self.by_key.entry(key.clone()).or_default(); + if !owners.insert(owner) { + return false; + } + self.by_owner.entry(owner).or_default().insert(key); + owners.len() == 1 + } + + fn contains(&self, owner: rpc::ProviderOwnerId, key: &[u8]) -> bool { + self.by_key + .get(key) + .is_some_and(|owners| owners.contains(&owner)) + } + + /// Release one claim. Returns true when the key lost its final owner. + fn release_key(&mut self, owner: rpc::ProviderOwnerId, key: &[u8]) -> bool { + let mut final_owner = false; + if let Some(owners) = self.by_key.get_mut(key) { + owners.remove(&owner); + final_owner = owners.is_empty(); + } + if final_owner { + self.by_key.remove(key); + } + if let Some(keys) = self.by_owner.get_mut(&owner) { + keys.remove(key); + if keys.is_empty() { + self.by_owner.remove(&owner); + } + } + final_owner + } + + /// Release all claims for an owner and return keys that lost their final owner. + fn release_owner(&mut self, owner: rpc::ProviderOwnerId) -> Vec> { + let keys = self.by_owner.remove(&owner).unwrap_or_default(); + let mut final_keys = Vec::new(); + for key in keys { + if let Some(owners) = self.by_key.get_mut(&key) { + owners.remove(&owner); + if owners.is_empty() { + self.by_key.remove(&key); + final_keys.push(key); + } + } + } + final_keys + } +} + // --------------------------------------------------------------------------- // Address classification // --------------------------------------------------------------------------- @@ -350,7 +577,7 @@ impl Net { let kad_store = kad::store::MemoryStore::new(peer_id); let mut kad_config = kad::Config::new(kad::PROTOCOL_NAME); kad_config.set_periodic_bootstrap_interval(Some(Duration::from_secs(bootstrap_secs))); - kad_config.set_replication_factor(NonZeroUsize::new(16).unwrap()); + kad_config.set_replication_factor(NonZeroUsize::new(KAD_REPLICATION_FACTOR).unwrap()); // NOTE: we'd like to call `kad_config.set_automatic_bootstrap_throttle` // here to rate-limit identify-triggered bootstrap fan-out (kad // auto-bootstraps whenever a new peer is inserted into the routing @@ -368,7 +595,7 @@ impl Net { let lan_proto = libp2p::StreamProtocol::new("/ipfs/lan/kad/1.0.0"); let mut kad_lan_config = kad::Config::new(lan_proto); kad_lan_config.set_periodic_bootstrap_interval(None); - kad_lan_config.set_replication_factor(NonZeroUsize::new(16).unwrap()); + kad_lan_config.set_replication_factor(NonZeroUsize::new(KAD_REPLICATION_FACTOR).unwrap()); // Same throttle limitation applies here — see WAN comment above. let mut kad_lan = kad::Behaviour::with_config(peer_id, kad_lan_store, kad_lan_config); kad_lan.set_mode(Some(kad::Mode::Server)); @@ -486,6 +713,10 @@ impl Net { let mut pending_provides: HashMap = HashMap::new(); // Compound (source, query_id) → logical request_id for provide. let mut provide_query_to_req: HashMap = HashMap::new(); + // One in-flight provide per owner/key pair. Duplicate calls join it. + let mut pending_provider_claims: HashMap<(rpc::ProviderOwnerId, Vec), u64> = + HashMap::new(); + let mut provider_ownership = ProviderOwnership::default(); // Logical request ID → FindRequest. Both DHT queries map here. let mut pending_finds: HashMap = HashMap::new(); @@ -548,7 +779,55 @@ impl Net { tokio::pin!(walk_timer); loop { + let find_delivery = send_next_find_provider(pending_find_deliveries(&pending_finds)); + let find_cancellation = + wait_for_next_find_cancellation(pending_find_cancellations(&pending_finds)); + tokio::select! { + request_id = find_cancellation => { + cancel_find_request( + request_id.expect("find cancellation future requires a request"), + &mut self.swarm, + &mut find_query_to_req, + &mut pending_finds, + &mut pending_peer_routing, + &mut routed_peers, + ); + } + delivery = find_delivery => { + let (request_id, peer_id, sent) = + delivery.expect("find delivery future only completes with a provider"); + let mut cancel = !sent; + if sent { + if let Some(request) = pending_finds.get_mut(&request_id) { + let delivered = request + .pending + .pop_front() + .expect("selected find delivery disappeared"); + debug_assert_eq!(delivered.peer_id, peer_id); + request.delivered = request.delivered.saturating_add(1); + cancel = request.delivered >= request.limit; + } + } + + if cancel { + cancel_find_request( + request_id, + &mut self.swarm, + &mut find_query_to_req, + &mut pending_finds, + &mut pending_peer_routing, + &mut routed_peers, + ); + } else if find_request_work_complete( + request_id, + &pending_finds, + &pending_peer_routing, + ) { + pending_finds.remove(&request_id); + routed_peers.remove(&request_id); + } + } event = self.swarm.select_next_some() => { match event { SwarmEvent::NewListenAddr { address, .. } => { @@ -654,17 +933,36 @@ impl Net { DhtSource::Wan, id, result, &step, &mut self.swarm, &mut pending_provides, &mut provide_query_to_req, + &mut pending_provider_claims, &mut provider_ownership, &mut pending_finds, &mut find_query_to_req, &mut peer_addr_book, &mut pending_peer_routing, &mut routed_peers, ); if step.last { - cleanup_query( + let cleanup = cleanup_query( DhtSource::Wan, id, &mut provide_query_to_req, &mut pending_provides, + &mut pending_provider_claims, &mut find_query_to_req, &mut pending_finds, &mut pending_peer_routing, &mut routed_peers, ); + if let Some(outcome) = cleanup.provide { + apply_provide_outcome( + outcome, + &mut provider_ownership, + &mut self.swarm, + ); + } + if let Some(request_id) = cleanup.completed_find { + cancel_find_request( + request_id, + &mut self.swarm, + &mut find_query_to_req, + &mut pending_finds, + &mut pending_peer_routing, + &mut routed_peers, + ); + } } } SwarmEvent::Behaviour(BehaviourEvent::Kad(ref ev)) => { @@ -678,17 +976,36 @@ impl Net { DhtSource::Lan, id, result, &step, &mut self.swarm, &mut pending_provides, &mut provide_query_to_req, + &mut pending_provider_claims, &mut provider_ownership, &mut pending_finds, &mut find_query_to_req, &mut peer_addr_book, &mut pending_peer_routing, &mut routed_peers, ); if step.last { - cleanup_query( + let cleanup = cleanup_query( DhtSource::Lan, id, &mut provide_query_to_req, &mut pending_provides, + &mut pending_provider_claims, &mut find_query_to_req, &mut pending_finds, &mut pending_peer_routing, &mut routed_peers, ); + if let Some(outcome) = cleanup.provide { + apply_provide_outcome( + outcome, + &mut provider_ownership, + &mut self.swarm, + ); + } + if let Some(request_id) = cleanup.completed_find { + cancel_find_request( + request_id, + &mut self.swarm, + &mut find_query_to_req, + &mut pending_finds, + &mut pending_peer_routing, + &mut routed_peers, + ); + } } } SwarmEvent::Behaviour(BehaviourEvent::KadLan(ref ev)) => { @@ -818,17 +1135,50 @@ impl Net { } } } - Some(SwarmCommand::KadProvide { key, reply }) => { + Some(SwarmCommand::KadProvide { owner, key, reply }) => { + if let Some(req_id) = pending_provider_claims.get(&(owner, key.clone())) { + if let Some(request) = pending_provides.get_mut(req_id) { + request.add_owner(owner, reply); + continue; + } + } + + if let Some(req_id) = pending_provides + .iter() + .find_map(|(id, request)| (request.key == key).then_some(*id)) + { + provider_ownership.claim(owner, key.clone()); + pending_provider_claims.insert((owner, key.clone()), req_id); + pending_provides + .get_mut(&req_id) + .expect("pending provider request disappeared") + .add_owner(owner, reply); + continue; + } + + if provider_ownership.contains(owner, &key) { + let _ = reply.send(Ok(())); + continue; + } + + let first_owner = provider_ownership.claim(owner, key.clone()); + if !first_owner { + // Another live owner already keeps both local + // DHT registrations eligible for republication. + let _ = reply.send(Ok(())); + continue; + } + let req_id = next_request_id; next_request_id += 1; let record_key = kad::RecordKey::new(&key); let beh = self.swarm.behaviour_mut(); - let mut req = ProvideRequest::new(reply); + let mut req = ProvideRequest::new(owner, key.clone(), reply); // WAN provide - match beh.kad.start_providing(record_key.clone()) { + match start_owned_providing(&mut beh.kad, record_key.clone()) { Ok(qid) => { provide_query_to_req.insert((DhtSource::Wan, qid), req_id); } @@ -838,8 +1188,8 @@ impl Net { } } - // LAN provide (fire-and-forget semantics, but tracked) - match beh.kad_lan.start_providing(record_key) { + // LAN registration has the same owner and lifetime. + match start_owned_providing(&mut beh.kad_lan, record_key) { Ok(qid) => { provide_query_to_req.insert((DhtSource::Lan, qid), req_id); } @@ -850,15 +1200,59 @@ impl Net { } if req.wan_done && req.lan_done { - req.finalize(); + apply_provide_outcome( + req.finalize(), + &mut provider_ownership, + &mut self.swarm, + ); } else { + pending_provider_claims.insert((owner, key), req_id); pending_provides.insert(req_id, req); } } - Some(SwarmCommand::KadFindProviders { key, reply }) => { - let req_id = next_request_id; - next_request_id += 1; + Some(SwarmCommand::KadReleaseProviderOwner { owner }) => { + let final_keys = provider_ownership.release_owner(owner); + for key in final_keys { + stop_local_providing(&mut self.swarm, &key); + } + let pending_ids: Vec = pending_provides + .iter() + .filter_map(|(id, request)| { + request.owners.contains(&owner).then_some(*id) + }) + .collect(); + for request_id in pending_ids { + let remove_request = if let Some(request) = pending_provides.get_mut(&request_id) { + pending_provider_claims.remove(&(owner, request.key.clone())); + request.release_owner( + owner, + "provider owner epoch ended during provide", + ); + request.owners.is_empty() + } else { + false + }; + if remove_request { + pending_provides.remove(&request_id); + } + } + } + Some(SwarmCommand::KadFindProviders { + request, + key, + count, + reply, + cancel, + }) => { + if count == 0 { + drop(reply); + continue; + } + if *cancel.borrow() { + drop(reply); + continue; + } let record_key = kad::RecordKey::new(&key); let beh = self.swarm.behaviour_mut(); @@ -866,20 +1260,25 @@ impl Net { // WAN query let wan_qid = beh.kad.get_providers(record_key.clone()); - find_query_to_req.insert((DhtSource::Wan, wan_qid), req_id); + find_query_to_req.insert((DhtSource::Wan, wan_qid), request.0); remaining += 1; // LAN query let lan_qid = beh.kad_lan.get_providers(record_key); - find_query_to_req.insert((DhtSource::Lan, lan_qid), req_id); + find_query_to_req.insert((DhtSource::Lan, lan_qid), request.0); remaining += 1; - pending_finds.insert(req_id, FindRequest { + pending_finds.insert(request.0, FindRequest { sender: reply, + cancellation: cancel, seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), remaining, + limit: find_provider_limit(count), + delivered: 0, }); - routed_peers.insert(req_id, HashSet::new()); + routed_peers.insert(request.0, HashSet::new()); } None => { break; @@ -908,6 +1307,125 @@ impl Net { // Extracted Kad event handler (shared by WAN and LAN) // --------------------------------------------------------------------------- +fn start_owned_providing( + behaviour: &mut kad::Behaviour, + key: kad::RecordKey, +) -> Result { + let query = behaviour + .start_providing(key.clone()) + .map_err(|error| format!("{error:?}"))?; + let stored = behaviour + .store_mut() + .provided() + .any(|record| record.key == key); + if !stored { + if let Some(mut query) = behaviour.query_mut(&query) { + query.finish(); + } + return Err("local provider store rejected the Wetware host record".into()); + } + Ok(query) +} + +fn stop_local_providing(swarm: &mut libp2p::swarm::Swarm, key: &[u8]) { + let record_key = kad::RecordKey::new(&key); + let behaviour = swarm.behaviour_mut(); + behaviour.kad.stop_providing(&record_key); + behaviour.kad_lan.stop_providing(&record_key); +} + +fn apply_provide_outcome( + outcome: ProvideOutcome, + ownership: &mut ProviderOwnership, + swarm: &mut libp2p::swarm::Swarm, +) { + if let ProvideOutcome::Failed { owners, key } = outcome { + let mut final_owner = false; + for owner in owners { + final_owner |= ownership.release_key(owner, &key); + } + if final_owner { + stop_local_providing(swarm, &key); + } + } +} + +fn finish_kad_query( + source: DhtSource, + query: kad::QueryId, + swarm: &mut libp2p::swarm::Swarm, +) { + let query = match source { + DhtSource::Wan => swarm.behaviour_mut().kad.query_mut(&query), + DhtSource::Lan => swarm.behaviour_mut().kad_lan.query_mut(&query), + }; + if let Some(mut query) = query { + query.finish(); + } +} + +fn cancel_find_request( + request_id: u64, + swarm: &mut libp2p::swarm::Swarm, + find_query_to_req: &mut HashMap, + pending_finds: &mut HashMap, + pending_peer_routing: &mut HashMap)>, + routed_peers: &mut HashMap>, +) { + let provider_queries: Vec = find_query_to_req + .iter() + .filter_map(|(query, owner)| (*owner == request_id).then_some(*query)) + .collect(); + for (source, query) in provider_queries { + find_query_to_req.remove(&(source, query)); + finish_kad_query(source, query, swarm); + } + + let route_queries: Vec = pending_peer_routing + .iter() + .filter_map(|(query, (_, owner))| (*owner == Some(request_id)).then_some(*query)) + .collect(); + for (source, query) in route_queries { + pending_peer_routing.remove(&(source, query)); + finish_kad_query(source, query, swarm); + } + + pending_finds.remove(&request_id); + routed_peers.remove(&request_id); +} + +fn finish_find_provider_queries( + request_id: u64, + swarm: &mut libp2p::swarm::Swarm, + find_query_to_req: &mut HashMap, + pending_finds: &mut HashMap, +) { + let provider_queries: Vec = find_query_to_req + .iter() + .filter_map(|(query, owner)| (*owner == request_id).then_some(*query)) + .collect(); + for (source, query) in provider_queries { + find_query_to_req.remove(&(source, query)); + finish_kad_query(source, query, swarm); + } + if let Some(request) = pending_finds.get_mut(&request_id) { + request.remaining = 0; + } +} + +fn find_request_work_complete( + request_id: u64, + pending_finds: &HashMap, + pending_peer_routing: &HashMap)>, +) -> bool { + pending_finds + .get(&request_id) + .is_some_and(|request| request.remaining == 0 && request.pending.is_empty()) + && !pending_peer_routing + .values() + .any(|(_, owner)| *owner == Some(request_id)) +} + #[allow(clippy::too_many_arguments)] fn handle_kad_event( source: DhtSource, @@ -917,6 +1435,8 @@ fn handle_kad_event( swarm: &mut libp2p::swarm::Swarm, pending_provides: &mut HashMap, provide_query_to_req: &mut HashMap, + pending_provider_claims: &mut HashMap<(rpc::ProviderOwnerId, Vec), u64>, + provider_ownership: &mut ProviderOwnership, pending_finds: &mut HashMap, find_query_to_req: &mut HashMap, peer_addr_book: &mut HashMap>, @@ -924,6 +1444,8 @@ fn handle_kad_event( routed_peers: &mut HashMap>, ) { let key = (source, id); + let mut cancel_find = None; + let mut finish_provider_find = None; let label = match source { DhtSource::Wan => "WAN", DhtSource::Lan => "LAN", @@ -947,7 +1469,10 @@ fn handle_kad_event( if let Some(req) = pending_provides.get_mut(&req_id) { if req.record(source, Ok(())) { if let Some(req) = pending_provides.remove(&req_id) { - req.finalize(); + for owner in &req.owners { + pending_provider_claims.remove(&(*owner, req.key.clone())); + } + apply_provide_outcome(req.finalize(), provider_ownership, swarm); } } } @@ -959,7 +1484,10 @@ fn handle_kad_event( if let Some(req) = pending_provides.get_mut(&req_id) { if req.record(source, Err(format!("{e:?}"))) { if let Some(req) = pending_provides.remove(&req_id) { - req.finalize(); + for owner in &req.owners { + pending_provider_claims.remove(&(*owner, req.key.clone())); + } + apply_provide_outcome(req.finalize(), provider_ownership, swarm); } } } @@ -976,9 +1504,8 @@ fn handle_kad_event( ); if let Some(&req_id) = find_query_to_req.get(&key) { if let Some(find_req) = pending_finds.get_mut(&req_id) { - let routed = routed_peers.entry(req_id).or_default(); for provider in &providers { - if !find_req.seen.insert(*provider) { + if !find_req.select_provider(*provider) { continue; } @@ -996,13 +1523,8 @@ fn handle_kad_event( addr_count = addrs.len(), "Provider discovered with addresses" ); - let _ = find_req.sender.send(PeerInfo { - peer_id: provider.to_bytes(), - addrs: addrs.iter().map(|a| a.to_vec()).collect(), - }); - } else if !routed.contains(provider) - && !pending_peer_routing.values().any(|(p, _)| p == provider) - { + find_req.queue_resolved_provider(*provider, &addrs); + } else { tracing::debug!( dht = label, peer = %provider, @@ -1017,6 +1539,11 @@ fn handle_kad_event( pending_peer_routing .insert((DhtSource::Lan, lan_qid), (*provider, Some(req_id))); } + + if find_req.selection_complete() { + finish_provider_find = Some(req_id); + break; + } } } } @@ -1057,12 +1584,9 @@ fn handle_kad_event( .extend(info.addrs.iter().cloned()); // Deliver the now-addressable provider to the owning find request. if let Some(req_id) = owner_req { - if let Some(find_req) = pending_finds.get(&req_id) { + if let Some(find_req) = pending_finds.get_mut(&req_id) { if !info.addrs.is_empty() { - let _ = find_req.sender.send(PeerInfo { - peer_id: target.to_bytes(), - addrs: info.addrs.iter().map(|a| a.to_vec()).collect(), - }); + find_req.queue_resolved_provider(target, &info.addrs); } } } @@ -1074,6 +1598,11 @@ fn handle_kad_event( "Peer routing: target not found in closest peers" ); } + if let Some(req_id) = owner_req { + if find_request_work_complete(req_id, pending_finds, pending_peer_routing) { + cancel_find = Some(req_id); + } + } } } kad::QueryResult::GetClosestPeers(Err(ref e)) => { @@ -1084,12 +1613,30 @@ fn handle_kad_event( } } tracing::warn!(dht = label, peer = %target, "Peer routing query failed: {e:?}"); + if let Some(req_id) = owner_req { + if find_request_work_complete(req_id, pending_finds, pending_peer_routing) { + cancel_find = Some(req_id); + } + } } } _ => { tracing::debug!(dht = label, "Kad query progress (other): {result:?}"); } } + if let Some(request_id) = finish_provider_find { + finish_find_provider_queries(request_id, swarm, find_query_to_req, pending_finds); + } + if let Some(request_id) = cancel_find { + cancel_find_request( + request_id, + swarm, + find_query_to_req, + pending_finds, + pending_peer_routing, + routed_peers, + ); + } tracing::debug!( dht = label, query_id = ?id, @@ -1100,27 +1647,38 @@ fn handle_kad_event( } /// Clean up maps when a DHT query finishes (`step.last == true`). -/// For find_providers, decrement `remaining` and close the channel when both -/// DHT queries are done. +/// For find_providers, decrement `remaining` and finish after both provider +/// queries and any bounded provider address-resolution queries are done. +#[derive(Default)] +struct QueryCleanup { + provide: Option, + completed_find: Option, +} + #[allow(clippy::too_many_arguments)] fn cleanup_query( source: DhtSource, id: kad::QueryId, provide_query_to_req: &mut HashMap, pending_provides: &mut HashMap, + pending_provider_claims: &mut HashMap<(rpc::ProviderOwnerId, Vec), u64>, find_query_to_req: &mut HashMap, pending_finds: &mut HashMap, pending_peer_routing: &mut HashMap)>, routed_peers: &mut HashMap>, -) { +) -> QueryCleanup { let key = (source, id); + let mut cleanup = QueryCleanup::default(); // Provide cleanup if let Some(req_id) = provide_query_to_req.remove(&key) { // If no more queries reference this request, finalize it. if !provide_query_to_req.values().any(|&r| r == req_id) { if let Some(req) = pending_provides.remove(&req_id) { - req.finalize(); + for owner in &req.owners { + pending_provider_claims.remove(&(*owner, req.key.clone())); + } + cleanup.provide = Some(req.finalize()); } } } @@ -1129,16 +1687,18 @@ fn cleanup_query( if let Some(req_id) = find_query_to_req.remove(&key) { if let Some(find_req) = pending_finds.get_mut(&req_id) { find_req.remaining = find_req.remaining.saturating_sub(1); - if find_req.remaining == 0 { - // Both DHTs done — drop the sender to close the channel. - pending_finds.remove(&req_id); - routed_peers.remove(&req_id); - } + } + if find_request_work_complete(req_id, pending_finds, pending_peer_routing) { + // Provider discovery and its bounded address resolution are done. + pending_finds.remove(&req_id); + routed_peers.remove(&req_id); + cleanup.completed_find = Some(req_id); } } // Peer routing cleanup (compound key removes the specific entry). pending_peer_routing.remove(&key); + cleanup } // --------------------------------------------------------------------------- @@ -1284,6 +1844,11 @@ mod tests { use std::collections::HashMap; use tokio::sync::oneshot; + fn find_cancellation_receiver() -> watch::Receiver { + let (_sender, receiver) = watch::channel(false); + receiver + } + // ------------------------------------------------------------------- // is_lan_addr / is_lan_ip // ------------------------------------------------------------------- @@ -1374,12 +1939,12 @@ mod tests { #[test] fn test_provide_request_first_success_wins() { let (tx, mut rx) = oneshot::channel(); - let mut req = ProvideRequest::new(tx); + let mut req = ProvideRequest::new(rpc::ProviderOwnerId(1), b"key".to_vec(), tx); // WAN succeeds first assert!(!req.record(DhtSource::Wan, Ok(()))); // Reply already sent - assert!(req.reply.is_none()); + assert!(req.replies.is_empty()); // LAN result comes later assert!(req.record(DhtSource::Lan, Err("no peers".into()))); @@ -1390,28 +1955,69 @@ mod tests { #[test] fn test_provide_request_both_fail_sends_wan_error() { let (tx, mut rx) = oneshot::channel(); - let mut req = ProvideRequest::new(tx); + let mut req = ProvideRequest::new(rpc::ProviderOwnerId(1), b"key".to_vec(), tx); assert!(!req.record(DhtSource::Lan, Err("lan fail".into()))); assert!(req.record(DhtSource::Wan, Err("wan fail".into()))); - req.finalize(); + assert!(matches!(req.finalize(), ProvideOutcome::Failed { .. })); let result = rx.try_recv().unwrap(); assert!(result.is_err()); assert_eq!(result.unwrap_err(), "wan fail"); } + #[test] + fn provide_request_lan_success_survives_wan_failure() { + let (tx, mut rx) = oneshot::channel(); + let mut request = ProvideRequest::new(rpc::ProviderOwnerId(1), b"key".to_vec(), tx); + + assert!(!request.record(DhtSource::Lan, Ok(()))); + assert!(request.record(DhtSource::Wan, Err("wan fail".into()))); + assert!(matches!(request.finalize(), ProvideOutcome::Active)); + assert!(rx.try_recv().expect("provide response").is_ok()); + } + + #[test] + fn failed_pending_provide_rolls_back_every_joined_owner() { + let first = rpc::ProviderOwnerId(1); + let second = rpc::ProviderOwnerId(2); + let (first_tx, mut first_rx) = oneshot::channel(); + let (second_tx, mut second_rx) = oneshot::channel(); + let mut request = ProvideRequest::new(first, b"key".to_vec(), first_tx); + request.add_owner(second, second_tx); + + assert!(!request.record(DhtSource::Wan, Err("wan fail".into()))); + assert!(request.record(DhtSource::Lan, Err("lan fail".into()))); + match request.finalize() { + ProvideOutcome::Failed { owners, key } => { + assert_eq!( + HashSet::<_>::from_iter(owners), + HashSet::from([first, second]) + ); + assert_eq!(key, b"key"); + } + ProvideOutcome::Active => panic!("failed dual-DHT request became active"), + } + assert!(first_rx.try_recv().expect("first reply").is_err()); + assert!(second_rx.try_recv().expect("second reply").is_err()); + } + // ------------------------------------------------------------------- // FindRequest dedup // ------------------------------------------------------------------- #[test] fn test_find_request_dedup_across_dhts() { - let (tx, mut rx) = mpsc::unbounded_channel(); + let (tx, mut rx) = mpsc::channel(1); let mut find = FindRequest { sender: tx, + cancellation: find_cancellation_receiver(), seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), remaining: 2, + limit: 1, + delivered: 0, }; let peer_bytes = vec![0u8; 32]; // dummy peer ID bytes @@ -1424,10 +2030,12 @@ mod tests { assert!(!find.seen.insert(peer_id)); // Send one provider through - let _ = find.sender.send(PeerInfo { - peer_id: peer_bytes.clone(), - addrs: vec![], - }); + find.sender + .try_send(PeerInfo { + peer_id: peer_bytes.clone(), + addrs: vec![], + }) + .expect("single-slot channel has capacity"); assert!(rx.try_recv().is_ok()); @@ -1438,18 +2046,207 @@ mod tests { assert_eq!(find.remaining, 0); } + #[tokio::test] + async fn find_request_retains_multi_provider_batch_while_handoff_is_busy() { + let (tx, mut rx) = mpsc::channel(1); + tx.send(PeerInfo { + peer_id: b"occupied".to_vec(), + addrs: Vec::new(), + }) + .await + .expect("occupy the single-slot handoff"); + + let mut request = FindRequest { + sender: tx, + cancellation: find_cancellation_receiver(), + seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), + remaining: 2, + limit: 3, + delivered: 0, + }; + let address: Multiaddr = "/ip4/127.0.0.1/tcp/4001".parse().expect("address"); + let peers = [PeerId::random(), PeerId::random(), PeerId::random()]; + for peer in peers { + assert!(request.select_provider(peer)); + request.queue_resolved_provider(peer, std::slice::from_ref(&address)); + } + + assert!(request.selection_complete()); + assert_eq!(request.pending.len(), 3); + assert_eq!( + rx.recv().await.expect("occupied result").peer_id, + b"occupied" + ); + + for expected in peers { + let provider = request.pending.front().expect("pending provider").clone(); + request + .sender + .send(provider.info) + .await + .expect("handoff accepts pending provider"); + let delivered = request + .pending + .pop_front() + .expect("remove delivered provider"); + assert_eq!(delivered.peer_id, expected); + assert_eq!( + rx.recv().await.expect("receive pending provider").peer_id, + expected.to_bytes() + ); + } + assert!(request.pending.is_empty()); + } + + #[test] + fn find_request_caps_untrusted_count_at_the_kad_replication_bound() { + let (sender, _receiver) = mpsc::channel(1); + let mut request = FindRequest { + sender, + cancellation: find_cancellation_receiver(), + seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), + remaining: 2, + limit: find_provider_limit(u32::MAX), + delivered: 0, + }; + let address: Multiaddr = "/ip4/127.0.0.1/tcp/4001".parse().expect("address"); + + for _ in 0..KAD_REPLICATION_FACTOR { + let peer = PeerId::random(); + assert!(request.select_provider(peer)); + request.queue_resolved_provider(peer, std::slice::from_ref(&address)); + } + assert!(!request.select_provider(PeerId::random())); + assert_eq!(request.pending.len(), KAD_REPLICATION_FACTOR); + assert_eq!(request.seen.len(), KAD_REPLICATION_FACTOR); + } + + #[tokio::test] + async fn slow_find_handoff_does_not_block_another_request() { + let (slow_sender, mut slow_receiver) = mpsc::channel(1); + slow_sender + .send(PeerInfo { + peer_id: b"occupied".to_vec(), + addrs: Vec::new(), + }) + .await + .expect("occupy slow handoff"); + let (fast_sender, mut fast_receiver) = mpsc::channel(1); + let slow_peer = PeerId::random(); + let fast_peer = PeerId::random(); + + let requests = HashMap::from([ + ( + 1, + FindRequest { + sender: slow_sender, + cancellation: find_cancellation_receiver(), + seen: HashSet::from([slow_peer]), + queued_peers: HashSet::from([slow_peer]), + pending: VecDeque::from([PendingProvider { + peer_id: slow_peer, + info: PeerInfo { + peer_id: slow_peer.to_bytes(), + addrs: Vec::new(), + }, + }]), + remaining: 2, + limit: 1, + delivered: 0, + }, + ), + ( + 2, + FindRequest { + sender: fast_sender, + cancellation: find_cancellation_receiver(), + seen: HashSet::from([fast_peer]), + queued_peers: HashSet::from([fast_peer]), + pending: VecDeque::from([PendingProvider { + peer_id: fast_peer, + info: PeerInfo { + peer_id: fast_peer.to_bytes(), + addrs: Vec::new(), + }, + }]), + remaining: 2, + limit: 1, + delivered: 0, + }, + ), + ]); + + let delivery = tokio::time::timeout( + Duration::from_secs(1), + send_next_find_provider(pending_find_deliveries(&requests)), + ) + .await + .expect("writable Finder request was blocked") + .expect("pending delivery"); + assert_eq!(delivery, (2, fast_peer, true)); + assert_eq!( + fast_receiver + .recv() + .await + .expect("fast provider delivery") + .peer_id, + fast_peer.to_bytes() + ); + assert_eq!( + slow_receiver + .recv() + .await + .expect("occupied slow handoff") + .peer_id, + b"occupied" + ); + } + + #[tokio::test] + async fn find_cancellation_token_identifies_the_request_without_a_command() { + let (cancel_sender, cancel_receiver) = watch::channel(false); + let (sender, _receiver) = mpsc::channel(1); + let requests = HashMap::from([( + 17, + FindRequest { + sender, + cancellation: cancel_receiver, + seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), + remaining: 2, + limit: 1, + delivered: 0, + }, + )]); + + cancel_sender.send_replace(true); + let canceled = tokio::time::timeout( + Duration::from_secs(1), + wait_for_next_find_cancellation(pending_find_cancellations(&requests)), + ) + .await + .expect("Finder cancellation token was not observed"); + assert_eq!(canceled, Some(17)); + } + // ------------------------------------------------------------------- // cleanup_query // ------------------------------------------------------------------- #[test] fn test_cleanup_find_providers_closes_on_both_done() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::channel::(1); let mut pending_finds: HashMap = HashMap::new(); let mut find_query_to_req: HashMap = HashMap::new(); let mut provide_query_to_req: HashMap = HashMap::new(); let mut pending_provides: HashMap = HashMap::new(); + let mut pending_provider_claims = HashMap::new(); let mut pending_peer_routing: HashMap)> = HashMap::new(); let mut routed: HashMap> = HashMap::new(); @@ -1463,18 +2260,24 @@ mod tests { req_id, FindRequest { sender: tx, + cancellation: find_cancellation_receiver(), seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), remaining: 2, + limit: 10, + delivered: 0, }, ); routed.insert(req_id, HashSet::new()); // WAN finishes first — channel should stay open. - cleanup_query( + let _ = cleanup_query( DhtSource::Wan, wan_qid, &mut provide_query_to_req, &mut pending_provides, + &mut pending_provider_claims, &mut find_query_to_req, &mut pending_finds, &mut pending_peer_routing, @@ -1484,11 +2287,12 @@ mod tests { assert!(rx.try_recv().is_err()); // not closed yet // LAN finishes — channel should close. - cleanup_query( + let _ = cleanup_query( DhtSource::Lan, lan_qid, &mut provide_query_to_req, &mut pending_provides, + &mut pending_provider_claims, &mut find_query_to_req, &mut pending_finds, &mut pending_peer_routing, @@ -1500,6 +2304,101 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn find_completion_waits_for_address_resolution() { + let request_id = 7; + let (sender, _receiver) = mpsc::channel(1); + let mut pending_finds = HashMap::from([( + request_id, + FindRequest { + sender, + cancellation: find_cancellation_receiver(), + seen: HashSet::new(), + queued_peers: HashSet::new(), + pending: VecDeque::new(), + remaining: 0, + limit: 1, + delivered: 0, + }, + )]); + let query: kad::QueryId = unsafe { std::mem::transmute(3u64) }; + let mut pending_peer_routing = HashMap::from([( + (DhtSource::Wan, query), + (PeerId::random(), Some(request_id)), + )]); + + assert!(!find_request_work_complete( + request_id, + &pending_finds, + &pending_peer_routing, + )); + pending_peer_routing.clear(); + assert!(find_request_work_complete( + request_id, + &pending_finds, + &pending_peer_routing, + )); + pending_finds.remove(&request_id); + } + + #[test] + fn provider_ownership_stops_only_after_final_owner() { + let key = b"cid-multihash".to_vec(); + let first = rpc::ProviderOwnerId(1); + let second = rpc::ProviderOwnerId(2); + let mut ownership = ProviderOwnership::default(); + + assert!(ownership.claim(first, key.clone())); + assert!( + !ownership.claim(first, key.clone()), + "duplicate claim is idempotent" + ); + assert!( + !ownership.claim(second, key.clone()), + "second owner shares provision" + ); + assert!(!ownership.release_key(first, &key)); + assert!(ownership.contains(second, &key)); + assert!(ownership.release_key(second, &key)); + assert!(ownership.by_key.is_empty()); + assert!(ownership.by_owner.is_empty()); + } + + #[test] + fn provider_owner_release_cleans_all_keys_without_affecting_other_owner() { + let first = rpc::ProviderOwnerId(1); + let second = rpc::ProviderOwnerId(2); + let shared = b"shared".to_vec(); + let exclusive = b"exclusive".to_vec(); + let mut ownership = ProviderOwnership::default(); + ownership.claim(first, shared.clone()); + ownership.claim(second, shared.clone()); + ownership.claim(first, exclusive.clone()); + + assert_eq!(ownership.release_owner(first), vec![exclusive]); + assert!(ownership.contains(second, &shared)); + assert_eq!(ownership.release_owner(second), vec![shared]); + } + + #[test] + fn libp2p_stop_providing_removes_local_republication_source() { + let peer = PeerId::random(); + let store = kad::store::MemoryStore::new(peer); + let config = kad::Config::new(kad::PROTOCOL_NAME); + let mut behaviour = kad::Behaviour::with_config(peer, store, config); + let key = kad::RecordKey::new(b"owned-provider"); + + start_owned_providing(&mut behaviour, key.clone()).expect("start local provision"); + assert_eq!(behaviour.store_mut().provided().count(), 1); + + behaviour.stop_providing(&key); + assert_eq!( + behaviour.store_mut().provided().count(), + 0, + "removed local records cannot enter a later republication cycle" + ); + } + // ------------------------------------------------------------------- // NAT traversal helpers // ------------------------------------------------------------------- diff --git a/src/kernel.rs b/src/kernel.rs index 24c48dab..5190de25 100644 --- a/src/kernel.rs +++ b/src/kernel.rs @@ -283,10 +283,7 @@ pub fn select_kernel_source(cli: Option<&str>, env: Option<&str>) -> Result Cid { - let digest = blake3::hash(bytes); - let mh = cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()) - .expect("blake3 digest always fits in 64-byte multihash"); - Cid::new_v1(0x55, mh) + cell::routing_key::derive(bytes) } /// Prepared filesystem root for one kernel generation. diff --git a/src/launcher.rs b/src/launcher.rs index 62181c38..ced36eee 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -573,13 +573,7 @@ impl system_capnp::executor::Server for ExecutorImpl { mut results: system_capnp::executor::CidResults, ) -> Promise<(), capnp::Error> { pry!(self.guard.check()); - const RAW_CODEC: u64 = 0x55; - const BLAKE3_MULTIHASH_CODE: u64 = 0x1e; - - let digest = blake3::hash(&self.bytecode); - let mh = cid::multihash::Multihash::<64>::wrap(BLAKE3_MULTIHASH_CODE, digest.as_bytes()) - .expect("valid blake3 multihash"); - let cid = cid::Cid::new_v1(RAW_CODEC, mh); + let cid = cell::routing_key::derive(&self.bytecode); results.get().set_cid(cid.to_string()); Promise::ok(()) } diff --git a/tests/child_authority_confinement.rs b/tests/child_authority_confinement.rs index 73e1a224..9856a196 100644 --- a/tests/child_authority_confinement.rs +++ b/tests/child_authority_confinement.rs @@ -26,7 +26,8 @@ const CAPNP_FORK_REVISION: &str = "c6eecf42da63296e5bf628251935cf5af09d80be"; const SENSITIVE_CAPS: &[&str] = &[ "host", "runtime", - "routing", + "routing-finder", + "routing-announcer", "authority", "identity", "ipfs", @@ -83,12 +84,6 @@ fn probe_bytes() -> Vec { std::fs::read(probe_wasm()).expect("read authority-probe WASM") } -#[derive(Default)] -struct SwarmCounts { - provide: Cell, - find: Cell, -} - #[derive(Default)] struct BackendCounts { http: Cell, @@ -97,7 +92,6 @@ struct BackendCounts { struct Harness { executor: system_capnp::executor::Client, - counts: Rc, backend_counts: Rc, backend_url: String, _epoch_tx: watch::Sender, @@ -154,7 +148,6 @@ async fn probe_backend() -> (String, Rc) { async fn harness(wasm: &[u8]) -> Harness { let (backend_url, backend_counts) = probe_backend().await; - let counts = Rc::new(SwarmCounts::default()); let epoch = authority::Epoch { seq: 1, @@ -170,7 +163,6 @@ async fn harness(wasm: &[u8]) -> Harness { let executor = load_executor(&runtime, wasm).await; Harness { executor, - counts, backend_counts, backend_url, _epoch_tx: epoch_tx, @@ -1192,43 +1184,124 @@ fn empty_grant_child_cannot_invoke_node_authority() { } #[test] -fn empty_grant_child_cannot_route_discover_or_publish() { +fn empty_grant_child_cannot_discover_or_announce_providers() { let wasm = probe_bytes(); let local = tokio::task::LocalSet::new(); local.block_on(&tokio::runtime::Runtime::new().unwrap(), async move { let harness = harness(&wasm).await; - let report = probe_report(&harness.executor, "routing", &[], &[]).await; - assert_ne!( - report["ok"], true, - "empty-grant child used routing authority: {report}" + for mode in ["routing-finder", "routing-announcer"] { + let report = probe_report(&harness.executor, mode, &[], &[]).await; + assert_ne!( + report["ok"], true, + "empty-grant child used {mode} authority: {report}" + ); + } + }); +} + +#[test] +fn finder_only_can_discover_but_cannot_use_announcer() { + let wasm = probe_bytes(); + let local = tokio::task::LocalSet::new(); + local.block_on(&tokio::runtime::Runtime::new().unwrap(), async move { + let harness = harness(&wasm).await; + let routing = ww::rpc::routing::LocalProviderRouting::new(); + routing.provide_as( + KNOWN_CID, + ww::rpc::PeerInfo { + peer_id: vec![1, 2, 3], + addrs: vec![vec![4, 5, 6]], + }, ); - assert_eq!(harness.counts.provide.get(), 0); - assert_eq!(harness.counts.find.get(), 0); + let finder: ww::routing_capnp::finder::Client = capnp_rpc::new_client(routing.finder()); + let report = probe_report( + &harness.executor, + "routing-finder", + &[], + &[Grant { + name: "routing-finder".into(), + cap: finder.client, + }], + ) + .await; + assert_eq!(report["ok"], true, "Finder probe failed: {report}"); + assert_eq!(report["detail"]["find_providers"], true); + assert_eq!(report["detail"]["providers"], 1); + assert_eq!(report["detail"]["done"], true); + assert_eq!(report["detail"]["announcer_cast_rejected"], true); }); } #[test] -fn explicitly_granted_routing_remains_concretely_callable() { +fn announcer_only_can_announce_but_cannot_use_finder() { let wasm = probe_bytes(); let local = tokio::task::LocalSet::new(); local.block_on(&tokio::runtime::Runtime::new().unwrap(), async move { let harness = harness(&wasm).await; - let routing: ww::routing_capnp::routing::Client = - capnp_rpc::new_client(ww::rpc::routing::LocalRouting::new()); + let routing = ww::rpc::routing::LocalProviderRouting::new(); + let announcer: ww::routing_capnp::announcer::Client = + capnp_rpc::new_client(routing.announcer()); let report = probe_report( &harness.executor, - "routing", + "routing-announcer", &[], &[Grant { - name: "routing".into(), - cap: routing.client, + name: "routing-announcer".into(), + cap: announcer.client, }], ) .await; - assert_eq!(report["ok"], true, "routing probe failed: {report}"); + assert_eq!(report["ok"], true, "Announcer probe failed: {report}"); + assert_eq!(report["detail"]["provide"], true); + assert_eq!(report["detail"]["finder_cast_rejected"], true); + }); +} + +#[test] +fn finder_and_announcer_are_available_only_when_both_are_explicitly_granted() { + let wasm = probe_bytes(); + let local = tokio::task::LocalSet::new(); + local.block_on(&tokio::runtime::Runtime::new().unwrap(), async move { + let harness = harness(&wasm).await; + let routing = ww::rpc::routing::LocalProviderRouting::new(); + routing.provide_as( + KNOWN_CID, + ww::rpc::PeerInfo { + peer_id: vec![1, 2, 3], + addrs: vec![vec![4, 5, 6]], + }, + ); + let finder: ww::routing_capnp::finder::Client = capnp_rpc::new_client(routing.finder()); + let announcer: ww::routing_capnp::announcer::Client = + capnp_rpc::new_client(routing.announcer()); + let report = probe_report( + &harness.executor, + "routing-both", + &[], + &[ + Grant { + name: "routing-finder".into(), + cap: finder.client, + }, + Grant { + name: "routing-announcer".into(), + cap: announcer.client, + }, + ], + ) + .await; + assert_eq!( + report["ok"], true, + "combined routing probe failed: {report}" + ); assert_eq!(report["detail"]["provide"], true); assert_eq!(report["detail"]["find_providers"], true); + assert_eq!(report["detail"]["providers"], 1); assert_eq!(report["detail"]["done"], true); + assert_eq!( + report["detail"]["explicit_refs"], + serde_json::json!(["routing-finder", "routing-announcer"]) + ); }); } diff --git a/tests/fixtures/authority-probe/src/lib.rs b/tests/fixtures/authority-probe/src/lib.rs index 2841f7a8..7770a7c9 100644 --- a/tests/fixtures/authority-probe/src/lib.rs +++ b/tests/fixtures/authority-probe/src/lib.rs @@ -41,6 +41,7 @@ mod http_capnp { } type InitialGrants = membrane_capnp::initial_grants::Client; +const PROVIDER_KEY: &str = "bafkreibm6jg3ux5quy7flfgn5gmxk5ubm6yur3apcu3to3d6tmjzptm2ye"; #[derive(Clone)] struct NamedCap { @@ -160,18 +161,15 @@ async fn invoke_named(initial_grants: InitialGrants, requested: String) -> Value .to_owned(); Ok(json!({"executor_obtained": true, "executor_cid": cid})) } - "routing" => { - let routing: routing_capnp::routing::Client = find_cap(&caps, &requested)?; - let mut hash = routing.hash_request(); - hash.get().set_data(b"authority-probe"); - let response = hash.send().promise.await?; - let key = response - .get()? - .get_key()? - .to_str() - .map_err(|error| capnp::Error::failed(error.to_string()))? - .to_owned(); - Ok(json!({"hash": key})) + "routing-finder" => { + let finder: routing_capnp::finder::Client = find_cap(&caps, &requested)?; + let (providers, done) = call_finder(&finder, 0).await?; + Ok(json!({"providers": providers, "done": done})) + } + "routing-announcer" => { + let announcer: routing_capnp::announcer::Client = find_cap(&caps, &requested)?; + call_announcer(&announcer).await?; + Ok(json!({"provide": true})) } "identity" => { let identity: auth_capnp::identity::Client = find_cap(&caps, &requested)?; @@ -455,7 +453,8 @@ fn run_invoke_all() { for name in [ "host", "runtime", - "routing", + "routing-finder", + "routing-announcer", "authority", "identity", "ipfs", @@ -486,6 +485,7 @@ impl routing_capnp::provider_sink::Server for ProviderSink { fn provider( self: Rc, _params: routing_capnp::provider_sink::ProviderParams, + _results: routing_capnp::provider_sink::ProviderResults, ) -> Promise<(), capnp::Error> { self.providers.set(self.providers.get() + 1); Promise::ok(()) @@ -501,65 +501,83 @@ impl routing_capnp::provider_sink::Server for ProviderSink { } } -fn run_routing() { +async fn call_finder( + finder: &routing_capnp::finder::Client, + count: u32, +) -> Result<(u32, bool), capnp::Error> { + let providers = Rc::new(Cell::new(0)); + let done = Rc::new(Cell::new(false)); + let sink: routing_capnp::provider_sink::Client = capnp_rpc::new_client(ProviderSink { + providers: providers.clone(), + done: done.clone(), + }); + let mut find = finder.find_providers_request(); + find.get().set_key(PROVIDER_KEY); + find.get().set_count(count); + find.get().set_sink(sink); + find.send().promise.await?; + Ok((providers.get(), done.get())) +} + +async fn call_announcer(announcer: &routing_capnp::announcer::Client) -> Result<(), capnp::Error> { + let mut provide = announcer.provide_request(); + provide.get().set_key(PROVIDER_KEY); + provide.send().promise.await.map(|_| ()) +} + +fn run_provider_routing(mode: &'static str) { system::run(|initial_grants: InitialGrants| async move { let result: Result = async { let caps = read_initial_grants(&initial_grants).await?; - let routing: routing_capnp::routing::Client = find_cap(&caps, "routing")?; - - let mut hash = routing.hash_request(); - hash.get().set_data(b"authority-probe-routing"); - let hash = hash.send().promise.await?; - let key = hash - .get()? - .get_key()? - .to_str() - .map_err(|error| capnp::Error::failed(error.to_string()))? - .to_owned(); - - let mut provide = routing.provide_request(); - provide.get().set_key(&key); - provide.send().promise.await?; - - let providers = Rc::new(Cell::new(0)); - let done = Rc::new(Cell::new(false)); - let sink: routing_capnp::provider_sink::Client = capnp_rpc::new_client(ProviderSink { - providers: providers.clone(), - done: done.clone(), - }); - let mut find = routing.find_providers_request(); - find.get().set_key(&key); - find.get().set_count(3); - find.get().set_sink(sink); - find.send().promise.await?; - - let mut publish = routing.publish_request(); - publish.get().set_name(""); - publish.get().set_cid(""); - publish.get().set_expected_current(""); - let publish_reached = publish.send().promise.await.is_err(); - - let mut write = routing.write_file_request(); - write.get().set_base_cid(""); - write.get().set_path("authority-probe"); - write.get().set_data(b"mutable"); - write.get().set_create_parents(true); - let mutable_rpc_reached = write.send().promise.await.is_err(); - - Ok(json!({ - "hash": key, - "provide": true, - "find_providers": true, - "providers": providers.get(), - "done": done.get(), - "publish_rpc_reached": publish_reached, - "mutable_rpc_reached": mutable_rpc_reached, - })) + match mode { + "routing-finder" => { + let finder: routing_capnp::finder::Client = find_cap(&caps, "routing-finder")?; + let (providers, done) = call_finder(&finder, 3).await?; + let wrong: routing_capnp::announcer::Client = + find_cap(&caps, "routing-finder")?; + let announcer_cast_rejected = call_announcer(&wrong).await.is_err(); + Ok(json!({ + "find_providers": true, + "providers": providers, + "done": done, + "announcer_cast_rejected": announcer_cast_rejected, + })) + } + "routing-announcer" => { + let announcer: routing_capnp::announcer::Client = + find_cap(&caps, "routing-announcer")?; + call_announcer(&announcer).await?; + let wrong: routing_capnp::finder::Client = + find_cap(&caps, "routing-announcer")?; + let finder_cast_rejected = call_finder(&wrong, 0).await.is_err(); + Ok(json!({ + "provide": true, + "finder_cast_rejected": finder_cast_rejected, + })) + } + "routing-both" => { + let finder: routing_capnp::finder::Client = find_cap(&caps, "routing-finder")?; + let announcer: routing_capnp::announcer::Client = + find_cap(&caps, "routing-announcer")?; + call_announcer(&announcer).await?; + let (providers, done) = call_finder(&finder, 3).await?; + Ok(json!({ + "provide": true, + "find_providers": true, + "providers": providers, + "done": done, + "explicit_refs": ["routing-finder", "routing-announcer"], + })) + } + _ => Err(capnp::Error::failed(format!( + "unknown provider-routing probe mode: {mode}" + ))), + } } .await; emit(match result { - Ok(detail) => json!({"mode": "routing", "ok": true, "detail": detail}), - Err(error) => json!({"mode": "routing", "ok": false, "error": text_error(error)}), + Ok(detail) => json!({"mode": mode, "ok": true, "detail": detail}), + Err(error) => json!({"mode": mode, "ok": false, "error": text_error(error)}), }); Ok(()) }); @@ -804,7 +822,9 @@ impl Guest for AuthorityProbe { Some("trusted-lattice") => run_trusted_lattice(), Some("late-delegation") => run_late_delegation(), Some("invoke-all") => run_invoke_all(), - Some("routing") => run_routing(), + Some("routing-finder") => run_provider_routing("routing-finder"), + Some("routing-announcer") => run_provider_routing("routing-announcer"), + Some("routing-both") => run_provider_routing("routing-both"), Some("descendant") => run_descendant(), Some("raw-host") => run_raw_host(), Some("substrate") => run_substrate(), diff --git a/tests/fixtures/routing-key-probe/Cargo.lock b/tests/fixtures/routing-key-probe/Cargo.lock new file mode 100644 index 00000000..508ac289 --- /dev/null +++ b/tests/fixtures/routing-key-probe/Cargo.lock @@ -0,0 +1,826 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "routing-key" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "routing-key-probe" +version = "0.1.0" +dependencies = [ + "routing-key", + "wit-bindgen", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/routing-key-probe/Cargo.toml b/tests/fixtures/routing-key-probe/Cargo.toml new file mode 100644 index 00000000..982fe643 --- /dev/null +++ b/tests/fixtures/routing-key-probe/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "routing-key-probe" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +routing-key = { path = "../../../crates/guest/routing-key" } +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] diff --git a/tests/fixtures/routing-key-probe/src/lib.rs b/tests/fixtures/routing-key-probe/src/lib.rs new file mode 100644 index 00000000..d2c1e567 --- /dev/null +++ b/tests/fixtures/routing-key-probe/src/lib.rs @@ -0,0 +1,16 @@ +mod bindings { + wit_bindgen::generate!({ + path: "wit", + world: "probe", + }); +} + +struct RoutingKeyProbe; + +impl bindings::Guest for RoutingKeyProbe { + fn probe() -> String { + routing_key::derive(b"ww.chess.v1") + } +} + +bindings::export!(RoutingKeyProbe with_types_in bindings); diff --git a/tests/fixtures/routing-key-probe/wit/probe.wit b/tests/fixtures/routing-key-probe/wit/probe.wit new file mode 100644 index 00000000..c8c24fbd --- /dev/null +++ b/tests/fixtures/routing-key-probe/wit/probe.wit @@ -0,0 +1,5 @@ +package wetware:routing-key-probe@0.1.0; + +world probe { + export probe: func() -> string; +} diff --git a/tests/kad_integration.rs b/tests/kad_integration.rs index dc930ba1..0d00237f 100644 --- a/tests/kad_integration.rs +++ b/tests/kad_integration.rs @@ -173,9 +173,9 @@ async fn test_kubo_bootstrap_entry() { /// Verify the CID → multihash → Kad RecordKey pipeline with a real CID /// produced by Kubo. /// -/// This exercises the same key-derivation logic used by `RoutingImpl::provide` -/// and `RoutingImpl::find_providers` (via `cid_to_kad_key`), ensuring that -/// CIDs returned by Kubo's `add` endpoint are valid Kad keys. +/// This exercises the same CID-to-multihash conversion used by +/// `AnnouncerImpl::provide` and `FinderImpl::find_providers`, ensuring that CIDs +/// returned by Kubo's `add` endpoint are valid Kad keys. #[tokio::test] async fn test_real_cid_to_kad_record_key() { if !ipfs_available().await {