From c8398d6cb7274c9ed0f7b020d9abf90b1df8a822 Mon Sep 17 00:00:00 2001 From: Chris Nennemann Date: Tue, 3 Mar 2026 14:41:56 +0100 Subject: [PATCH] feat: DM epoch fix, federation relay, and mDNS mesh discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema: createChannel returns wasNew :Bool to elect the MLS initiator unambiguously; prevents duplicate group creation on concurrent /dm calls - core: group helpers for epoch tracking and key-package lifecycle - server: federation subsystem — mTLS QUIC server-to-server relay with Cap'n Proto RPC; enqueue/batchEnqueue relay unknown recipients to their home domain via FederationClient - server: mDNS _quicproquo._udp.local. service announcement on startup - server: storage + sql_store — identity_exists, peek/ack, federation home-server lookup helpers - client: /mesh peers REPL command (mDNS discovery, feature = "mesh") - client: MeshDiscovery — background mDNS browse with ServiceDaemon - client: was_new=false path in cmd_dm waits for peer Welcome instead of creating a duplicate initiator group - p2p: fix ALPN from quicnprotochat/p2p/1 → quicproquo/p2p/1 - workspace: re-include quicproquo-p2p in members --- Cargo.lock | 1903 ++++++++++++++++- Cargo.toml | 8 +- crates/quicproquo-client/Cargo.toml | 11 + .../quicproquo-client/src/client/commands.rs | 42 +- .../src/client/conversation.rs | 142 +- .../src/client/mesh_discovery.rs | 148 ++ crates/quicproquo-client/src/client/mod.rs | 1 + crates/quicproquo-client/src/client/repl.rs | 99 +- crates/quicproquo-client/src/client/rpc.rs | 18 +- .../quicproquo-client/src/client/session.rs | 4 +- crates/quicproquo-client/src/lib.rs | 4 +- crates/quicproquo-client/tests/e2e.rs | 594 +++-- crates/quicproquo-core/src/group.rs | 306 ++- crates/quicproquo-core/src/lib.rs | 2 +- crates/quicproquo-p2p/src/lib.rs | 5 +- crates/quicproquo-server/Cargo.toml | 3 + crates/quicproquo-server/src/config.rs | 1 + .../src/federation/address.rs | 2 + .../src/federation/client.rs | 5 +- .../quicproquo-server/src/federation/mod.rs | 2 - .../quicproquo-server/src/federation/tls.rs | 2 +- crates/quicproquo-server/src/main.rs | 59 + .../src/node_service/channel_ops.rs | 8 +- .../src/node_service/delivery.rs | 146 +- crates/quicproquo-server/src/sql_store.rs | 109 +- crates/quicproquo-server/src/storage.rs | 49 +- schemas/node.capnp | 5 +- 27 files changed, 3375 insertions(+), 303 deletions(-) create mode 100644 crates/quicproquo-client/src/client/mesh_discovery.rs diff --git a/Cargo.lock b/Cargo.lock index e8a2b38..76e929c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,7 +32,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -123,6 +123,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -206,6 +212,18 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_cmd" version = "2.1.2" @@ -221,6 +239,41 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atk" version = "0.18.2" @@ -244,12 +297,33 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -278,6 +352,17 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -299,6 +384,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.21.7" @@ -350,6 +441,20 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -368,6 +473,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array 0.4.7", +] + [[package]] name = "block2" version = "0.6.2" @@ -476,7 +590,7 @@ version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e985a566bdaae9a428a957d12b10c318d41b2afddb54cfbb764878059df636e" dependencies = [ - "embedded-io", + "embedded-io 0.6.1", ] [[package]] @@ -702,7 +816,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -756,6 +870,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -772,18 +895,48 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -794,6 +947,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -888,6 +1051,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -951,6 +1120,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array 0.4.7", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -1029,7 +1207,7 @@ dependencies = [ "cpufeatures", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rand_core 0.6.4", "rustc_version", "serde", @@ -1037,6 +1215,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.0-rc.10", + "fiat-crypto 0.3.0", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -1061,14 +1257,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1085,13 +1305,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1109,14 +1340,31 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1141,19 +1389,79 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "difflib" version = "0.4.0" @@ -1176,11 +1484,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +dependencies = [ + "block-buffer 0.11.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1215,6 +1534,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -1229,6 +1550,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -1252,6 +1584,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1294,13 +1635,13 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", "serdect", "signature 2.2.0", - "spki", + "spki 0.7.3", ] [[package]] @@ -1318,11 +1659,22 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "serde", "signature 2.2.0", ] +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8 0.11.0-rc.11", + "serde", + "signature 3.0.0-rc.10", +] + [[package]] name = "ed25519-dalek" version = "1.0.1" @@ -1353,6 +1705,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.4", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.10", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1372,8 +1740,8 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "serdect", @@ -1392,7 +1760,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1401,12 +1769,41 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + [[package]] name = "embedded-io" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1489,6 +1886,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1515,6 +1918,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1527,6 +1941,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1594,6 +2014,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1627,6 +2060,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1775,6 +2221,21 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1832,10 +2293,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1949,6 +2412,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2053,6 +2528,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2074,7 +2558,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2082,6 +2566,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2092,6 +2581,20 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2116,6 +2619,59 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2246,6 +2802,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "hybrid-array" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b229d73f5803b562cc26e4da0396c8610a4ee209f4fac8fa4f8d709166dc45" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2285,6 +2850,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2304,7 +2870,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -2437,6 +3003,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -2458,6 +3030,37 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2499,6 +3102,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2515,6 +3130,213 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117" +dependencies = [ + "backon", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek 3.0.0-pre.1", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "igd-next", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netwatch", + "papaya", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.11", + "portmapper", + "rand 0.9.2", + "reqwest 0.12.28", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum", + "sync_wrapper", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", +] + +[[package]] +name = "iroh-base" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c99d836a1c99e037e98d1bf3ef209c3a4df97555a00ce9510eb78eccdf5567" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "data-encoding", + "derive_more 2.1.1", + "digest 0.11.0-rc.10", + "ed25519-dalek 3.0.0-pre.1", + "n0-error", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c946095f060e6e59b9ff30cc26c75cdb758e7fb0cde8312c89e2144654989fcb" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iroh-quinn" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de99ad8adc878ee0e68509ad256152ce23b8bbe45f5539d04e179630aca40a9" +dependencies = [ + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "fastbloom", + "getrandom 0.3.4", + "identity-hash", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "iroh-relay" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "lru", + "n0-error", + "n0-future", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.2", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", + "z32", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -2775,6 +3597,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2790,6 +3618,28 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2802,6 +3652,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2842,6 +3698,20 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "mdns-sd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36e83ad165be7e0ad2e3ae3be9afffdda0c2c9a7e70c628e5541f11443e3041" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "polling", + "socket2 0.5.10", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2946,12 +3816,29 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" dependencies = [ - "hybrid-array", + "hybrid-array 0.2.3", "kem", "rand_core 0.6.4", "sha3", ] +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "muda" version = "0.17.1" @@ -2973,6 +3860,59 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more 2.1.1", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" +dependencies = [ + "derive_more 2.1.1", + "n0-error", + "n0-future", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3003,6 +3943,124 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "netdev" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9815643a243856e7bd84524e1ff739e901e846cfb06ad9627cd2b6d59bd737" +dependencies = [ + "block2", + "dispatch2", + "dlopen2 0.5.0", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.25.1", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "iroh-quinn-udp", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3015,6 +4073,21 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3071,6 +4144,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3131,7 +4213,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -3255,6 +4339,20 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -3297,6 +4395,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3462,6 +4564,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "papaya" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3496,6 +4614,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -3515,12 +4639,31 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.8.0" @@ -3655,6 +4798,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3667,14 +4830,55 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkarr" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f950360d31be432c0c9467fba5024a94f55128e7f32bc9d32db140369f24c77" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "futures-buffered", + "futures-lite", + "getrandom 0.4.1", + "log", + "lru", + "ntimestamp", + "reqwest 0.13.2", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "spki 0.8.0-rc.4", ] [[package]] @@ -3737,6 +4941,22 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "poly1305" version = "0.7.2" @@ -3789,6 +5009,36 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portmapper" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d2a8825353ace3285138da3378b1e21860d60351942f7aa3b99b13b41f80318" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more 2.1.1", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "netwatch", + "num_enum", + "rand 0.9.2", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + [[package]] name = "portpicker" version = "0.1.1" @@ -3798,6 +5048,31 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4003,10 +5278,12 @@ dependencies = [ "dashmap", "futures", "hex", + "mdns-sd", "opaque-ke", "openmls_rust_crypto", "portpicker", "quicproquo-core", + "quicproquo-p2p", "quicproquo-proto", "quinn", "quinn-proto", @@ -4080,6 +5357,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "quicproquo-p2p" +version = "0.1.0" +dependencies = [ + "anyhow", + "iroh", + "tokio", + "tracing", +] + [[package]] name = "quicproquo-proto" version = "0.1.0" @@ -4100,6 +5387,7 @@ dependencies = [ "dashmap", "futures", "hex", + "mdns-sd", "metrics 0.22.4", "metrics-exporter-prometheus", "opaque-ke", @@ -4137,7 +5425,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -4150,6 +5438,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "fastbloom", "getrandom 0.3.4", @@ -4176,7 +5465,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -4423,6 +5712,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -4437,15 +5767,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4453,10 +5789,16 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -4622,6 +5964,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4691,6 +6039,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4704,9 +6058,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "serdect", "subtle", "zeroize", @@ -4735,6 +6089,16 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4743,7 +6107,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", @@ -4753,6 +6117,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -4763,6 +6133,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -4785,6 +6161,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4858,6 +6244,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.17.0" @@ -4883,7 +6281,7 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.117", @@ -4931,6 +6329,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.9.9" @@ -4955,6 +6359,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.10", +] + [[package]] name = "sha3" version = "0.10.8" @@ -5006,12 +6421,33 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5042,6 +6478,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -5074,6 +6520,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "soup3" version = "0.5.0" @@ -5100,6 +6552,32 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -5107,7 +6585,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -5147,6 +6635,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5225,6 +6734,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.34.5" @@ -5237,7 +6752,7 @@ dependencies = [ "core-graphics", "crossbeam-channel", "dispatch", - "dlopen2", + "dlopen2 0.8.2", "dpi", "gdkwayland-sys", "gdkx11-sys", @@ -5259,7 +6774,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5312,7 +6827,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -5330,7 +6845,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5418,7 +6933,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5444,7 +6959,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5584,7 +7099,10 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -5697,7 +7215,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -5723,6 +7241,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5733,10 +7263,33 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.23" @@ -6073,7 +7626,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -6150,6 +7703,54 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib 9.1.0", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib 0.1.6", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -6340,6 +7941,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -6438,6 +8052,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6446,7 +8069,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -6470,10 +8093,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -6526,11 +8155,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -6542,6 +8183,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -6576,7 +8226,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -6623,6 +8284,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6668,6 +8339,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6719,6 +8399,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6761,6 +8456,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -6776,6 +8480,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6794,6 +8504,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6812,6 +8528,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6842,6 +8564,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6860,6 +8588,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6878,6 +8612,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6896,6 +8636,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6926,6 +8672,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -7024,6 +8780,21 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wmi" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003e65f4934cf9449b9ce913ad822cd054a5af669d24f93db101fdb02856bb23" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7069,12 +8840,31 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", ] +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "x11" version = "2.21.0" @@ -7120,6 +8910,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yasna" version = "0.5.2" @@ -7152,6 +8957,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + [[package]] name = "zerocopy" version = "0.8.40" diff --git a/Cargo.toml b/Cargo.toml index 88f92b5..a60be12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,11 @@ members = [ "crates/quicproquo-client", "crates/quicproquo-gui", "crates/quicproquo-mobile", + # P2P crate uses iroh (~90 extra deps). Kept in the workspace so it can be + # referenced as an optional dependency; only compiled when the `mesh` feature + # is enabled on quicproquo-client. + "crates/quicproquo-p2p", ] -# P2P-Crate (iroh-Transport) ist vom Default-Build ausgeschlossen, -# um ~90 exklusive iroh-Dependencies nicht mitzukompilieren. -# Quellcode bleibt im Repo für spätere Integration. -exclude = ["crates/quicproquo-p2p"] # Shared dependency versions — bump here to affect the whole workspace. [workspace.dependencies] diff --git a/crates/quicproquo-client/Cargo.toml b/crates/quicproquo-client/Cargo.toml index e1421f5..6f90cf9 100644 --- a/crates/quicproquo-client/Cargo.toml +++ b/crates/quicproquo-client/Cargo.toml @@ -59,6 +59,17 @@ hex = { workspace = true } # Secure password prompting (no echo) rpassword = "5" +# mDNS discovery for mesh mode (Freifunk). Only compiled with --features mesh. +mdns-sd = { version = "0.12", optional = true } + +# Optional P2P transport for direct node-to-node messaging. +quicproquo-p2p = { path = "../quicproquo-p2p", optional = true } + +[features] +# Enable mesh-mode features: mDNS local peer discovery + P2P transport. +# Build: cargo build -p quicproquo-client --features mesh +mesh = ["dep:mdns-sd", "dep:quicproquo-p2p"] + [dev-dependencies] dashmap = { workspace = true } assert_cmd = "2" diff --git a/crates/quicproquo-client/src/client/commands.rs b/crates/quicproquo-client/src/client/commands.rs index 75d1f51..99b1cc0 100644 --- a/crates/quicproquo-client/src/client/commands.rs +++ b/crates/quicproquo-client/src/client/commands.rs @@ -7,7 +7,7 @@ use opaque_ke::{ }; use quicproquo_core::{ generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite, - GroupMember, HybridKeypair, IdentityKeypair, + GroupMember, HybridKeypair, IdentityKeypair, ReceivedMessage, }; use super::{ @@ -376,7 +376,7 @@ pub(crate) async fn opaque_register( /// Perform OPAQUE login and return the raw session token bytes. /// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated. -pub(crate) async fn opaque_login( +pub async fn opaque_login( client: &quicproquo_proto::node_capnp::node_service::Client, username: &str, password: &str, @@ -725,9 +725,10 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> .context("joiner: missing ciphertext from DS")?; let inner_creator_joiner = hybrid_decrypt(&joiner_hybrid, raw_creator_joiner, b"", b"").context("hybrid decrypt failed")?; - let plaintext_creator_joiner = joiner - .receive_message(&inner_creator_joiner)? - .context("expected application message")?; + let plaintext_creator_joiner = match joiner.receive_message(&inner_creator_joiner)? { + ReceivedMessage::Application(pt) => pt, + other => anyhow::bail!("expected application message, got {other:?}"), + }; println!( "creator -> joiner plaintext: {}", String::from_utf8_lossy(&plaintext_creator_joiner) @@ -749,9 +750,10 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> .context("creator: missing ciphertext from DS")?; let inner_joiner_creator = hybrid_decrypt(&creator_hybrid, raw_joiner_creator, b"", b"").context("hybrid decrypt failed")?; - let plaintext_joiner_creator = creator - .receive_message(&inner_joiner_creator)? - .context("expected application message")?; + let plaintext_joiner_creator = match creator.receive_message(&inner_joiner_creator)? { + ReceivedMessage::Application(pt) => pt, + other => anyhow::bail!("expected application message, got {other:?}"), + }; println!( "joiner -> creator plaintext: {}", String::from_utf8_lossy(&plaintext_joiner_creator) @@ -1013,8 +1015,8 @@ pub async fn cmd_recv( } }; match member.receive_message(&mls_payload) { - Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)), - Ok(None) => println!("[{idx}] commit applied"), + Ok(ReceivedMessage::Application(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)), + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => println!("[{idx}] commit applied"), Err(_) => pending.push((idx, mls_payload)), } } @@ -1023,11 +1025,11 @@ pub async fn cmd_recv( let before = pending.len(); pending.retain(|(idx, mls_payload)| { match member.receive_message(mls_payload) { - Ok(Some(pt)) => { + Ok(ReceivedMessage::Application(pt)) => { println!("[{idx}/retry] plaintext: {}", String::from_utf8_lossy(&pt)); false } - Ok(None) => { + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => { println!("[{idx}/retry] commit applied"); false } @@ -1078,8 +1080,8 @@ pub async fn receive_pending_plaintexts( Err(_) => continue, }; match member.receive_message(&mls_payload) { - Ok(Some(pt)) => plaintexts.push(pt), - Ok(None) => {} + Ok(ReceivedMessage::Application(pt)) => plaintexts.push(pt), + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {} Err(_) => pending.push(mls_payload), } } @@ -1088,11 +1090,11 @@ pub async fn receive_pending_plaintexts( let before = pending.len(); pending.retain(|mls_payload| { match member.receive_message(mls_payload) { - Ok(Some(pt)) => { + Ok(ReceivedMessage::Application(pt)) => { plaintexts.push(pt); false } - Ok(None) => false, + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false, Err(_) => true, } }); @@ -1250,12 +1252,12 @@ pub async fn cmd_chat( Err(_) => continue, }; match member.receive_message(&mls_payload) { - Ok(Some(pt)) => { + Ok(ReceivedMessage::Application(pt)) => { let s = String::from_utf8_lossy(&pt); println!("\r\n[peer] {s}\n> "); std::io::stdout().flush().context("flush stdout")?; } - Ok(None) => {} + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {} Err(_) => retry_payloads.push(mls_payload), } } @@ -1264,13 +1266,13 @@ pub async fn cmd_chat( let before = retry_payloads.len(); retry_payloads.retain(|mls_payload| { match member.receive_message(mls_payload) { - Ok(Some(pt)) => { + Ok(ReceivedMessage::Application(pt)) => { let s = String::from_utf8_lossy(&pt); println!("\r\n[peer] {s}\n> "); let _ = std::io::stdout().flush(); false } - Ok(None) => false, + Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false, Err(_) => true, } }); diff --git a/crates/quicproquo-client/src/client/conversation.rs b/crates/quicproquo-client/src/client/conversation.rs index 94915fa..ff13385 100644 --- a/crates/quicproquo-client/src/client/conversation.rs +++ b/crates/quicproquo-client/src/client/conversation.rs @@ -71,6 +71,10 @@ pub struct Conversation { pub unread_count: u32, pub last_activity_ms: u64, pub created_at_ms: u64, + /// Whether this conversation uses hybrid (X25519 + ML-KEM-768) MLS keys. + pub is_hybrid: bool, + /// Highest server-side delivery sequence number seen. + pub last_seen_seq: u64, } #[derive(Clone, Debug)] @@ -251,9 +255,26 @@ impl ConversationStore { ); CREATE INDEX IF NOT EXISTS idx_messages_conv - ON messages(conversation_id, timestamp_ms);", + ON messages(conversation_id, timestamp_ms); + + CREATE TABLE IF NOT EXISTS outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id BLOB NOT NULL, + recipient_key BLOB NOT NULL, + payload BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' + ); + CREATE INDEX IF NOT EXISTS idx_outbox_status + ON outbox(status, created_at_ms);", ) .context("migrate conversation db")?; + + // Additive migrations for new columns (safe to re-run; errors ignored if column already exists). + conn.execute_batch("ALTER TABLE conversations ADD COLUMN is_hybrid INTEGER NOT NULL DEFAULT 0;").ok(); + conn.execute_batch("ALTER TABLE conversations ADD COLUMN last_seen_seq INTEGER NOT NULL DEFAULT 0;").ok(); + Ok(()) } @@ -274,15 +295,17 @@ impl ConversationStore { "INSERT INTO conversations (id, kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, - last_activity_ms, created_at_ms) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + last_activity_ms, created_at_ms, is_hybrid, last_seen_seq) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, mls_group_blob = excluded.mls_group_blob, keystore_blob = excluded.keystore_blob, member_keys = excluded.member_keys, unread_count = excluded.unread_count, - last_activity_ms = excluded.last_activity_ms", + last_activity_ms = excluded.last_activity_ms, + is_hybrid = excluded.is_hybrid, + last_seen_seq = excluded.last_seen_seq", params![ conv.id.0.as_slice(), kind_str, @@ -296,6 +319,8 @@ impl ConversationStore { conv.unread_count, conv.last_activity_ms, conv.created_at_ms, + conv.is_hybrid as i32, + conv.last_seen_seq as i64, ], )?; Ok(()) @@ -306,7 +331,7 @@ impl ConversationStore { .query_row( "SELECT kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, - last_activity_ms, created_at_ms + last_activity_ms, created_at_ms, is_hybrid, last_seen_seq FROM conversations WHERE id = ?1", params![id.0.as_slice()], |row| { @@ -321,6 +346,8 @@ impl ConversationStore { let unread_count: u32 = row.get(8)?; let last_activity_ms: u64 = row.get(9)?; let created_at_ms: u64 = row.get(10)?; + let is_hybrid_int: i32 = row.get(11)?; + let last_seen_seq: i64 = row.get(12)?; let kind = if kind_str == "dm" { ConversationKind::Dm { @@ -347,6 +374,8 @@ impl ConversationStore { unread_count, last_activity_ms, created_at_ms, + is_hybrid: is_hybrid_int != 0, + last_seen_seq: last_seen_seq as u64, }) }, ) @@ -358,7 +387,7 @@ impl ConversationStore { let mut stmt = self.conn.prepare( "SELECT id, kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, - last_activity_ms, created_at_ms + last_activity_ms, created_at_ms, is_hybrid, last_seen_seq FROM conversations ORDER BY last_activity_ms DESC", )?; let rows = stmt.query_map([], |row| { @@ -374,6 +403,8 @@ impl ConversationStore { let unread_count: u32 = row.get(9)?; let last_activity_ms: u64 = row.get(10)?; let created_at_ms: u64 = row.get(11)?; + let is_hybrid_int: i32 = row.get(12)?; + let last_seen_seq: i64 = row.get(13)?; let id = ConversationId::from_slice(&id_blob).unwrap_or(ConversationId([0; 16])); let kind = if kind_str == "dm" { @@ -400,6 +431,8 @@ impl ConversationStore { unread_count, last_activity_ms, created_at_ms, + is_hybrid: is_hybrid_int != 0, + last_seen_seq: last_seen_seq as u64, }) })?; @@ -553,6 +586,103 @@ impl ConversationStore { msgs.reverse(); Ok(msgs) } + + /// Save a message, deduplicating by message_id within the same conversation. + /// Returns `true` if the message was saved (new), `false` if it was a duplicate. + pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result { + if let Some(ref mid) = msg.message_id { + let exists: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ?1 AND conversation_id = ?2)", + params![mid.as_slice(), msg.conversation_id.0.as_slice()], + |row| row.get(0), + )?; + if exists { + return Ok(false); + } + } + self.save_message(msg)?; + Ok(true) + } + + // ── Sequence tracking ────────────────────────────────────────────── + + pub fn update_last_seen_seq(&self, id: &ConversationId, seq: u64) -> anyhow::Result<()> { + self.conn.execute( + "UPDATE conversations SET last_seen_seq = ?2 WHERE id = ?1 AND last_seen_seq < ?2", + params![id.0.as_slice(), seq as i64], + )?; + Ok(()) + } + + // ── Outbox (offline queue) ──────────────────────────────────────── + + pub fn enqueue_outbox( + &self, + conv_id: &ConversationId, + recipient_key: &[u8], + payload: &[u8], + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT INTO outbox (conversation_id, recipient_key, payload, created_at_ms) + VALUES (?1, ?2, ?3, ?4)", + params![conv_id.0.as_slice(), recipient_key, payload, now_ms() as i64], + )?; + Ok(()) + } + + pub fn load_pending_outbox(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT id, conversation_id, recipient_key, payload, retry_count + FROM outbox WHERE status = 'pending' ORDER BY created_at_ms", + )?; + let rows = stmt.query_map([], |row| { + let id: i64 = row.get(0)?; + let conv_blob: Vec = row.get(1)?; + let recipient_key: Vec = row.get(2)?; + let payload: Vec = row.get(3)?; + let retry_count: u32 = row.get(4)?; + Ok(OutboxEntry { + id, + conversation_id: ConversationId::from_slice(&conv_blob) + .unwrap_or(ConversationId([0; 16])), + recipient_key, + payload, + retry_count, + }) + })?; + let mut entries = Vec::new(); + for row in rows { + entries.push(row?); + } + Ok(entries) + } + + pub fn mark_outbox_sent(&self, id: i64) -> anyhow::Result<()> { + self.conn.execute( + "UPDATE outbox SET status = 'sent' WHERE id = ?1", + params![id], + )?; + Ok(()) + } + + pub fn mark_outbox_failed(&self, id: i64, retry_count: u32) -> anyhow::Result<()> { + let new_status = if retry_count > 5 { "failed" } else { "pending" }; + self.conn.execute( + "UPDATE outbox SET retry_count = ?2, status = ?3 WHERE id = ?1", + params![id, retry_count, new_status], + )?; + Ok(()) + } +} + +/// An entry in the offline outbox queue. +#[derive(Clone, Debug)] +pub struct OutboxEntry { + pub id: i64, + pub conversation_id: ConversationId, + pub recipient_key: Vec, + pub payload: Vec, + pub retry_count: u32, } pub fn now_ms() -> u64 { diff --git a/crates/quicproquo-client/src/client/mesh_discovery.rs b/crates/quicproquo-client/src/client/mesh_discovery.rs new file mode 100644 index 0000000..e09684d --- /dev/null +++ b/crates/quicproquo-client/src/client/mesh_discovery.rs @@ -0,0 +1,148 @@ +//! mDNS-based peer discovery for Freifunk / community mesh deployments. +//! +//! Browse for `_quicproquo._udp.local.` services on the local network and +//! surface them as [`DiscoveredPeer`] structs. Servers announce themselves +//! automatically on startup; this module lets clients find them without manual +//! configuration. +//! +//! # Usage +//! +//! ```no_run +//! use quicproquo_client::client::mesh_discovery::MeshDiscovery; +//! +//! let disc = MeshDiscovery::start()?; +//! // Give mDNS time to collect announcements before reading. +//! std::thread::sleep(std::time::Duration::from_secs(2)); +//! for peer in disc.peers() { +//! println!("found: {} at {}", peer.domain, peer.server_addr); +//! } +//! # Ok::<(), quicproquo_client::client::mesh_discovery::MeshDiscoveryError>(()) +//! ``` + +#[cfg(feature = "mesh")] +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::net::SocketAddr; +#[cfg(feature = "mesh")] +use std::sync::{Arc, Mutex}; +#[cfg(feature = "mesh")] +use std::collections::HashMap; + +/// A qpq server discovered on the local network via mDNS. +#[derive(Debug, Clone)] +pub struct DiscoveredPeer { + /// Federation domain of the remote server (e.g. `"node1.freifunk.net"`). + pub domain: String, + /// QUIC RPC address to connect to. + pub server_addr: SocketAddr, +} + +/// A running mDNS browse session. +/// +/// Starts immediately on construction; drop to stop browsing. +pub struct MeshDiscovery { + #[cfg(feature = "mesh")] + _daemon: ServiceDaemon, + #[cfg(feature = "mesh")] + peers: Arc>>, +} + +#[derive(thiserror::Error, Debug)] +pub enum MeshDiscoveryError { + #[error("mDNS daemon failed to start: {0}")] + DaemonError(String), + #[error("mDNS browse failed: {0}")] + BrowseError(String), + #[error("mesh feature not compiled (rebuild with --features mesh)")] + FeatureDisabled, +} + +impl MeshDiscovery { + /// Start browsing for `_quicproquo._udp.local.` services. + /// + /// Returns immediately; peers are collected in the background. + /// Returns [`MeshDiscoveryError::FeatureDisabled`] when built without the + /// `mesh` feature. + pub fn start() -> Result { + #[cfg(feature = "mesh")] + { + Self::start_inner() + } + #[cfg(not(feature = "mesh"))] + { + Err(MeshDiscoveryError::FeatureDisabled) + } + } + + #[cfg(feature = "mesh")] + fn start_inner() -> Result { + let daemon = ServiceDaemon::new() + .map_err(|e| MeshDiscoveryError::DaemonError(e.to_string()))?; + + let receiver = daemon + .browse("_quicproquo._udp.local.") + .map_err(|e| MeshDiscoveryError::BrowseError(e.to_string()))?; + + let peers: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let peers_bg = Arc::clone(&peers); + + // Process mDNS events in a background thread (ServiceDaemon is sync). + std::thread::spawn(move || { + for event in receiver { + match event { + ServiceEvent::ServiceResolved(info) => { + // Extract the qpq server address from TXT records. + let server_addr_str = info + .get_property_val_str("server") + .map(|s| s.to_string()); + let domain = info + .get_property_val_str("domain") + .map(|s| s.to_string()) + .unwrap_or_else(|| info.get_fullname().to_string()); + + if let Some(addr_str) = server_addr_str { + if let Ok(addr) = addr_str.parse::() { + let peer = DiscoveredPeer { + domain: domain.clone(), + server_addr: addr, + }; + if let Ok(mut map) = peers_bg.lock() { + map.insert(domain, peer); + } + } + } + } + ServiceEvent::ServiceRemoved(_ty, fullname) => { + if let Ok(mut map) = peers_bg.lock() { + map.retain(|_, p| { + !fullname.contains(&p.domain) + }); + } + } + // Other events (SearchStarted, SearchStopped) are informational. + _ => {} + } + } + }); + + Ok(Self { + _daemon: daemon, + peers, + }) + } + + /// Return a snapshot of all peers discovered so far. + pub fn peers(&self) -> Vec { + #[cfg(feature = "mesh")] + { + self.peers + .lock() + .map(|m| m.values().cloned().collect()) + .unwrap_or_default() + } + #[cfg(not(feature = "mesh"))] + { + vec![] + } + } +} diff --git a/crates/quicproquo-client/src/client/mod.rs b/crates/quicproquo-client/src/client/mod.rs index bdddd1f..49d3f38 100644 --- a/crates/quicproquo-client/src/client/mod.rs +++ b/crates/quicproquo-client/src/client/mod.rs @@ -2,6 +2,7 @@ pub mod commands; pub mod conversation; pub mod display; pub mod hex; +pub mod mesh_discovery; pub mod repl; pub mod retry; pub mod rpc; diff --git a/crates/quicproquo-client/src/client/repl.rs b/crates/quicproquo-client/src/client/repl.rs index 3117013..d088e70 100644 --- a/crates/quicproquo-client/src/client/repl.rs +++ b/crates/quicproquo-client/src/client/repl.rs @@ -54,6 +54,9 @@ enum SlashCommand { Join, Members, History { count: usize }, + /// Mesh subcommands: /mesh peers, /mesh server + MeshPeers, + MeshServer { addr: String }, } fn parse_input(line: &str) -> Input { @@ -116,6 +119,22 @@ fn parse_input(line: &str) -> Input { let count = arg.and_then(|s| s.parse().ok()).unwrap_or(20); Input::Slash(SlashCommand::History { count }) } + "/mesh" => match arg.as_deref() { + Some("peers") => Input::Slash(SlashCommand::MeshPeers), + Some(rest) if rest.starts_with("server ") => { + let addr = rest.trim_start_matches("server ").trim().to_string(); + if addr.is_empty() { + display::print_error("usage: /mesh server "); + Input::Empty + } else { + Input::Slash(SlashCommand::MeshServer { addr }) + } + } + _ => { + display::print_error("usage: /mesh peers | /mesh server "); + Input::Empty + } + }, _ => { display::print_error(&format!("unknown command: {cmd}. Try /help")); Input::Empty @@ -575,6 +594,13 @@ async fn handle_slash( SlashCommand::Join => cmd_join(session, client).await, SlashCommand::Members => cmd_members(session), SlashCommand::History { count } => cmd_history(session, count), + SlashCommand::MeshPeers => cmd_mesh_peers(), + SlashCommand::MeshServer { addr } => { + display::print_status(&format!( + "mesh server hint: reconnect with --server {addr} to use this node" + )); + Ok(()) + } }; if let Err(e) = result { display::print_error(&format!("{e:#}")); @@ -583,18 +609,49 @@ async fn handle_slash( fn print_help() { display::print_status("Commands:"); - display::print_status(" /dm - Start or switch to a DM (federation supported)"); - display::print_status(" /create-group - Create a new group"); - display::print_status(" /invite - Invite user to current group"); - display::print_status(" /remove - Remove a member from the current group"); - display::print_status(" /leave - Leave the current group"); - display::print_status(" /join - Join a group from pending Welcome"); - display::print_status(" /switch <@user|#group> - Switch conversation"); - display::print_status(" /list - List all conversations"); - display::print_status(" /members - Show members of current conversation"); - display::print_status(" /history [N] - Show last N messages (default: 20)"); - display::print_status(" /whoami - Show your identity"); - display::print_status(" /quit - Exit"); + display::print_status(" /dm - Start or switch to a DM (federation supported)"); + display::print_status(" /create-group - Create a new group"); + display::print_status(" /invite - Invite user to current group"); + display::print_status(" /remove - Remove a member from the current group"); + display::print_status(" /leave - Leave the current group"); + display::print_status(" /join - Join a group from pending Welcome"); + display::print_status(" /switch <@user|#group> - Switch conversation"); + display::print_status(" /list - List all conversations"); + display::print_status(" /members - Show members of current conversation"); + display::print_status(" /history [N] - Show last N messages (default: 20)"); + display::print_status(" /whoami - Show your identity"); + display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS"); + display::print_status(" /mesh server - Show how to reconnect to a mesh node"); + display::print_status(" /quit - Exit"); +} + +/// Discover nearby qpq servers via mDNS (requires `--features mesh` build). +fn cmd_mesh_peers() -> anyhow::Result<()> { + use super::mesh_discovery::MeshDiscovery; + + match MeshDiscovery::start() { + Err(e) => { + display::print_error(&format!("mesh discovery: {e}")); + return Ok(()); + } + Ok(disc) => { + display::print_status("scanning for nearby qpq nodes (2s)..."); + // Block briefly to collect mDNS announcements from the local network. + std::thread::sleep(std::time::Duration::from_secs(2)); + let peers = disc.peers(); + if peers.is_empty() { + display::print_status("no qpq nodes found on the local network"); + } else { + display::print_status(&format!("found {} node(s):", peers.len())); + for p in &peers { + display::print_status(&format!(" {} at {}", p.domain, p.server_addr)); + } + display::print_status("use: /mesh server to note the address,"); + display::print_status("then reconnect with: qpq --server "); + } + } + } + Ok(()) } fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> { @@ -725,9 +782,23 @@ async fn cmd_dm( return Ok(()); } - // Create server-side channel. + // Create or look up the server-side channel. + // was_new=true → this call created the channel; we are the MLS initiator. + // was_new=false → channel already existed; peer is the MLS initiator and has + // sent (or will send) us a Welcome. Wait for try_auto_join. display::print_status("creating channel..."); - let channel_id = create_channel(client, &peer_key).await?; + let (channel_id, was_new) = create_channel(client, &peer_key).await?; + + if !was_new { + // Peer is the MLS initiator. Their Welcome is en route; the background + // poller's try_auto_join will process it within the next poll interval + // and auto-switch to the conversation automatically. + display::print_status(&format!( + "DM channel with @{username} exists — peer is initiator, auto-joining via Welcome (arrives within ~1 s)" + )); + return Ok(()); + } + let conv_id = ConversationId::from_slice(&channel_id) .context("server returned invalid channel_id length")?; diff --git a/crates/quicproquo-client/src/client/rpc.rs b/crates/quicproquo-client/src/client/rpc.rs index 678406b..1213646 100644 --- a/crates/quicproquo-client/src/client/rpc.rs +++ b/crates/quicproquo-client/src/client/rpc.rs @@ -645,12 +645,16 @@ pub async fn resolve_identity( } } -/// Create a 1:1 DM channel with a peer. Returns the 16-byte channel ID. -/// If a channel already exists between the two users, returns the existing ID. +/// Create a 1:1 DM channel with a peer. +/// +/// Returns `(channel_id, was_new)` where `channel_id` is the stable 16-byte identifier and +/// `was_new` is `true` iff this call created the channel for the first time. When `was_new` is +/// `false`, the channel already existed (created by the peer), and the caller should wait for +/// the peer's MLS Welcome to arrive via the background poller rather than creating a new MLS group. pub async fn create_channel( client: &node_service::Client, peer_key: &[u8], -) -> anyhow::Result> { +) -> anyhow::Result<(Vec, bool)> { let mut req = client.create_channel_request(); { let mut p = req.get(); @@ -665,14 +669,14 @@ pub async fn create_channel( .await .context("create_channel RPC failed")?; - let channel_id = resp - .get() - .context("create_channel: bad response")? + let reader = resp.get().context("create_channel: bad response")?; + let channel_id = reader .get_channel_id() .context("create_channel: missing channel_id")? .to_vec(); + let was_new = reader.get_was_new(); - Ok(channel_id) + Ok((channel_id, was_new)) } /// Return the current Unix timestamp in milliseconds. diff --git a/crates/quicproquo-client/src/client/session.rs b/crates/quicproquo-client/src/client/session.rs index 6d9724d..e40fc9e 100644 --- a/crates/quicproquo-client/src/client/session.rs +++ b/crates/quicproquo-client/src/client/session.rs @@ -133,6 +133,8 @@ impl SessionState { unread_count: 0, last_activity_ms: now_ms(), created_at_ms: now_ms(), + is_hybrid: false, + last_seen_seq: 0, }; self.conv_store.save_conversation(&conv)?; @@ -171,7 +173,7 @@ impl SessionState { Arc::clone(&self.identity), ks, group, - false, // existing conversations default to classical + conv.is_hybrid, )) } diff --git a/crates/quicproquo-client/src/lib.rs b/crates/quicproquo-client/src/lib.rs index 9832394..5db91e4 100644 --- a/crates/quicproquo-client/src/lib.rs +++ b/crates/quicproquo-client/src/lib.rs @@ -22,11 +22,11 @@ pub use client::commands::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health, cmd_health_json, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, - receive_pending_plaintexts, whoami_json, + opaque_login, receive_pending_plaintexts, whoami_json, }; pub use client::repl::run_repl; -pub use client::rpc::{connect_node, enqueue, fetch_wait}; +pub use client::rpc::{connect_node, create_channel, enqueue, fetch_wait, resolve_user}; // Global auth context — RwLock so the REPL can set it after OPAQUE login. pub(crate) static AUTH_CONTEXT: RwLock> = RwLock::new(None); diff --git a/crates/quicproquo-client/tests/e2e.rs b/crates/quicproquo-client/tests/e2e.rs index 93de34e..3253078 100644 --- a/crates/quicproquo-client/tests/e2e.rs +++ b/crates/quicproquo-client/tests/e2e.rs @@ -1,7 +1,7 @@ // cargo_bin! only works for current package's binary; we spawn qpq-server from another package. #![allow(deprecated)] -use std::{path::PathBuf, process::Command, time::Duration}; +use std::{path::PathBuf, process::Command, sync::Mutex, time::Duration}; use assert_cmd::cargo::cargo_bin; use portpicker::pick_unused_port; @@ -17,11 +17,15 @@ fn ensure_rustls_provider() { use quicproquo_client::{ cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state, - cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth, - receive_pending_plaintexts, ClientAuth, + cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth, + opaque_login, receive_pending_plaintexts, resolve_user, ClientAuth, }; use quicproquo_core::IdentityKeypair; +/// Serialises all tests that call `init_auth` with a non-devtoken session to prevent +/// the global `AUTH_CONTEXT` from being overwritten by concurrent tests. +static AUTH_LOCK: Mutex<()> = Mutex::new(()); + fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -33,6 +37,13 @@ struct StoredStateCompat { group: Option>, } +struct ChildGuard(std::process::Child); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> { let local = tokio::task::LocalSet::new(); for _ in 0..30 { @@ -48,26 +59,17 @@ async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::bail!("server health never became ready") } -/// Creator and joiner register; creator creates group and invites joiner; joiner joins; -/// creator sends a message; assert joiner's mailbox receives it. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { - ensure_rustls_provider(); - - let temp = TempDir::new()?; - let base = temp.path(); +/// Spawns a server with the given extra args and returns (listen_addr, ca_cert_path, ChildGuard). +fn spawn_server(base: &std::path::Path, extra_args: &[&str]) -> (String, PathBuf, ChildGuard) { let port = pick_unused_port().expect("free port"); let listen = format!("127.0.0.1:{port}"); - let server = listen.clone(); let ca_cert = base.join("server-cert.der"); let tls_key = base.join("server-key.der"); let data_dir = base.join("data"); - let auth_token = "devtoken"; - // Spawn server binary. let server_bin = cargo_bin("qpq-server"); - let child = Command::new(server_bin) - .arg("--listen") + let mut cmd = Command::new(server_bin); + cmd.arg("--listen") .arg(&listen) .arg("--data-dir") .arg(&data_dir) @@ -76,25 +78,30 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { .arg("--tls-key") .arg(&tls_key) .arg("--auth-token") - .arg(auth_token) - .arg("--allow-insecure-auth") - .spawn() - .expect("spawn server"); - - // Ensure we always terminate the child. - struct ChildGuard(std::process::Child); - impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - } + .arg("devtoken") + .arg("--allow-insecure-auth"); + for arg in extra_args { + cmd.arg(arg); } - let child_guard = ChildGuard(child); - let _ = child_guard; + let child = cmd.spawn().expect("spawn server"); + (listen, ca_cert, ChildGuard(child)) +} + +// ─── existing tests (fixed: add --sealed-sender so enqueue works with bearer token) ───────────── + +/// Creator and joiner register; creator creates group and invites joiner; joiner joins; +/// creator sends a message; assert joiner's mailbox receives it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + let auth_token = "devtoken"; + + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); - // Wait for server to be healthy and certs to be generated. wait_for_health(&server, &ca_cert, "localhost").await?; - - // Set client auth context. init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); let local = tokio::task::LocalSet::new(); @@ -179,37 +186,9 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> { let temp = TempDir::new()?; let base = temp.path(); - let port = pick_unused_port().expect("free port"); - let listen = format!("127.0.0.1:{port}"); - let server = listen.clone(); - let ca_cert = base.join("server-cert.der"); - let tls_key = base.join("server-key.der"); - let data_dir = base.join("data"); let auth_token = "devtoken"; - let server_bin = cargo_bin("qpq-server"); - let child = Command::new(server_bin) - .arg("--listen") - .arg(&listen) - .arg("--data-dir") - .arg(&data_dir) - .arg("--tls-cert") - .arg(&ca_cert) - .arg("--tls-key") - .arg(&tls_key) - .arg("--auth-token") - .arg(auth_token) - .arg("--allow-insecure-auth") - .spawn() - .expect("spawn server"); - - struct ChildGuard(std::process::Child); - impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - } - } - let _child_guard = ChildGuard(child); + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); @@ -388,46 +367,16 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> { ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); let temp = TempDir::new()?; let base = temp.path(); - let port = pick_unused_port().expect("free port"); - let listen = format!("127.0.0.1:{port}"); - let server = listen.clone(); - let ca_cert = base.join("server-cert.der"); - let tls_key = base.join("server-key.der"); - let data_dir = base.join("data"); - let auth_token = "devtoken"; - // Spawn server binary. - let server_bin = cargo_bin("qpq-server"); - let child = Command::new(server_bin) - .arg("--listen") - .arg(&listen) - .arg("--data-dir") - .arg(&data_dir) - .arg("--tls-cert") - .arg(&ca_cert) - .arg("--tls-key") - .arg(&tls_key) - .arg("--auth-token") - .arg(auth_token) - .arg("--allow-insecure-auth") - .spawn() - .expect("spawn server"); - - struct ChildGuard(std::process::Child); - impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - } - } - let child_guard = ChildGuard(child); - let _ = child_guard; + let (server, ca_cert, _child) = spawn_server(base, &[]); wait_for_health(&server, &ca_cert, "localhost").await?; - init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); let state_path = base.join("user.bin"); @@ -482,7 +431,6 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> { match result { Ok(_) => anyhow::bail!("login unexpectedly succeeded with mismatched identity"), Err(e) => { - // Show the full error chain so we can match the server's E016 response. let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("identity") || msg.contains("E016"), @@ -501,41 +449,11 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> { let temp = TempDir::new()?; let base = temp.path(); - let port = pick_unused_port().expect("free port"); - let listen = format!("127.0.0.1:{port}"); - let server = listen.clone(); - let ca_cert = base.join("server-cert.der"); - let tls_key = base.join("server-key.der"); - let data_dir = base.join("data"); - let auth_token = "devtoken"; - let server_bin = cargo_bin("qpq-server"); - let child = Command::new(server_bin) - .arg("--listen") - .arg(&listen) - .arg("--data-dir") - .arg(&data_dir) - .arg("--tls-cert") - .arg(&ca_cert) - .arg("--tls-key") - .arg(&tls_key) - .arg("--auth-token") - .arg(auth_token) - .arg("--allow-insecure-auth") - .arg("--sealed-sender") - .spawn() - .expect("spawn server"); - - struct ChildGuard(std::process::Child); - impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - } - } - let _child_guard = ChildGuard(child); + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; - init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); let state_path = base.join("recipient.bin"); @@ -595,3 +513,425 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> { Ok(()) } + +// ─── new tests: was_new semantics, resolve_user, DM MLS flow ───────────────────────────────── + +/// `create_channel` returns `was_new=true` for the first caller and `was_new=false` for the +/// second, and both callers receive the same stable `channel_id` regardless of argument order. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_create_channel_was_new_semantics() -> anyhow::Result<()> { + ensure_rustls_provider(); + // Holds AUTH_CONTEXT for the duration of this test. + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + // No --sealed-sender: create_channel requires identity-bound session. + let (server, ca_cert, _child) = spawn_server(base, &[]); + + wait_for_health(&server, &ca_cert, "localhost").await?; + + // Register identity states (uses devtoken / allow-insecure for upload_key_package). + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed: [u8; 32] = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let bob_seed: [u8; 32] = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); + let alice_pk_hex = hex_encode(&alice_pk); + let bob_pk_hex = hex_encode(&bob_pk); + + // OPAQUE register (unauthenticated — no AUTH_CONTEXT needed). + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex))) + .await?; + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex))) + .await?; + + // Alice OPAQUE login → identity-bound session. + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + let session_alice = local + .run_until(opaque_login(&client, "alice", "pass", &alice_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_alice, None)); + + let (ch_alice, was_new_alice) = local + .run_until(create_channel(&client, &bob_pk)) + .await?; + + anyhow::ensure!(was_new_alice, "Alice's create_channel must return was_new=true"); + anyhow::ensure!(ch_alice.len() == 16, "channel_id must be 16 bytes"); + + // Bob OPAQUE login → identity-bound session. + let session_bob = local + .run_until(opaque_login(&client, "bob", "pass", &bob_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_bob, None)); + + let (ch_bob, was_new_bob) = local + .run_until(create_channel(&client, &alice_pk)) + .await?; + + anyhow::ensure!(!was_new_bob, "Bob's create_channel must return was_new=false (channel already exists)"); + anyhow::ensure!( + ch_alice == ch_bob, + "Both callers must receive the same channel_id (got alice={} bob={})", + hex_encode(&ch_alice), + hex_encode(&ch_bob) + ); + + Ok(()) +} + +/// `resolve_user` returns the identity key when the user registered WITH one, +/// and returns `None` when the user registered WITHOUT an identity key. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_resolve_user_requires_identity_key_binding() -> anyhow::Result<()> { + ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &[]); + + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + // Generate Alice's identity (bound) and Bob's identity (unbound). + let alice_state = base.join("alice.bin"); + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let alice_pk_hex = hex_encode(&alice_pk); + + // Alice registers WITH identity key. + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex))) + .await?; + + // Bob registers WITHOUT identity key. + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", None)) + .await?; + + // resolve_user needs a valid auth context (devtoken is sufficient — just needs bearer token). + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + + let alice_resolved = local + .run_until(resolve_user(&client, "alice")) + .await?; + + anyhow::ensure!( + alice_resolved == Some(alice_pk.clone()), + "resolve_user('alice') must return alice's identity key, got {:?}", + alice_resolved.as_ref().map(|k| hex_encode(k)) + ); + + let bob_resolved = local + .run_until(resolve_user(&client, "bob")) + .await?; + + anyhow::ensure!( + bob_resolved.is_none(), + "resolve_user('bob') must return None (no identity key bound), got {:?}", + bob_resolved.as_ref().map(|k| hex_encode(k)) + ); + + let ghost_resolved = local + .run_until(resolve_user(&client, "nonexistent")) + .await?; + + anyhow::ensure!( + ghost_resolved.is_none(), + "resolve_user('nonexistent') must return None" + ); + + Ok(()) +} + +/// Both Alice and Bob call `/dm` on each other (simultaneous DM initiation). +/// Only the first caller (was_new=true) creates the MLS group and sends a Welcome. +/// The second caller (was_new=false) joins via the Welcome. +/// After joining, Alice sends a message and Bob decrypts it with no epoch error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_bidirectional_dm_mls_no_epoch_conflict() -> anyhow::Result<()> { + ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + // No --sealed-sender: tests the production path where enqueue requires identity session. + let (server, ca_cert, _child) = spawn_server(base, &[]); + + wait_for_health(&server, &ca_cert, "localhost").await?; + + // Register state files (uploads KeyPackages + hybrid keys) using devtoken. + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let bob_seed = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); + let alice_pk_hex = hex_encode(&alice_pk); + let bob_pk_hex = hex_encode(&bob_pk); + + // OPAQUE register both users. + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex))) + .await?; + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex))) + .await?; + + // Alice logs in and calls create_channel → must get was_new=true. + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + let session_alice = local + .run_until(opaque_login(&client, "alice", "pass", &alice_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_alice.clone(), None)); + + let (channel_id, was_new_alice) = local + .run_until(create_channel(&client, &bob_pk)) + .await?; + anyhow::ensure!(was_new_alice, "Alice must get was_new=true"); + + // Alice creates MLS group (channel_id as group name) and invites Bob. + local + .run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) + .await?; + + // Bob logs in and calls create_channel → must get was_new=false with same channel_id. + let session_bob = local + .run_until(opaque_login(&client, "bob", "pass", &bob_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_bob.clone(), None)); + + let (channel_id_bob, was_new_bob) = local + .run_until(create_channel(&client, &alice_pk)) + .await?; + anyhow::ensure!(!was_new_bob, "Bob must get was_new=false (Alice created first)"); + anyhow::ensure!( + channel_id == channel_id_bob, + "Both sides must see the same channel_id" + ); + + // Bob joins via Welcome that Alice sent (was_new=false path: no group creation, just join). + local + .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + // Alice sends "hello" to Bob. + init_auth(ClientAuth::from_raw(session_alice, None)); + local + .run_until(cmd_send( + &alice_state, + &server, + &ca_cert, + "localhost", + Some(&bob_pk_hex), + false, + "hello from alice", + None, + )) + .await?; + + // Bob receives and decrypts — no epoch conflict. + init_auth(ClientAuth::from_raw(session_bob, None)); + let plaintexts = local + .run_until(receive_pending_plaintexts( + &bob_state, + &server, + &ca_cert, + "localhost", + 1000, + None, + )) + .await?; + + anyhow::ensure!( + plaintexts.iter().any(|p| p.as_slice() == b"hello from alice"), + "Bob must decrypt Alice's message without epoch error; got {:?}", + plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + Ok(()) +} + +/// Send 10 messages alternating Alice→Bob and Bob→Alice through an MLS DM channel. +/// All messages must decrypt successfully, proving epoch stays in sync. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_dm_multi_message_epoch_synchronized() -> anyhow::Result<()> { + ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &[]); + + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let bob_seed = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); + let alice_pk_hex = hex_encode(&alice_pk); + let bob_pk_hex = hex_encode(&bob_pk); + + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex))) + .await?; + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex))) + .await?; + + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + + // Alice creates the DM channel and invites Bob. + let session_alice = local + .run_until(opaque_login(&client, "alice", "pass", &alice_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_alice.clone(), None)); + + let (channel_id, was_new) = local + .run_until(create_channel(&client, &bob_pk)) + .await?; + anyhow::ensure!(was_new, "first create_channel must be was_new=true"); + + local + .run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) + .await?; + + // Bob joins. + let session_bob = local + .run_until(opaque_login(&client, "bob", "pass", &bob_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_bob.clone(), None)); + local + .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + // 10 messages: Alice→Bob on even, Bob→Alice on odd. + for i in 0u32..10 { + let msg = format!("msg_{i}"); + if i % 2 == 0 { + // Alice sends to Bob. + init_auth(ClientAuth::from_raw(session_alice.clone(), None)); + local + .run_until(cmd_send( + &alice_state, + &server, + &ca_cert, + "localhost", + Some(&bob_pk_hex), + false, + &msg, + None, + )) + .await?; + + // Bob receives. + init_auth(ClientAuth::from_raw(session_bob.clone(), None)); + let plaintexts = local + .run_until(receive_pending_plaintexts( + &bob_state, + &server, + &ca_cert, + "localhost", + 1000, + None, + )) + .await?; + anyhow::ensure!( + plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()), + "Bob did not receive '{msg}' at iteration {i}; got {:?}", + plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + } else { + // Bob sends to Alice. + init_auth(ClientAuth::from_raw(session_bob.clone(), None)); + local + .run_until(cmd_send( + &bob_state, + &server, + &ca_cert, + "localhost", + Some(&alice_pk_hex), + false, + &msg, + None, + )) + .await?; + + // Alice receives. + init_auth(ClientAuth::from_raw(session_alice.clone(), None)); + let plaintexts = local + .run_until(receive_pending_plaintexts( + &alice_state, + &server, + &ca_cert, + "localhost", + 1000, + None, + )) + .await?; + anyhow::ensure!( + plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()), + "Alice did not receive '{msg}' at iteration {i}; got {:?}", + plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + } + } + + Ok(()) +} diff --git a/crates/quicproquo-core/src/group.rs b/crates/quicproquo-core/src/group.rs index 9168207..8247b29 100644 --- a/crates/quicproquo-core/src/group.rs +++ b/crates/quicproquo-core/src/group.rs @@ -677,17 +677,17 @@ mod tests { joiner.join_group(&welcome).expect("joiner join group"); let ct_creator = creator.send_message(b"hello").expect("creator send"); - let pt_joiner = joiner - .receive_message(&ct_creator) - .expect("joiner recv") - .expect("application message"); + let pt_joiner = match joiner.receive_message(&ct_creator).expect("joiner recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; assert_eq!(pt_joiner, b"hello"); let ct_joiner = joiner.send_message(b"hello back").expect("joiner send"); - let pt_creator = creator - .receive_message(&ct_joiner) - .expect("creator recv") - .expect("application message"); + let pt_creator = match creator.receive_message(&ct_joiner).expect("creator recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; assert_eq!(pt_creator, b"hello back"); } @@ -718,17 +718,17 @@ mod tests { joiner.join_group(&welcome).expect("joiner join hybrid group"); let ct_creator = creator.send_message(b"hello PQ").expect("creator send"); - let pt_joiner = joiner - .receive_message(&ct_creator) - .expect("joiner recv") - .expect("application message"); + let pt_joiner = match joiner.receive_message(&ct_creator).expect("joiner recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; assert_eq!(pt_joiner, b"hello PQ"); let ct_joiner = joiner.send_message(b"quantum safe!").expect("joiner send"); - let pt_creator = creator - .receive_message(&ct_joiner) - .expect("creator recv") - .expect("application message"); + let pt_creator = match creator.receive_message(&ct_joiner).expect("creator recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; assert_eq!(pt_creator, b"quantum safe!"); } @@ -746,4 +746,278 @@ mod tests { "group_id must match what was passed" ); } + + /// Helper: set up a 3-party group (creator + A + B). + fn setup_three_party(hybrid: bool) -> (GroupMember, GroupMember, GroupMember) { + let creator_id = Arc::new(IdentityKeypair::generate()); + let a_id = Arc::new(IdentityKeypair::generate()); + let b_id = Arc::new(IdentityKeypair::generate()); + + let (mut creator, mut a, mut b) = if hybrid { + ( + GroupMember::new_hybrid(creator_id), + GroupMember::new_hybrid(a_id), + GroupMember::new_hybrid(b_id), + ) + } else { + ( + GroupMember::new(creator_id), + GroupMember::new(a_id), + GroupMember::new(b_id), + ) + }; + + let a_kp = a.generate_key_package().expect("A KeyPackage"); + let b_kp = b.generate_key_package().expect("B KeyPackage"); + + creator.create_group(b"three-party").expect("create group"); + + // Add A + let (_commit_a, welcome_a) = creator.add_member(&a_kp).expect("add A"); + a.join_group(&welcome_a).expect("A join"); + + // A must process the commit that added them (it's a StateChanged for A since + // the commit itself is what brought them in — but actually A joined via Welcome, + // so A doesn't process the add-commit). The creator already merged the pending + // commit in add_member, so creator is at epoch 2. + + // Add B — at this point creator is at epoch 2 (after adding A). + let (commit_b, welcome_b) = creator.add_member(&b_kp).expect("add B"); + b.join_group(&welcome_b).expect("B join"); + + // A must process the commit that added B to stay in sync. + match a.receive_message(&commit_b).expect("A recv add-B commit") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged, got {other:?}"), + } + + (creator, a, b) + } + + /// Three-party hybrid MLS round-trip: all members exchange messages. + #[test] + fn three_party_hybrid_mls_round_trip() { + let (mut creator, mut a, mut b) = setup_three_party(true); + + // Creator sends to A and B + let ct = creator.send_message(b"hello group").expect("creator send"); + let pt_a = match a.receive_message(&ct).expect("A recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + let pt_b = match b.receive_message(&ct).expect("B recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt_a, b"hello group"); + assert_eq!(pt_b, b"hello group"); + + // A sends, creator and B receive + let ct_a = a.send_message(b"from A").expect("A send"); + let pt_creator = match creator.receive_message(&ct_a).expect("creator recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + let pt_b2 = match b.receive_message(&ct_a).expect("B recv A") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt_creator, b"from A"); + assert_eq!(pt_b2, b"from A"); + } + + /// Creator adds A and B, then removes B. A and creator can still communicate. + /// B can no longer decrypt. + #[test] + fn three_party_remove_member() { + let (mut creator, mut a, mut b) = setup_three_party(false); + + // Get B's identity for removal + let b_identity = b.identity.public_key_bytes().to_vec(); + + // Creator removes B + let remove_commit = creator.remove_member(&b_identity).expect("remove B"); + + // A processes the remove commit + match a.receive_message(&remove_commit).expect("A recv remove") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged, got {other:?}"), + } + + // B processes the remove commit — should get SelfRemoved + match b.receive_message(&remove_commit).expect("B recv remove") { + ReceivedMessage::SelfRemoved => {} + other => panic!("expected SelfRemoved, got {other:?}"), + } + + // B's group should be cleared + assert!(b.group_id().is_none(), "B's group should be None after removal"); + + // Creator and A can still communicate + let ct = creator.send_message(b"after removal").expect("creator send"); + let pt = match a.receive_message(&ct).expect("A recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt, b"after removal"); + + // B cannot send (no group) + assert!(b.send_message(b"should fail").is_err()); + } + + /// A proposes to leave, creator commits the proposal, A receives SelfRemoved. + #[test] + fn leave_group_proposal() { + let (mut creator, mut a, _b) = setup_three_party(false); + + // A proposes to leave + let leave_proposal = a.leave_group().expect("A leave"); + + // Creator receives the proposal (stored as pending) + match creator.receive_message(&leave_proposal).expect("creator recv proposal") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged for proposal, got {other:?}"), + } + + // Creator should have pending proposals + assert!(creator.has_pending_proposals(), "should have pending proposal"); + + // Creator commits the pending proposals + let (commit_bytes, _welcome) = creator + .commit_pending_proposals() + .expect("commit pending"); + + // A processes the commit — should get SelfRemoved + match a.receive_message(&commit_bytes).expect("A recv commit") { + ReceivedMessage::SelfRemoved => {} + other => panic!("expected SelfRemoved, got {other:?}"), + } + + assert!(a.group_id().is_none(), "A's group should be None after leave"); + } + + /// Propose self-update, commit, other member processes the commit. + #[test] + fn propose_self_update_round_trip() { + let creator_id = Arc::new(IdentityKeypair::generate()); + let joiner_id = Arc::new(IdentityKeypair::generate()); + + let mut creator = GroupMember::new(Arc::clone(&creator_id)); + let mut joiner = GroupMember::new(Arc::clone(&joiner_id)); + + let joiner_kp = joiner.generate_key_package().expect("joiner KP"); + creator.create_group(b"update-test").expect("create"); + let (_commit, welcome) = creator.add_member(&joiner_kp).expect("add"); + joiner.join_group(&welcome).expect("join"); + + // Creator proposes a self-update + let update_proposal = creator.propose_self_update().expect("propose update"); + + // Joiner receives the proposal + match joiner.receive_message(&update_proposal).expect("joiner recv proposal") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged, got {other:?}"), + } + + // Joiner commits the pending update proposal + let (commit_bytes, _) = joiner.commit_pending_proposals().expect("commit update"); + + // Creator processes the commit + match creator.receive_message(&commit_bytes).expect("creator recv commit") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged, got {other:?}"), + } + + // Both can still communicate after the update + let ct = creator.send_message(b"post-update").expect("send"); + let pt = match joiner.receive_message(&ct).expect("recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt, b"post-update"); + } + + /// Receiving a ciphertext from a stale (lower) epoch returns an error — not a panic. + /// This is the core invariant violated by the bidirectional-/dm race condition. + #[test] + fn receive_stale_epoch_message_returns_error() { + let creator_id = Arc::new(IdentityKeypair::generate()); + let joiner_a_id = Arc::new(IdentityKeypair::generate()); + let joiner_b_id = Arc::new(IdentityKeypair::generate()); + + let mut creator = GroupMember::new(Arc::clone(&creator_id)); + let mut joiner_a = GroupMember::new(Arc::clone(&joiner_a_id)); + let mut joiner_b = GroupMember::new(Arc::clone(&joiner_b_id)); + + // Set up group with joiner_a (epoch 1 after create_group, epoch 2 after add). + let kp_a = joiner_a.generate_key_package().expect("kp_a"); + creator.create_group(b"stale-epoch-test").expect("create"); + let (_, welcome_a) = creator.add_member(&kp_a).expect("add a"); + joiner_a.join_group(&welcome_a).expect("join a"); + + // Creator sends a message at the current epoch (epoch 2). + let ct_epoch2 = creator.send_message(b"epoch-2 message").expect("send"); + + // Creator now adds joiner_b, advancing to epoch 3. joiner_a must process the commit. + let kp_b = joiner_b.generate_key_package().expect("kp_b"); + let (commit_b, welcome_b) = creator.add_member(&kp_b).expect("add b"); + joiner_b.join_group(&welcome_b).expect("join b"); + match joiner_a.receive_message(&commit_b).expect("a recv add-b commit") { + ReceivedMessage::StateChanged => {} + other => panic!("expected StateChanged, got {other:?}"), + } + + // joiner_b joined at epoch 3 via Welcome. Attempting to decrypt ct_epoch2 (epoch 2) + // must return an error, not panic. + let result = joiner_b.receive_message(&ct_epoch2); + assert!( + result.is_err(), + "decrypting an epoch-2 ciphertext in epoch-3 context must fail, not panic" + ); + } + + /// 10 messages alternating Alice→Bob and Bob→Alice all decrypt successfully. + /// Verifies that epoch state stays in sync across multiple application messages. + #[test] + fn multi_message_roundtrip_epoch_stays_in_sync() { + let alice_id = Arc::new(IdentityKeypair::generate()); + let bob_id = Arc::new(IdentityKeypair::generate()); + + let mut alice = GroupMember::new(Arc::clone(&alice_id)); + let mut bob = GroupMember::new(Arc::clone(&bob_id)); + + let bob_kp = bob.generate_key_package().expect("bob kp"); + alice.create_group(b"multi-msg-test").expect("create"); + let (_, welcome) = alice.add_member(&bob_kp).expect("add bob"); + bob.join_group(&welcome).expect("join"); + + for i in 0u32..5 { + let payload_alice = format!("alice msg {i}"); + let ct = alice.send_message(payload_alice.as_bytes()).expect("alice send"); + let pt = match bob.receive_message(&ct).expect("bob recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt, payload_alice.as_bytes()); + + let payload_bob = format!("bob reply {i}"); + let ct = bob.send_message(payload_bob.as_bytes()).expect("bob send"); + let pt = match alice.receive_message(&ct).expect("alice recv") { + ReceivedMessage::Application(pt) => pt, + other => panic!("expected Application, got {other:?}"), + }; + assert_eq!(pt, payload_bob.as_bytes()); + } + } + + /// A member who has not yet joined (no group) cannot send messages. + #[test] + fn send_before_join_returns_error() { + let id = Arc::new(IdentityKeypair::generate()); + let mut member = GroupMember::new(id); + assert!( + member.send_message(b"too early").is_err(), + "send_message before join must return an error" + ); + } } diff --git a/crates/quicproquo-core/src/lib.rs b/crates/quicproquo-core/src/lib.rs index 5c72c11..57b81ea 100644 --- a/crates/quicproquo-core/src/lib.rs +++ b/crates/quicproquo-core/src/lib.rs @@ -32,7 +32,7 @@ pub use app_message::{ serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, }; pub use error::CoreError; -pub use group::GroupMember; +pub use group::{GroupMember, ReceivedMessage, ReceivedMessageWithSender}; pub use hybrid_kem::{ hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes, HybridPublicKey, diff --git a/crates/quicproquo-p2p/src/lib.rs b/crates/quicproquo-p2p/src/lib.rs index f4abb88..761b976 100644 --- a/crates/quicproquo-p2p/src/lib.rs +++ b/crates/quicproquo-p2p/src/lib.rs @@ -15,8 +15,9 @@ use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey}; /// ALPN protocol identifier for quicproquo P2P messaging. -/// Frozen at the original project name for wire compatibility. -const P2P_ALPN: &[u8] = b"quicnprotochat/p2p/1"; +/// Updated from the original project name "quicnprotochat" to "quicproquo" (breaking wire change; +/// all peers must be on the same version to connect). +const P2P_ALPN: &[u8] = b"quicproquo/p2p/1"; /// A P2P node backed by an iroh endpoint. /// diff --git a/crates/quicproquo-server/Cargo.toml b/crates/quicproquo-server/Cargo.toml index 152a832..4be32aa 100644 --- a/crates/quicproquo-server/Cargo.toml +++ b/crates/quicproquo-server/Cargo.toml @@ -56,5 +56,8 @@ toml = { version = "0.8" } metrics = "0.22" metrics-exporter-prometheus = "0.15" +# mDNS service announcement for local mesh / Freifunk node discovery. +mdns-sd = "0.12" + [dev-dependencies] tempfile = "3" diff --git a/crates/quicproquo-server/src/config.rs b/crates/quicproquo-server/src/config.rs index cbfcd9f..13dbd4b 100644 --- a/crates/quicproquo-server/src/config.rs +++ b/crates/quicproquo-server/src/config.rs @@ -72,6 +72,7 @@ pub struct FederationPeerConfig { } #[derive(Debug)] +#[allow(dead_code)] // federation not yet wired up pub struct EffectiveFederationConfig { pub enabled: bool, pub domain: String, diff --git a/crates/quicproquo-server/src/federation/address.rs b/crates/quicproquo-server/src/federation/address.rs index e0fa399..913c92a 100644 --- a/crates/quicproquo-server/src/federation/address.rs +++ b/crates/quicproquo-server/src/federation/address.rs @@ -2,6 +2,8 @@ //! //! A bare `username` (no `@`) is treated as local. +#![allow(dead_code)] // federation not yet wired up + /// A parsed federated address. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FederatedAddress { diff --git a/crates/quicproquo-server/src/federation/client.rs b/crates/quicproquo-server/src/federation/client.rs index 84e1f4a..5072db3 100644 --- a/crates/quicproquo-server/src/federation/client.rs +++ b/crates/quicproquo-server/src/federation/client.rs @@ -2,15 +2,16 @@ //! //! Uses a lazy connection pool (DashMap) to reuse QUIC connections to known peers. +#![allow(dead_code)] // federation not yet wired up + use std::collections::HashMap; use std::net::SocketAddr; -use std::sync::Arc; use anyhow::Context; use dashmap::DashMap; use quinn::Endpoint; -use crate::config::{EffectiveFederationConfig, FederationPeerConfig}; +use crate::config::EffectiveFederationConfig; /// Outbound federation client for relaying to peer servers. pub struct FederationClient { diff --git a/crates/quicproquo-server/src/federation/mod.rs b/crates/quicproquo-server/src/federation/mod.rs index 04a666e..60cf361 100644 --- a/crates/quicproquo-server/src/federation/mod.rs +++ b/crates/quicproquo-server/src/federation/mod.rs @@ -11,6 +11,4 @@ pub mod routing; pub mod service; pub mod tls; -pub use address::FederatedAddress; pub use client::FederationClient; -pub use routing::Destination; diff --git a/crates/quicproquo-server/src/federation/tls.rs b/crates/quicproquo-server/src/federation/tls.rs index 94d1dd7..a631871 100644 --- a/crates/quicproquo-server/src/federation/tls.rs +++ b/crates/quicproquo-server/src/federation/tls.rs @@ -46,7 +46,7 @@ pub fn build_federation_server_config( let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13]) .with_client_cert_verifier(client_verifier) .with_single_cert(cert_chain, key)?; - tls.alpn_protocols = vec![b"qnpc-fed".to_vec()]; + tls.alpn_protocols = vec![b"quicproquo/federation/1".to_vec()]; let crypto = QuicServerConfig::try_from(tls) .map_err(|e| anyhow::anyhow!("invalid federation server TLS config: {e}"))?; diff --git a/crates/quicproquo-server/src/main.rs b/crates/quicproquo-server/src/main.rs index 21fac18..5cbc5bf 100644 --- a/crates/quicproquo-server/src/main.rs +++ b/crates/quicproquo-server/src/main.rs @@ -354,6 +354,65 @@ async fn main() -> anyhow::Result<()> { None }; + // ── mDNS local mesh discovery ───────────────────────────────────────────── + // Announce this server on the local network so mesh-mode clients (and other + // Freifunk nodes) can discover it automatically without manual configuration. + // Non-critical: failures are logged as warnings; the server starts regardless. + let _mdns_daemon = { + let listen_port: u16 = listen.port(); + // Use the federation domain as the mDNS instance name when available. + let mdns_instance = effective + .federation + .as_ref() + .map(|f| f.domain.clone()) + .unwrap_or_else(|| "qpq-server".to_string()); + // mDNS host names must end with a dot. + let mdns_host = if mdns_instance.ends_with('.') { + mdns_instance.clone() + } else { + format!("{mdns_instance}.local.") + }; + + match mdns_sd::ServiceDaemon::new() { + Ok(daemon) => { + let mut props = std::collections::HashMap::new(); + props.insert("ver".to_string(), "1".to_string()); + props.insert("server".to_string(), effective.listen.clone()); + props.insert("domain".to_string(), mdns_instance.clone()); + + match mdns_sd::ServiceInfo::new( + "_quicproquo._udp.local.", + &mdns_instance, + &mdns_host, + &[] as &[std::net::IpAddr], + listen_port, + Some(props), + ) { + Ok(info) => match daemon.register(info) { + Ok(()) => { + tracing::info!( + instance = %mdns_instance, + port = listen_port, + "mDNS: announced qpq server on local network (_quicproquo._udp.local.)" + ); + } + Err(e) => { + tracing::warn!(error = %e, "mDNS: service registration failed; mesh discovery disabled"); + } + }, + Err(e) => { + tracing::warn!(error = %e, "mDNS: failed to build service info; mesh discovery disabled"); + } + } + Some(daemon) + } + Err(e) => { + tracing::warn!(error = %e, "mDNS: daemon start failed; mesh discovery disabled"); + None + } + } + }; + // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet. let local = LocalSet::new(); local diff --git a/crates/quicproquo-server/src/node_service/channel_ops.rs b/crates/quicproquo-server/src/node_service/channel_ops.rs index 419ca5f..d86d313 100644 --- a/crates/quicproquo-server/src/node_service/channel_ops.rs +++ b/crates/quicproquo-server/src/node_service/channel_ops.rs @@ -51,12 +51,14 @@ impl NodeServiceImpl { )); } - let channel_id = match self.store.create_channel(&identity, &peer_key) { - Ok(id) => id, + let (channel_id, was_new) = match self.store.create_channel(&identity, &peer_key) { + Ok(pair) => pair, Err(e) => return Promise::err(storage_err(e)), }; - results.get().set_channel_id(&channel_id); + let mut r = results.get(); + r.set_channel_id(&channel_id); + r.set_was_new(was_new); Promise::ok(()) } } diff --git a/crates/quicproquo-server/src/node_service/delivery.rs b/crates/quicproquo-server/src/node_service/delivery.rs index 183e26f..4b50233 100644 --- a/crates/quicproquo-server/src/node_service/delivery.rs +++ b/crates/quicproquo-server/src/node_service/delivery.rs @@ -100,6 +100,42 @@ impl NodeServiceImpl { } } + // Federation routing: if the recipient's home server differs from ours, relay the + // message to the remote server instead of enqueueing locally. This enables + // cross-node delivery in a Freifunk / community mesh deployment. + if let (Some(fed_client), Some(local_domain)) = + (&self.federation_client, &self.local_domain) + { + let dest = crate::federation::routing::resolve_destination( + &self.store, + &recipient_key, + local_domain, + ); + if let crate::federation::routing::Destination::Remote(remote_domain) = dest { + let fed = Arc::clone(fed_client); + let rk = recipient_key; + let pl = payload; + let ch = channel_id; + tracing::info!( + recipient_prefix = %fmt_hex(&rk[..4]), + domain = %remote_domain, + "federation: routing enqueue to remote server" + ); + return Promise::from_future(async move { + let seq = fed + .relay_enqueue(&remote_domain, &rk, &pl, &ch) + .await + .map_err(|e| { + capnp::Error::failed(format!("federation relay failed: {e}")) + })?; + results.get().set_seq(seq); + metrics::record_enqueue_total(); + metrics::record_enqueue_bytes(pl.len() as u64); + Ok(()) + }); + } + } + // DM channel authz: channel_id.len() == 16 means a created channel; caller and recipient must be the two members. if channel_id.len() == 16 { let members = match self.store.get_channel_members(&channel_id) { @@ -591,7 +627,8 @@ impl NodeServiceImpl { } } - let mut seqs = Vec::with_capacity(recipient_keys.len() as usize); + // Eagerly collect recipient keys so params can be dropped before any async work. + let mut recipient_key_vecs: Vec> = Vec::with_capacity(recipient_keys.len() as usize); for i in 0..recipient_keys.len() { let rk = match recipient_keys.get(i) { Ok(v) => v.to_vec(), @@ -604,7 +641,7 @@ impl NodeServiceImpl { )); } - // Per-recipient DM channel membership check. + // Per-recipient DM channel membership check (only when channel_id is a 16-byte UUID). if channel_id.len() == 16 { let members = match self.store.get_channel_members(&channel_id) { Ok(Some(m)) => m, @@ -631,44 +668,79 @@ impl NodeServiceImpl { } } - match self.store.queue_depth(&rk, &channel_id) { - Ok(depth) if depth >= MAX_QUEUE_DEPTH => { - return Promise::err(coded_error( - E015_QUEUE_FULL, - format!("queue depth {} exceeds limit {}", depth, MAX_QUEUE_DEPTH), - )); - } - Err(e) => return Promise::err(storage_err(e)), - _ => {} + recipient_key_vecs.push(rk); + } + + let n = recipient_key_vecs.len(); + let store = Arc::clone(&self.store); + let waiters = Arc::clone(&self.waiters); + let fed_client = self.federation_client.clone(); + let local_domain = self.local_domain.clone(); + + // Use an async future to support federation relay alongside local enqueue. + // All storage operations are synchronous; only federation relay calls are await-ed. + Promise::from_future(async move { + let mut seqs = Vec::with_capacity(n); + + for rk in &recipient_key_vecs { + // Federation routing: relay to the recipient's home server when remote. + let dest = if let (Some(ref _fed), Some(ref domain)) = (&fed_client, &local_domain) { + crate::federation::routing::resolve_destination(&store, rk, domain) + } else { + crate::federation::routing::Destination::Local + }; + + let seq = match dest { + crate::federation::routing::Destination::Remote(ref remote_domain) => { + let fed = fed_client.as_deref().ok_or_else(|| { + capnp::Error::failed("federation client unavailable for remote routing".into()) + })?; + tracing::info!( + recipient_prefix = %fmt_hex(&rk[..4]), + domain = %remote_domain, + "federation: routing batch enqueue to remote server" + ); + fed.relay_enqueue(remote_domain, rk, &payload, &channel_id) + .await + .map_err(|e| { + capnp::Error::failed(format!("federation relay failed: {e}")) + })? + } + crate::federation::routing::Destination::Local => { + match store.queue_depth(rk, &channel_id) { + Ok(depth) if depth >= MAX_QUEUE_DEPTH => { + return Err(coded_error( + E015_QUEUE_FULL, + format!("queue depth {} exceeds limit {MAX_QUEUE_DEPTH}", depth), + )); + } + Err(e) => return Err(storage_err(e)), + _ => {} + } + store + .enqueue(rk, &channel_id, payload.clone()) + .map_err(storage_err)? + } + }; + + seqs.push(seq); + metrics::record_enqueue_total(); + metrics::record_enqueue_bytes(payload.len() as u64); + crate::auth::waiter(&waiters, rk).notify_waiters(); } - let seq = match self - .store - .enqueue(&rk, &channel_id, payload.clone()) - .map_err(storage_err) - { - Ok(seq) => seq, - Err(e) => return Promise::err(e), - }; - seqs.push(seq); + let mut list = results.get().init_seqs(seqs.len() as u32); + for (i, seq) in seqs.iter().enumerate() { + list.set(i as u32, *seq); + } - metrics::record_enqueue_total(); - metrics::record_enqueue_bytes(payload.len() as u64); + tracing::info!( + recipient_count = n, + payload_len = payload.len(), + "audit: batch_enqueue" + ); - crate::auth::waiter(&self.waiters, &rk).notify_waiters(); - } - - let mut list = results.get().init_seqs(seqs.len() as u32); - for (i, seq) in seqs.iter().enumerate() { - list.set(i as u32, *seq); - } - - tracing::info!( - recipient_count = recipient_keys.len(), - payload_len = payload.len(), - "audit: batch_enqueue" - ); - - Promise::ok(()) + Ok(()) + }) } } diff --git a/crates/quicproquo-server/src/sql_store.rs b/crates/quicproquo-server/src/sql_store.rs index 3151c36..cd4796b 100644 --- a/crates/quicproquo-server/src/sql_store.rs +++ b/crates/quicproquo-server/src/sql_store.rs @@ -457,7 +457,7 @@ impl Store for SqlStore { .map_err(|e| StorageError::Db(e.to_string())) } - fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result, StorageError> { + fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec, bool), StorageError> { let (a, b) = if member_a < member_b { (member_a.to_vec(), member_b.to_vec()) } else { @@ -473,7 +473,7 @@ impl Store for SqlStore { .optional() .map_err(|e| StorageError::Db(e.to_string()))?; if let Some(id) = existing { - return Ok(id); + return Ok((id, false)); } let mut channel_id = [0u8; 16]; rand::thread_rng().fill_bytes(&mut channel_id); @@ -482,7 +482,7 @@ impl Store for SqlStore { params![channel_id.as_slice(), a, b], ) .map_err(|e| StorageError::Db(e.to_string()))?; - Ok(channel_id.to_vec()) + Ok((channel_id.to_vec(), true)) } fn get_channel_members(&self, channel_id: &[u8]) -> Result, Vec)>, StorageError> { @@ -721,4 +721,107 @@ mod tests { let b_msgs = store.fetch(&rk, b"ch-b").unwrap(); assert_eq!(b_msgs, vec![(0u64, b"b1".to_vec())]); } + + #[test] + fn create_channel_was_new_first_call() { + let store = open_in_memory(); + let a = [10u8; 32]; + let b = [11u8; 32]; + let (id, was_new) = store.create_channel(&a, &b).unwrap(); + assert_eq!(id.len(), 16, "channel_id must be 16 bytes"); + assert!(was_new, "first create_channel must return was_new=true"); + } + + #[test] + fn create_channel_idempotent_same_direction() { + let store = open_in_memory(); + let a = [12u8; 32]; + let b = [13u8; 32]; + let (id1, was_new1) = store.create_channel(&a, &b).unwrap(); + let (id2, was_new2) = store.create_channel(&a, &b).unwrap(); + assert_eq!(id1, id2, "repeated call must return same channel_id"); + assert!(was_new1); + assert!(!was_new2, "second call must return was_new=false"); + } + + #[test] + fn create_channel_idempotent_reversed_direction() { + let store = open_in_memory(); + let a = [14u8; 32]; + let b = [15u8; 32]; + let (id1, was_new1) = store.create_channel(&a, &b).unwrap(); + let (id2, was_new2) = store.create_channel(&b, &a).unwrap(); + assert_eq!(id1, id2, "reversed-key call must return same channel_id"); + assert!(was_new1); + assert!(!was_new2, "reversed-key second call must return was_new=false"); + } + + #[test] + fn create_channel_different_pairs_isolated() { + let store = open_in_memory(); + let a = [16u8; 32]; + let b = [17u8; 32]; + let c = [18u8; 32]; + let (id_ab, _) = store.create_channel(&a, &b).unwrap(); + let (id_ac, _) = store.create_channel(&a, &c).unwrap(); + let (id_bc, _) = store.create_channel(&b, &c).unwrap(); + assert_ne!(id_ab, id_ac); + assert_ne!(id_ab, id_bc); + assert_ne!(id_ac, id_bc); + } + + #[test] + fn create_channel_get_members_roundtrip() { + let store = open_in_memory(); + let a = [20u8; 32]; + let b = [21u8; 32]; + let (id, _) = store.create_channel(&a, &b).unwrap(); + let members = store.get_channel_members(&id).unwrap(); + assert!(members.is_some(), "get_channel_members must return Some after create"); + let (ma, mb) = members.unwrap(); + // members stored in canonical (lex) order + let (expected_a, expected_b) = if a < b { + (a.to_vec(), b.to_vec()) + } else { + (b.to_vec(), a.to_vec()) + }; + assert_eq!(ma, expected_a); + assert_eq!(mb, expected_b); + } + + #[test] + fn get_channel_members_unknown_id_returns_none() { + let store = open_in_memory(); + assert!(store.get_channel_members(&[0u8; 16]).unwrap().is_none()); + } + + #[test] + fn resolve_identity_key_after_store() { + let store = open_in_memory(); + let ik = [30u8; 32]; + store.store_user_record("carol", b"record".to_vec()).unwrap(); + store.store_user_identity_key("carol", ik.to_vec()).unwrap(); + let resolved = store.resolve_identity_key(&ik).unwrap(); + assert_eq!(resolved, Some("carol".to_string())); + } + + #[test] + fn resolve_identity_key_unknown_returns_none() { + let store = open_in_memory(); + let unknown = [31u8; 32]; + assert!(store.resolve_identity_key(&unknown).unwrap().is_none()); + } + + #[test] + fn resolve_identity_key_two_users_distinct() { + let store = open_in_memory(); + let ik_a = [32u8; 32]; + let ik_b = [33u8; 32]; + store.store_user_record("user_a", b"ra".to_vec()).unwrap(); + store.store_user_record("user_b", b"rb".to_vec()).unwrap(); + store.store_user_identity_key("user_a", ik_a.to_vec()).unwrap(); + store.store_user_identity_key("user_b", ik_b.to_vec()).unwrap(); + assert_eq!(store.resolve_identity_key(&ik_a).unwrap(), Some("user_a".to_string())); + assert_eq!(store.resolve_identity_key(&ik_b).unwrap(), Some("user_b".to_string())); + } } diff --git a/crates/quicproquo-server/src/storage.rs b/crates/quicproquo-server/src/storage.rs index 4d67b18..c7a3728 100644 --- a/crates/quicproquo-server/src/storage.rs +++ b/crates/quicproquo-server/src/storage.rs @@ -127,9 +127,12 @@ pub trait Store: Send + Sync { /// Resolve a peer's P2P endpoint address. fn resolve_endpoint(&self, identity_key: &[u8]) -> Result>, StorageError>; - /// Create a 1:1 channel between two members. Returns 16-byte channel_id (UUID). - /// Members are stored in sorted order for deterministic lookup. - fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result, StorageError>; + /// Create a 1:1 channel between two members. + /// Returns `(channel_id, was_new)` where `was_new` is true iff the channel was created by + /// this call (false = it already existed). Members are stored in sorted order for deterministic + /// lookup — both `create_channel(a, b)` and `create_channel(b, a)` return the same channel_id. + /// The caller who receives `was_new = true` is the MLS group initiator and must send the Welcome. + fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec, bool), StorageError>; /// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order. fn get_channel_members(&self, channel_id: &[u8]) -> Result, Vec)>, StorageError>; @@ -137,6 +140,7 @@ pub trait Store: Send + Sync { // ── Federation ────────────────────────────────────────────────────────── /// Store the home server domain for an identity key. + #[allow(dead_code)] // federation not yet wired up fn store_identity_home_server( &self, identity_key: &[u8], @@ -157,6 +161,7 @@ pub trait Store: Send + Sync { ) -> Result<(), StorageError>; /// List all active federation peers. + #[allow(dead_code)] // federation not yet wired up fn list_federation_peers(&self) -> Result, StorageError>; } @@ -647,7 +652,7 @@ impl Store for FileBackedStore { Ok(map.get(identity_key).cloned()) } - fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result, StorageError> { + fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec, bool), StorageError> { let (a, b) = if member_a < member_b { (member_a.to_vec(), member_b.to_vec()) } else { @@ -655,14 +660,14 @@ impl Store for FileBackedStore { }; let mut map = lock(&self.channels)?; if let Some((channel_id, _)) = map.iter().find(|(_, (ma, mb))| ma == &a && mb == &b) { - return Ok(channel_id.clone()); + return Ok((channel_id.clone(), false)); } let mut channel_id = [0u8; 16]; rand::thread_rng().fill_bytes(&mut channel_id); let channel_id = channel_id.to_vec(); map.insert(channel_id.clone(), (a, b)); self.flush_channels(&self.channels_path, &*map)?; - Ok(channel_id) + Ok((channel_id, true)) } fn get_channel_members(&self, channel_id: &[u8]) -> Result, Vec)>, StorageError> { @@ -812,12 +817,40 @@ mod tests { let a = vec![1u8; 32]; let b = vec![2u8; 32]; assert_eq!(store.get_channel_members(&[0u8; 16]).unwrap(), None); - let id1 = store.create_channel(&a, &b).unwrap(); + let (id1, was_new1) = store.create_channel(&a, &b).unwrap(); assert_eq!(id1.len(), 16); + assert!(was_new1, "first call must return was_new=true"); let members = store.get_channel_members(&id1).unwrap().unwrap(); assert_eq!(members.0, a); assert_eq!(members.1, b); - let id2 = store.create_channel(&b, &a).unwrap(); + let (id2, was_new2) = store.create_channel(&b, &a).unwrap(); + assert_eq!(id1, id2, "reversed key order must return same channel_id"); + assert!(!was_new2, "second call (reversed) must return was_new=false"); + } + + #[test] + fn create_channel_idempotent_same_direction() { + let (_dir, store) = temp_store(); + let a = vec![3u8; 32]; + let b = vec![4u8; 32]; + let (id1, was_new1) = store.create_channel(&a, &b).unwrap(); + let (id2, was_new2) = store.create_channel(&a, &b).unwrap(); assert_eq!(id1, id2); + assert!(was_new1); + assert!(!was_new2); + } + + #[test] + fn create_channel_different_pairs_get_different_ids() { + let (_dir, store) = temp_store(); + let a = vec![5u8; 32]; + let b = vec![6u8; 32]; + let c = vec![7u8; 32]; + let (id_ab, _) = store.create_channel(&a, &b).unwrap(); + let (id_ac, _) = store.create_channel(&a, &c).unwrap(); + let (id_bc, _) = store.create_channel(&b, &c).unwrap(); + assert_ne!(id_ab, id_ac); + assert_ne!(id_ab, id_bc); + assert_ne!(id_ac, id_bc); } } diff --git a/schemas/node.capnp b/schemas/node.capnp index 0c2d9be..f228adf 100644 --- a/schemas/node.capnp +++ b/schemas/node.capnp @@ -82,7 +82,10 @@ interface NodeService { # Create a 1:1 channel between the caller and the given peer. Returns a 16-byte channelId (UUID). # Both members can enqueue/fetch for this channel; recipientKey must be the other member. - createChannel @18 (peerKey :Data, auth :Auth) -> (channelId :Data); + # wasNew is true iff this call created the channel; false if it already existed. + # The caller who receives wasNew=true is the MLS group initiator and must send the Welcome. + # The caller who receives wasNew=false must wait for the peer's Welcome via the background poller. + createChannel @18 (peerKey :Data, auth :Auth) -> (channelId :Data, wasNew :Bool); # Resolve a username to its Ed25519 identity key (32 bytes). # Returns empty Data if the username is not registered.