diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml deleted file mode 100644 index e43c07a..0000000 --- a/.cargo/mutants.toml +++ /dev/null @@ -1,55 +0,0 @@ -# cargo-mutants configuration -# Run with: cargo mutants -# Install with: cargo install cargo-mutants - -# Exclude entire files that are untestable (main requires full app, setup requires interactive stdin) -exclude_globs = [ - "src/main.rs", - "src/setup.rs", -] - -# Exclude specific functions that require hardware, network, terminal, or full async infrastructure -exclude_re = [ - # Equivalent mutants: Instant arithmetic never produces exact equality, - # so `< ttl` and `<= ttl` behave identically in practice. - "replace < with <= in DedupCache", - "replace < with <= in decode_peer_list", - # rate_limit.rs: equivalent mutant (secs is always > 0 from lora_air_time) - "replace > with >= in RateLimiter::from_radio_config", - # radio.rs: hardware-dependent functions - "radio_loop", - "connect_and_run", - # gossip.rs: async network functions - "Gossip::new", - "Gossip::shutdown", - "spawn_accept_loop", - "handle_incoming", - "spawn_receiver", - "spawn_broadcaster", - "spawn_dht_heartbeat", - "dht_read_and_join", - "dht_publish", - "dht_get_peers", - # router.rs: async event loop - "^src/router\\.rs:.* replace run ", - "^src/router\\.rs:.* in run$", - # tui.rs: terminal rendering functions - "tui::run", - "tui_loop", - "restore_terminal", - "draw_ui", - "draw_header", - "draw_network", - "draw_radio", - "draw_stats", - "draw_log", - "draw_activity", - "draw_sparkline_section", - "draw_shutdown_overlay", - "panel_block", - "status_pill", - "kv_line", -] - -# Timeout per mutant test (seconds) -timeout_multiplier = 3.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index bdef620..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libudev-dev - - - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.94.1" - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@v2 - - - name: Format check - run: cargo fmt --check - - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Test - run: cargo test - - - name: Install cargo-deny - run: cargo install cargo-deny --locked - - - name: Audit (advisories + licenses) - run: cargo deny check diff --git a/.gitignore b/.gitignore deleted file mode 100644 index f843f2d..0000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -/target -/fuzz/target -/fuzz/corpus -/fuzz/Cargo.lock -/mutants.out -/mutants.out.old -.jj/ -*.swp -*.swo -*~ -.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7ddbac6..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,41 +0,0 @@ -# Changelog - -## [0.2.3] - 2026-04-06 - -### Changed - -- README: clarify that the bridge relays raw LoRa packets and works with any - protocol (Meshtastic, MeshCore, etc.) simultaneously. - -## [0.2.2] - 2026-04-06 - -### Fixed - -- TUI now fits in 80-column terminals (was 86). - -## [0.2.1] - 2026-04-06 - -### Fixed - -- CI: removed stale sibling-clone step, added `libudev-dev` dependency, - upgraded `actions/checkout` to v5 (Node.js 24). - -## [0.2.0] - 2026-04-06 - -### Added - -- Auto-detect connection mode: when no `port` is configured, the bridge now - tries the mux daemon first and falls back to direct USB serial - (`find_port()`) if the mux is unavailable. Previously it would retry the mux - forever. - -### Changed - -- Config negotiation on direct serial connections now pushes `SetConfig` with - the desired radio config when it differs from the dongle's current config. - On mux connections, the existing behavior (accept the mux's config) is - unchanged. - -## [0.1.0] - 2026-04-06 - -Initial release. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 263c12e..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,5539 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[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 = "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 = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[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", - "http", - "log", - "url", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[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 = "base32" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "blake3" -version = "1.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -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", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cc" -version = "1.2.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[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.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[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 0.2.17", - "curve25519-dalek-derive", - "digest 0.11.0-rc.10", - "fiat-crypto", - "rand_core 0.9.5", - "rustc_version", - "serde", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[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]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "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.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[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 = "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", - "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 = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[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", - "crypto-common 0.2.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "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 = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "donglora-bridge" -version = "0.2.3" -dependencies = [ - "anyhow", - "blake3", - "bytes", - "clap", - "crossterm", - "dirs", - "donglora-client", - "futures-lite", - "hex", - "iroh", - "iroh-gossip", - "jiff", - "mainline", - "proptest", - "rand 0.9.2", - "ratatui", - "serde", - "sha2 0.10.9", - "tempfile", - "tokio", - "tokio-util", - "toml", - "tracing", - "tracing-appender", - "tracing-subscriber", - "zeroize", -] - -[[package]] -name = "donglora-client" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162468289ac189a798c4ecd961ed92c5dde2d9c5709d779ae324e0bb9a5ac4dc" -dependencies = [ - "anyhow", - "serialport", - "tracing", - "ucobs", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ed25519" -version = "3.0.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" -dependencies = [ - "pkcs8", - "serde", - "signature", -] - -[[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", - "ed25519", - "rand_core 0.9.5", - "serde", - "sha2 0.11.0-rc.2", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[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", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set 0.5.3", - "regex", -] - -[[package]] -name = "fastbloom" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" -dependencies = [ - "getrandom 0.3.4", - "libm", - "rand 0.9.2", - "siphasher", -] - -[[package]] -name = "fastrand" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -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 = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset 0.5.7", - "futures-core", - "futures-lite", - "pin-project", - "smallvec", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[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", - "windows-result", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", - "wasm-bindgen", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[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 = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[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.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -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 = "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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -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 = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[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 = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "io-kit-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" -dependencies = [ - "core-foundation-sys", - "mach2", -] - -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "iroh" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb56e7e4b0ec7fba7efa6a236b016a52b5d927d50244aceb9e20566159b1a32" -dependencies = [ - "backon", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-util", - "getrandom 0.3.4", - "hickory-resolver", - "http", - "ipnet", - "iroh-base", - "iroh-metrics", - "iroh-relay", - "n0-error", - "n0-future", - "n0-watcher", - "netwatch", - "noq", - "noq-proto", - "noq-udp", - "papaya", - "pin-project", - "pkarr", - "pkcs8", - "portable-atomic", - "portmapper", - "rand 0.9.2", - "reqwest", - "rustc-hash", - "rustls", - "rustls-pki-types", - "rustls-webpki", - "serde", - "smallvec", - "strum 0.28.0", - "sync_wrapper", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", - "webpki-roots", -] - -[[package]] -name = "iroh-base" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" -dependencies = [ - "curve25519-dalek", - "data-encoding", - "derive_more", - "digest 0.11.0-rc.10", - "ed25519-dalek", - "n0-error", - "rand_core 0.9.5", - "serde", - "sha2 0.11.0-rc.2", - "url", - "zeroize", - "zeroize_derive", -] - -[[package]] -name = "iroh-gossip" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4db5b64f3cb0a0c8b68b57888acd4cefcd2f0774f1a132d2a498cbb2a92fbc55" -dependencies = [ - "blake3", - "bytes", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-concurrency", - "futures-lite", - "futures-util", - "hex", - "indexmap", - "iroh", - "iroh-base", - "iroh-metrics", - "irpc", - "n0-error", - "n0-future", - "postcard", - "rand 0.9.2", - "serde", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "iroh-metrics" -version = "0.38.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" -dependencies = [ - "iroh-metrics-derive", - "itoa", - "n0-error", - "portable-atomic", - "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", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "iroh-relay" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d786b260cadfe82ae0b6a9e372e8c78949096a06c857d1c3521355cefced0f55" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more", - "getrandom 0.3.4", - "hickory-resolver", - "http", - "http-body-util", - "hyper", - "hyper-util", - "iroh-base", - "iroh-metrics", - "lru", - "n0-error", - "n0-future", - "noq", - "noq-proto", - "num_enum", - "pin-project", - "pkarr", - "postcard", - "rand 0.9.2", - "reqwest", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "strum 0.28.0", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "vergen-gitcl", - "webpki-roots", - "ws_stream_wasm", - "z32", -] - -[[package]] -name = "irpc" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f47b7c52662d673df377b5ac40c121c7ff56eb764e520fae6543686132f7957" -dependencies = [ - "futures-util", - "irpc-derive", - "n0-error", - "n0-future", - "serde", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "irpc-derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c1a4b460634aeed6dc01236a0047867de70e30562d91a0ad031dcb3ac33fb4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-sys 0.61.2", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "js-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.184" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" -dependencies = [ - "libc", -] - -[[package]] -name = "libudev" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" -dependencies = [ - "libc", - "libudev-sys", -] - -[[package]] -name = "libudev-sys" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", -] - -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "mainline" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff27d378ca495eaf3be8616d5d7319c1c18e93fd60e13698fcdc7e19448f1a4" -dependencies = [ - "crc", - "document-features", - "dyn-clone", - "ed25519-dalek", - "flume", - "futures-lite", - "getrandom 0.3.4", - "lru", - "serde", - "serde_bencode", - "serde_bytes", - "sha1_smol", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "n0-error" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" -dependencies = [ - "anyhow", - "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", - "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", - "n0-error", - "n0-future", -] - -[[package]] -name = "netdev" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "libc", - "mac-addr", - "netlink-packet-core", - "netlink-packet-route", - "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.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" -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.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1b27babe89ef9f2237bc6c028bea24fa84163a1b6f8f17ff93573ebd7d861f" -dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more", - "js-sys", - "libc", - "n0-error", - "n0-future", - "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route", - "netlink-proto", - "netlink-sys", - "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", - "pin-project-lite", - "serde", - "socket2", - "time", - "tokio", - "tokio-util", - "tracing", - "web-sys", - "windows", - "windows-result", - "wmi", -] - -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "noq" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf" -dependencies = [ - "bytes", - "cfg_aliases", - "noq-proto", - "noq-udp", - "pin-project-lite", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "web-time", -] - -[[package]] -name = "noq-proto" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" -dependencies = [ - "aes-gcm", - "bytes", - "derive_more", - "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 = "noq-udp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44" -dependencies = [ - "cfg_aliases", - "libc", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "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 = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "papaya" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[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 = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - -[[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.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkarr" -version = "5.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" -dependencies = [ - "base32", - "bytes", - "cfg_aliases", - "document-features", - "ed25519-dalek", - "getrandom 0.4.2", - "ntimestamp", - "self_cell", - "serde", - "simple-dns", - "thiserror 2.0.18", -] - -[[package]] -name = "pkcs8" -version = "0.11.0-rc.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -dependencies = [ - "serde", -] - -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "portmapper" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" -dependencies = [ - "base64", - "bytes", - "derive_more", - "futures-lite", - "futures-util", - "hyper-util", - "igd-next", - "iroh-metrics", - "libc", - "n0-error", - "netwatch", - "num_enum", - "rand 0.9.2", - "serde", - "smallvec", - "socket2", - "time", - "tokio", - "tokio-util", - "tower-layer", - "tracing", - "url", -] - -[[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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.0", - "num-traits", - "rand 0.9.2", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.0", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", - "kasuari", - "lru", - "strum 0.27.2", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum 0.27.2", - "time", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -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", - "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", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[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 = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bencode" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" -dependencies = [ - "serde", - "serde_bytes", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -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 = "serialport" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "core-foundation", - "core-foundation-sys", - "io-kit-sys", - "libudev", - "mach2", - "nix 0.26.4", - "scopeguard", - "unescaper", - "windows-sys 0.52.0", -] - -[[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.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "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 0.2.17", - "digest 0.11.0-rc.10", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "3.0.0-rc.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" - -[[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.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df350943049174c4ae8ced56c604e28270258faec12a6a48637a7655287c9ce0" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "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 = "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.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -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 0.27.2", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset 0.4.2", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix 0.29.0", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "js-sys", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "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", - "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 = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" -dependencies = [ - "crossbeam-channel", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "ucobs" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fbab511bafd13c109aff41f67d4c4426bf919a6670647e69f0d2af95ac01d" - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[package]] -name = "unescaper" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[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_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "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 = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[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 = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "wmi" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" -dependencies = [ - "chrono", - "futures", - "log", - "serde", - "thiserror 2.0.18", - "windows", - "windows-core", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[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 = "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 = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "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.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index e621aa7..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "donglora-bridge" -version = "0.2.3" -edition = "2024" -authors = ["Stephen Waits "] -license = "MIT" -description = "Peer-to-peer LoRa bridge using iroh and DongLoRa" -repository = "https://github.com/swaits/donglora-bridge" -homepage = "https://github.com/swaits/donglora-bridge" -keywords = ["lora", "bridge", "gossip", "p2p", "iot"] -categories = ["network-programming", "command-line-utilities"] -readme = "README.md" -rust-version = "1.94.1" -exclude = [".jj/", ".github/"] - -[dependencies] -donglora-client = "0.1" -iroh = "0.97" -iroh-gossip = "0.97" -mainline = { version = "6", features = ["async"] } -tokio = { version = "1", features = ["full"] } -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -toml = "1" -blake3 = "1" -ratatui = "0.30" -crossterm = "0.29" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" -anyhow = "1" -dirs = "6" -bytes = "1" -futures-lite = "2" -rand = "0.9" -hex = "0.4.3" -sha2 = "0.10" -tokio-util = { version = "0.7.18", features = ["rt"] } -jiff = "0.2.23" -zeroize = "1" - -[dev-dependencies] -proptest = "1" -tempfile = "3" - -[lints.clippy] -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -unwrap_used = "deny" -expect_used = "deny" -panic = "deny" -similar_names = "allow" -module_name_repetitions = "allow" -missing_errors_doc = "allow" diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a4ad6f1..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Stephen Waits - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index 0238f4e..b05140d 100644 --- a/README.md +++ b/README.md @@ -1,293 +1,25 @@ -# donglora-bridge +# donglora-bridge has moved -[![CI](https://github.com/swaits/donglora-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/swaits/donglora-bridge/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Rust](https://img.shields.io/badge/rust-1.94.1%2B-orange) +This repository has moved to the +[**donglora** GitHub organization](https://github.com/donglora): -**donglora-bridge** relays raw LoRa packets over the internet. Every packet your -radio hears is forwarded to every other bridge running the same passphrase, and -their radios retransmit it locally. Because it operates on raw packets, it works -with any protocol built on LoRa -- -[Meshtastic](https://meshtastic.org/), -[MeshCore](https://github.com/rocketgithub/meshcore-firmware), -or anything else -- simultaneously and without configuration. If your radio can -hear it, the bridge will relay it. +**[donglora/bridge](https://github.com/donglora/bridge)** -[LoRa](https://en.wikipedia.org/wiki/LoRa) is a long-range, low-power wireless -modulation used by off-grid mesh networks. A single radio might cover a few -kilometers. This bridge removes that limit, extending a local LoRa network -across a larger geographic area. +donglora-bridge connects LoRa radios to the internet so packets heard by +one radio can be retransmitted by another radio anywhere in the world. +Bridges discover each other via the Mainline DHT and communicate through +an encrypted peer-to-peer gossip swarm — no central server required. -There is no central server. Bridges discover each other automatically using the -[Mainline DHT](https://en.wikipedia.org/wiki/Mainline_DHT) and communicate -through an encrypted peer-to-peer gossip swarm. +## Other DongLoRa repos -![TUI Screenshot](screenshot.png) +| What | Location | +|------|----------| +| Firmware | [donglora/firmware](https://github.com/donglora/firmware) | +| Rust client library | [donglora/client-rs](https://github.com/donglora/client-rs) | +| Python client library | [donglora/client-py](https://github.com/donglora/client-py) | +| Rust mux daemon | [donglora/mux-rs](https://github.com/donglora/mux-rs) | +| Python mux daemon | [donglora/mux-py](https://github.com/donglora/mux-py) | +| MeshCore AI bot | [donglora/ai-bot](https://github.com/donglora/ai-bot) | +| Example scripts | [donglora/examples](https://github.com/donglora/examples) | -## What you need - -### 1. A DongLoRa radio - -[DongLoRa](https://github.com/swaits/donglora) is open-source firmware that -turns a supported LoRa development board (such as a Heltec WiFi LoRa 32 or -similar ESP32+SX1262 board) into a USB LoRa dongle. You will need to: - -1. **Get a board** -- any ESP32 board with an SX1262 LoRa radio that DongLoRa - supports. See the [DongLoRa README](https://github.com/swaits/donglora) for - the full list. -2. **Flash the DongLoRa firmware** onto the board. Instructions are in the - DongLoRa repository. -3. **Plug it in via USB**. The board appears as a serial device - (e.g. `/dev/ttyACM0` on Linux). - -### 2. (Optional) donglora-mux - -The bridge works fine without the mux -- it connects directly to the USB device. -The mux is only needed if you want multiple programs to share a single DongLoRa -radio at the same time (e.g. running two bridges with different configs, or a -bridge alongside another donglora-client program). - -```sh -cargo install donglora-mux -``` - -Run `donglora-mux` before starting the bridge. The bridge auto-detects the mux -when it's running and falls back to direct USB serial when it's not. - -### 3. donglora-bridge (this project) - -Install via cargo: - -```sh -cargo install donglora-bridge -``` - -Or via [mise](https://mise.jdx.dev/): - -```sh -mise use -g cargo:donglora-bridge -``` - -Or build from source: - -```sh -git clone https://github.com/swaits/donglora-bridge.git -cd donglora-bridge -cargo build --release -``` - -Requires Linux. macOS may work but is untested. - -## Quick start - -### 1. Plug in your DongLoRa board and (optionally) start the mux - -```sh -donglora-mux # optional -- skip if not sharing the radio -``` - -### 2. Run the bridge - -```sh -donglora-bridge -``` - -On first run, an interactive wizard walks you through configuration: - -- **Passphrase** (required) -- every bridge using the same passphrase joins the - same network. Pick something unique to your group. -- **Radio settings** -- frequency, bandwidth, spreading factor, etc. The - defaults match MeshCore US/Canada recommended values. If you're bridging a - MeshCore network, just press Enter through these. - -The config is saved to `~/.config/donglora-bridge/config.toml`. To change it -later, run `donglora-bridge config`. - -### 3. That's it - -The TUI shows real-time stats. On another machine (or continent), repeat the -same steps with the same passphrase. The two bridges will discover each other -via DHT and start relaying packets within seconds. - -```sh -donglora-bridge # TUI mode (default) -donglora-bridge --log-only # headless mode (structured logs to stdout) -donglora-bridge --config path.toml # custom config file -donglora-bridge config # re-run setup wizard -``` - -### Running multiple bridges - -To run more than one bridge on the same machine (e.g. bridging two separate -MeshCore networks), create a config file per bridge and run each with -`--config`: - -```sh -donglora-bridge --config ~/bridge-network-a.toml -donglora-bridge --config ~/bridge-network-b.toml -``` - -Each config can specify a different passphrase, different radio settings, and -(if using the mux) share the same radio. Without the mux, each bridge needs its -own USB dongle and an explicit `port` in its config to avoid both trying to open -the same device. - -## Features - -- **Gossip swarm** -- broadcast packets via [iroh-gossip](https://github.com/n0-computer/iroh), a QUIC-based overlay network -- **Zero-config peer discovery** -- automatic bootstrapping via BitTorrent mainline DHT ([BEP44](http://bittorrent.org/beps/bep_0044.html) mutable items) -- **Content-hash deduplication** -- blake3 hashing suppresses duplicate packets across all bridges -- **Air-time-aware rate limiting** -- token bucket calculated from LoRa radio parameters (Semtech LoRa time-on-air formula) -- **Real-time terminal UI** -- network status, radio config, packet log, activity sparklines -- **Resilient radio connection** -- exponential backoff reconnection with liveness pings -- **Headless mode** -- structured logs to stdout for servers (`--log-only`) -- **Interactive setup wizard** -- guided first-run configuration -- **Shared passphrase security** -- all cryptographic keys derived deterministically from a single passphrase - -## TUI overview - -| Panel | Content | -|-------|---------| -| **Network** | Topic ID, peer count, DHT status, last publish time | -| **Radio** | Device, frequency, bandwidth, SF, CR, sync word, TX power, preamble, CAD | -| **Stats** | Uptime, RF/Net RX/TX, dedup hits, rate limit drops, queue drops | -| **Packet Log** | Scrolling log: age, hash, RF/Net direction, size, RSSI, SNR, action | -| **Activity** | Sparkline charts: RX rate, TX rate, average SNR (1-second buckets, 5-minute window) | - -Press `q` or `Ctrl+C` to shut down gracefully. - -## Configuration - -Default location: `~/.config/donglora-bridge/config.toml` - -```toml -[radio] -# port = "/dev/ttyACM0" # serial port (omit for auto-detect: tries mux, then USB) -frequency = 910525000 # Hz (default: 910.525 MHz) -bandwidth = "62.5kHz" # 7.8kHz, 10.4kHz, 15.6kHz, 20.8kHz, 31.25kHz, - # 41.7kHz, 62.5kHz, 125kHz, 250kHz, 500kHz -spreading_factor = 7 # 7-12 -coding_rate = 5 # 5-8 (meaning 4/5 to 4/8) -sync_word = 0x3444 # hex, 2 bytes -tx_power = "22" # dBm integer or "max" -preamble = 16 # symbols -cad = true # channel activity detection - -[bridge] -passphrase = "your-shared-secret" # REQUIRED: all bridges with same passphrase join the same swarm -dedup_window_secs = 300 # duplicate suppression window (default: 5 minutes) -tx_queue_size = 32 # max pending radio TX packets -# rate_limit_pps = 5.0 # optional: override calculated TX rate (packets/sec) -# log_file = "/path/to/bridge.log" # optional: override log path -``` - -Default radio settings match MeshCore US/Canada recommended parameters. - -## How it works - -### Passphrase key derivation - -A single passphrase deterministically derives three values: - -1. **Topic ID** -- `blake3(APP_PREFIX + passphrase)` -- identifies the gossip swarm -2. **DHT signing key** -- ed25519 from `SHA-256(APP_PREFIX + "sign:" + passphrase)` -- authenticates DHT records -3. **DHT salt** -- first 20 bytes of `SHA-256(APP_PREFIX + "salt:" + passphrase)` -- namespaces the DHT key - -All nodes with the same passphrase compute identical keys and automatically join -the same network. - -### Gossip swarm - -Each bridge creates an ephemeral iroh endpoint (fresh ed25519 identity per -launch) and subscribes to the derived topic. Radio packets are wrapped in a -`GossipFrame` (32-byte sender ID + RSSI + SNR + payload) and broadcast to all -neighbors. Received frames are decoded and queued for radio transmission. - -### DHT peer discovery - -Peers find each other through BitTorrent's mainline DHT using BEP44 mutable -items. Peer lists are encoded as `[count:u16 LE][id:32B]...` (max 27 peers per -record to stay under the 1000-byte value limit). - -**When alone** (no neighbors): reads DHT every 3 seconds, merge-publishes every -30 seconds (preserves existing peers, never destructive). - -**When connected**: relaxed 5-minute heartbeat with random jitter. Publishes -self + live neighbors only (purges stale peers). - -**On shutdown**: publishes neighbors without self, so other nodes can still -bootstrap from live peers. - -### Deduplication - -Every packet payload is hashed with blake3 (32 bytes). A time-bounded cache -(default 300s, pruned every 30s) suppresses duplicates from both radio and -gossip directions. - -### Rate limiting - -A token bucket rate limiter prevents radio channel saturation. The rate is -calculated from the LoRa air time formula (Semtech SX1276): - -``` -air_time = preamble_time + payload_symbol_count * symbol_time -rate = 0.5 / air_time (50% duty cycle target) -``` - -Default burst allowance is 3 packets. The rate can be manually overridden with -`rate_limit_pps` in the config. - -### Radio communication - -A dedicated blocking thread communicates with the DongLoRa hardware via -`donglora-client`. Features: - -- **Config negotiation**: reads existing config first; only sets if needed -- **Exponential backoff reconnection**: 250ms to 5s, resets after 5s of uptime -- **Liveness pings**: every 2s of idle, detects silently-dead connections -- **SNR filtering**: drops packets below the demodulation floor - -### Architecture - -``` - ┌─────────────────────────────────────┐ - │ donglora-bridge │ - │ │ - DongLoRa ┌────────┐│ ┌────────┐ ┌──────┐ ┌───────┐ │ Internet - USB dongle◄─┤ Radio ├┤◄─┤ Router ├──►│Gossip├──►│ iroh ├──┤◄──► Other - │ Thread ││─►│ (dedup ││ │Swarm │ │ QUIC │ │ Bridges - └────────┘│ │ rate ││ └──┬───┘ └───────┘ │ - │ │ limit)│| │ │ - │ └────────┘│ ┌──┴───┐ │ - │ ▲ │ │ DHT │ │ - │ │ │ │(peer │ │ - │ ┌──┴──┐ │ │disc.)│ │ - │ │ TUI │ │ └──────┘ │ - │ └─────┘ │ │ - └─────────────────────────────────────┘ -``` - -## Testing - -```sh -cargo test # unit tests + property tests (proptest) -cargo clippy --all-targets # lint check (pedantic + nursery) -cargo fmt --check # format check -cargo mutants # mutation testing (install: cargo install cargo-mutants) -cargo +nightly fuzz run fuzz_gossip_frame_decode # fuzz testing (requires nightly) -cargo +nightly fuzz run fuzz_decode_peer_list -``` - -## Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| "passphrase is required" | Empty or placeholder passphrase | Run `donglora-bridge config` | -| No peers found | Passphrase mismatch or firewall | Check passphrase matches; iroh uses QUIC (UDP), DHT uses UDP | -| Radio disconnected | USB dongle unplugged or mux crash | Check USB connection; bridge will auto-reconnect | -| Yellow `!` on radio fields | Mux has different radio config | Another client set different params; bridge adopts mux config | -| Log file location | Default path | `~/.local/state/donglora-bridge/bridge.log` | - -## License - -[MIT](LICENSE) +This repo is archived and will not receive further updates. diff --git a/deny.toml b/deny.toml deleted file mode 100644 index 9ca5fb5..0000000 --- a/deny.toml +++ /dev/null @@ -1,39 +0,0 @@ -# cargo-deny configuration (v0.19+) -# Run with: cargo deny check - -[licenses] -version = 2 - -# Permissive licenses only. No copyleft. -allow = [ - "MIT", - "MIT-0", - "Apache-2.0", - "BSD-1-Clause", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "ISC", - "Zlib", - "Unlicense", - "CC0-1.0", - "CDLA-Permissive-2.0", - "Unicode-3.0", - "MPL-2.0", -] - -[advisories] -# Unmaintained transitive deps from upstream (iroh dep tree) — not actionable by us. -ignore = [ - "RUSTSEC-2023-0089", # atomic-polyfill: unmaintained (transitive via iroh) - "RUSTSEC-2024-0436", # paste: unmaintained (transitive via iroh) -] - -[bans] -multiple-versions = "warn" -wildcards = "warn" - -[sources] -unknown-registry = "deny" -unknown-git = "warn" -allow-git = [] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml deleted file mode 100644 index 8540953..0000000 --- a/fuzz/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "donglora-bridge-fuzz" -version = "0.0.0" -publish = false -edition = "2024" - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" - -[dependencies.donglora-bridge] -path = ".." - -[[bin]] -name = "fuzz_gossip_frame_decode" -path = "fuzz_targets/fuzz_gossip_frame_decode.rs" -doc = false - -[[bin]] -name = "fuzz_decode_peer_list" -path = "fuzz_targets/fuzz_decode_peer_list.rs" -doc = false - -[workspace] diff --git a/fuzz/fuzz_targets/fuzz_decode_peer_list.rs b/fuzz/fuzz_targets/fuzz_decode_peer_list.rs deleted file mode 100644 index 7a1ff40..0000000 --- a/fuzz/fuzz_targets/fuzz_decode_peer_list.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![no_main] -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // decode_peer_list must never panic on arbitrary input. - let _ = donglora_bridge::gossip::decode_peer_list(data); -}); diff --git a/fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs b/fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs deleted file mode 100644 index fecc055..0000000 --- a/fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![no_main] -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // GossipFrame::decode must never panic on arbitrary input. - let _ = donglora_bridge::packet::GossipFrame::decode(data); -}); diff --git a/justfile b/justfile deleted file mode 100644 index 6fd73c7..0000000 --- a/justfile +++ /dev/null @@ -1,69 +0,0 @@ -set shell := ["bash", "-c"] - -default: build - -[private] -_ensure_tools: - @mise trust --yes . 2>/dev/null; mise install --quiet - -# Run the bridge (TUI mode) -run *args: _ensure_tools - cargo run --release -- {{args}} - -# Run in headless mode -run-headless *args: _ensure_tools - cargo run --release -- --log-only {{args}} - -# Run the config wizard -config: _ensure_tools - cargo run --release -- config - -# Run all checks (fmt, clippy, test, audit) -check: fmt-check clippy test audit - -# Format code -fmt: _ensure_tools - cargo fmt - -# Check formatting without changing files -fmt-check: _ensure_tools - cargo fmt --check - -# Lint with clippy -clippy: _ensure_tools - cargo clippy --all-targets -- -D warnings - -# Run all tests -test: test-unit - -# Normal unit tests -test-unit: _ensure_tools - cargo nextest run - -# Mutation testing -test-mutants *args: _ensure_tools - cargo mutants {{args}} - -# Fuzz a target (requires nightly) -test-fuzz TARGET="fuzz_gossip_frame_decode" DURATION="60": _ensure_tools - cargo +nightly fuzz run {{TARGET}} -- -max_total_time={{DURATION}} - -# Security + license audit -audit: _ensure_tools - cargo deny check - -# Build release binary -build: _ensure_tools - cargo build --release - -# Generate docs -doc: _ensure_tools - cargo doc --no-deps --open - -# Check for outdated dependencies -outdated: _ensure_tools - cargo outdated - -# Clean build artifacts -clean: - cargo clean diff --git a/mise.toml b/mise.toml deleted file mode 100644 index 35d983c..0000000 --- a/mise.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tools] -rust = "1.94" -just = "latest" diff --git a/rustfmt.toml b/rustfmt.toml deleted file mode 100644 index 3139ac8..0000000 --- a/rustfmt.toml +++ /dev/null @@ -1,3 +0,0 @@ -edition = "2024" -max_width = 120 -use_small_heuristics = "Max" diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 09b9af7..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 8654dab..0000000 --- a/src/config.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! TOML configuration loading and validation. -//! -//! Handles the `[radio]` and `[bridge]` sections with `MeshCore` US/Canada recommended -//! defaults, and conversion to donglora-client `RadioConfig`. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use donglora_client::{Bandwidth, RadioConfig, TX_POWER_MAX}; -use serde::{Deserialize, Serialize}; - -/// Top-level bridge configuration. -#[derive(Debug, Deserialize, Serialize)] -pub struct Config { - #[serde(default)] - pub radio: RadioSection, - #[serde(default)] - pub bridge: BridgeSection, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RadioSection { - /// Serial port path (auto-detect if omitted). - pub port: Option, - #[serde(default = "default_frequency")] - pub frequency: u32, - #[serde(default = "default_bandwidth")] - pub bandwidth: String, - #[serde(default = "default_sf")] - pub spreading_factor: u8, - #[serde(default = "default_cr")] - pub coding_rate: u8, - #[serde(default = "default_sync_word")] - pub sync_word: u16, - #[serde(default = "default_tx_power")] - pub tx_power: String, - #[serde(default = "default_preamble")] - pub preamble: u16, - #[serde(default = "default_true")] - pub cad: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BridgeSection { - /// Shared passphrase. All bridges with the same passphrase join the same swarm. - #[serde(default)] - pub passphrase: String, - #[serde(default = "default_dedup_window")] - pub dedup_window_secs: u64, - #[serde(default = "default_tx_queue_size")] - pub tx_queue_size: usize, - /// Optional manual override for the radio TX rate limit (packets per second). - /// If omitted, a rate is calculated from the radio config. - pub rate_limit_pps: Option, - pub log_file: Option, -} - -// ── Defaults (MeshCore US/Canada Recommended) ──────────────────── - -const fn default_frequency() -> u32 { - 910_525_000 -} -fn default_bandwidth() -> String { - "62.5kHz".into() -} -const fn default_sf() -> u8 { - 7 -} -const fn default_cr() -> u8 { - 5 -} -const fn default_sync_word() -> u16 { - 0x3444 -} -fn default_tx_power() -> String { - "22".into() -} -const fn default_preamble() -> u16 { - 16 -} -const fn default_true() -> bool { - true -} -const fn default_dedup_window() -> u64 { - 300 -} -const fn default_tx_queue_size() -> usize { - 32 -} - -impl Default for RadioSection { - fn default() -> Self { - Self { - port: None, - frequency: default_frequency(), - bandwidth: default_bandwidth(), - spreading_factor: default_sf(), - coding_rate: default_cr(), - sync_word: default_sync_word(), - tx_power: default_tx_power(), - preamble: default_preamble(), - cad: true, - } - } -} - -impl Default for BridgeSection { - fn default() -> Self { - Self { - passphrase: String::new(), - dedup_window_secs: default_dedup_window(), - tx_queue_size: default_tx_queue_size(), - rate_limit_pps: None, - log_file: None, - } - } -} - -// ── Conversion ──────────────────────────────────────────────────── - -fn parse_bandwidth(s: &str) -> Result { - match s.to_lowercase().as_str() { - "7.8khz" | "7khz" | "khz7" => Ok(Bandwidth::Khz7), - "10.4khz" | "10khz" | "khz10" => Ok(Bandwidth::Khz10), - "15.6khz" | "15khz" | "khz15" => Ok(Bandwidth::Khz15), - "20.8khz" | "20khz" | "khz20" => Ok(Bandwidth::Khz20), - "31.25khz" | "31khz" | "khz31" => Ok(Bandwidth::Khz31), - "41.7khz" | "41khz" | "khz41" => Ok(Bandwidth::Khz41), - "62.5khz" | "62khz" | "khz62" => Ok(Bandwidth::Khz62), - "125khz" | "khz125" => Ok(Bandwidth::Khz125), - "250khz" | "khz250" => Ok(Bandwidth::Khz250), - "500khz" | "khz500" => Ok(Bandwidth::Khz500), - _ => bail!("unknown bandwidth: {s}"), - } -} - -fn parse_tx_power(s: &str) -> Result { - if s.eq_ignore_ascii_case("max") { - Ok(TX_POWER_MAX) - } else { - s.parse::().context("invalid tx_power (use 'max' or a dBm integer)") - } -} - -impl RadioSection { - /// Convert to the donglora-client `RadioConfig`. - /// - /// Validates that all radio parameters are within hardware-supported ranges - /// before constructing the config. - pub fn to_radio_config(&self) -> Result { - if !(7..=12).contains(&self.spreading_factor) { - bail!("spreading_factor must be 7-12, got {}", self.spreading_factor); - } - if !(5..=8).contains(&self.coding_rate) { - bail!("coding_rate must be 5-8 (meaning 4/5 to 4/8), got {}", self.coding_rate); - } - if self.frequency < 150_000_000 || self.frequency > 1_020_000_000 { - bail!("frequency {:.3} MHz out of range (150 MHz to 1.02 GHz)", f64::from(self.frequency) / 1_000_000.0); - } - Ok(RadioConfig { - freq_hz: self.frequency, - bw: parse_bandwidth(&self.bandwidth)?, - sf: self.spreading_factor, - cr: self.coding_rate, - sync_word: self.sync_word, - tx_power_dbm: parse_tx_power(&self.tx_power)?, - preamble_len: self.preamble, - cad: u8::from(self.cad), - }) - } -} - -// ── File I/O ────────────────────────────────────────────────────── - -/// Default config directory: `$XDG_CONFIG_HOME/donglora-bridge/` or `~/.config/donglora-bridge/`. -pub fn config_dir() -> Result { - let dir = dirs::config_dir().context("cannot determine config directory")?.join("donglora-bridge"); - Ok(dir) -} - -/// Default config file path. -pub fn config_path() -> Result { - Ok(config_dir()?.join("config.toml")) -} - -/// Default log file path: `$XDG_STATE_HOME/donglora-bridge/bridge.log`. -pub fn default_log_path() -> Result { - let dir = dirs::state_dir() - .or_else(dirs::data_local_dir) - .context("cannot determine state directory")? - .join("donglora-bridge"); - Ok(dir.join("bridge.log")) -} - -/// Load config from a TOML file. Returns None-passphrase error if not set. -pub fn load_config(path: &Path) -> Result { - let text = std::fs::read_to_string(path).with_context(|| format!("reading config: {}", path.display()))?; - let cfg: Config = toml::from_str(&text).context("parsing config TOML")?; - if cfg.bridge.passphrase.is_empty() || cfg.bridge.passphrase == "change-me" { - bail!("passphrase is required — run `donglora-bridge config` to set it"); - } - Ok(cfg) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn parse_bandwidth_canonical() { - assert!(matches!(parse_bandwidth("7.8kHz"), Ok(Bandwidth::Khz7))); - assert!(matches!(parse_bandwidth("62.5kHz"), Ok(Bandwidth::Khz62))); - assert!(matches!(parse_bandwidth("125kHz"), Ok(Bandwidth::Khz125))); - assert!(matches!(parse_bandwidth("500kHz"), Ok(Bandwidth::Khz500))); - } - - #[test] - fn parse_bandwidth_aliases() { - assert!(matches!(parse_bandwidth("7khz"), Ok(Bandwidth::Khz7))); - assert!(matches!(parse_bandwidth("khz125"), Ok(Bandwidth::Khz125))); - assert!(matches!(parse_bandwidth("10khz"), Ok(Bandwidth::Khz10))); - } - - #[test] - fn parse_bandwidth_case_insensitive() { - assert!(matches!(parse_bandwidth("125KHZ"), Ok(Bandwidth::Khz125))); - assert!(matches!(parse_bandwidth("62.5KHZ"), Ok(Bandwidth::Khz62))); - } - - #[test] - fn parse_bandwidth_rejects_unknown() { - assert!(parse_bandwidth("999kHz").is_err()); - assert!(parse_bandwidth("").is_err()); - assert!(parse_bandwidth("not_a_bandwidth").is_err()); - } - - #[test] - fn parse_tx_power_numeric() { - assert_eq!(parse_tx_power("22").unwrap(), 22); - assert_eq!(parse_tx_power("-4").unwrap(), -4); - assert_eq!(parse_tx_power("0").unwrap(), 0); - } - - #[test] - fn parse_tx_power_max_keyword() { - assert_eq!(parse_tx_power("max").unwrap(), TX_POWER_MAX); - assert_eq!(parse_tx_power("MAX").unwrap(), TX_POWER_MAX); - assert_eq!(parse_tx_power("Max").unwrap(), TX_POWER_MAX); - } - - #[test] - fn parse_tx_power_rejects_garbage() { - assert!(parse_tx_power("abc").is_err()); - assert!(parse_tx_power("").is_err()); - } - - #[test] - fn radio_section_defaults_to_radio_config() { - let section = RadioSection::default(); - let config = section.to_radio_config().unwrap(); - assert_eq!(config.freq_hz, 910_525_000); - assert_eq!(config.sf, 7); - assert_eq!(config.cr, 5); - assert_eq!(config.sync_word, 0x3444); - assert_eq!(config.preamble_len, 16); - } - - #[test] - fn radio_section_max_power() { - let section = RadioSection { tx_power: "max".into(), ..RadioSection::default() }; - let config = section.to_radio_config().unwrap(); - assert_eq!(config.tx_power_dbm, TX_POWER_MAX); - } - - #[test] - fn to_radio_config_rejects_invalid_sf() { - let section = RadioSection { spreading_factor: 6, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - let section = RadioSection { spreading_factor: 13, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - } - - #[test] - fn to_radio_config_rejects_invalid_cr() { - let section = RadioSection { coding_rate: 4, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - let section = RadioSection { coding_rate: 9, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - } - - #[test] - fn to_radio_config_rejects_invalid_frequency() { - let section = RadioSection { frequency: 0, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - let section = RadioSection { frequency: 2_000_000_000, ..RadioSection::default() }; - assert!(section.to_radio_config().is_err()); - } - - #[test] - fn load_config_valid_minimal() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[bridge]\npassphrase = \"my-secret\"\n").unwrap(); - let cfg = load_config(&path).unwrap(); - assert_eq!(cfg.bridge.passphrase, "my-secret"); - assert_eq!(cfg.radio.frequency, 910_525_000); - } - - #[test] - fn load_config_empty_passphrase_rejected() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[bridge]\npassphrase = \"\"\n").unwrap(); - assert!(load_config(&path).is_err()); - } - - #[test] - fn load_config_change_me_rejected() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[bridge]\npassphrase = \"change-me\"\n").unwrap(); - assert!(load_config(&path).is_err()); - } - - #[test] - fn load_config_missing_file() { - assert!(load_config(std::path::Path::new("/nonexistent/config.toml")).is_err()); - } - - #[test] - fn load_config_invalid_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "not valid toml {{{{").unwrap(); - assert!(load_config(&path).is_err()); - } - - // ── Mutant killers: default values (items 1-5) ───────────────── - - #[test] - fn default_radio_cad_is_true() { - let radio = RadioSection::default(); - assert!(radio.cad); - // Also test via serde (exercises the default_true function used by #[serde(default)]) - let radio: RadioSection = toml::from_str("").unwrap(); - assert!(radio.cad); - } - - #[test] - fn default_dedup_window_is_300() { - let bridge = BridgeSection::default(); - assert_eq!(bridge.dedup_window_secs, 300); - } - - #[test] - fn default_tx_queue_size_is_32() { - let bridge = BridgeSection::default(); - assert_eq!(bridge.tx_queue_size, 32); - } - - // ── Mutant killers: missing bandwidth variants (items 6-10) ── - - #[test] - fn parse_bandwidth_all_canonical_variants() { - assert!(matches!(parse_bandwidth("15.6kHz"), Ok(Bandwidth::Khz15))); - assert!(matches!(parse_bandwidth("20.8kHz"), Ok(Bandwidth::Khz20))); - assert!(matches!(parse_bandwidth("31.25kHz"), Ok(Bandwidth::Khz31))); - assert!(matches!(parse_bandwidth("41.7kHz"), Ok(Bandwidth::Khz41))); - assert!(matches!(parse_bandwidth("250kHz"), Ok(Bandwidth::Khz250))); - } - - // ── Mutant killers: frequency boundary (items 11-12) ───────── - - #[test] - fn to_radio_config_accepts_boundary_frequencies() { - let low = RadioSection { frequency: 150_000_000, ..RadioSection::default() }; - assert!(low.to_radio_config().is_ok()); - - let high = RadioSection { frequency: 1_020_000_000, ..RadioSection::default() }; - assert!(high.to_radio_config().is_ok()); - } - - // ── Mutant killers: path helpers (items 13-15) ─────────────── - - #[test] - fn config_dir_is_non_empty() { - let dir = config_dir().unwrap(); - assert!(dir.components().count() > 0); - } - - #[test] - fn config_path_ends_with_config_toml() { - let path = config_path().unwrap(); - assert_eq!(path.file_name().unwrap(), "config.toml"); - } - - #[test] - fn default_log_path_ends_with_bridge_log() { - let path = default_log_path().unwrap(); - assert_eq!(path.file_name().unwrap(), "bridge.log"); - } - - #[test] - fn load_config_full_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[radio] -frequency = 915000000 -bandwidth = "125kHz" -spreading_factor = 12 -coding_rate = 8 -sync_word = 0x1234 -tx_power = "max" -preamble = 8 -cad = false - -[bridge] -passphrase = "test-phrase" -dedup_window_secs = 600 -tx_queue_size = 64 -"#, - ) - .unwrap(); - let cfg = load_config(&path).unwrap(); - assert_eq!(cfg.radio.frequency, 915_000_000); - assert_eq!(cfg.radio.spreading_factor, 12); - assert_eq!(cfg.bridge.dedup_window_secs, 600); - assert_eq!(cfg.bridge.tx_queue_size, 64); - } -} diff --git a/src/gossip.rs b/src/gossip.rs deleted file mode 100644 index cca2b75..0000000 --- a/src/gossip.rs +++ /dev/null @@ -1,769 +0,0 @@ -//! iroh-gossip swarm management and mainline DHT peer discovery. -//! -//! Derives cryptographic keys from a shared passphrase, manages the gossip -//! subscription, and maintains a DHT heartbeat for zero-configuration peer -//! bootstrapping. - -use std::collections::HashSet; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use bytes::Bytes; -use futures_lite::StreamExt; -use iroh::endpoint::Incoming; -use iroh::{Endpoint, PublicKey, SecretKey}; -use iroh_gossip::TopicId; -use iroh_gossip::api::Event; -use iroh_gossip::net::{GOSSIP_ALPN, Gossip as GossipActor}; -use mainline::async_dht::AsyncDht; -use mainline::{Dht, MutableItem, SigningKey}; -use sha2::{Digest, Sha256}; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; - -use crate::packet::GossipFrame; - -/// Maximum number of peers to store in a DHT record. -/// 28 × 32 bytes = 896 bytes raw. Bencoded: 28 × ~36 = ~1008. -/// Conservative cap to stay under BEP44's 1000-byte value limit. -const MAX_DHT_PEERS: usize = 27; - -/// DHT heartbeat interval when connected (base, before jitter). 5 minutes -/// balances freshness against DHT write amplification — connected nodes already -/// have live gossip links and only need DHT for newcomer discovery. -const DHT_HEARTBEAT_SECS: u64 = 300; - -/// Maximum random jitter added to heartbeat interval. Prevents synchronized -/// publish storms when multiple bridges start simultaneously. -const DHT_HEARTBEAT_JITTER_SECS: u64 = 60; - -// ── Key derivation ─────────────────────────────────────────────── - -/// Cryptographic keys derived from a shared passphrase. -pub struct PassphraseKeys { - pub topic_id: TopicId, - pub dht_signing_key: SigningKey, - pub dht_salt: [u8; 20], -} - -impl PassphraseKeys { - /// Derive all keys from a passphrase. - /// - /// Intermediate buffers containing the passphrase are zeroized after use to - /// minimize time sensitive material spends in memory. - #[must_use] - pub fn derive(passphrase: &str) -> Self { - // Application-specific prefix to avoid DHT collisions with other apps. - const APP_PREFIX: &[u8] = b"donglora-bridge:"; - - // Helper: build a zeroized buffer of [prefix | suffix | passphrase]. - let keyed_buf = |suffix: &[u8]| -> Vec { - let mut buf = Vec::with_capacity(APP_PREFIX.len() + suffix.len() + passphrase.len()); - buf.extend_from_slice(APP_PREFIX); - buf.extend_from_slice(suffix); - buf.extend_from_slice(passphrase.as_bytes()); - buf - }; - - // Topic ID: blake3 hash of prefixed passphrase. - let mut buf = keyed_buf(b""); - let topic_bytes = *blake3::hash(&buf).as_bytes(); - let topic_id = TopicId::from_bytes(topic_bytes); - zeroize::Zeroize::zeroize(&mut buf); - - // DHT signing key: ed25519 from SHA-256 of prefixed passphrase + "sign:". - let mut buf = keyed_buf(b"sign:"); - let sign_seed = Sha256::digest(&buf); - let seed_bytes: [u8; 32] = sign_seed.into(); - let dht_signing_key = SigningKey::from_bytes(&seed_bytes); - zeroize::Zeroize::zeroize(&mut buf); - - // DHT salt: first 20 bytes of SHA-256 of prefixed passphrase + "salt:". - let mut buf = keyed_buf(b"salt:"); - let salt_hash = Sha256::digest(&buf); - let mut dht_salt = [0u8; 20]; - dht_salt.copy_from_slice(&salt_hash[..20]); - zeroize::Zeroize::zeroize(&mut buf); - - Self { topic_id, dht_signing_key, dht_salt } - } -} - -// ── Swarm state (for TUI) ─────────────────────────────────────── - -/// Observable state of the gossip swarm. -#[derive(Debug, Clone)] -pub struct SwarmState { - pub topic_hash: String, - pub neighbor_count: usize, - pub dht_status: DhtStatus, - pub last_dht_publish: Option, -} - -/// Status of the mainline DHT connection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DhtStatus { - Bootstrapping, - Ready, - PublishFailed, -} - -impl std::fmt::Display for DhtStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bootstrapping => write!(f, "Bootstrapping"), - Self::Ready => write!(f, "Ready"), - Self::PublishFailed => write!(f, "Failed"), - } - } -} - -// ── Events to router ──────────────────────────────────────────── - -/// Events from the gossip swarm to the router. -pub enum GossipEvent { - /// A gossip frame received from the swarm. - Frame(GossipFrame), - /// Neighbor count changed. - NeighborChanged(usize), -} - -// ── Gossip handle ─────────────────────────────────────────────── - -/// Handle to the gossip swarm. Manages iroh endpoint, gossip subscription, and DHT. -pub struct Gossip { - endpoint: Endpoint, - actor: GossipActor, - // std::sync::Mutex (not tokio::sync::Mutex) because locks are never held across - // .await points. This avoids async-aware mutex overhead for brief critical sections. - state: Arc>, - neighbors: Arc>>, - dht_signing_key: SigningKey, - dht_salt: [u8; 20], - cancel: CancellationToken, -} - -impl Gossip { - /// Create and start the gossip network. - /// - /// Spawns background tasks for connection acceptance, gossip event forwarding, - /// gossip broadcasting, and DHT heartbeat. - /// - /// Returns `(handle, event_rx, frame_tx)`: - /// - `event_rx`: channel of events from the swarm to the router - /// - `frame_tx`: channel for the router to send frames to broadcast - pub async fn new( - secret_key: SecretKey, - keys: &PassphraseKeys, - ) -> Result<(Self, mpsc::Receiver, mpsc::Sender)> { - let cancel = CancellationToken::new(); - - // Build iroh endpoint with n0 discovery. - let endpoint = Endpoint::builder(iroh::endpoint::presets::N0) - .secret_key(secret_key) - .alpns(vec![GOSSIP_ALPN.to_vec()]) - .bind() - .await - .context("binding iroh endpoint")?; - - let my_id = endpoint.id(); - info!("iroh endpoint bound, id: {}", my_id.fmt_short()); - - // Build gossip actor. - let actor = GossipActor::builder().spawn(endpoint.clone()); - - // Bootstrap from DHT. - let bootstrap_peers = dht_get_peers(keys).await; - if bootstrap_peers.is_empty() { - info!("no peers found in DHT — we are the first node"); - } else { - info!("found {} peer(s) in DHT", bootstrap_peers.len()); - } - - // Subscribe to gossip topic. - let topic = actor.subscribe(keys.topic_id, bootstrap_peers).await.context("subscribing to gossip topic")?; - - let (sender, receiver) = topic.split(); - - // Initial swarm state. - let topic_hash = hex::encode(&keys.topic_id.as_bytes()[..8]); - let state = Arc::new(Mutex::new(SwarmState { - topic_hash, - neighbor_count: 0, - dht_status: DhtStatus::Bootstrapping, - last_dht_publish: None, - })); - let neighbor_count = Arc::new(AtomicUsize::new(0)); - let neighbors: Arc>> = Arc::new(Mutex::new(HashSet::new())); - - // Channels. - let (event_tx, event_rx) = mpsc::channel::(256); - let (frame_tx, frame_rx) = mpsc::channel::(256); - - let handle = Self { - endpoint: endpoint.clone(), - actor: actor.clone(), - state: state.clone(), - neighbors: neighbors.clone(), - dht_signing_key: keys.dht_signing_key.clone(), - dht_salt: keys.dht_salt, - cancel: cancel.clone(), - }; - - // Spawn accept loop. - spawn_accept_loop(endpoint.clone(), actor.clone(), cancel.clone()); - - // Spawn gossip receiver task. - spawn_receiver(receiver, event_tx, state.clone(), neighbor_count.clone(), neighbors.clone(), cancel.clone()); - - // Clone sender for DHT heartbeat to feed discovered peers via join_peers. - let dht_sender = sender.clone(); - - // Spawn gossip broadcaster task. - spawn_broadcaster(sender, frame_rx, cancel.clone()); - - // Spawn DHT heartbeat. - spawn_dht_heartbeat( - my_id, - keys.dht_signing_key.clone(), - keys.dht_salt, - dht_sender, - neighbors, - state, - neighbor_count, - cancel, - ); - - // Try to join (non-blocking — fire and forget with timeout). - // The receiver task handles events regardless. - - Ok((handle, event_rx, frame_tx)) - } - - /// Snapshot of swarm state for TUI. - #[must_use] - pub fn swarm_state(&self) -> SwarmState { - self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() - } - - /// Graceful shutdown. Publishes neighbors (without ourselves) to DHT - /// so other nodes can still bootstrap from live peers. - pub async fn shutdown(&self) { - // Publish neighbors-only to DHT before shutting down. - let neighbor_ids: Vec<[u8; 32]> = self - .neighbors - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .map(|id| *id.as_bytes()) - .collect(); - - if let Ok(dht) = Dht::client() { - let dht = dht.as_async(); - let value = encode_peer_list(&neighbor_ids); - let seq = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs().cast_signed()) - .unwrap_or(0); - let item = MutableItem::new(self.dht_signing_key.clone(), &value, seq, Some(self.dht_salt.as_ref())); - match dht.put_mutable(item, None).await { - Ok(_) => info!("shutdown: published {} neighbor(s) to DHT (removed self)", neighbor_ids.len()), - Err(e) => debug!("shutdown: DHT publish failed: {e:#}"), - } - } - - self.cancel.cancel(); - let _ = self.actor.shutdown().await; - self.endpoint.close().await; - } -} - -// ── Background tasks ──────────────────────────────────────────── - -/// Accept incoming iroh connections and forward gossip ALPN to the gossip actor. -fn spawn_accept_loop(endpoint: Endpoint, gossip: GossipActor, cancel: CancellationToken) { - tokio::spawn(async move { - loop { - let incoming = tokio::select! { - incoming = endpoint.accept() => incoming, - () = cancel.cancelled() => break, - }; - let Some(incoming) = incoming else { break }; - let gossip = gossip.clone(); - tokio::spawn(handle_incoming(incoming, gossip)); - } - debug!("accept loop stopped"); - }); -} - -async fn handle_incoming(incoming: Incoming, gossip: GossipActor) { - let Ok(conn) = incoming.await else { return }; - let alpn = conn.alpn(); - if *alpn == *GOSSIP_ALPN { - if let Err(e) = gossip.handle_connection(conn).await { - debug!("gossip handle_connection error: {e:#}"); - } - } else { - debug!("unexpected ALPN: {:?}", String::from_utf8_lossy(alpn)); - } -} - -/// Forward gossip events to the router via channel. -fn spawn_receiver( - mut receiver: iroh_gossip::api::GossipReceiver, - event_tx: mpsc::Sender, - state: Arc>, - neighbor_count: Arc, - neighbors: Arc>>, - cancel: CancellationToken, -) { - tokio::spawn(async move { - loop { - let event = tokio::select! { - event = receiver.next() => event, - () = cancel.cancelled() => break, - }; - let Some(Ok(event)) = event else { break }; - match event { - Event::Received(msg) => { - if let Some(frame) = GossipFrame::decode(&msg.content) { - let _ = event_tx.send(GossipEvent::Frame(frame)).await; - } else { - debug!("received malformed gossip frame ({} bytes)", msg.content.len()); - } - } - // Neighbor count is tracked in both AtomicUsize (for lock-free reads in DHT - // heartbeat) and SwarmState (for TUI snapshots). Brief inconsistency between - // the two is acceptable — the TUI polls at 250ms intervals. - Event::NeighborUp(id) => { - let count = neighbor_count.fetch_add(1, Ordering::Relaxed) + 1; - info!("neighbor up: {} (total: {count})", id.fmt_short()); - if let Ok(mut n) = neighbors.lock() { - n.insert(id); - } - if let Ok(mut s) = state.lock() { - s.neighbor_count = count; - } - let _ = event_tx.send(GossipEvent::NeighborChanged(count)).await; - } - Event::NeighborDown(id) => { - let count = neighbor_count.fetch_sub(1, Ordering::Relaxed).saturating_sub(1); - info!("neighbor down: {} (total: {count})", id.fmt_short()); - if let Ok(mut n) = neighbors.lock() { - n.remove(&id); - } - if let Ok(mut s) = state.lock() { - s.neighbor_count = count; - } - let _ = event_tx.send(GossipEvent::NeighborChanged(count)).await; - } - Event::Lagged => { - warn!("gossip receiver lagged — some messages were dropped"); - } - } - } - debug!("gossip receiver stopped"); - }); -} - -/// Read frames from the router and broadcast them to the swarm. -fn spawn_broadcaster( - sender: iroh_gossip::api::GossipSender, - mut frame_rx: mpsc::Receiver, - cancel: CancellationToken, -) { - tokio::spawn(async move { - loop { - let frame = tokio::select! { - frame = frame_rx.recv() => frame, - () = cancel.cancelled() => break, - }; - let Some(frame) = frame else { break }; - let encoded = Bytes::from(frame.encode()); - if let Err(e) = sender.broadcast(encoded).await { - debug!("gossip broadcast error: {e:#}"); - } - } - debug!("gossip broadcaster stopped"); - }); -} - -/// DHT poll interval while alone. 3 seconds is aggressive but reads are cheap -/// (single DHT GET) and fast discovery matters when two nodes start together. -const DHT_ALONE_READ_SECS: u64 = 3; - -/// How often a lonely node publishes (merge-only, never destructive). 30 seconds -/// is much slower than reads because DHT PUT is heavier and a lonely node only -/// adds itself to the peer list. -const DHT_ALONE_PUBLISH_SECS: u64 = 30; - -/// Periodically interact with the DHT to discover and share peers. -/// -/// Strategy: -/// - **Alone:** Read every 3s (cheap). Publish every 30s via merge — reads -/// existing data first, appends ourselves, writes back. Two fresh nodes -/// both doing this will eventually see each other. Never blindly overwrites. -/// - **Connected:** Read + publish on relaxed 5-min schedule. We have -/// valuable neighbor info to share. -/// - **Transition to connected:** Publish immediately. -#[allow(clippy::too_many_arguments)] -fn spawn_dht_heartbeat( - my_id: PublicKey, - signing_key: SigningKey, - salt: [u8; 20], - gossip_sender: iroh_gossip::api::GossipSender, - neighbors: Arc>>, - state: Arc>, - neighbor_count: Arc, - cancel: CancellationToken, -) { - tokio::spawn(async move { - let dht = match Dht::client() { - Ok(d) => d.as_async(), - Err(e) => { - warn!("failed to create DHT client: {e:#}"); - return; - } - }; - - let pub_key_bytes = signing_key.verifying_key().to_bytes(); - let mut was_alone = true; - let mut last_publish = Instant::now(); - - // Initial: read to discover existing peers, then merge-publish ourselves. - dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await; - dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await; - - loop { - let alone = neighbor_count.load(Ordering::Relaxed) == 0; - - // Transition: alone → connected. Publish immediately. - if was_alone && !alone { - info!("first neighbor joined — publishing to DHT"); - dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await; - last_publish = Instant::now(); - } - was_alone = alone; - - // Sleep: fast reads while alone, relaxed when connected. - let sleep_dur = if alone { - Duration::from_secs(DHT_ALONE_READ_SECS) - } else { - let jitter = rand::random::() % DHT_HEARTBEAT_JITTER_SECS.max(1); - Duration::from_secs(DHT_HEARTBEAT_SECS + jitter) - }; - - tokio::select! { - () = tokio::time::sleep(sleep_dur) => {} - () = cancel.cancelled() => break, - } - - // Always read — cheap, discovers peers to join. - dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await; - - // Publish cadence depends on state. - let publish_interval = if alone { - Duration::from_secs(DHT_ALONE_PUBLISH_SECS) - } else { - Duration::from_secs(DHT_HEARTBEAT_SECS) - }; - if last_publish.elapsed() >= publish_interval { - dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await; - last_publish = Instant::now(); - } - } - debug!("DHT heartbeat stopped"); - }); -} - -/// Read the DHT and feed any discovered peers into the gossip swarm. -async fn dht_read_and_join( - dht: &AsyncDht, - my_id: &PublicKey, - pub_key_bytes: &[u8; 32], - salt: &[u8; 20], - gossip_sender: &iroh_gossip::api::GossipSender, - state: &Arc>, -) { - if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await { - let peers = decode_peer_list(record.value()); - let new_peers: Vec<_> = peers.into_iter().filter(|id| *id != *my_id).collect(); - if !new_peers.is_empty() { - info!("DHT discovered {} peer(s), feeding to gossip", new_peers.len()); - if let Err(e) = gossip_sender.join_peers(new_peers).await { - debug!("join_peers error: {e:#}"); - } - } - if let Ok(mut s) = state.lock() { - s.dht_status = DhtStatus::Ready; - } - } -} - -async fn dht_publish( - dht: &AsyncDht, - my_id: &PublicKey, - signing_key: &SigningKey, - salt: &[u8; 20], - pub_key_bytes: &[u8; 32], - neighbors: &Arc>>, - state: &Arc>, -) { - let has_neighbors = neighbors.lock().is_ok_and(|n| !n.is_empty()); - - let mut seen = HashSet::new(); - let mut peer_list: Vec<[u8; 32]> = Vec::with_capacity(MAX_DHT_PEERS); - - // Always include ourselves. - peer_list.push(*my_id.as_bytes()); - seen.insert(*my_id.as_bytes()); - - if has_neighbors { - // Connected: publish authoritative data — self + live neighbors only. - // This purges dead/stale peers from the DHT. We know who's alive - // because they're our active gossip neighbors. - if let Ok(n) = neighbors.lock() { - for id in n.iter() { - if seen.insert(*id.as_bytes()) && peer_list.len() < MAX_DHT_PEERS { - peer_list.push(*id.as_bytes()); - } - } - } - } else { - // Alone: merge-publish — read existing DHT data first, add ourselves, - // preserve other peers. We don't know who's alive so we can't purge. - if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await { - for existing in decode_peer_list(record.value()) { - if seen.insert(*existing.as_bytes()) && peer_list.len() < MAX_DHT_PEERS { - peer_list.push(*existing.as_bytes()); - } - } - } - } - - let value = encode_peer_list(&peer_list); - let seq = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs().cast_signed()) - .unwrap_or(0); - - let item = MutableItem::new(signing_key.clone(), &value, seq, Some(salt.as_ref())); - match dht.put_mutable(item, None).await { - Ok(_) => { - debug!("published to DHT (seq={seq}, peers={})", peer_list.len()); - if let Ok(mut s) = state.lock() { - s.dht_status = DhtStatus::Ready; - s.last_dht_publish = Some(Instant::now()); - } - } - Err(e) => { - warn!("DHT publish failed: {e:#}"); - if let Ok(mut s) = state.lock() { - s.dht_status = DhtStatus::PublishFailed; - } - } - } -} - -// ── DHT helpers ───────────────────────────────────────────────── - -/// Query the DHT for existing peers (blocking bootstrap, called once on startup). -async fn dht_get_peers(keys: &PassphraseKeys) -> Vec { - let dht = match Dht::client() { - Ok(d) => d.as_async(), - Err(e) => { - warn!("failed to create DHT client for bootstrap: {e:#}"); - return vec![]; - } - }; - - let pub_key_bytes = keys.dht_signing_key.verifying_key().to_bytes(); - dht.get_mutable_most_recent(&pub_key_bytes, Some(&keys.dht_salt)).await.map_or_else( - || { - debug!("DHT bootstrap: no existing record"); - vec![] - }, - |record| { - let peers = decode_peer_list(record.value()); - debug!("DHT bootstrap: found {} peer(s)", peers.len()); - peers - }, - ) -} - -/// Encode a list of 32-byte peer IDs into bytes for DHT storage. -/// Format: `[count: u16 LE] [id_0: 32B] [id_1: 32B] ...` -#[must_use] -pub fn encode_peer_list(peers: &[[u8; 32]]) -> Vec { - #[allow(clippy::cast_possible_truncation)] // MAX_DHT_PEERS (27) fits in u16 - let count = peers.len().min(MAX_DHT_PEERS) as u16; - let mut buf = Vec::with_capacity(2 + (count as usize) * 32); - buf.extend_from_slice(&count.to_le_bytes()); - for id in peers.iter().take(MAX_DHT_PEERS) { - buf.extend_from_slice(id); - } - buf -} - -/// Decode a peer list from DHT record value. -#[must_use] -pub fn decode_peer_list(data: &[u8]) -> Vec { - if data.len() < 2 { - return vec![]; - } - let count = u16::from_le_bytes([data[0], data[1]]) as usize; - let mut peers = Vec::with_capacity(count); - let mut offset = 2; - for _ in 0..count { - if offset + 32 > data.len() { - break; - } - let bytes: [u8; 32] = match data[offset..offset + 32].try_into() { - Ok(b) => b, - Err(_) => break, - }; - if let Ok(pk) = PublicKey::from_bytes(&bytes) { - peers.push(pk); - } - offset += 32; - } - peers -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn passphrase_keys_deterministic() { - let k1 = PassphraseKeys::derive("test-passphrase"); - let k2 = PassphraseKeys::derive("test-passphrase"); - assert_eq!(k1.topic_id, k2.topic_id); - assert_eq!(k1.dht_signing_key.to_bytes(), k2.dht_signing_key.to_bytes()); - assert_eq!(k1.dht_salt, k2.dht_salt); - } - - #[test] - fn passphrase_keys_different() { - let k1 = PassphraseKeys::derive("alpha"); - let k2 = PassphraseKeys::derive("beta"); - assert_ne!(k1.topic_id, k2.topic_id); - assert_ne!(k1.dht_signing_key.to_bytes(), k2.dht_signing_key.to_bytes()); - } - - #[test] - fn peer_list_roundtrip() { - // Generate valid ed25519 public keys. - let keys: Vec<_> = (0..3) - .map(|_| { - let sk = iroh::SecretKey::generate(&mut rand::rng()); - *sk.public().as_bytes() - }) - .collect(); - let encoded = encode_peer_list(&keys); - let decoded = decode_peer_list(&encoded); - assert_eq!(decoded.len(), 3); - for (i, key) in keys.iter().enumerate() { - assert_eq!(*decoded[i].as_bytes(), *key); - } - } - - #[test] - fn peer_list_empty() { - let encoded = encode_peer_list(&[]); - let decoded = decode_peer_list(&encoded); - assert!(decoded.is_empty()); - } - - #[test] - fn peer_list_truncated_data() { - // Only 1 byte — too short for count. - assert!(decode_peer_list(&[0]).is_empty()); - // Count says 2 but only 1 peer present. - let mut data = vec![2, 0]; // count = 2 - data.extend_from_slice(&[0xAA; 32]); // only 1 peer - let decoded = decode_peer_list(&data); - assert_eq!(decoded.len(), 1); - } - - #[test] - fn dht_status_display() { - assert_eq!(format!("{}", DhtStatus::Bootstrapping), "Bootstrapping"); - assert_eq!(format!("{}", DhtStatus::Ready), "Ready"); - assert_eq!(format!("{}", DhtStatus::PublishFailed), "Failed"); - } - - #[test] - fn decode_peer_list_exact_boundary() { - // Build data with count=2 and exactly 2 + 32 + 32 = 66 bytes. - // Both peers should decode successfully. - let sk0 = iroh::SecretKey::generate(&mut rand::rng()); - let sk1 = iroh::SecretKey::generate(&mut rand::rng()); - let id0 = *sk0.public().as_bytes(); - let id1 = *sk1.public().as_bytes(); - - let mut data = vec![2, 0]; // count = 2 (u16 LE) - data.extend_from_slice(&id0); - data.extend_from_slice(&id1); - assert_eq!(data.len(), 66); - - let decoded = decode_peer_list(&data); - assert_eq!(decoded.len(), 2); - assert_eq!(*decoded[0].as_bytes(), id0); - assert_eq!(*decoded[1].as_bytes(), id1); - } - - #[test] - fn decode_peer_list_one_byte_short() { - // Build data with count=2 but only 2 + 32 + 31 = 65 bytes. - // First peer decodes, second is truncated and skipped. - let sk0 = iroh::SecretKey::generate(&mut rand::rng()); - let sk1 = iroh::SecretKey::generate(&mut rand::rng()); - let id0 = *sk0.public().as_bytes(); - let id1 = *sk1.public().as_bytes(); - - let mut data = vec![2, 0]; // count = 2 (u16 LE) - data.extend_from_slice(&id0); - data.extend_from_slice(&id1[..31]); // only 31 bytes of second peer - assert_eq!(data.len(), 65); - - let decoded = decode_peer_list(&data); - assert_eq!(decoded.len(), 1); - assert_eq!(*decoded[0].as_bytes(), id0); - } - - #[test] - fn decode_peer_list_count_header_only() { - // Data with exactly 2 bytes (count=0). Must not be rejected by the length check. - // Kills mutant: `data.len() < 2` → `data.len() <= 2`. - let data = [0u8, 0]; // count = 0 - let decoded = decode_peer_list(&data); - assert!(decoded.is_empty()); - - // Data with 2 bytes but count=1 and no peer data — decodes 0 (truncated). - let data = [1u8, 0]; // count = 1 but no peer bytes - let decoded = decode_peer_list(&data); - assert!(decoded.is_empty()); - } - - mod proptests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn peer_list_roundtrip_prop(count in 0usize..=30) { - let keys: Vec<[u8; 32]> = (0..count) - .map(|_| *iroh::SecretKey::generate(&mut rand::rng()).public().as_bytes()) - .collect(); - let encoded = encode_peer_list(&keys); - let decoded = decode_peer_list(&encoded); - let expected = count.min(MAX_DHT_PEERS); - prop_assert_eq!(decoded.len(), expected); - } - } - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index bbac209..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Peer-to-peer `LoRa` bridge connecting `DongLoRa` USB dongles across the internet. -//! -//! Uses iroh-gossip for swarm-based packet broadcast and mainline DHT for -//! zero-configuration peer discovery. Nodes sharing a passphrase automatically -//! discover each other and relay radio packets through a gossip swarm. - -pub mod config; -pub mod gossip; -pub mod packet; -pub mod radio; -pub mod rate_limit; -pub mod router; -pub mod setup; - -// TUI is public only so its types are visible to the binary; not intended for -// external consumption. -#[doc(hidden)] -pub mod tui; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 7b84e49..0000000 --- a/src/main.rs +++ /dev/null @@ -1,210 +0,0 @@ -use donglora_bridge::{config, gossip, radio, rate_limit, router, setup, tui}; - -use std::sync::Arc; -use std::time::Instant; - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use iroh::SecretKey; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; - -use crate::gossip::PassphraseKeys; -use crate::rate_limit::RateLimiter; -use crate::router::Stats; - -#[derive(Parser)] -#[command(name = "donglora-bridge", about = "LoRa gossip bridge using iroh")] -struct Cli { - /// Path to config file (default: ~/.config/donglora-bridge/config.toml) - #[arg(long)] - config: Option, - - /// Run in headless mode (structured logs to stdout, no TUI) - #[arg(long)] - log_only: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Interactive configuration wizard - Config, -} - -#[tokio::main] -#[allow(clippy::too_many_lines)] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - // Resolve config path. - let config_path = match &cli.config { - Some(p) => std::path::PathBuf::from(p), - None => config::config_path()?, - }; - - // Config subcommand: run wizard with existing config as defaults. - if matches!(cli.command, Some(Commands::Config)) { - let existing = if config_path.exists() { config::load_config(&config_path).ok() } else { None }; - return setup::run_wizard(&config_path, existing.as_ref()); - } - - // First-run: no config file → run wizard automatically. - if !config_path.exists() { - println!(" No config found. Let's set one up!\n"); - return setup::run_wizard(&config_path, None); - } - - // Load config. - let cfg = - config::load_config(&config_path).with_context(|| format!("loading config from {}", config_path.display()))?; - - // Set up logging. - setup_logging(cli.log_only, &cfg); - - // Ephemeral identity — fresh keypair each launch. - let secret_key = SecretKey::generate(&mut rand::rng()); - let node_id = secret_key.public(); - info!("donglora-bridge starting, ephemeral id: {}", node_id.fmt_short()); - - // Parse radio config. - let radio_config = cfg.radio.to_radio_config()?; - - // Derive passphrase keys. - let keys = PassphraseKeys::derive(&cfg.bridge.passphrase); - info!("topic: {}", hex::encode(&keys.topic_id.as_bytes()[..8])); - - // Create rate limiter. - let rate_limiter = RateLimiter::from_radio_config( - radio_config.sf, - radio_config.bw, - radio_config.cr, - radio_config.preamble_len, - cfg.bridge.rate_limit_pps, - ); - info!("rate limiter: {:.1} pps", rate_limiter.rate_pps()); - - // Create gossip network. - let (swarm, gossip_event_rx, gossip_frame_tx) = gossip::Gossip::new(secret_key, &keys).await?; - let swarm = Arc::new(swarm); - - // Start radio. - let (radio_event_rx, radio_tx) = radio::spawn(radio_config, cfg.radio.port.clone())?; - - // Stats + log channel + radio config watch. - let stats = Arc::new(Stats::default()); - let (log_tx, log_rx) = mpsc::channel(512); - let (config_watch_tx, config_watch_rx) = tokio::sync::watch::channel(router::RadioConfigInfo { - active: radio_config, - requested: radio_config, - source: radio::ConfigSource::Ours, - device: String::new(), - connected: false, - }); - - // Cancellation. - let cancel = CancellationToken::new(); - let start_time = Instant::now(); - - // Spawn router. - let router_stats = stats.clone(); - let router_cancel = cancel.clone(); - let router_our_id = node_id; - let dedup_window = std::time::Duration::from_secs(cfg.bridge.dedup_window_secs); - let tx_queue_size = cfg.bridge.tx_queue_size; - - tokio::spawn(async move { - router::run( - router_our_id, - dedup_window, - tx_queue_size, - radio_config, - rate_limiter, - radio_event_rx, - radio_tx, - gossip_event_rx, - gossip_frame_tx, - &router_stats, - log_tx, - config_watch_tx, - ) - .await; - router_cancel.cancel(); - }); - - // Spawn a task that cancels on ctrl-c or SIGTERM. - { - let cancel = cancel.clone(); - tokio::spawn(async move { - let sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()); - match sigterm { - Ok(mut sigterm) => { - tokio::select! { - _ = tokio::signal::ctrl_c() => info!("received SIGINT"), - _ = sigterm.recv() => info!("received SIGTERM"), - } - } - Err(e) => { - warn!("failed to register SIGTERM handler: {e:#}"); - let _ = tokio::signal::ctrl_c().await; - info!("received SIGINT"); - } - } - cancel.cancel(); - }); - } - - // Run TUI or headless. - if cli.log_only { - info!("running in headless mode"); - cancel.cancelled().await; - } else { - let terminal = tui::run(config_watch_rx, swarm.clone(), stats, log_rx, cancel.clone(), start_time); - - cancel.cancel(); - swarm.shutdown().await; - info!("donglora-bridge stopped"); - - if let Ok(mut term) = terminal { - tui::restore_terminal(&mut term); - } - - return Ok(()); - } - - // Shutdown (headless path). - swarm.shutdown().await; - info!("donglora-bridge stopped"); - - Ok(()) -} - -fn setup_logging(log_only: bool, cfg: &config::Config) { - let env_filter = || { - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) - }; - - if log_only { - tracing_subscriber::fmt().with_env_filter(env_filter()).init(); - } else { - let log_path = cfg - .bridge - .log_file - .clone() - .unwrap_or_else(|| config::default_log_path().unwrap_or_else(|_| "/tmp/donglora-bridge.log".into())); - if let Some(parent) = log_path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - eprintln!("warning: failed to create log directory {}: {e}", parent.display()); - } - let file_appender = tracing_appender::rolling::never( - log_path.parent().unwrap_or_else(|| std::path::Path::new("/tmp")), - log_path.file_name().unwrap_or_else(|| std::ffi::OsStr::new("donglora-bridge.log")), - ); - tracing_subscriber::fmt().with_env_filter(env_filter()).with_writer(file_appender).with_ansi(false).init(); - } -} diff --git a/src/packet.rs b/src/packet.rs deleted file mode 100644 index 56d5aa5..0000000 --- a/src/packet.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Wire format definitions for radio packets and gossip frames. -//! -//! Includes SNR quality grading against `LoRa` demodulation floors and blake3 -//! content hashing for deduplication. - -/// Gossip frame header size: `sender(32) + rssi(2) + snr(2) + payload_len(2) = 38` -const FRAME_HEADER_SIZE: usize = 38; - -/// SNR quality grade for a received `LoRa` packet. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Grade { - /// SNR well above demodulation floor (margin >= 3 dB). - Good, - /// SNR near demodulation floor (0 <= margin < 3 dB). - Marginal, - /// SNR below demodulation floor — likely corrupt. - Unreliable, - /// SNR outside valid range (-32..32) — firmware/decode error. - Invalid, -} - -impl Grade { - /// Returns true if the packet is reliable enough to forward. - #[must_use] - pub const fn should_forward(self) -> bool { - matches!(self, Self::Good | Self::Marginal) - } -} - -impl std::fmt::Display for Grade { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Good => write!(f, "GOOD"), - Self::Marginal => write!(f, "MARG"), - Self::Unreliable => write!(f, "UNRL"), - Self::Invalid => write!(f, "INVL"), - } - } -} - -/// Grade a received packet's SNR against the demodulation floor for the given -/// spreading factor. -#[must_use] -pub fn snr_grade(snr: i16, sf: u8) -> Grade { - if !(-32..=32).contains(&snr) { - return Grade::Invalid; - } - let min_snr = -2.5 * (f32::from(sf) - 4.0); - let margin = f32::from(snr) - min_snr; - if margin < 0.0 { - Grade::Unreliable - } else if margin < 3.0 { - Grade::Marginal - } else { - Grade::Good - } -} - -/// A packet received from the radio, before wrapping in a gossip frame. -#[derive(Debug, Clone)] -pub struct RadioPacket { - pub rssi: i16, - pub snr: i16, - pub payload: Vec, -} - -/// A gossip frame — the wire format sent through the iroh-gossip swarm. -/// -/// Wire format: `[sender: 32B] [rssi: i16 LE] [snr: i16 LE] [payload_len: u16 LE] [payload: NB]` -#[derive(Debug, Clone)] -pub struct GossipFrame { - /// `EndpointId` (`PublicKey` bytes) of the bridge that first heard this packet on radio. - pub sender: [u8; 32], - /// RSSI at the originating bridge (informational). - pub rssi: i16, - /// SNR at the originating bridge (informational). - pub snr: i16, - /// Raw `LoRa` payload. - pub payload: Vec, -} - -impl GossipFrame { - /// Create a new gossip frame from a public key and radio packet data. - #[must_use] - pub fn new(sender: &iroh::PublicKey, rssi: i16, snr: i16, payload: Vec) -> Self { - Self { sender: *sender.as_bytes(), rssi, snr, payload } - } - - /// Encode this frame into bytes for gossip broadcast. - #[must_use] - pub fn encode(&self) -> Vec { - let mut buf = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len()); - buf.extend_from_slice(&self.sender); - buf.extend_from_slice(&self.rssi.to_le_bytes()); - buf.extend_from_slice(&self.snr.to_le_bytes()); - #[allow(clippy::cast_possible_truncation)] // LoRa payloads never exceed 256 bytes - let len = self.payload.len() as u16; - buf.extend_from_slice(&len.to_le_bytes()); - buf.extend_from_slice(&self.payload); - buf - } - - /// Decode a gossip frame from bytes received from the swarm. - #[must_use] - pub fn decode(data: &[u8]) -> Option { - if data.len() < FRAME_HEADER_SIZE { - return None; - } - let sender: [u8; 32] = data[..32].try_into().ok()?; - let rssi = i16::from_le_bytes(data[32..34].try_into().ok()?); - let snr = i16::from_le_bytes(data[34..36].try_into().ok()?); - let payload_len = u16::from_le_bytes(data[36..38].try_into().ok()?) as usize; - if data.len() != FRAME_HEADER_SIZE + payload_len { - return None; - } - Some(Self { sender, rssi, snr, payload: data[FRAME_HEADER_SIZE..].to_vec() }) - } -} - -/// Compute a 32-byte blake3 content hash for deduplication. -#[must_use] -pub fn content_hash(payload: &[u8]) -> [u8; 32] { - *blake3::hash(payload).as_bytes() -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn snr_grading() { - // SF7: min_snr = -2.5 * (7 - 4) = -7.5 - assert_eq!(snr_grade(10, 7), Grade::Good); // margin = 17.5 - assert_eq!(snr_grade(-5, 7), Grade::Marginal); // margin = 2.5 < 3 - assert_eq!(snr_grade(-8, 7), Grade::Unreliable); // margin = -0.5 - assert_eq!(snr_grade(-50, 7), Grade::Invalid); // out of range - - // SF12: min_snr = -2.5 * (12 - 4) = -20.0 - assert_eq!(snr_grade(-15, 12), Grade::Good); // margin = 5.0 - assert_eq!(snr_grade(-18, 12), Grade::Marginal); // margin = 2.0 - assert_eq!(snr_grade(-22, 12), Grade::Unreliable); // margin = -2.0 - } - - #[test] - fn gossip_frame_roundtrip() { - let key = iroh::SecretKey::generate(&mut rand::rng()); - let frame = GossipFrame::new(&key.public(), -85, 8, vec![1, 2, 3, 4, 5]); - let encoded = frame.encode(); - let decoded = GossipFrame::decode(&encoded).expect("decode should succeed"); - assert_eq!(decoded.sender, *key.public().as_bytes()); - assert_eq!(decoded.rssi, -85); - assert_eq!(decoded.snr, 8); - assert_eq!(decoded.payload, vec![1, 2, 3, 4, 5]); - } - - #[test] - fn gossip_frame_reject_short() { - assert!(GossipFrame::decode(&[0u8; 10]).is_none()); - } - - #[test] - fn gossip_frame_decode_empty_payload() { - // Exactly FRAME_HEADER_SIZE (38) bytes with payload_len=0 must decode. - // Kills mutant: `data.len() < 38` → `data.len() <= 38`. - let mut data = vec![0u8; 38]; - data[36..38].copy_from_slice(&0u16.to_le_bytes()); // payload_len = 0 - let frame = GossipFrame::decode(&data); - assert!(frame.is_some(), "38-byte frame with 0-length payload must decode"); - assert!(frame.unwrap().payload.is_empty()); - } - - #[test] - fn gossip_frame_reject_wrong_length() { - // Header says payload_len=5 but only 3 bytes follow. - let mut data = vec![0u8; 38 + 3]; - data[36..38].copy_from_slice(&5u16.to_le_bytes()); - assert!(GossipFrame::decode(&data).is_none()); - } - - #[test] - fn should_forward_true_for_good_and_marginal() { - assert!(Grade::Good.should_forward()); - assert!(Grade::Marginal.should_forward()); - } - - #[test] - fn should_forward_false_for_unreliable_and_invalid() { - assert!(!Grade::Unreliable.should_forward()); - assert!(!Grade::Invalid.should_forward()); - } - - #[test] - fn grade_display() { - assert_eq!(format!("{}", Grade::Good), "GOOD"); - assert_eq!(format!("{}", Grade::Marginal), "MARG"); - assert_eq!(format!("{}", Grade::Unreliable), "UNRL"); - assert_eq!(format!("{}", Grade::Invalid), "INVL"); - } - - #[test] - fn snr_grade_exact_boundaries() { - // SF12: min_snr = -2.5 * (12 - 4) = -20.0 - // - // At snr=-20, margin = -20 - (-20.0) = 0.0 exactly. - // The code uses `margin < 0.0`, so 0.0 is NOT < 0.0 → Marginal (not Unreliable). - assert_eq!(snr_grade(-20, 12), Grade::Marginal); - // One below: snr=-21, margin = -1.0 → Unreliable. - assert_eq!(snr_grade(-21, 12), Grade::Unreliable); - - // At snr=-17, margin = -17 - (-20.0) = 3.0 exactly. - // The code uses `margin < 3.0`, so 3.0 is NOT < 3.0 → Good (not Marginal). - assert_eq!(snr_grade(-17, 12), Grade::Good); - // One below: snr=-18, margin = 2.0 → Marginal. - assert_eq!(snr_grade(-18, 12), Grade::Marginal); - } - - #[test] - fn content_hash_deterministic() { - let h1 = content_hash(b"hello"); - let h2 = content_hash(b"hello"); - assert_eq!(h1, h2); - let h3 = content_hash(b"world"); - assert_ne!(h1, h3); - } - - mod proptests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn gossip_frame_roundtrip_prop( - rssi in -200i16..=200i16, - snr in -32i16..=32i16, - payload in proptest::collection::vec(any::(), 0..256), - ) { - let key = iroh::SecretKey::generate(&mut rand::rng()); - let frame = GossipFrame::new(&key.public(), rssi, snr, payload.clone()); - let encoded = frame.encode(); - let decoded = GossipFrame::decode(&encoded).unwrap(); - prop_assert_eq!(decoded.rssi, rssi); - prop_assert_eq!(decoded.snr, snr); - prop_assert_eq!(decoded.payload, payload); - } - - #[test] - fn content_hash_deterministic_prop(data in proptest::collection::vec(any::(), 0..1024)) { - prop_assert_eq!(content_hash(&data), content_hash(&data)); - } - - #[test] - fn snr_grade_never_panics(snr in i16::MIN..=i16::MAX, sf in 5u8..=12u8) { - let _ = snr_grade(snr, sf); - } - } - } -} diff --git a/src/radio.rs b/src/radio.rs deleted file mode 100644 index 2a311a3..0000000 --- a/src/radio.rs +++ /dev/null @@ -1,444 +0,0 @@ -//! `DongLoRa` hardware communication on a blocking thread. -//! -//! Handles connection, config negotiation, RX/TX, liveness pings, and -//! exponential backoff reconnection. - -use std::time::Duration; - -use anyhow::{Context, Result}; -use donglora_client::{RadioConfig, Response, Transport}; -use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; - -use crate::packet::{RadioPacket, snr_grade}; - -/// How the radio config was resolved. -#[derive(Debug, Clone, Copy)] -pub enum ConfigSource { - /// We set the config ourselves. - Ours, - /// The mux already had a locked config; we adopted it. - Mux, -} - -/// Messages from the radio thread to the router. -pub enum RadioEvent { - /// A valid packet received from the radio (already SNR-filtered). - Packet(RadioPacket), - /// Radio connected (or reconnected) with the active config and device info. - Connected(RadioConfig, ConfigSource, String), - /// Radio disconnected — attempting reconnect. - Disconnected, -} - -/// Spawn the blocking radio thread. -/// -/// Returns a receiver for radio events and a sender for TX payloads. -pub fn spawn(config: RadioConfig, port: Option) -> Result<(mpsc::Receiver, mpsc::Sender>)> { - let (event_tx, event_rx) = mpsc::channel::(256); - let (tx_send, tx_recv) = mpsc::channel::>(64); - - std::thread::Builder::new() - .name("radio".into()) - .spawn(move || radio_loop(config, port, event_tx, tx_recv)) - .context("failed to spawn radio thread")?; - - Ok((event_rx, tx_send)) -} - -#[allow(clippy::needless_pass_by_value)] // values moved across thread boundary in spawn() -fn radio_loop( - config: RadioConfig, - port: Option, - event_tx: mpsc::Sender, - mut tx_recv: mpsc::Receiver>, -) { - let initial_backoff = Duration::from_millis(250); - let max_backoff = Duration::from_secs(5); - let mut backoff = initial_backoff; - - loop { - let started = std::time::Instant::now(); - match connect_and_run(&config, port.as_deref(), &event_tx, &mut tx_recv) { - Ok(()) => { - info!("radio thread exiting"); - return; - } - Err(e) => { - error!("radio error: {e:#}"); - let _ = event_tx.blocking_send(RadioEvent::Disconnected); - - // If we were connected for a while, reset backoff so - // the next reconnect attempt is fast. - if started.elapsed() > Duration::from_secs(5) { - backoff = initial_backoff; - } - - info!("reconnecting in {backoff:?}"); - std::thread::sleep(backoff); - backoff = (backoff * 2).min(max_backoff); - } - } - } -} - -fn connect_and_run( - config: &RadioConfig, - port: Option<&str>, - event_tx: &mpsc::Sender, - tx_recv: &mut mpsc::Receiver>, -) -> Result<()> { - // Long initial timeout: the mux serializes commands through the dongle, - // so our `GetConfig` might have to wait behind other clients' commands. - let timeout = Duration::from_secs(10); - - info!("connecting to dongle..."); - let (mut client, device) = if let Some(port) = port { - let c = donglora_client::connect(Some(port), timeout)?; - (c, shorten_path(port)) - } else if let Ok(c) = donglora_client::connect_mux_auto(timeout) { - (c, "mux".to_string()) - } else { - let port_path = donglora_client::find_port().ok_or_else(|| anyhow::anyhow!("no mux or USB dongle found"))?; - info!("no mux found, connecting to serial port {port_path}"); - let c = donglora_client::connect(Some(&port_path), timeout)?; - (c, shorten_path(&port_path)) - }; - info!("[radio] transport connected: {device}"); - - info!("[radio] sending ping..."); - match client.ping() { - Ok(()) => info!("[radio] ping OK"), - Err(e) => { - info!("[radio] ping FAILED: {e:#}"); - return Err(e); - } - } - - info!("[radio] negotiating config..."); - let (active_config, config_source) = match negotiate_config(&mut client, config, &device) { - Ok(result) => { - info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0)); - result - } - Err(e) => { - info!("[radio] config negotiation FAILED: {e:#}"); - return Err(e); - } - }; - - info!("[radio] sending start_rx..."); - match client.start_rx() { - Ok(()) => info!("[radio] start_rx OK"), - Err(e) => { - info!("[radio] start_rx FAILED: {e:#}"); - return Err(e); - } - } - - let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device)); - - // Set a short timeout so we can interleave RX and TX. - client.transport_mut().set_timeout(Duration::from_millis(100))?; - info!("[radio] entering main loop"); - - // Liveness: ping the mux periodically when idle to detect a dead connection. - // Without this, a killed mux causes recv() to silently return Ok(None) forever. - let liveness_interval = Duration::from_secs(2); - let mut last_activity = std::time::Instant::now(); - - loop { - // Check for TX requests (non-blocking). - while let Ok(payload) = tx_recv.try_recv() { - debug!("TX {} bytes", payload.len()); - if let Err(e) = client.transmit(&payload, None) { - warn!("transmit error: {e:#}"); - return Err(e); - } - last_activity = std::time::Instant::now(); - } - - // Check for RX packets. - match client.recv() { - Ok(Some(Response::RxPacket { rssi, snr, payload })) => { - last_activity = std::time::Instant::now(); - let grade = snr_grade(snr, active_config.sf); - if grade.should_forward() { - debug!("RX {} bytes rssi={rssi} snr={snr} grade={grade}", payload.len()); - let pkt = RadioPacket { rssi, snr, payload }; - if event_tx.blocking_send(RadioEvent::Packet(pkt)).is_err() { - return Ok(()); - } - } else { - debug!("RX drop {} bytes rssi={rssi} snr={snr} grade={grade}", payload.len()); - } - } - Ok(Some(_)) => { - last_activity = std::time::Instant::now(); - } - Ok(None) => {} - Err(e) if is_timeout_error(&e) => {} - Err(e) => return Err(e), - } - - // Liveness check: if no real activity for a while, ping the mux. - if last_activity.elapsed() >= liveness_interval { - // Temporarily extend the timeout for the ping command. - let _ = client.transport_mut().set_timeout(Duration::from_secs(2)); - match client.ping() { - Ok(()) => { - last_activity = std::time::Instant::now(); - } - Err(e) => { - warn!("liveness ping failed: {e:#}"); - return Err(e); - } - } - // Restore the short timeout. - let _ = client.transport_mut().set_timeout(Duration::from_millis(100)); - } - - if event_tx.is_closed() { - return Ok(()); - } - } -} - -/// Negotiate radio config with the dongle/mux. -/// -/// Strategy: -/// 1. `GetConfig` first to read what's active. -/// 2. If it matches our desired config, use it — no `SetConfig` needed. -/// 3. If it differs and we're on the mux, accept the mux's config. -/// 4. If it differs and we're on direct serial, `SetConfig` to push ours. -/// 5. If `GetConfig` fails, try `SetConfig` with our desired config. -fn negotiate_config( - client: &mut donglora_client::Client, - desired: &RadioConfig, - device: &str, -) -> Result<(RadioConfig, ConfigSource)> { - info!("[negotiate] get_config..."); - match client.get_config() { - Ok(cfg) => { - if configs_match(&cfg, desired) { - info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg)); - Ok((cfg, ConfigSource::Ours)) - } else if device == "mux" { - info!("[negotiate] get_config OK (mux config differs, accepting): {}", format_radio_config(&cfg)); - Ok((cfg, ConfigSource::Mux)) - } else { - info!("[negotiate] get_config OK (serial config differs, setting ours)"); - client.set_config(*desired)?; - info!("[negotiate] set_config OK"); - Ok((*desired, ConfigSource::Ours)) - } - } - Err(e) => { - info!("[negotiate] get_config FAILED: {e:#}"); - // No config on radio — try to set ours. - info!("[negotiate] trying set_config..."); - match client.set_config(*desired) { - Ok(()) => { - info!("[negotiate] set_config OK"); - Ok((*desired, ConfigSource::Ours)) - } - Err(e2) => { - info!("[negotiate] set_config FAILED: {e2:#}"); - anyhow::bail!("GetConfig failed ({e:#}) and SetConfig also failed ({e2:#})"); - } - } - } - } -} - -/// Shorten a device path to just the filename for display. -fn shorten_path(path: &str) -> String { - std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned()) -} - -fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool { - a.freq_hz == b.freq_hz - && a.bw == b.bw - && a.sf == b.sf - && a.cr == b.cr - && a.sync_word == b.sync_word - && a.tx_power_dbm == b.tx_power_dbm - && a.preamble_len == b.preamble_len - && a.cad == b.cad -} - -fn is_timeout_error(e: &anyhow::Error) -> bool { - e.downcast_ref::() - .is_some_and(|io_err| matches!(io_err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut)) -} - -/// Format a bandwidth value for display. -#[must_use] -pub const fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str { - match bw { - donglora_client::Bandwidth::Khz7 => "7.8 kHz", - donglora_client::Bandwidth::Khz10 => "10.4 kHz", - donglora_client::Bandwidth::Khz15 => "15.6 kHz", - donglora_client::Bandwidth::Khz20 => "20.8 kHz", - donglora_client::Bandwidth::Khz31 => "31.25 kHz", - donglora_client::Bandwidth::Khz41 => "41.7 kHz", - donglora_client::Bandwidth::Khz62 => "62.5 kHz", - donglora_client::Bandwidth::Khz125 => "125 kHz", - donglora_client::Bandwidth::Khz250 => "250 kHz", - donglora_client::Bandwidth::Khz500 => "500 kHz", - } -} - -/// Format a `RadioConfig` for display. -#[must_use] -pub fn format_radio_config(config: &RadioConfig) -> String { - let freq_mhz = f64::from(config.freq_hz) / 1_000_000.0; - let bw = format_bandwidth(config.bw); - let power = if config.tx_power_dbm == donglora_client::TX_POWER_MAX { - "max".to_string() - } else { - format!("{} dBm", config.tx_power_dbm) - }; - let preamble = if config.preamble_len == 0 { 16 } else { config.preamble_len }; - let cad = if config.cad != 0 { "on" } else { "off" }; - format!( - "{freq_mhz:.3}MHz SF{} BW{bw} CR4/{} SW0x{:04X} TX:{power} Pre:{preamble} CAD:{cad}", - config.sf, config.cr, config.sync_word - ) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - fn default_config() -> RadioConfig { - RadioConfig { - freq_hz: 910_525_000, - bw: donglora_client::Bandwidth::Khz62, - sf: 7, - cr: 5, - sync_word: 0x3444, - tx_power_dbm: 22, - preamble_len: 16, - cad: 1, - } - } - - #[test] - fn configs_match_identical() { - let a = default_config(); - let b = default_config(); - assert!(configs_match(&a, &b)); - } - - #[test] - fn configs_match_differs_each_field() { - let base = default_config(); - - let mut c = base; - c.freq_hz = 915_000_000; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.sf = 12; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.cr = 8; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.sync_word = 0x1234; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.tx_power_dbm = 10; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.preamble_len = 8; - assert!(!configs_match(&base, &c)); - - let mut c = base; - c.cad = 0; - assert!(!configs_match(&base, &c)); - } - - #[test] - fn is_timeout_would_block() { - let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::WouldBlock, "test")); - assert!(is_timeout_error(&e)); - } - - #[test] - fn is_timeout_timed_out() { - let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "test")); - assert!(is_timeout_error(&e)); - } - - #[test] - fn is_timeout_false_for_other() { - let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test")); - assert!(!is_timeout_error(&e)); - } - - #[test] - fn shorten_path_extracts_filename() { - assert_eq!(shorten_path("/dev/ttyACM0"), "ttyACM0"); - assert_eq!(shorten_path("ttyACM0"), "ttyACM0"); - } - - #[test] - fn format_bandwidth_all_variants() { - assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz7), "7.8 kHz"); - assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz125), "125 kHz"); - assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz500), "500 kHz"); - } - - #[test] - fn format_radio_config_typical() { - let cfg = default_config(); - let s = format_radio_config(&cfg); - assert!(s.contains("910.525MHz")); - assert!(s.contains("SF7")); - assert!(s.contains("CR4/5")); - assert!(s.contains("0x3444")); - assert!(s.contains("CAD:on"), "cad=1 should show CAD:on: {s}"); - } - - #[test] - fn format_radio_config_cad_off() { - let mut cfg = default_config(); - cfg.cad = 0; - let s = format_radio_config(&cfg); - assert!(s.contains("CAD:off"), "cad=0 should show CAD:off: {s}"); - } - - #[test] - fn format_radio_config_max_power() { - let mut cfg = default_config(); - cfg.tx_power_dbm = donglora_client::TX_POWER_MAX; - let s = format_radio_config(&cfg); - assert!(s.contains("TX:max")); - assert!(!s.contains("dBm"), "max power should not contain 'dBm': {s}"); - } - - #[test] - fn format_radio_config_non_max_power() { - let mut cfg = default_config(); - cfg.tx_power_dbm = 22; - assert_ne!(cfg.tx_power_dbm, donglora_client::TX_POWER_MAX); - let s = format_radio_config(&cfg); - assert!(s.contains("22 dBm"), "non-max power should contain '22 dBm': {s}"); - assert!(!s.contains("max"), "non-max power should not contain 'max': {s}"); - } - - #[test] - fn format_radio_config_zero_preamble() { - let mut cfg = default_config(); - cfg.preamble_len = 0; - let s = format_radio_config(&cfg); - assert!(s.contains("Pre:16")); - } -} diff --git a/src/rate_limit.rs b/src/rate_limit.rs deleted file mode 100644 index cd62021..0000000 --- a/src/rate_limit.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! `LoRa` air-time-aware token bucket rate limiter. -//! -//! Calculates transmit rate from the Semtech `LoRa` air time formula and -//! enforces it with a configurable burst allowance. - -use std::time::{Duration, Instant}; - -use donglora_client::Bandwidth; - -/// Token-bucket rate limiter for radio transmissions. -/// -/// Calculates a safe transmit rate from the `LoRa` radio configuration (spreading factor, -/// bandwidth, coding rate) and enforces it with a token bucket that allows short bursts. -pub struct RateLimiter { - tokens: f64, - max_tokens: f64, - refill_rate: f64, // tokens per second - last_refill: Instant, -} - -/// Default burst size (max tokens). Allows short bursts of 3 back-to-back packets -/// to handle bursty mesh traffic without triggering rate limiting on every packet. -const DEFAULT_BURST: f64 = 3.0; - -/// Reference payload size (bytes) used to estimate air time for rate calculation. -/// 50 bytes is a representative `MeshCore` packet size (header + small payload). -const REFERENCE_PAYLOAD_BYTES: usize = 50; - -/// Target duty cycle fraction. 50% leaves headroom for other radio traffic sharing -/// the channel while still forwarding bridge traffic aggressively. -const TARGET_DUTY_CYCLE: f64 = 0.5; - -impl RateLimiter { - /// Create a rate limiter derived from radio config. - /// - /// If `override_pps` is `Some`, use that rate directly instead of calculating. - #[must_use] - pub fn from_radio_config(sf: u8, bw: Bandwidth, cr: u8, preamble: u16, override_pps: Option) -> Self { - let refill_rate = override_pps.map_or_else( - || { - let air_time = lora_air_time(REFERENCE_PAYLOAD_BYTES, sf, bw, cr, preamble); - let secs = air_time.as_secs_f64(); - if secs > 0.0 { TARGET_DUTY_CYCLE / secs } else { 10.0 } - }, - |pps| pps.max(0.01), - ); - Self { tokens: DEFAULT_BURST, max_tokens: DEFAULT_BURST, refill_rate, last_refill: Instant::now() } - } - - /// Try to consume one token. Returns `true` if the transmission is allowed. - pub fn try_acquire(&mut self) -> bool { - self.refill(); - if self.tokens >= 1.0 { - self.tokens -= 1.0; - true - } else { - false - } - } - - /// Current rate in packets per second. - #[must_use] - pub const fn rate_pps(&self) -> f64 { - self.refill_rate - } - - fn refill(&mut self) { - let now = Instant::now(); - let elapsed = now.duration_since(self.last_refill).as_secs_f64(); - self.tokens = elapsed.mul_add(self.refill_rate, self.tokens).min(self.max_tokens); - self.last_refill = now; - } -} - -/// Estimate `LoRa` packet air time using the Semtech `LoRa` time-on-air formula. -/// -/// Parameters: -/// - `payload_bytes`: payload size in bytes -/// - `sf`: spreading factor (7–12) -/// - `bw`: bandwidth -/// - `cr`: coding rate denominator (5–8, meaning 4/5 to 4/8) -/// - `preamble`: preamble length in symbols (0 = firmware default of 16) -#[must_use] -pub fn lora_air_time(payload_bytes: usize, sf: u8, bw: Bandwidth, cr: u8, preamble: u16) -> Duration { - let sf_f = f64::from(sf); - let bw_hz = bandwidth_hz(bw); - let preamble_symbols = if preamble == 0 { 16.0 } else { f64::from(preamble) }; - - // Symbol time in seconds. - let t_sym = sf_f.exp2() / bw_hz; - - // Preamble time. - let t_preamble = (preamble_symbols + 4.25) * t_sym; - - // Low data rate optimize: enabled when SF >= 11 and BW <= 125 kHz. - let de: f64 = if sf >= 11 && bw_hz <= 125_000.0 { 1.0 } else { 0.0 }; - - // Payload symbol count (Semtech formula with CRC enabled, explicit header). - #[allow(clippy::cast_precision_loss)] // payload_bytes is always < 256 - let payload_f = payload_bytes as f64; - let numerator = 8.0f64.mul_add(payload_f, (-4.0f64).mul_add(sf_f, 44.0)); - let denominator = 4.0f64.mul_add(sf_f, -8.0 * de); - let n_payload = (numerator / denominator).ceil().max(0.0).mul_add(f64::from(cr), 8.0); - - let t_payload = n_payload * t_sym; - let total = t_preamble + t_payload; - - Duration::from_secs_f64(total) -} - -const fn bandwidth_hz(bw: Bandwidth) -> f64 { - match bw { - Bandwidth::Khz7 => 7_800.0, - Bandwidth::Khz10 => 10_400.0, - Bandwidth::Khz15 => 15_600.0, - Bandwidth::Khz20 => 20_800.0, - Bandwidth::Khz31 => 31_250.0, - Bandwidth::Khz41 => 41_700.0, - Bandwidth::Khz62 => 62_500.0, - Bandwidth::Khz125 => 125_000.0, - Bandwidth::Khz250 => 250_000.0, - Bandwidth::Khz500 => 500_000.0, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn air_time_sf7_bw125() { - let t = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16); - // SF7/125kHz/CR4/5/16-preamble/50 bytes: ~100ms range - assert!(t.as_millis() > 50, "air time too short: {t:?}"); - assert!(t.as_millis() < 300, "air time too long: {t:?}"); - } - - #[test] - fn air_time_sf12_bw125() { - let t = lora_air_time(50, 12, Bandwidth::Khz125, 5, 16); - // SF12/125kHz: should be multiple seconds - assert!(t.as_secs() >= 1, "air time too short: {t:?}"); - assert!(t.as_secs() < 10, "air time too long: {t:?}"); - } - - #[test] - fn rate_limiter_allows_burst() { - let mut rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, None); - // Should allow burst of 3 - assert!(rl.try_acquire()); - assert!(rl.try_acquire()); - assert!(rl.try_acquire()); - // 4th should fail (no time for refill) - assert!(!rl.try_acquire()); - } - - #[test] - fn rate_limiter_override() { - let rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, Some(42.0)); - assert!((rl.rate_pps() - 42.0).abs() < f64::EPSILON); - } - - #[test] - fn air_time_zero_preamble_uses_default() { - let t_default = lora_air_time(50, 7, Bandwidth::Khz125, 5, 0); - let t_explicit = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16); - assert_eq!(t_default, t_explicit); - } - - // ── Mutant-killing tests for from_radio_config (items 1-5) ──────── - - #[test] - fn from_radio_config_calculates_reasonable_rate() { - // SF7/BW125/CR5/preamble16 with 50-byte reference payload: - // air time ~105.7ms, so rate = 0.5 / 0.1057 ~= 4.73 pps. - let rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, None); - let rate = rl.rate_pps(); - // Must be between 0.1 and 100 (rules out fallback of 10.0 indirectly, - // and catches if secs<=0 path is taken or division is wrong). - assert!(rate > 0.1, "rate too low: {rate}"); - assert!(rate < 100.0, "rate too high: {rate}"); - // Specifically verify it is NOT the 10.0 fallback, proving secs > 0.0 was true. - assert!((rate - 10.0).abs() > 0.5, "rate should not be the 10.0 fallback: {rate}"); - // Tight check around the expected ~4.73 pps. - assert!(rate > 4.0, "rate should be near 4.73: {rate}"); - assert!(rate < 5.5, "rate should be near 4.73: {rate}"); - } - - #[test] - fn from_radio_config_sf12_rate_much_slower() { - // SF12/BW125: air time ~2564ms, rate = 0.5 / 2.564 ~= 0.195 pps. - let rl = RateLimiter::from_radio_config(12, Bandwidth::Khz125, 5, 16, None); - let rate = rl.rate_pps(); - assert!(rate > 0.1, "rate too low: {rate}"); - assert!(rate < 0.5, "rate too high: {rate}"); - // Definitely not 10.0 fallback. - assert!((rate - 10.0).abs() > 1.0, "rate should not be the fallback: {rate}"); - } - - // ── Mutant-killing tests for refill (item 6) ─────────────────── - - #[test] - fn rate_limiter_refills_after_waiting() { - // Use override_pps for predictable timing: 10 pps = 1 token per 100ms. - let mut rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, Some(10.0)); - // Exhaust all burst tokens. - assert!(rl.try_acquire()); - assert!(rl.try_acquire()); - assert!(rl.try_acquire()); - assert!(!rl.try_acquire(), "burst should be exhausted"); - - // Sleep enough for at least 1 token to refill (10 pps -> 100ms per token). - std::thread::sleep(Duration::from_millis(200)); - - // After sleeping, refill should have added tokens, so acquire succeeds. - assert!(rl.try_acquire(), "should succeed after refill"); - } - - // ── Mutant-killing tests for lora_air_time (items 7-14) ──────── - - #[test] - fn air_time_exact_sf7_bw125() { - // SF7, BW125kHz, CR4/5, preamble=16, payload=50 bytes. - // Expected: 105.728 ms (computed from Semtech formula). - let t = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16); - let ms = t.as_secs_f64() * 1000.0; - assert!((ms - 105.728).abs() < 0.01, "SF7/BW125 air time should be ~105.728ms, got {ms:.3}ms"); - } - - #[test] - fn air_time_exact_sf12_bw125() { - // SF12, BW125kHz, CR4/5, preamble=16, payload=50 bytes. - // Expected: 2564.096 ms. - let t = lora_air_time(50, 12, Bandwidth::Khz125, 5, 16); - let ms = t.as_secs_f64() * 1000.0; - assert!((ms - 2564.096).abs() < 0.01, "SF12/BW125 air time should be ~2564.096ms, got {ms:.3}ms"); - } - - #[test] - fn air_time_ldro_sf11_bw125_vs_sf10_bw125() { - // SF>=11 && BW<=125kHz triggers LDRO. SF10 does not. - // SF11/BW125: 1445.888 ms (de=1), SF10/BW125: 681.984 ms (de=0). - let t11 = lora_air_time(50, 11, Bandwidth::Khz125, 5, 16); - let t10 = lora_air_time(50, 10, Bandwidth::Khz125, 5, 16); - - let ms11 = t11.as_secs_f64() * 1000.0; - let ms10 = t10.as_secs_f64() * 1000.0; - - // Verify exact values. - assert!((ms11 - 1445.888).abs() < 0.01, "SF11/BW125 should be ~1445.888ms, got {ms11:.3}ms"); - assert!((ms10 - 681.984).abs() < 0.01, "SF10/BW125 should be ~681.984ms, got {ms10:.3}ms"); - - // SF11 must be more than double SF10 due to LDRO + SF increase. - // (Kills mutant: replace >= with < in sf >= 11 check.) - assert!(t11 > t10 * 2, "SF11 with LDRO should be >2x SF10"); - } - - #[test] - fn air_time_ldro_not_triggered_high_bw() { - // SF11/BW250: LDRO should NOT be active (BW > 125kHz). - // Expected: 641.024 ms (de=0). - let t11_250 = lora_air_time(50, 11, Bandwidth::Khz250, 5, 16); - let ms = t11_250.as_secs_f64() * 1000.0; - assert!((ms - 641.024).abs() < 0.01, "SF11/BW250 should be ~641.024ms (no LDRO), got {ms:.3}ms"); - - // Compare with SF11/BW125 which HAS LDRO. - let t11_125 = lora_air_time(50, 11, Bandwidth::Khz125, 5, 16); - // Even accounting for halved BW doubling the base time, the LDRO effect - // on SF11/BW125 makes it much longer than simply 2x SF11/BW250. - assert!(t11_125 > t11_250 * 2, "SF11/BW125 (LDRO on) should be >2x SF11/BW250 (LDRO off)"); - } - - #[test] - fn air_time_preamble_scaling() { - // Changing preamble by N symbols changes air time by N * t_sym. - // t_sym for SF7/BW125 = 2^7 / 125000 = 1.024 ms. - let t8 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 8); - let t16 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16); - let t32 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 32); - - let diff_8_to_16 = t16.as_secs_f64() - t8.as_secs_f64(); - let diff_16_to_32 = t32.as_secs_f64() - t16.as_secs_f64(); - - let t_sym = 128.0 / 125_000.0; // 1.024 ms - - // 8 -> 16: 8 symbols difference. - let expected_8 = 8.0f64.mul_add(t_sym, -diff_8_to_16).abs(); - assert!(expected_8 < 1e-9, "preamble 8->16 should add 8*t_sym, got {diff_8_to_16:.6}s"); - - // 16 -> 32: 16 symbols difference. - let expected_16 = 16.0f64.mul_add(t_sym, -diff_16_to_32).abs(); - assert!(expected_16 < 1e-9, "preamble 16->32 should add 16*t_sym, got {diff_16_to_32:.6}s"); - } - - #[test] - fn air_time_sensible_range_all_sf_bw() { - // Verify reasonable bounds for all SF/BW combos with 50-byte payload. - // This catches gross formula errors (e.g. division replaced with modulo). - let bandwidths = [ - Bandwidth::Khz7, - Bandwidth::Khz10, - Bandwidth::Khz15, - Bandwidth::Khz20, - Bandwidth::Khz31, - Bandwidth::Khz41, - Bandwidth::Khz62, - Bandwidth::Khz125, - Bandwidth::Khz250, - Bandwidth::Khz500, - ]; - for sf in 7..=12u8 { - for &bw in &bandwidths { - let t = lora_air_time(50, sf, bw, 5, 16); - let ms = t.as_secs_f64() * 1000.0; - // Fastest: SF7/BW500 ~26ms. Slowest: SF12/BW7.8kHz ~many seconds. - // All should be > 1ms and < 300s. - assert!(ms > 1.0, "SF{sf}/BW{bw:?}: air time too short: {ms:.3}ms"); - assert!(ms < 300_000.0, "SF{sf}/BW{bw:?}: air time too long: {ms:.3}ms"); - } - } - } - - mod proptests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn air_time_always_positive( - payload in 0usize..256, - sf in 7u8..=12u8, - cr in 5u8..=8u8, - preamble in 0u16..=64u16, - ) { - for bw in [Bandwidth::Khz7, Bandwidth::Khz62, Bandwidth::Khz125, Bandwidth::Khz500] { - let t = lora_air_time(payload, sf, bw, cr, preamble); - prop_assert!(t > Duration::ZERO, "air time must be positive"); - prop_assert!(t < Duration::from_secs(600), "air time should be < 600s"); - } - } - } - } -} diff --git a/src/router.rs b/src/router.rs deleted file mode 100644 index a43799f..0000000 --- a/src/router.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! Central packet bus bridging radio and gossip swarm. -//! -//! Applies blake3 content-hash deduplication, token bucket rate limiting, -//! and TX queue management. - -use std::collections::{HashMap, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -use iroh::PublicKey; -use tokio::sync::mpsc; -use tracing::{debug, warn}; - -use crate::gossip::GossipEvent; -use crate::packet::{GossipFrame, content_hash}; -use crate::radio::{ConfigSource, RadioEvent}; -use crate::rate_limit::RateLimiter; - -/// Active radio config info, sent to the TUI via watch channel. -#[derive(Debug, Clone)] -pub struct RadioConfigInfo { - pub active: donglora_client::RadioConfig, - pub requested: donglora_client::RadioConfig, - pub source: ConfigSource, - pub device: String, - pub connected: bool, -} - -/// Packet event for the TUI log. -#[derive(Debug, Clone)] -pub struct PacketLogEntry { - pub timestamp: Instant, - pub hash: [u8; 32], - pub direction: PacketDirection, - pub size: usize, - pub snr: Option, - pub rssi: Option, - pub action: PacketAction, -} - -/// Which side the packet arrived from. -#[derive(Debug, Clone, Copy)] -pub enum PacketDirection { - /// Heard on local radio → forwarded to gossip swarm. - RadioIn, - /// Received from gossip swarm → queued for radio TX. - GossipIn, -} - -/// What happened to the packet. -#[derive(Debug, Clone, Copy)] -pub enum PacketAction { - /// Successfully bridged (radio→gossip or gossip→radio). - Bridged, - /// Dropped: duplicate payload. - DroppedDedup, - /// Dropped: TX queue full. - DroppedQueueFull, - /// Dropped: rate limiter. - DroppedRateLimit, -} - -impl std::fmt::Display for PacketAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bridged => write!(f, ""), - Self::DroppedDedup => write!(f, "DUP"), - Self::DroppedQueueFull => write!(f, "FULL"), - Self::DroppedRateLimit => write!(f, "RATE"), - } - } -} - -/// Aggregate stats for the TUI. -#[derive(Debug, Default)] -pub struct Stats { - pub radio_rx: AtomicU64, - pub radio_tx: AtomicU64, - pub gossip_rx: AtomicU64, - pub gossip_tx: AtomicU64, - pub dedup_hits: AtomicU64, - pub rate_limit_drops: AtomicU64, - pub dropped_queue: AtomicU64, - pub neighbor_count: AtomicU64, - pub radio_connected: AtomicU64, // 0 = disconnected, 1 = connected -} - -impl Stats { - #[must_use] - pub fn snapshot(&self) -> StatsSnapshot { - StatsSnapshot { - radio_rx: self.radio_rx.load(Ordering::Relaxed), - radio_tx: self.radio_tx.load(Ordering::Relaxed), - gossip_rx: self.gossip_rx.load(Ordering::Relaxed), - gossip_tx: self.gossip_tx.load(Ordering::Relaxed), - dedup_hits: self.dedup_hits.load(Ordering::Relaxed), - rate_limit_drops: self.rate_limit_drops.load(Ordering::Relaxed), - dropped_queue: self.dropped_queue.load(Ordering::Relaxed), - neighbor_count: self.neighbor_count.load(Ordering::Relaxed), - radio_connected: self.radio_connected.load(Ordering::Relaxed) != 0, - } - } -} - -/// Point-in-time snapshot of bridge statistics, cloned from atomic counters. -#[derive(Debug, Clone)] -pub struct StatsSnapshot { - pub radio_rx: u64, - pub radio_tx: u64, - pub gossip_rx: u64, - pub gossip_tx: u64, - pub dedup_hits: u64, - pub rate_limit_drops: u64, - pub dropped_queue: u64, - pub neighbor_count: u64, - pub radio_connected: bool, -} - -/// Time-bounded dedup cache. -struct DedupCache { - entries: HashMap<[u8; 32], Instant>, - ttl: Duration, -} - -impl DedupCache { - fn new(ttl: Duration) -> Self { - Self { entries: HashMap::new(), ttl } - } - - /// Returns true if the key was already present (i.e., duplicate). - fn check_and_insert(&mut self, key: [u8; 32]) -> bool { - let now = Instant::now(); - if let Some(ts) = self.entries.get(&key) - && now.duration_since(*ts) < self.ttl - { - return true; - } - self.entries.insert(key, now); - false - } - - fn prune(&mut self) { - let now = Instant::now(); - self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl); - } -} - -/// Run the router — the central packet bus. -/// -/// Bridges radio packets to/from the gossip swarm with deduplication and rate limiting. -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub async fn run( - our_id: PublicKey, - dedup_window: Duration, - tx_queue_size: usize, - requested_radio_config: donglora_client::RadioConfig, - mut rate_limiter: RateLimiter, - mut radio_rx: mpsc::Receiver, - radio_tx: mpsc::Sender>, - mut gossip_rx: mpsc::Receiver, - gossip_tx: mpsc::Sender, - stats: &Stats, - log_tx: mpsc::Sender, - config_tx: tokio::sync::watch::Sender, -) { - let mut dedup = DedupCache::new(dedup_window); - let mut tx_queue: VecDeque> = VecDeque::with_capacity(tx_queue_size); - let mut last_cleanup = Instant::now(); - let cleanup_interval = Duration::from_secs(30); - - loop { - // Periodic dedup cleanup. - if last_cleanup.elapsed() >= cleanup_interval { - dedup.prune(); - last_cleanup = Instant::now(); - } - - tokio::select! { - // Radio events. - event = radio_rx.recv() => { - let Some(event) = event else { break }; - match event { - RadioEvent::Packet(pkt) => { - stats.radio_rx.fetch_add(1, Ordering::Relaxed); - - let hash = content_hash(&pkt.payload); - if dedup.check_and_insert(hash) { - stats.dedup_hits.fetch_add(1, Ordering::Relaxed); - debug!("radio RX dedup suppressed ({} bytes)", pkt.payload.len()); - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::RadioIn, - size: pkt.payload.len(), - snr: Some(pkt.snr), - rssi: Some(pkt.rssi), - action: PacketAction::DroppedDedup, - }).await; - continue; - } - - // Wrap and broadcast to gossip. - let frame = GossipFrame::new(&our_id, pkt.rssi, pkt.snr, pkt.payload.clone()); - stats.gossip_tx.fetch_add(1, Ordering::Relaxed); - let _ = gossip_tx.send(frame).await; - - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::RadioIn, - size: pkt.payload.len(), - snr: Some(pkt.snr), - rssi: Some(pkt.rssi), - action: PacketAction::Bridged, - }).await; - } - RadioEvent::Connected(active_config, source, device) => { - stats.radio_connected.store(1, Ordering::Relaxed); - let _ = config_tx.send(RadioConfigInfo { - active: active_config, - requested: requested_radio_config, - source, - device, - connected: true, - }); - } - RadioEvent::Disconnected => { - stats.radio_connected.store(0, Ordering::Relaxed); - config_tx.send_modify(|info| info.connected = false); - } - } - } - - // Gossip events. - event = gossip_rx.recv() => { - let Some(event) = event else { break }; - match event { - GossipEvent::Frame(frame) => { - stats.gossip_rx.fetch_add(1, Ordering::Relaxed); - - let hash = content_hash(&frame.payload); - if dedup.check_and_insert(hash) { - stats.dedup_hits.fetch_add(1, Ordering::Relaxed); - debug!("gossip RX dedup suppressed ({} bytes)", frame.payload.len()); - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::GossipIn, - size: frame.payload.len(), - snr: Some(frame.snr), - rssi: Some(frame.rssi), - action: PacketAction::DroppedDedup, - }).await; - continue; - } - - // Rate limit check. - if !rate_limiter.try_acquire() { - stats.rate_limit_drops.fetch_add(1, Ordering::Relaxed); - debug!("rate limited ({} bytes)", frame.payload.len()); - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::GossipIn, - size: frame.payload.len(), - snr: Some(frame.snr), - rssi: Some(frame.rssi), - action: PacketAction::DroppedRateLimit, - }).await; - continue; - } - - // Enqueue for radio TX. - if tx_queue.len() >= tx_queue_size { - stats.dropped_queue.fetch_add(1, Ordering::Relaxed); - warn!("TX queue full, dropping packet ({} bytes)", frame.payload.len()); - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::GossipIn, - size: frame.payload.len(), - snr: Some(frame.snr), - rssi: Some(frame.rssi), - action: PacketAction::DroppedQueueFull, - }).await; - continue; - } - - tx_queue.push_back(frame.payload.clone()); - let _ = log_tx.send(PacketLogEntry { - timestamp: Instant::now(), - hash, - direction: PacketDirection::GossipIn, - size: frame.payload.len(), - snr: Some(frame.snr), - rssi: Some(frame.rssi), - action: PacketAction::Bridged, - }).await; - } - GossipEvent::NeighborChanged(count) => { - #[allow(clippy::cast_possible_truncation)] - stats.neighbor_count.store(count as u64, Ordering::Relaxed); - } - } - } - } - - // Drain TX queue to radio. - while let Some(payload) = tx_queue.pop_front() { - stats.radio_tx.fetch_add(1, Ordering::Relaxed); - if radio_tx.send(payload).await.is_err() { - break; - } - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn dedup_cache_new_key_not_duplicate() { - let mut cache = DedupCache::new(Duration::from_secs(10)); - assert!(!cache.check_and_insert([0u8; 32])); - } - - #[test] - fn dedup_cache_duplicate_detected() { - let mut cache = DedupCache::new(Duration::from_secs(10)); - cache.check_and_insert([1u8; 32]); - assert!(cache.check_and_insert([1u8; 32])); - } - - #[test] - fn dedup_cache_different_keys_independent() { - let mut cache = DedupCache::new(Duration::from_secs(10)); - cache.check_and_insert([1u8; 32]); - assert!(!cache.check_and_insert([2u8; 32])); - } - - #[test] - fn dedup_cache_expired_entry_not_duplicate() { - let mut cache = DedupCache::new(Duration::from_millis(1)); - cache.check_and_insert([1u8; 32]); - std::thread::sleep(Duration::from_millis(5)); - assert!(!cache.check_and_insert([1u8; 32])); - } - - #[test] - fn dedup_cache_prune_removes_expired() { - let mut cache = DedupCache::new(Duration::from_millis(1)); - cache.check_and_insert([1u8; 32]); - cache.check_and_insert([2u8; 32]); - std::thread::sleep(Duration::from_millis(5)); - cache.prune(); - assert!(cache.entries.is_empty()); - } - - #[test] - fn dedup_cache_prune_keeps_fresh() { - let mut cache = DedupCache::new(Duration::from_secs(10)); - cache.check_and_insert([1u8; 32]); - cache.prune(); - assert_eq!(cache.entries.len(), 1); - } - - #[test] - fn stats_snapshot_reflects_increments() { - let stats = Stats::default(); - stats.radio_rx.fetch_add(5, Ordering::Relaxed); - stats.gossip_tx.fetch_add(3, Ordering::Relaxed); - stats.radio_connected.store(1, Ordering::Relaxed); - let snap = stats.snapshot(); - assert_eq!(snap.radio_rx, 5); - assert_eq!(snap.gossip_tx, 3); - assert!(snap.radio_connected); - } - - #[test] - fn stats_default_is_zeroed() { - let snap = Stats::default().snapshot(); - assert_eq!(snap.radio_rx, 0); - assert_eq!(snap.gossip_rx, 0); - assert!(!snap.radio_connected); - } - - #[test] - fn packet_action_display() { - assert_eq!(format!("{}", PacketAction::Bridged), ""); - assert_eq!(format!("{}", PacketAction::DroppedDedup), "DUP"); - assert_eq!(format!("{}", PacketAction::DroppedQueueFull), "FULL"); - assert_eq!(format!("{}", PacketAction::DroppedRateLimit), "RATE"); - } - - #[test] - fn dedup_cache_ttl_boundary_check_and_insert() { - // Force an entry's timestamp to be exactly TTL ago. With `< ttl`, the entry - // at exactly TTL is NOT a duplicate. With `<= ttl` (the mutant), it would - // wrongly be treated as still live. - let ttl = Duration::from_secs(10); - let mut cache = DedupCache::new(ttl); - cache.check_and_insert([9u8; 32]); - // Backdate the entry to exactly TTL ago. - let exactly_ttl_ago = Instant::now().checked_sub(ttl).unwrap(); - cache.entries.insert([9u8; 32], exactly_ttl_ago); - // duration_since(exactly_ttl_ago) == ttl, so `< ttl` is false → not duplicate. - // The mutant `<= ttl` would say true → duplicate. This catches it. - assert!(!cache.check_and_insert([9u8; 32]), "entry at exact TTL must be treated as expired"); - } - - #[test] - fn dedup_cache_ttl_boundary_prune() { - let ttl = Duration::from_secs(10); - let mut cache = DedupCache::new(ttl); - cache.check_and_insert([8u8; 32]); - // Backdate to exactly TTL ago. - let exactly_ttl_ago = Instant::now().checked_sub(ttl).unwrap(); - cache.entries.insert([8u8; 32], exactly_ttl_ago); - cache.prune(); - assert!(cache.entries.is_empty(), "entry at exact TTL must be pruned"); - } -} diff --git a/src/setup.rs b/src/setup.rs deleted file mode 100644 index 5326985..0000000 --- a/src/setup.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Interactive console setup wizard for bridge configuration. - -use std::io::{self, Write}; -use std::path::Path; - -use anyhow::{Context, Result}; - -use crate::config::{BridgeSection, Config, RadioSection}; - -/// ANSI escape helpers. -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const CYAN: &str = "\x1b[36m"; -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RESET: &str = "\x1b[0m"; - -/// Run the interactive setup wizard. If `existing` is Some, use those values as defaults. -pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> { - let defaults = existing - .map_or_else(|| (RadioSection::default(), BridgeSection::default()), |c| (c.radio.clone(), c.bridge.clone())); - let (radio, bridge) = defaults; - - println!(); - println!(" {BOLD}donglora-bridge setup{RESET}"); - println!(" {DIM}Configure your LoRa gossip bridge.{RESET}"); - println!(); - - // ── Passphrase ─────────────────────────────────────────────── - - let default_pass = if bridge.passphrase.is_empty() || bridge.passphrase == "change-me" { - None - } else { - Some(bridge.passphrase.as_str()) - }; - let passphrase = prompt_required("Passphrase", default_pass)?; - - println!(); - println!(" {BOLD}Radio Settings{RESET} {DIM}(Enter to keep default){RESET}"); - println!(" {DIM}────────────────────────────────────{RESET}"); - - // ── Radio ──────────────────────────────────────────────────── - - let freq_default = format!("{:.3}", f64::from(radio.frequency) / 1_000_000.0); - let freq_mhz: f64 = prompt_parse("Frequency (MHz)", &freq_default)?; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // validated by prompt_parse - let frequency = (freq_mhz * 1_000_000.0) as u32; - - let bandwidth = prompt_default("Bandwidth", &radio.bandwidth)?; - - let sf_default = format!("{}", radio.spreading_factor); - let spreading_factor: u8 = prompt_parse("Spreading Factor", &sf_default)?; - - let cr_default = format!("{}", radio.coding_rate); - let coding_rate: u8 = prompt_parse("Coding Rate", &cr_default)?; - - let sw_default = format!("0x{:04X}", radio.sync_word); - let sync_word_str = prompt_default("Sync Word", &sw_default)?; - let sync_word = parse_sync_word(&sync_word_str)?; - - let tx_power = prompt_default("TX Power (dBm or 'max')", &radio.tx_power)?; - - let pre_default = format!("{}", radio.preamble); - let preamble: u16 = prompt_parse("Preamble", &pre_default)?; - - let cad_default = if radio.cad { "on" } else { "off" }; - let cad_str = prompt_default("CAD", cad_default)?; - let cad = matches!(cad_str.to_lowercase().as_str(), "on" | "true" | "yes" | "1"); - - let port_default = radio.port.as_deref().unwrap_or("auto"); - let port_str = prompt_default("Serial Port", port_default)?; - let port = if port_str == "auto" || port_str.is_empty() { None } else { Some(port_str) }; - - // ── Build and save ─────────────────────────────────────────── - - let cfg = Config { - radio: RadioSection { - port, - frequency, - bandwidth, - spreading_factor, - coding_rate, - sync_word, - tx_power, - preamble, - cad, - }, - bridge: BridgeSection { - passphrase, - dedup_window_secs: bridge.dedup_window_secs, - tx_queue_size: bridge.tx_queue_size, - rate_limit_pps: bridge.rate_limit_pps, - log_file: bridge.log_file, - }, - }; - - write_config(config_path, &cfg)?; - - println!(); - println!(" {GREEN}Config saved to {}{RESET}", config_path.display()); - println!(" Run {CYAN}donglora-bridge{RESET} to start!"); - println!(); - - Ok(()) -} - -fn write_config(path: &Path, cfg: &Config) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).with_context(|| format!("creating directory: {}", parent.display()))?; - } - let content = toml::to_string_pretty(cfg).context("serializing config")?; - std::fs::write(path, content).with_context(|| format!("writing config: {}", path.display()))?; - Ok(()) -} - -// ── Prompt helpers ─────────────────────────────────────────────── - -fn prompt_default(label: &str, default: &str) -> Result { - print!(" {label} {DIM}[{default}]{RESET}: "); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let trimmed = input.trim(); - if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) } -} - -fn prompt_required(label: &str, default: Option<&str>) -> Result { - loop { - let prompt = default.map_or_else(|| format!(" {label}: "), |d| format!(" {label} {DIM}[{d}]{RESET}: ")); - print!("{prompt}"); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let trimmed = input.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); - } - if let Some(d) = default { - return Ok(d.to_string()); - } - println!(" {YELLOW}This field is required.{RESET}"); - } -} - -fn prompt_parse(label: &str, default: &str) -> Result -where - T::Err: std::fmt::Display, -{ - loop { - let input = prompt_default(label, default)?; - match input.parse::() { - Ok(v) => return Ok(v), - Err(e) => println!(" {YELLOW}Invalid input: {e}{RESET}"), - } - } -} - -fn parse_sync_word(s: &str) -> Result { - let s = s.trim().to_lowercase(); - let s = s.strip_prefix("0x").unwrap_or(&s); - u16::from_str_radix(s, 16).with_context(|| format!("invalid sync word: {s}")) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn parse_sync_word_hex_prefix() { - assert_eq!(parse_sync_word("0x3444").unwrap(), 0x3444); - } - - #[test] - fn parse_sync_word_no_prefix() { - assert_eq!(parse_sync_word("3444").unwrap(), 0x3444); - } - - #[test] - fn parse_sync_word_case_insensitive() { - assert_eq!(parse_sync_word("0xABCD").unwrap(), 0xABCD); - } - - #[test] - fn parse_sync_word_whitespace() { - assert_eq!(parse_sync_word(" 0x3444 ").unwrap(), 0x3444); - } - - #[test] - fn parse_sync_word_invalid() { - assert!(parse_sync_word("ZZZZ").is_err()); - assert!(parse_sync_word("").is_err()); - } -} diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index eef9d87..0000000 --- a/src/tui.rs +++ /dev/null @@ -1,1153 +0,0 @@ -//! Ratatui terminal user interface. -//! -//! Displays network status, radio configuration, aggregate statistics, -//! a scrolling packet log, and real-time activity sparklines. - -use std::collections::VecDeque; -use std::io::{self, Stdout}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crossterm::ExecutableCommand; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::symbols; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Sparkline}; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; - -use crate::gossip::Gossip; -use crate::radio::ConfigSource; -use crate::router::{PacketAction, PacketDirection, PacketLogEntry, RadioConfigInfo, Stats, StatsSnapshot}; - -const TICK_RATE: Duration = Duration::from_millis(250); -const MAX_LOG_ENTRIES: usize = 200; -const BUCKET_SECS: u64 = 1; -const MAX_BUCKETS: usize = 300; - -// ── Color palette (consistent across panels) ───────────────────── - -const C_RADIO_RX: Color = Color::Green; -const C_RADIO_TX: Color = Color::Blue; -const C_NET_RX: Color = Color::Magenta; -const C_NET_TX: Color = Color::Cyan; -const C_DEDUP: Color = Color::DarkGray; -const C_RATE_DROP: Color = Color::Yellow; -const C_QUEUE_DROP: Color = Color::Red; -const C_SNR: Color = Color::Yellow; -const C_LABEL: Color = Color::DarkGray; -const C_VALUE: Color = Color::White; -const C_ACCENT: Color = Color::Cyan; -const C_BORDER: Color = Color::DarkGray; -const C_TITLE: Color = Color::White; - -// ── Time series ────────────────────────────────────────────────── - -#[derive(Default, Clone, Copy)] -struct Bucket { - radio_rx: u64, - radio_tx: u64, - snr_sum: i64, - snr_count: u64, -} - -struct TimeSeries { - buckets: VecDeque, - last_snap: StatsSnapshot, - last_bucket_time: Instant, -} - -impl TimeSeries { - fn new(snap: &StatsSnapshot) -> Self { - Self { - buckets: VecDeque::from(vec![Bucket::default(); MAX_BUCKETS]), - last_snap: snap.clone(), - last_bucket_time: Instant::now(), - } - } - - fn update(&mut self, snap: &StatsSnapshot, log_entries: &[PacketLogEntry]) { - let now = Instant::now(); - if now.duration_since(self.last_bucket_time).as_secs() >= BUCKET_SECS { - let cutoff = self.last_bucket_time; - let (snr_sum, snr_count) = snr_stats_since(log_entries, cutoff); - let bucket = Bucket { - radio_rx: snap.radio_rx.saturating_sub(self.last_snap.radio_rx), - radio_tx: snap.radio_tx.saturating_sub(self.last_snap.radio_tx), - snr_sum, - snr_count, - }; - self.buckets.push_back(bucket); - if self.buckets.len() > MAX_BUCKETS { - self.buckets.pop_front(); - } - self.last_snap = snap.clone(); - self.last_bucket_time = now; - } - } - - fn radio_rx(&self) -> Vec { - self.buckets.iter().map(|b| b.radio_rx).collect() - } - fn radio_tx(&self) -> Vec { - self.buckets.iter().map(|b| b.radio_tx).collect() - } - fn avg_snr(&self) -> Vec { - self.buckets - .iter() - .map(|b| { - if b.snr_count > 0 { - let avg = b.snr_sum / b.snr_count.cast_signed(); - (avg + 30).max(0).cast_unsigned() - } else { - 0 - } - }) - .collect() - } - - fn peak_rx(&self) -> u64 { - self.buckets.iter().map(|b| b.radio_rx).max().unwrap_or(0) - } - fn peak_tx(&self) -> u64 { - self.buckets.iter().map(|b| b.radio_tx).max().unwrap_or(0) - } - fn latest_avg_snr(&self) -> Option { - self.buckets.iter().rev().find(|b| b.snr_count > 0).map(|b| b.snr_sum / b.snr_count.cast_signed()) - } -} - -fn snr_stats_since(entries: &[PacketLogEntry], since: Instant) -> (i64, u64) { - let mut sum = 0i64; - let mut count = 0u64; - for e in entries.iter().rev() { - if e.timestamp <= since { - break; - } - if let Some(s) = e.snr { - sum += i64::from(s); - count += 1; - } - } - (sum, count) -} - -// ── Public API ─────────────────────────────────────────────────── - -type Term = Terminal>; - -/// Start the terminal UI event loop. Returns the terminal handle for cleanup on exit. -#[allow(clippy::needless_pass_by_value)] // Arc/CancellationToken are cheaply cloned and owned by this scope -pub fn run( - config_watch: tokio::sync::watch::Receiver, - gossip: Arc, - stats: Arc, - mut log_rx: mpsc::Receiver, - cancel: CancellationToken, - start_time: Instant, -) -> anyhow::Result { - enable_raw_mode()?; - io::stdout().execute(EnterAlternateScreen)?; - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend)?; - - let mut log_entries: VecDeque = VecDeque::with_capacity(MAX_LOG_ENTRIES); - let mut series = TimeSeries::new(&stats.snapshot()); - - let _result = tui_loop( - &mut terminal, - config_watch, - &gossip, - &stats, - &mut log_rx, - &cancel, - start_time, - &mut log_entries, - &mut series, - ); - - terminal.draw(|frame| draw_shutdown_overlay(frame, frame.area()))?; - Ok(terminal) -} - -/// Restore the terminal to its normal state (disable raw mode, leave alternate screen). -pub fn restore_terminal(terminal: &mut Term) { - let _ = disable_raw_mode(); - let _ = io::stdout().execute(LeaveAlternateScreen); - let _ = terminal.show_cursor(); -} - -#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] -fn tui_loop( - terminal: &mut Term, - config_watch: tokio::sync::watch::Receiver, - gossip: &Gossip, - stats: &Stats, - log_rx: &mut mpsc::Receiver, - cancel: &CancellationToken, - start_time: Instant, - log_entries: &mut VecDeque, - series: &mut TimeSeries, -) -> anyhow::Result<()> { - let mut last_tick = Instant::now(); - loop { - while let Ok(entry) = log_rx.try_recv() { - if log_entries.len() >= MAX_LOG_ENTRIES { - log_entries.pop_front(); - } - log_entries.push_back(entry); - } - if cancel.is_cancelled() { - return Ok(()); - } - let snap = stats.snapshot(); - let log_slice = log_entries.make_contiguous(); - series.update(&snap, log_slice); - let swarm_state = gossip.swarm_state(); - let config_info = config_watch.borrow().clone(); - terminal.draw(|frame| { - draw_ui(frame, frame.area(), &config_info, &snap, &swarm_state, log_slice, start_time, series); - })?; - let timeout = TICK_RATE.checked_sub(last_tick.elapsed()).unwrap_or(Duration::ZERO); - if event::poll(timeout)? - && let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - && matches!( - (key.code, key.modifiers), - (KeyCode::Char('q'), _) | (KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL) - ) - { - cancel.cancel(); - return Ok(()); - } - if last_tick.elapsed() >= TICK_RATE { - last_tick = Instant::now(); - } - } -} - -// ── Layout ─────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -fn draw_ui( - frame: &mut ratatui::Frame, - area: Rect, - config_info: &RadioConfigInfo, - stats: &StatsSnapshot, - swarm_state: &crate::gossip::SwarmState, - log_entries: &[PacketLogEntry], - start_time: Instant, - series: &TimeSeries, -) { - // Minimum terminal size for the 3-column layout. - if area.width < 80 || area.height < 12 { - let msg = format!("Terminal too small ({}x{}), need 80x12+", area.width, area.height); - frame.render_widget( - Paragraph::new(msg).alignment(Alignment::Center).style(Style::default().fg(C_RATE_DROP)), - area, - ); - return; - } - - // Header row + body. - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(area); - - draw_header(frame, rows[0], stats); - - // Body: 3 columns — left panels | packet log (fixed) | activity charts (fill). - // Packet log columns: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 = 43 + 2 border + 1 pad = 46 - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(4)]) - .split(rows[1]); - - // Left column: Network, Radio, Stats stacked. - let left = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(6), Constraint::Length(12), Constraint::Min(5)]) - .split(cols[0]); - - draw_network(frame, left[0], swarm_state); - draw_radio(frame, left[1], config_info); - draw_stats(frame, left[2], stats, start_time); - draw_log(frame, cols[1], log_entries); - draw_activity(frame, cols[2], series); -} - -// ── Header ─────────────────────────────────────────────────────── - -fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) { - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(14), // pills - Constraint::Min(0), // fill - ]) - .split(area); - - // Left: status pills. - let pills = Line::from(vec![ - Span::raw(" "), - status_pill("RADIO", stats.radio_connected), - Span::raw(" "), - status_pill("NET", stats.neighbor_count > 0), - ]); - frame.render_widget(Paragraph::new(pills), cols[0]); - - // Right: clock with timezone. - let clock = Line::from(Span::styled(format!("{} ", chrono_time()), Style::default().fg(C_LABEL))); - frame.render_widget(Paragraph::new(clock).alignment(Alignment::Right), cols[1]); -} - -fn status_pill(label: &str, ok: bool) -> Span<'static> { - let (fg, bg) = if ok { (Color::Black, Color::Green) } else { (Color::White, C_LABEL) }; - Span::styled(format!(" {label} "), Style::default().fg(fg).bg(bg)) -} - -// ── Network panel (was Swarm) ──────────────────────────────────── - -fn draw_network(frame: &mut ratatui::Frame, area: Rect, state: &crate::gossip::SwarmState) { - let block = panel_block(" Network "); - - let dht_color = match state.dht_status { - crate::gossip::DhtStatus::Ready => Color::Green, - crate::gossip::DhtStatus::Bootstrapping => C_RATE_DROP, - crate::gossip::DhtStatus::PublishFailed => C_QUEUE_DROP, - }; - let neighbor_color = if state.neighbor_count > 0 { Color::Green } else { C_RATE_DROP }; - let last_pub = state.last_dht_publish.map_or_else(|| "-".into(), |t| format!("{}s ago", t.elapsed().as_secs())); - - let lines = vec![ - kv_line("ID", &state.topic_hash, C_ACCENT), - kv_line("Peers", &format!("{}", state.neighbor_count), neighbor_color), - kv_line("DHT", &format!("{}", state.dht_status), dht_color), - kv_line("Published", &last_pub, C_LABEL), - ]; - frame.render_widget(Paragraph::new(lines).block(block), area); -} - -// ── Radio panel ────────────────────────────────────────────────── - -fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigInfo) { - let connected = config_info.connected; - let is_mux = matches!(config_info.source, ConfigSource::Mux); - - let border_color = if !connected { - C_QUEUE_DROP - } else if is_mux { - C_RATE_DROP - } else { - C_BORDER - }; - - let title = if !connected { - " Radio (disconnected) " - } else if is_mux { - " Radio (mux) " - } else { - " Radio " - }; - - let block = Block::default() - .title(Span::styled(title, Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD))) - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); - - let a = &config_info.active; - let r = &config_info.requested; - - let freq_mhz = f64::from(a.freq_hz) / 1_000_000.0; - let bw_str = crate::radio::format_bandwidth(a.bw); - let power_str = if a.tx_power_dbm == donglora_client::TX_POWER_MAX { - "max".to_string() - } else { - format!("{} dBm", a.tx_power_dbm) - }; - let preamble = if a.preamble_len == 0 { 16 } else { a.preamble_len }; - let cad = if a.cad != 0 { "on" } else { "off" }; - - // Dim values when disconnected; highlight mux mismatches when connected. - let val_color = if connected { C_ACCENT } else { C_LABEL }; - let cf = |label: &str, value: String, matches: bool| -> Line<'static> { - let color = if !connected { - C_LABEL - } else if !is_mux || matches { - C_ACCENT - } else { - C_RATE_DROP - }; - let mark = if connected && is_mux && !matches { " !" } else { "" }; - kv_line(label, &format!("{value}{mark}"), color) - }; - - let dev = if config_info.device.is_empty() { "-" } else { &config_info.device }; - let lines = vec![ - kv_line("Device", dev, val_color), - cf("Freq", format!("{freq_mhz:.3} MHz"), a.freq_hz == r.freq_hz), - cf("BW", bw_str.to_string(), a.bw == r.bw), - cf("SF", format!("{}", a.sf), a.sf == r.sf), - cf("CR", format!("4/{}", a.cr), a.cr == r.cr), - cf("Sync", format!("0x{:04X}", a.sync_word), a.sync_word == r.sync_word), - cf("TX", power_str, a.tx_power_dbm == r.tx_power_dbm), - cf("Preamble", format!("{preamble}"), a.preamble_len == r.preamble_len), - cf("CAD", cad.to_string(), a.cad == r.cad), - ]; - frame.render_widget(Paragraph::new(lines).block(block), area); -} - -// ── Stats panel ────────────────────────────────────────────────── - -fn draw_stats(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot, start_time: Instant) { - let block = panel_block(" Stats "); - - let s = |label: &str, value: u64, color: Color| -> Line<'static> { - Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{label:<9}"), Style::default().fg(C_LABEL)), - Span::styled(format!("{value:>6}"), Style::default().fg(color)), - ]) - }; - - let uptime = format_duration(start_time.elapsed()); - let lines = vec![ - Line::from(vec![ - Span::raw(" "), - Span::styled("Uptime ", Style::default().fg(C_LABEL)), - Span::styled(format!("{uptime:>6}"), Style::default().fg(C_VALUE)), - ]), - s("RF RX", stats.radio_rx, C_RADIO_RX), - s("RF TX", stats.radio_tx, C_RADIO_TX), - s("Net RX", stats.gossip_rx, C_NET_RX), - s("Net TX", stats.gossip_tx, C_NET_TX), - s("Deduped", stats.dedup_hits, C_DEDUP), - s("Rate lim", stats.rate_limit_drops, C_RATE_DROP), - s("Q drop", stats.dropped_queue, C_QUEUE_DROP), - ]; - frame.render_widget(Paragraph::new(lines).block(block), area); -} - -// ── Packet log ─────────────────────────────────────────────────── - -#[allow(clippy::option_if_let_else)] // match is more readable for RSSI/SNR formatting -fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry]) { - let block = panel_block(" Packet Log "); - - let inner_height = area.height.saturating_sub(2) as usize; - let data_height = inner_height.saturating_sub(1); // header row - let start = entries.len().saturating_sub(data_height); - let visible = &entries[start..]; - - // Fixed column widths: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 - // Every span (header and data) uses the same width per column. - let hdr = Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD); - let header = Line::from(vec![ - Span::styled(format!("{:>5}", "Age"), hdr), - Span::styled(format!("{:>8}", "Hash"), hdr), - Span::styled(format!("{:>4}", "RF"), hdr), - Span::styled(format!("{:>4}", "Net"), hdr), - Span::styled(format!("{:>6}", "Size"), hdr), - Span::styled(format!("{:>6}", "RSSI"), hdr), - Span::styled(format!("{:>5}", "SNR"), hdr), - Span::styled(format!("{:>5}", "Act"), hdr), - ]); - - let mut lines = vec![header]; - let mut prev_hash: Option<[u8; 32]> = None; - - for e in visible { - let age = format_short_duration(e.timestamp.elapsed()); - let bridged = matches!(e.action, PacketAction::Bridged); - - // Show short hash, or ellipsis if same as previous line. - let hash_span = if prev_hash == Some(e.hash) { - Span::styled(format!("{:>8}", "··"), Style::default().fg(C_BORDER)) - } else { - Span::styled(format!("{:>8}", hex::encode(&e.hash[..3])), Style::default().fg(C_LABEL)) - }; - prev_hash = Some(e.hash); - - // Each row shows both columns. Bridged packets light up both sides. - // Dropped packets only show the input side. - let dash = Span::styled(format!("{:>4}", "──"), Style::default().fg(C_BORDER)); - let (rf_col, net_col) = match e.direction { - PacketDirection::RadioIn => ( - Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_RADIO_RX)), - if bridged { Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_NET_TX)) } else { dash }, - ), - PacketDirection::GossipIn => ( - if bridged { Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_RADIO_TX)) } else { dash }, - Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_NET_RX)), - ), - }; - - let action_color = match e.action { - PacketAction::Bridged => C_LABEL, - PacketAction::DroppedDedup => C_DEDUP, - PacketAction::DroppedQueueFull => C_QUEUE_DROP, - PacketAction::DroppedRateLimit => C_RATE_DROP, - }; - - let rssi_span = match e.rssi { - Some(r) => Span::styled(format!("{r:>6}"), Style::default().fg(rssi_color(r))), - None => Span::styled(format!("{:>6}", "-"), Style::default().fg(C_LABEL)), - }; - let snr_span = match e.snr { - Some(s) => Span::styled(format!("{s:>5}"), Style::default().fg(snr_color(s))), - None => Span::styled(format!("{:>5}", "-"), Style::default().fg(C_LABEL)), - }; - - let action_str = match e.action { - PacketAction::Bridged => "", - PacketAction::DroppedDedup => "DUP", - PacketAction::DroppedQueueFull => "FULL", - PacketAction::DroppedRateLimit => "RATE", - }; - let action_span = Span::styled(format!(" {action_str:>4}"), Style::default().fg(action_color)); - - let size_str = format!("{}B", e.size); - lines.push(Line::from(vec![ - Span::styled(format!("{age:>5}"), Style::default().fg(C_LABEL)), - hash_span, - rf_col, - net_col, - Span::styled(format!("{size_str:>6}"), Style::default().fg(C_VALUE)), - rssi_span, - snr_span, - action_span, - ])); - } - - frame.render_widget(Paragraph::new(lines).block(block), area); -} - -// ── Activity chart ─────────────────────────────────────────────── - -fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) { - let block = panel_block(" Activity (1s) "); - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height < 6 || inner.width < 6 { - return; - } - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3)]) - .split(inner); - - let spark_w = inner.width as usize; - - let rx_data = pad_right_align(&series.radio_rx(), spark_w); - let tx_data = pad_right_align(&series.radio_tx(), spark_w); - let snr_data = pad_right_align(&series.avg_snr(), spark_w); - - let peak_rx = series.peak_rx(); - let peak_tx = series.peak_tx(); - let snr_label = series.latest_avg_snr().map_or_else(|| "-".into(), |s| format!("{s}dB")); - - draw_sparkline_section(frame, rows[0], "RX", &format!("pk:{peak_rx}"), &rx_data, C_RADIO_RX); - draw_sparkline_section(frame, rows[1], "TX", &format!("pk:{peak_tx}"), &tx_data, C_RADIO_TX); - draw_sparkline_section(frame, rows[2], "SNR", &snr_label, &snr_data, C_SNR); -} - -/// Draw a label line on top, sparkline filling the rest below. -fn draw_sparkline_section( - frame: &mut ratatui::Frame, - area: Rect, - label: &str, - detail: &str, - data: &[u64], - color: Color, -) { - if area.height < 2 || area.width < 4 { - return; - } - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(1)]) - .split(area); - - let label_line = Line::from(vec![ - Span::styled(format!(" {label} "), Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled(detail.to_string(), Style::default().fg(C_LABEL)), - ]); - frame.render_widget(Paragraph::new(label_line), rows[0]); - - let spark = Sparkline::default().data(data).bar_set(symbols::bar::NINE_LEVELS).style(Style::default().fg(color)); - frame.render_widget(spark, rows[1]); -} - -/// Pad data with leading zeros so values are right-aligned in `width` columns. -fn pad_right_align(data: &[u64], width: usize) -> Vec { - let tail_start = data.len().saturating_sub(width); - let tail = &data[tail_start..]; - let mut padded = vec![0u64; width.saturating_sub(tail.len())]; - padded.extend_from_slice(tail); - padded -} - -// ── Shutdown overlay ───────────────────────────────────────────── - -fn draw_shutdown_overlay(frame: &mut ratatui::Frame, area: Rect) { - let dim = Paragraph::new("").style(Style::default().bg(Color::Black)); - frame.render_widget(dim, area); - - let w = 36u16; - let h = 3u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - let popup = Rect::new(x, y, w.min(area.width), h.min(area.height)); - - frame.render_widget(Clear, popup); - let block = Block::default().borders(Borders::ALL).border_style(Style::default().fg(C_BORDER)); - let text = Paragraph::new(Line::from(vec![ - Span::styled(" Shutting down", Style::default().fg(C_RATE_DROP)), - Span::styled(" — updating DHT...", Style::default().fg(C_LABEL)), - ])) - .alignment(Alignment::Center) - .block(block); - frame.render_widget(text, popup); -} - -// ── Shared helpers ─────────────────────────────────────────────── - -fn panel_block(title: &str) -> Block<'static> { - Block::default() - .title(Span::styled(title.to_string(), Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD))) - .borders(Borders::ALL) - .border_style(Style::default().fg(C_BORDER)) -} - -const fn rssi_color(rssi: i16) -> Color { - if rssi >= -70 { - Color::Green - } else if rssi >= -90 { - C_RATE_DROP - } else { - C_QUEUE_DROP - } -} - -const fn snr_color(snr: i16) -> Color { - if snr >= 5 { - Color::Green - } else if snr >= 0 { - C_RATE_DROP - } else { - C_QUEUE_DROP - } -} - -fn kv_line(label: &str, value: &str, color: Color) -> Line<'static> { - Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{label:<10}"), Style::default().fg(C_LABEL)), - Span::styled(value.to_string(), Style::default().fg(color)), - ]) -} - -fn format_duration(d: Duration) -> String { - let secs = d.as_secs(); - if secs < 60 { - format!("{secs}s") - } else if secs < 3600 { - format!("{}m{}s", secs / 60, secs % 60) - } else { - format!("{}h{}m", secs / 3600, (secs % 3600) / 60) - } -} - -fn format_short_duration(d: Duration) -> String { - let secs = d.as_secs(); - if secs < 60 { - format!("{secs}s") - } else if secs < 3600 { - format!("{}m", secs / 60) - } else { - format!("{}h", secs / 3600) - } -} - -fn chrono_time() -> String { - jiff::Zoned::now().strftime("%H:%M:%S %Z").to_string() -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - // ── Helpers ────────────────────────────────────────────────── - - fn make_snapshot(radio_rx: u64, radio_tx: u64) -> StatsSnapshot { - StatsSnapshot { - radio_rx, - radio_tx, - gossip_rx: 0, - gossip_tx: 0, - dedup_hits: 0, - rate_limit_drops: 0, - dropped_queue: 0, - neighbor_count: 0, - radio_connected: true, - } - } - - fn make_log_entry(timestamp: Instant, snr: Option) -> PacketLogEntry { - PacketLogEntry { - timestamp, - hash: [0u8; 32], - direction: PacketDirection::RadioIn, - size: 10, - snr, - rssi: Some(-80), - action: PacketAction::Bridged, - } - } - - // ── format_duration tests ─────────────────────────────────── - - #[test] - fn format_duration_seconds() { - assert_eq!(format_duration(Duration::from_secs(45)), "45s"); - } - - #[test] - fn format_duration_minutes() { - assert_eq!(format_duration(Duration::from_secs(125)), "2m5s"); - } - - #[test] - fn format_duration_hours() { - assert_eq!(format_duration(Duration::from_secs(7500)), "2h5m"); - } - - #[test] - fn format_duration_zero() { - assert_eq!(format_duration(Duration::ZERO), "0s"); - } - - #[test] - fn format_duration_boundary_59s() { - assert_eq!(format_duration(Duration::from_secs(59)), "59s"); - } - - #[test] - fn format_duration_boundary_60s() { - assert_eq!(format_duration(Duration::from_secs(60)), "1m0s"); - } - - #[test] - fn format_duration_boundary_3599s() { - assert_eq!(format_duration(Duration::from_secs(3599)), "59m59s"); - } - - #[test] - fn format_duration_boundary_3600s() { - assert_eq!(format_duration(Duration::from_secs(3600)), "1h0m"); - } - - // ── format_short_duration tests ───────────────────────────── - - #[test] - fn format_short_duration_rounds() { - assert_eq!(format_short_duration(Duration::from_secs(30)), "30s"); - assert_eq!(format_short_duration(Duration::from_secs(90)), "1m"); - assert_eq!(format_short_duration(Duration::from_secs(7500)), "2h"); - } - - #[test] - fn format_short_duration_boundary_59s() { - assert_eq!(format_short_duration(Duration::from_secs(59)), "59s"); - } - - #[test] - fn format_short_duration_boundary_60s() { - assert_eq!(format_short_duration(Duration::from_secs(60)), "1m"); - } - - #[test] - fn format_short_duration_boundary_3599s() { - assert_eq!(format_short_duration(Duration::from_secs(3599)), "59m"); - } - - #[test] - fn format_short_duration_boundary_3600s() { - assert_eq!(format_short_duration(Duration::from_secs(3600)), "1h"); - } - - // ── chrono_time tests ─────────────────────────────────────── - - #[test] - fn chrono_time_returns_non_empty_string() { - let t = chrono_time(); - assert!(!t.is_empty(), "chrono_time() should not be empty"); - } - - #[test] - fn chrono_time_contains_colons() { - // HH:MM:SS format should have at least two colons - let t = chrono_time(); - assert!(t.matches(':').count() >= 2, "chrono_time() should contain HH:MM:SS, got: {t}"); - } - - // ── rssi_color tests ──────────────────────────────────────── - - #[test] - fn rssi_color_green_at_minus_70() { - assert_eq!(rssi_color(-70), Color::Green); - } - - #[test] - fn rssi_color_yellow_at_minus_71() { - assert_eq!(rssi_color(-71), C_RATE_DROP); - } - - #[test] - fn rssi_color_yellow_at_minus_90() { - assert_eq!(rssi_color(-90), C_RATE_DROP); - } - - #[test] - fn rssi_color_red_at_minus_91() { - assert_eq!(rssi_color(-91), C_QUEUE_DROP); - } - - #[test] - fn rssi_color_green_at_zero() { - assert_eq!(rssi_color(0), Color::Green); - } - - // ── snr_color tests ───────────────────────────────────────── - - #[test] - fn snr_color_green_at_5() { - assert_eq!(snr_color(5), Color::Green); - } - - #[test] - fn snr_color_yellow_at_4() { - assert_eq!(snr_color(4), C_RATE_DROP); - } - - #[test] - fn snr_color_yellow_at_0() { - assert_eq!(snr_color(0), C_RATE_DROP); - } - - #[test] - fn snr_color_red_at_minus_1() { - assert_eq!(snr_color(-1), C_QUEUE_DROP); - } - - #[test] - fn snr_color_green_at_high_value() { - assert_eq!(snr_color(20), Color::Green); - } - - // ── snr_stats_since tests ─────────────────────────────────── - - #[test] - fn snr_stats_since_empty() { - assert_eq!(snr_stats_since(&[], Instant::now()), (0, 0)); - } - - #[test] - fn snr_stats_since_sums_and_counts_correctly() { - let now = Instant::now(); - let entries = vec![ - make_log_entry(now + Duration::from_secs(1), Some(10)), - make_log_entry(now + Duration::from_secs(2), Some(20)), - make_log_entry(now + Duration::from_secs(3), Some(-5)), - ]; - let (sum, count) = snr_stats_since(&entries, now); - assert_eq!(sum, 25); // 10 + 20 + (-5) - assert_eq!(count, 3); - } - - #[test] - fn snr_stats_since_respects_cutoff() { - let now = Instant::now(); - let entries = vec![ - make_log_entry(now + Duration::from_secs(1), Some(10)), - make_log_entry(now + Duration::from_secs(2), Some(20)), - make_log_entry(now + Duration::from_secs(5), Some(30)), - ]; - // Use a cutoff after the first two entries - let cutoff = now + Duration::from_secs(3); - let (sum, count) = snr_stats_since(&entries, cutoff); - assert_eq!(sum, 30); - assert_eq!(count, 1); - } - - #[test] - fn snr_stats_since_skips_none_snr() { - let now = Instant::now(); - let entries = vec![ - make_log_entry(now + Duration::from_secs(1), Some(10)), - make_log_entry(now + Duration::from_secs(2), None), - make_log_entry(now + Duration::from_secs(3), Some(20)), - ]; - let (sum, count) = snr_stats_since(&entries, now); - assert_eq!(sum, 30); // 10 + 20, skipping None - assert_eq!(count, 2); - } - - #[test] - fn snr_stats_since_all_before_cutoff() { - let now = Instant::now(); - let entries = vec![make_log_entry(now, Some(10)), make_log_entry(now, Some(20))]; - // Cutoff at `now` means entries at exactly `now` are <= since, so excluded - let (sum, count) = snr_stats_since(&entries, now); - assert_eq!(sum, 0); - assert_eq!(count, 0); - } - - #[test] - fn snr_stats_since_all_none_snr() { - let now = Instant::now(); - let entries = vec![ - make_log_entry(now + Duration::from_secs(1), None), - make_log_entry(now + Duration::from_secs(2), None), - ]; - let (sum, count) = snr_stats_since(&entries, now); - assert_eq!(sum, 0); - assert_eq!(count, 0); - } - - // ── TimeSeries tests ──────────────────────────────────────── - - #[test] - fn time_series_new_has_max_buckets() { - let snap = make_snapshot(0, 0); - let ts = TimeSeries::new(&snap); - assert_eq!(ts.buckets.len(), MAX_BUCKETS); - } - - #[test] - fn time_series_radio_rx_returns_bucket_data() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - // Manually push a bucket with known data - ts.buckets.push_back(Bucket { radio_rx: 42, radio_tx: 0, snr_sum: 0, snr_count: 0 }); - let rx = ts.radio_rx(); - assert_eq!(*rx.last().unwrap(), 42); - } - - #[test] - fn time_series_radio_tx_returns_bucket_data() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 99, snr_sum: 0, snr_count: 0 }); - let tx = ts.radio_tx(); - assert_eq!(*tx.last().unwrap(), 99); - } - - #[test] - fn time_series_avg_snr_with_data() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - // Push bucket: snr_sum=10, snr_count=2 -> avg=5 -> shifted = 5+30 = 35 - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 0, snr_sum: 10, snr_count: 2 }); - let snr = ts.avg_snr(); - assert_eq!(*snr.last().unwrap(), 35); - } - - #[test] - fn time_series_avg_snr_zero_count() { - let snap = make_snapshot(0, 0); - let ts = TimeSeries::new(&snap); - // All default buckets have snr_count=0, so avg_snr should be all zeros - let snr = ts.avg_snr(); - assert!(snr.iter().all(|&v| v == 0)); - } - - #[test] - fn time_series_avg_snr_negative_clamps_to_zero() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - // avg = -50, shifted = -50+30 = -20, clamped to 0 - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 0, snr_sum: -50, snr_count: 1 }); - let snr = ts.avg_snr(); - assert_eq!(*snr.last().unwrap(), 0); - } - - #[test] - fn time_series_peak_rx() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - ts.buckets.push_back(Bucket { radio_rx: 5, radio_tx: 0, snr_sum: 0, snr_count: 0 }); - ts.buckets.push_back(Bucket { radio_rx: 15, radio_tx: 0, snr_sum: 0, snr_count: 0 }); - ts.buckets.push_back(Bucket { radio_rx: 3, radio_tx: 0, snr_sum: 0, snr_count: 0 }); - assert_eq!(ts.peak_rx(), 15); - } - - #[test] - fn time_series_peak_tx() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 7, snr_sum: 0, snr_count: 0 }); - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 22, snr_sum: 0, snr_count: 0 }); - assert_eq!(ts.peak_tx(), 22); - } - - #[test] - fn time_series_peak_rx_default_is_zero() { - let snap = make_snapshot(0, 0); - let ts = TimeSeries::new(&snap); - assert_eq!(ts.peak_rx(), 0); - } - - #[test] - fn time_series_peak_tx_default_is_zero() { - let snap = make_snapshot(0, 0); - let ts = TimeSeries::new(&snap); - assert_eq!(ts.peak_tx(), 0); - } - - #[test] - fn time_series_latest_avg_snr_with_data() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - // Push an older bucket with different SNR - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 0, snr_sum: 100, snr_count: 10 }); - // Push the latest bucket with known SNR: sum=20, count=4 -> avg=5 - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 0, snr_sum: 20, snr_count: 4 }); - assert_eq!(ts.latest_avg_snr(), Some(5)); - } - - #[test] - fn time_series_latest_avg_snr_none_when_no_data() { - let snap = make_snapshot(0, 0); - let ts = TimeSeries::new(&snap); - // All default buckets have snr_count=0 - assert_eq!(ts.latest_avg_snr(), None); - } - - #[test] - fn time_series_latest_avg_snr_skips_trailing_empty() { - let snap = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap); - // A bucket with data followed by an empty bucket - ts.buckets.push_back(Bucket { radio_rx: 0, radio_tx: 0, snr_sum: -12, snr_count: 3 }); - ts.buckets.push_back(Bucket::default()); // snr_count = 0 - // latest_avg_snr scans backwards, skips the empty one, finds -12/3 = -4 - assert_eq!(ts.latest_avg_snr(), Some(-4)); - } - - #[test] - fn time_series_update_creates_bucket() { - let snap0 = make_snapshot(10, 5); - let mut ts = TimeSeries::new(&snap0); - // Force the last_bucket_time to be old enough to trigger a new bucket - ts.last_bucket_time = Instant::now().checked_sub(Duration::from_secs(BUCKET_SECS + 1)).unwrap(); - let initial_len = ts.buckets.len(); - - let snap1 = make_snapshot(15, 8); - let entries = vec![make_log_entry(Instant::now(), Some(10))]; - ts.update(&snap1, &entries); - - // A new bucket should have been pushed - assert!(ts.buckets.len() >= initial_len); - let last = ts.buckets.back().unwrap(); - assert_eq!(last.radio_rx, 5); // 15 - 10 - assert_eq!(last.radio_tx, 3); // 8 - 5 - } - - #[test] - fn time_series_update_does_not_create_bucket_when_interval_not_elapsed() { - let snap0 = make_snapshot(10, 5); - let mut ts = TimeSeries::new(&snap0); - // last_bucket_time is now, so interval has NOT elapsed - let len_before = ts.buckets.len(); - - let snap1 = make_snapshot(20, 10); - ts.update(&snap1, &[]); - - // No new bucket should be added - assert_eq!(ts.buckets.len(), len_before); - } - - #[test] - fn time_series_update_rotates_when_exceeding_max() { - let snap0 = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap0); - assert_eq!(ts.buckets.len(), MAX_BUCKETS); - - // Force old timestamp so update triggers - ts.last_bucket_time = Instant::now().checked_sub(Duration::from_secs(BUCKET_SECS + 1)).unwrap(); - let snap1 = make_snapshot(1, 1); - ts.update(&snap1, &[]); - - // Should still be at MAX_BUCKETS (the front was popped) - assert_eq!(ts.buckets.len(), MAX_BUCKETS); - // The last bucket should be our new one - let last = ts.buckets.back().unwrap(); - assert_eq!(last.radio_rx, 1); - } - - #[test] - fn time_series_update_no_pop_at_exactly_max() { - // Start with MAX_BUCKETS - 1 entries. Push one to reach exactly MAX_BUCKETS. - // With `> MAX_BUCKETS`: 300 is NOT > 300, so no pop → len stays 300. (correct) - // With `>= MAX_BUCKETS`: 300 IS >= 300, so pop → len becomes 299. (wrong) - let snap0 = make_snapshot(0, 0); - let mut ts = TimeSeries::new(&snap0); - ts.buckets.pop_front(); // now MAX_BUCKETS - 1 - assert_eq!(ts.buckets.len(), MAX_BUCKETS - 1); - - ts.last_bucket_time = Instant::now().checked_sub(Duration::from_secs(BUCKET_SECS + 1)).unwrap(); - let snap1 = make_snapshot(1, 0); - ts.update(&snap1, &[]); - - assert_eq!(ts.buckets.len(), MAX_BUCKETS, "should be exactly MAX_BUCKETS, not popped"); - } - - // ── pad_right_align tests ─────────────────────────────────── - - #[test] - fn pad_right_align_shorter_than_width() { - assert_eq!(pad_right_align(&[1, 2, 3], 5), vec![0, 0, 1, 2, 3]); - } - - #[test] - fn pad_right_align_longer_than_width() { - assert_eq!(pad_right_align(&[1, 2, 3, 4, 5], 3), vec![3, 4, 5]); - } - - #[test] - fn pad_right_align_exact() { - assert_eq!(pad_right_align(&[1, 2, 3], 3), vec![1, 2, 3]); - } - - #[test] - fn pad_right_align_empty() { - assert_eq!(pad_right_align(&[], 3), vec![0, 0, 0]); - } - - mod proptests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn pad_right_align_output_length( - data in proptest::collection::vec(0u64..100, 0..500), - width in 1usize..200, - ) { - let result = pad_right_align(&data, width); - prop_assert_eq!(result.len(), width); - } - } - } -}