diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..e43c07a --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,55 @@ +# 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 new file mode 100644 index 0000000..821d3a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +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@v4 + + - name: Clone donglora (sibling dependency) + uses: actions/checkout@v4 + with: + repository: swaits/donglora + path: ../donglora + + - 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 index ea8c4bf..f843f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ /target +/fuzz/target +/fuzz/corpus +/fuzz/Cargo.lock +/mutants.out +/mutants.out.old +.jj/ +*.swp +*.swo +*~ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 1125096..b886c01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -108,7 +108,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -137,7 +137,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -151,6 +151,15 @@ dependencies = [ "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" @@ -213,6 +222,36 @@ 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" @@ -272,6 +311,12 @@ 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" @@ -287,12 +332,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -377,7 +416,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -392,7 +431,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -403,9 +442,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -527,12 +566,14 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", + "derive_more", + "document-features", "mio", "parking_lot", "rustix", @@ -569,6 +610,16 @@ 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" @@ -589,7 +640,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.0-rc.10", "fiat-crypto", - "rand_core", + "rand_core 0.9.5", "rustc_version", "serde", "subtle", @@ -604,7 +655,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -638,7 +689,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -651,7 +702,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -662,7 +713,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -673,7 +724,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -682,6 +733,12 @@ 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" @@ -720,7 +777,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -730,7 +787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -752,7 +809,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -801,7 +858,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -824,7 +881,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -864,21 +921,26 @@ dependencies = [ "iroh-gossip", "jiff", "mainline", - "rand", + "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", @@ -911,7 +973,7 @@ checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.9.5", "serde", "sha2 0.11.0-rc.2", "signature", @@ -946,7 +1008,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -957,7 +1019,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -973,7 +1035,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "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]] @@ -984,7 +1065,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand", + "rand 0.9.2", "siphasher", ] @@ -1000,12 +1081,35 @@ 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" @@ -1094,7 +1198,7 @@ version = "7.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "futures-core", "futures-lite", "pin-project", @@ -1145,7 +1249,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1300,8 +1404,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -1361,10 +1463,10 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand", + "rand 0.9.2", "ring", "rustls", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tokio", "tokio-rustls", @@ -1385,11 +1487,11 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand", + "rand 0.9.2", "resolv-conf", "rustls", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", @@ -1671,7 +1773,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand", + "rand 0.9.2", "tokio", "url", "xmltree", @@ -1717,7 +1819,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1792,7 +1894,7 @@ dependencies = [ "pkcs8", "portable-atomic", "portmapper", - "rand", + "rand 0.9.2", "reqwest", "rustc-hash", "rustls", @@ -1824,7 +1926,7 @@ dependencies = [ "digest 0.11.0-rc.10", "ed25519-dalek", "n0-error", - "rand_core", + "rand_core 0.9.5", "serde", "sha2 0.11.0-rc.2", "url", @@ -1855,7 +1957,7 @@ dependencies = [ "n0-error", "n0-future", "postcard", - "rand", + "rand 0.9.2", "serde", "tokio", "tokio-util", @@ -1887,7 +1989,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1909,7 +2011,7 @@ dependencies = [ "hyper-util", "iroh-base", "iroh-metrics", - "lru 0.16.3", + "lru", "n0-error", "n0-future", "noq", @@ -1918,7 +2020,7 @@ dependencies = [ "pin-project", "pkarr", "postcard", - "rand", + "rand 0.9.2", "reqwest", "rustls", "rustls-pki-types", @@ -1961,7 +2063,7 @@ checksum = "83c1a4b460634aeed6dc01236a0047867de70e30562d91a0ad031dcb3ac33fb4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1972,9 +2074,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1997,7 +2099,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2008,7 +2110,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2038,6 +2140,23 @@ dependencies = [ "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" @@ -2092,10 +2211,19 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +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" @@ -2137,15 +2265,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.3" @@ -2167,6 +2286,16 @@ 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" @@ -2189,12 +2318,12 @@ dependencies = [ "flume", "futures-lite", "getrandom 0.3.4", - "lru 0.16.3", + "lru", "serde", "serde_bencode", "serde_bytes", "sha1_smol", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -2213,6 +2342,27 @@ 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" @@ -2261,7 +2411,7 @@ checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2350,7 +2500,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2413,6 +2563,29 @@ dependencies = [ "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" @@ -2427,7 +2600,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2448,14 +2621,14 @@ dependencies = [ "getrandom 0.3.4", "identity-hash", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "sorted-index-buffer", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2495,7 +2668,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2504,6 +2677,17 @@ 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" @@ -2532,7 +2716,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2625,6 +2809,15 @@ 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" @@ -2685,6 +2878,49 @@ 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" @@ -2695,6 +2931,58 @@ dependencies = [ "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" @@ -2712,7 +3000,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2737,7 +3025,7 @@ dependencies = [ "self_cell", "serde", "simple-dns", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2817,7 +3105,7 @@ dependencies = [ "n0-error", "netwatch", "num_enum", - "rand", + "rand 0.9.2", "serde", "smallvec", "socket2", @@ -2851,7 +3139,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2885,7 +3173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2894,7 +3182,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.10+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2906,6 +3194,31 @@ 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" @@ -2929,7 +3242,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2944,13 +3257,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2991,6 +3304,15 @@ 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" @@ -2998,7 +3320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3008,9 +3330,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "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" @@ -3021,24 +3349,97 @@ dependencies = [ ] [[package]] -name = "ratatui" -version = "0.29.0" +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +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", - "cassowary", "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", - "lru 0.12.5", - "paste", - "strum 0.26.3", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -3058,7 +3459,19 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "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]] @@ -3156,15 +3569,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3209,6 +3622,18 @@ 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" @@ -3234,7 +3659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3302,7 +3727,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3320,11 +3745,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3352,7 +3777,7 @@ dependencies = [ "io-kit-sys", "libudev", "mach2", - "nix", + "nix 0.26.4", "scopeguard", "unescaper", "windows-sys 0.52.0", @@ -3478,7 +3903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3495,7 +3920,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3543,11 +3968,11 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] @@ -3561,15 +3986,14 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -3581,7 +4005,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3590,6 +4014,17 @@ 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" @@ -3618,7 +4053,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3627,13 +4062,109 @@ 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", + "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]] @@ -3644,7 +4175,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3740,7 +4271,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3792,7 +4323,7 @@ dependencies = [ "getrandom 0.3.4", "http", "httparse", - "rand", + "rand 0.9.2", "ring", "rustls-pki-types", "simdutf8", @@ -3803,23 +4334,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -3831,20 +4356,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.10+spec-1.1.0" @@ -3852,9 +4363,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.1", + "winnow", ] [[package]] @@ -3863,14 +4374,14 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -3936,7 +4447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -3949,7 +4460,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4003,19 +4514,31 @@ 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", + "thiserror 2.0.18", ] [[package]] @@ -4032,26 +4555,20 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -4106,6 +4623,7 @@ 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", @@ -4171,6 +4689,24 @@ 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" @@ -4246,7 +4782,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4335,6 +4871,78 @@ 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" @@ -4416,7 +5024,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4427,7 +5035,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4649,15 +5257,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.1" @@ -4697,7 +5296,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4713,7 +5312,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4765,7 +5364,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror", + "thiserror 2.0.18", "windows", "windows-core", ] @@ -4789,7 +5388,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror", + "thiserror 2.0.18", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -4829,7 +5428,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4856,7 +5455,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4876,7 +5475,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4897,7 +5496,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4930,7 +5529,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1765912..53746c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,19 +5,26 @@ 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 = { path = "../donglora/clients/rust" } +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 = "0.8" +toml = "1" blake3 = "1" -ratatui = "0.29" -crossterm = "0.28" +ratatui = "0.30" +crossterm = "0.29" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" @@ -30,8 +37,18 @@ 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 new file mode 100644 index 0000000..a4ad6f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..06a30f6 --- /dev/null +++ b/README.md @@ -0,0 +1,273 @@ +# donglora-bridge + +[![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) + +**donglora-bridge** connects LoRa radios to the internet so packets heard by one +radio can be retransmitted by another radio anywhere in the world. + +[LoRa](https://en.wikipedia.org/wiki/LoRa) is a long-range, low-power wireless +protocol used by off-grid mesh networks like +[MeshCore](https://github.com/rocketgithub/meshcore-firmware). A single LoRa +radio might cover a few kilometers. This bridge removes that limit: any packet +your radio hears gets relayed over the internet to every other bridge running the +same passphrase, and their radios retransmit it locally. The result is a single +mesh network across a larger geographic area. + +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. + +![TUI Screenshot](screenshot.png) + +## 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 + +If you want multiple programs to share a single DongLoRa radio at the same time, +install the multiplexer daemon: + +```sh +cargo install donglora-mux +``` + +Run `donglora-mux` before starting the bridge. The bridge will auto-detect and +connect to the mux. Without the mux, the bridge talks to the USB device +directly, which means only one program can use the radio at a time. + +### 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 +``` + +## 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 via mux) +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) diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9ca5fb5 --- /dev/null +++ b/deny.toml @@ -0,0 +1,39 @@ +# 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 new file mode 100644 index 0000000..8540953 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[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 new file mode 100644 index 0000000..7a1ff40 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_decode_peer_list.rs @@ -0,0 +1,7 @@ +#![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 new file mode 100644 index 0000000..fecc055 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs @@ -0,0 +1,7 @@ +#![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 new file mode 100644 index 0000000..5074385 --- /dev/null +++ b/justfile @@ -0,0 +1,60 @@ +# Default: run all checks +default: build + +# Run all checks (fmt, clippy, test) +check: fmt-check clippy test audit + +# Format code +fmt: + cargo fmt + +# Check formatting without changing files +fmt-check: + cargo fmt --check + +# Lint with clippy (pedantic + nursery) +clippy: + cargo clippy --all-targets -- -D warnings + +# Build release binary +build: + cargo build --release + +# Generate docs +doc: + cargo doc --no-deps --open + +# Run the bridge (TUI mode) +run *ARGS: + cargo run --release -- {{ARGS}} + +# Run in headless mode +run-headless *ARGS: + cargo run --release -- --log-only {{ARGS}} + +# Run the config wizard +config: + cargo run --release -- config + +# Security + license audit +audit: + cargo deny check + +# Run all tests +test: test-unit test-fuzz test-mutants + +# Normal unit tests +test-unit: + cargo nextest run + +# Mutation testing +test-mutants *ARGS: + cargo mutants {{ARGS}} + +# Fuzz a target (requires nightly) +test-fuzz TARGET="fuzz_gossip_frame_decode" DURATION="60": + cargo +nightly fuzz run {{TARGET}} -- -max_total_time={{DURATION}} + +# Clean build artifacts +clean: + cargo clean diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3139ac8 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 120 +use_small_heuristics = "Max" diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..09b9af7 Binary files /dev/null and b/screenshot.png differ diff --git a/src/config.rs b/src/config.rs index fdb1934..8654dab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,8 @@ +//! 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}; @@ -52,34 +57,34 @@ pub struct BridgeSection { // ── Defaults (MeshCore US/Canada Recommended) ──────────────────── -fn default_frequency() -> u32 { +const fn default_frequency() -> u32 { 910_525_000 } fn default_bandwidth() -> String { "62.5kHz".into() } -fn default_sf() -> u8 { +const fn default_sf() -> u8 { 7 } -fn default_cr() -> u8 { +const fn default_cr() -> u8 { 5 } -fn default_sync_word() -> u16 { +const fn default_sync_word() -> u16 { 0x3444 } fn default_tx_power() -> String { "22".into() } -fn default_preamble() -> u16 { +const fn default_preamble() -> u16 { 16 } -fn default_true() -> bool { +const fn default_true() -> bool { true } -fn default_dedup_window() -> u64 { +const fn default_dedup_window() -> u64 { 300 } -fn default_tx_queue_size() -> usize { +const fn default_tx_queue_size() -> usize { 32 } @@ -138,8 +143,20 @@ fn parse_tx_power(s: &str) -> Result { } impl RadioSection { - /// Convert to the donglora-client RadioConfig. + /// 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)?, @@ -157,9 +174,7 @@ impl RadioSection { /// 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"); + let dir = dirs::config_dir().context("cannot determine config directory")?.join("donglora-bridge"); Ok(dir) } @@ -179,13 +194,241 @@ pub fn default_log_path() -> Result { /// 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 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" - ); + 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 index 95bbaed..cca2b75 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -1,3 +1,9 @@ +//! 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}; @@ -5,12 +11,12 @@ 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 futures_lite::StreamExt; -use iroh_gossip::api::Event; -use iroh_gossip::net::{Gossip as GossipActor, GOSSIP_ALPN}; 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}; @@ -25,13 +31,15 @@ use crate::packet::GossipFrame; /// Conservative cap to stay under BEP44's 1000-byte value limit. const MAX_DHT_PEERS: usize = 27; -/// DHT heartbeat interval (base, before jitter). +/// 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. +/// 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. @@ -43,29 +51,44 @@ pub struct PassphraseKeys { 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: &str = "donglora-bridge:"; + 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 topic_bytes = *blake3::hash(format!("{APP_PREFIX}{passphrase}").as_bytes()).as_bytes(); + 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 sign_seed = Sha256::digest(format!("{APP_PREFIX}sign:{passphrase}").as_bytes()); + // 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 salt_hash = Sha256::digest(format!("{APP_PREFIX}salt:{passphrase}").as_bytes()); + // 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, - } + Self { topic_id, dht_signing_key, dht_salt } } } @@ -80,6 +103,7 @@ pub struct SwarmState { pub last_dht_publish: Option, } +/// Status of the mainline DHT connection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DhtStatus { Bootstrapping, @@ -112,7 +136,9 @@ pub enum GossipEvent { /// Handle to the gossip swarm. Manages iroh endpoint, gossip subscription, and DHT. pub struct Gossip { endpoint: Endpoint, - gossip: GossipActor, + 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, @@ -147,7 +173,7 @@ impl Gossip { info!("iroh endpoint bound, id: {}", my_id.fmt_short()); // Build gossip actor. - let gossip = GossipActor::builder().spawn(endpoint.clone()); + let actor = GossipActor::builder().spawn(endpoint.clone()); // Bootstrap from DHT. let bootstrap_peers = dht_get_peers(keys).await; @@ -158,10 +184,7 @@ impl Gossip { } // Subscribe to gossip topic. - let topic = gossip - .subscribe(keys.topic_id, bootstrap_peers) - .await - .context("subscribing to gossip topic")?; + let topic = actor.subscribe(keys.topic_id, bootstrap_peers).await.context("subscribing to gossip topic")?; let (sender, receiver) = topic.split(); @@ -182,7 +205,7 @@ impl Gossip { let handle = Self { endpoint: endpoint.clone(), - gossip: gossip.clone(), + actor: actor.clone(), state: state.clone(), neighbors: neighbors.clone(), dht_signing_key: keys.dht_signing_key.clone(), @@ -191,7 +214,7 @@ impl Gossip { }; // Spawn accept loop. - spawn_accept_loop(endpoint.clone(), gossip.clone(), cancel.clone()); + 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()); @@ -221,8 +244,9 @@ impl Gossip { } /// Snapshot of swarm state for TUI. + #[must_use] pub fn swarm_state(&self) -> SwarmState { - self.state.lock().unwrap_or_else(|e| e.into_inner()).clone() + self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() } /// Graceful shutdown. Publishes neighbors (without ourselves) to DHT @@ -232,7 +256,7 @@ impl Gossip { let neighbor_ids: Vec<[u8; 32]> = self .neighbors .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .map(|id| *id.as_bytes()) .collect(); @@ -242,14 +266,9 @@ impl Gossip { let value = encode_peer_list(&neighbor_ids); let seq = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) + .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()), - ); + 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:#}"), @@ -257,7 +276,7 @@ impl Gossip { } self.cancel.cancel(); - let _ = self.gossip.shutdown().await; + let _ = self.actor.shutdown().await; self.endpoint.close().await; } } @@ -316,18 +335,29 @@ fn spawn_receiver( 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; } + 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; } + 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 => { @@ -361,11 +391,13 @@ fn spawn_broadcaster( }); } -/// DHT poll interval while alone (read frequently, publish infrequently). +/// 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). -/// Slower than reads — publishing is heavier and a lonely node only adds itself. +/// 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. @@ -458,10 +490,7 @@ async fn dht_read_and_join( ) { 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(); + 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 { @@ -483,7 +512,7 @@ async fn dht_publish( neighbors: &Arc>>, state: &Arc>, ) { - let has_neighbors = neighbors.lock().map(|n| !n.is_empty()).unwrap_or(false); + 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); @@ -518,7 +547,7 @@ async fn dht_publish( let value = encode_peer_list(&peer_list); let seq = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) + .map(|d| d.as_secs().cast_signed()) .unwrap_or(0); let item = MutableItem::new(signing_key.clone(), &value, seq, Some(salt.as_ref())); @@ -552,22 +581,24 @@ async fn dht_get_peers(keys: &PassphraseKeys) -> Vec { }; let pub_key_bytes = keys.dht_signing_key.verifying_key().to_bytes(); - match dht.get_mutable_most_recent(&pub_key_bytes, Some(&keys.dht_salt)).await { - Some(record) => { + 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 - } - None => { - debug!("DHT bootstrap: no existing record"); - vec![] - } - } + }, + ) } /// Encode a list of 32-byte peer IDs into bytes for DHT storage. -/// Format: [count: u16 LE] [id_0: 32B] [id_1: 32B] ... -fn encode_peer_list(peers: &[[u8; 32]]) -> Vec { +/// 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()); @@ -578,7 +609,8 @@ fn encode_peer_list(peers: &[[u8; 32]]) -> Vec { } /// Decode a peer list from DHT record value. -fn decode_peer_list(data: &[u8]) -> Vec { +#[must_use] +pub fn decode_peer_list(data: &[u8]) -> Vec { if data.len() < 2 { return vec![]; } @@ -656,4 +688,82 @@ mod tests { 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 new file mode 100644 index 0000000..bbac209 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +//! 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 index a5c0709..7b84e49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,4 @@ -mod config; -mod gossip; -mod packet; -mod radio; -mod rate_limit; -mod router; -mod setup; -mod tui; +use donglora_bridge::{config, gossip, radio, rate_limit, router, setup, tui}; use std::sync::Arc; use std::time::Instant; @@ -43,6 +36,7 @@ enum Commands { } #[tokio::main] +#[allow(clippy::too_many_lines)] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -53,12 +47,8 @@ async fn main() -> Result<()> { }; // Config subcommand: run wizard with existing config as defaults. - if let Some(Commands::Config) = cli.command { - let existing = if config_path.exists() { - config::load_config(&config_path).ok() - } else { - None - }; + 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()); } @@ -69,39 +59,11 @@ async fn main() -> Result<()> { } // Load config. - let cfg = config::load_config(&config_path) - .with_context(|| format!("loading config from {}", config_path.display()))?; + let cfg = + config::load_config(&config_path).with_context(|| format!("loading config from {}", config_path.display()))?; // Set up logging. - if cli.log_only { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .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() { - std::fs::create_dir_all(parent).ok(); - } - let file_appender = tracing_appender::rolling::never( - log_path.parent().unwrap_or(std::path::Path::new("/tmp")), - log_path.file_name().unwrap_or(std::ffi::OsStr::new("donglora-bridge.log")), - ); - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_writer(file_appender) - .with_ansi(false) - .init(); - } + setup_logging(cli.log_only, &cfg); // Ephemeral identity — fresh keypair each launch. let secret_key = SecretKey::generate(&mut rand::rng()); @@ -126,25 +88,22 @@ async fn main() -> Result<()> { 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, 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()); + 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, - }, - ); + 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(); @@ -180,9 +139,7 @@ async fn main() -> Result<()> { { let cancel = cancel.clone(); tokio::spawn(async move { - let sigterm = tokio::signal::unix::signal( - tokio::signal::unix::SignalKind::terminate(), - ); + let sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()); match sigterm { Ok(mut sigterm) => { tokio::select! { @@ -205,15 +162,7 @@ async fn main() -> Result<()> { 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, - ) - .await; + let terminal = tui::run(config_watch_rx, swarm.clone(), stats, log_rx, cancel.clone(), start_time); cancel.cancel(); swarm.shutdown().await; @@ -232,3 +181,30 @@ async fn main() -> Result<()> { 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 index d8134f3..56d5aa5 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -1,7 +1,12 @@ -/// Gossip frame header size: sender(32) + rssi(2) + snr(2) + payload_len(2) = 38 +//! 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. +/// 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). @@ -16,7 +21,8 @@ pub enum Grade { impl Grade { /// Returns true if the packet is reliable enough to forward. - pub fn should_forward(self) -> bool { + #[must_use] + pub const fn should_forward(self) -> bool { matches!(self, Self::Good | Self::Marginal) } } @@ -34,6 +40,7 @@ impl std::fmt::Display for Grade { /// 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; @@ -62,33 +69,31 @@ pub struct RadioPacket { /// 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. + /// `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. + /// 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, - } + 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); @@ -96,6 +101,7 @@ impl GossipFrame { } /// 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; @@ -107,21 +113,18 @@ impl GossipFrame { if data.len() != FRAME_HEADER_SIZE + payload_len { return None; } - Some(Self { - sender, - rssi, - snr, - payload: data[FRAME_HEADER_SIZE..].to_vec(), - }) + 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::*; @@ -156,6 +159,17 @@ mod tests { 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. @@ -164,6 +178,43 @@ mod tests { 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"); @@ -172,4 +223,36 @@ mod tests { 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 index a952a4a..41e0244 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -1,6 +1,11 @@ +//! `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::Result; +use anyhow::{Context, Result}; use donglora_client::{RadioConfig, Response, Transport}; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -29,21 +34,19 @@ pub enum RadioEvent { /// Spawn the blocking radio thread. /// /// Returns a receiver for radio events and a sender for TX payloads. -pub fn spawn( - config: RadioConfig, - port: Option, -) -> (mpsc::Receiver, mpsc::Sender>) { +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)) - .ok(); + .context("failed to spawn radio thread")?; - (event_rx, tx_send) + 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, @@ -56,7 +59,7 @@ fn radio_loop( loop { let started = std::time::Instant::now(); - match connect_and_run(&config, &port, &event_tx, &mut tx_recv) { + match connect_and_run(&config, port.as_deref(), &event_tx, &mut tx_recv) { Ok(()) => { info!("radio thread exiting"); return; @@ -81,16 +84,16 @@ fn radio_loop( fn connect_and_run( config: &RadioConfig, - port: &Option, + 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. + // 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 (mut client, device) = if let Some(port) = port { let c = donglora_client::connect(Some(port), timeout)?; (c, shorten_path(port)) } else { @@ -132,9 +135,7 @@ fn connect_and_run( 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))?; + 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. @@ -159,19 +160,13 @@ fn connect_and_run( 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() - ); + 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() - ); + debug!("RX drop {} bytes rssi={rssi} snr={snr} grade={grade}", payload.len()); } } Ok(Some(_)) => { @@ -208,13 +203,13 @@ fn connect_and_run( /// Negotiate radio config with the dongle/mux. /// /// Strategy: -/// 1. GetConfig first to read what's active (caches the result). -/// 2. If it matches our desired config, use it — no SetConfig needed. -/// 3. If it differs, only try SetConfig if we haven't been rejected before. -/// 4. If SetConfig is rejected, set the flag IMMEDIATELY (before the mux can -/// disconnect us and lose this state), then return the cached GetConfig result. +/// 1. `GetConfig` first to read what's active (caches the result). +/// 2. If it matches our desired config, use it — no `SetConfig` needed. +/// 3. If it differs, only try `SetConfig` if we haven't been rejected before. +/// 4. If `SetConfig` is rejected, set the flag IMMEDIATELY (before the mux can +/// disconnect us and lose this state), then return the cached `GetConfig` result. /// -/// On reconnect, the flag ensures we never retry SetConfig. +/// On reconnect, the flag ensures we never retry `SetConfig`. fn negotiate_config( client: &mut donglora_client::Client, desired: &RadioConfig, @@ -251,10 +246,7 @@ fn negotiate_config( /// Shorten a device path to just the filename for display. fn shorten_path(path: &str) -> String { - std::path::Path::new(path) - .file_name() - .map(|f| f.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.to_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 { @@ -269,18 +261,13 @@ fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool { } fn is_timeout_error(e: &anyhow::Error) -> bool { - if let Some(io_err) = e.downcast_ref::() { - matches!( - io_err.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) - } else { - false - } + 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. -pub fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str { +#[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", @@ -295,23 +282,156 @@ pub fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str { } } -/// Format a RadioConfig for display. +/// Format a `RadioConfig` for display. +#[must_use] pub fn format_radio_config(config: &RadioConfig) -> String { - let freq_mhz = config.freq_hz as f64 / 1_000_000.0; + 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 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 index 6d35ef3..cd62021 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -1,10 +1,15 @@ +//! `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, +/// 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, @@ -13,43 +18,33 @@ pub struct RateLimiter { last_refill: Instant, } -/// Default burst size (max tokens). +/// 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 (0.5 = 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. - pub fn from_radio_config( - sf: u8, - bw: Bandwidth, - cr: u8, - preamble: u16, - override_pps: Option, - ) -> Self { - let refill_rate = if let Some(pps) = override_pps { - pps.max(0.01) - } 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 // fallback - } - }; - Self { - tokens: DEFAULT_BURST, - max_tokens: DEFAULT_BURST, - refill_rate, - last_refill: Instant::now(), - } + #[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. @@ -64,19 +59,20 @@ impl RateLimiter { } /// Current rate in packets per second. - pub fn rate_pps(&self) -> f64 { + #[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 = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens); + 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 SX1276 formula. +/// Estimate `LoRa` packet air time using the Semtech `LoRa` time-on-air formula. /// /// Parameters: /// - `payload_bytes`: payload size in bytes @@ -84,28 +80,27 @@ impl RateLimiter { /// - `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 = 2.0_f64.powf(sf_f) / bw_hz; + 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 - }; + 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). - let numerator = 8.0 * payload_bytes as f64 - 4.0 * sf_f + 28.0 + 16.0; - let denominator = 4.0 * (sf_f - 2.0 * de); - let n_payload = 8.0 + (numerator / denominator).ceil().max(0.0) * f64::from(cr); + #[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; @@ -113,7 +108,7 @@ pub fn lora_air_time(payload_bytes: usize, sf: u8, bw: Bandwidth, cr: u8, preamb Duration::from_secs_f64(total) } -fn bandwidth_hz(bw: Bandwidth) -> f64 { +const fn bandwidth_hz(bw: Bandwidth) -> f64 { match bw { Bandwidth::Khz7 => 7_800.0, Bandwidth::Khz10 => 10_400.0, @@ -171,4 +166,178 @@ mod tests { 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 index 59d81e0..a43799f 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,3 +1,8 @@ +//! 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}; @@ -81,6 +86,7 @@ pub struct Stats { } impl Stats { + #[must_use] pub fn snapshot(&self) -> StatsSnapshot { StatsSnapshot { radio_rx: self.radio_rx.load(Ordering::Relaxed), @@ -96,6 +102,7 @@ impl Stats { } } +/// Point-in-time snapshot of bridge statistics, cloned from atomic counters. #[derive(Debug, Clone)] pub struct StatsSnapshot { pub radio_rx: u64, @@ -117,10 +124,7 @@ struct DedupCache { impl DedupCache { fn new(ttl: Duration) -> Self { - Self { - entries: HashMap::new(), - ttl, - } + Self { entries: HashMap::new(), ttl } } /// Returns true if the key was already present (i.e., duplicate). @@ -144,7 +148,7 @@ impl DedupCache { /// 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)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn run( our_id: PublicKey, dedup_window: Duration, @@ -294,6 +298,7 @@ pub async fn run( }).await; } GossipEvent::NeighborChanged(count) => { + #[allow(clippy::cast_possible_truncation)] stats.neighbor_count.store(count as u64, Ordering::Relaxed); } } @@ -309,3 +314,111 @@ pub async fn run( } } } + +#[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 index 714d258..5326985 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -17,10 +17,8 @@ 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 defaults = existing + .map_or_else(|| (RadioSection::default(), BridgeSection::default()), |c| (c.radio.clone(), c.bridge.clone())); let (radio, bridge) = defaults; println!(); @@ -43,8 +41,9 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> { // ── Radio ──────────────────────────────────────────────────── - let freq_default = format!("{:.3}", radio.frequency as f64 / 1_000_000.0); + 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)?; @@ -70,11 +69,7 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> { 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) - }; + let port = if port_str == "auto" || port_str.is_empty() { None } else { Some(port_str) }; // ── Build and save ─────────────────────────────────────────── @@ -111,12 +106,10 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> { 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()))?; + 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()))?; + std::fs::write(path, content).with_context(|| format!("writing config: {}", path.display()))?; Ok(()) } @@ -128,20 +121,12 @@ fn prompt_default(label: &str, default: &str) -> Result { 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()) - } + 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 = if let Some(d) = default { - format!(" {label} {DIM}[{d}]{RESET}: ") - } else { - format!(" {label}: ") - }; + let prompt = default.map_or_else(|| format!(" {label}: "), |d| format!(" {label} {DIM}[{d}]{RESET}: ")); print!("{prompt}"); io::stdout().flush()?; let mut input = String::new(); @@ -175,3 +160,35 @@ fn parse_sync_word(s: &str) -> Result { 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 index c5af0a8..47db82b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,19 +1,23 @@ +//! 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::event::{self, Event, KeyCode, KeyEventKind}; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; 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 ratatui::Terminal; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -53,7 +57,7 @@ struct Bucket { } struct TimeSeries { - buckets: Vec, + buckets: VecDeque, last_snap: StatsSnapshot, last_bucket_time: Instant, } @@ -61,7 +65,7 @@ struct TimeSeries { impl TimeSeries { fn new(snap: &StatsSnapshot) -> Self { Self { - buckets: vec![Bucket::default(); MAX_BUCKETS], + buckets: VecDeque::from(vec![Bucket::default(); MAX_BUCKETS]), last_snap: snap.clone(), last_bucket_time: Instant::now(), } @@ -78,9 +82,9 @@ impl TimeSeries { snr_sum, snr_count, }; - self.buckets.push(bucket); + self.buckets.push_back(bucket); if self.buckets.len() > MAX_BUCKETS { - self.buckets.remove(0); + self.buckets.pop_front(); } self.last_snap = snap.clone(); self.last_bucket_time = now; @@ -98,8 +102,8 @@ impl TimeSeries { .iter() .map(|b| { if b.snr_count > 0 { - let avg = b.snr_sum / b.snr_count as i64; - (avg + 30).max(0) as u64 + let avg = b.snr_sum / b.snr_count.cast_signed(); + (avg + 30).max(0).cast_unsigned() } else { 0 } @@ -114,7 +118,7 @@ impl TimeSeries { 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 as i64) + self.buckets.iter().rev().find(|b| b.snr_count > 0).map(|b| b.snr_sum / b.snr_count.cast_signed()) } } @@ -137,7 +141,9 @@ fn snr_stats_since(entries: &[PacketLogEntry], since: Instant) -> (i64, u64) { type Term = Terminal>; -pub async fn run( +/// 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, @@ -150,26 +156,34 @@ pub async fn run( let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; - let mut log_entries: Vec = Vec::with_capacity(MAX_LOG_ENTRIES); + 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, - ).await; + &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)] -async fn tui_loop( +#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] +fn tui_loop( terminal: &mut Term, config_watch: tokio::sync::watch::Receiver, gossip: &Gossip, @@ -177,26 +191,27 @@ async fn tui_loop( log_rx: &mut mpsc::Receiver, cancel: &CancellationToken, start_time: Instant, - log_entries: &mut Vec, + 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.remove(0); + log_entries.pop_front(); } - log_entries.push(entry); + log_entries.push_back(entry); } if cancel.is_cancelled() { return Ok(()); } let snap = stats.snapshot(); - series.update(&snap, log_entries); + 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_entries, start_time, series); + 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)? @@ -204,8 +219,7 @@ async fn tui_loop( && key.kind == KeyEventKind::Press && matches!( (key.code, key.modifiers), - (KeyCode::Char('q'), _) - | (KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL) + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL) ) { cancel.cancel(); @@ -230,6 +244,16 @@ fn draw_ui( start_time: Instant, series: &TimeSeries, ) { + // Minimum terminal size for the 3-column layout. + if area.width < 86 || area.height < 12 { + let msg = format!("Terminal too small ({}x{}), need 86x12+", 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) @@ -248,11 +272,7 @@ fn draw_ui( // Left column: Network, Radio, Stats stacked. let left = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(6), - Constraint::Length(12), - Constraint::Min(5), - ]) + .constraints([Constraint::Length(6), Constraint::Length(12), Constraint::Min(5)]) .split(cols[0]); draw_network(frame, left[0], swarm_state); @@ -269,7 +289,7 @@ fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) { .direction(Direction::Horizontal) .constraints([ Constraint::Length(14), // pills - Constraint::Min(0), // fill + Constraint::Min(0), // fill ]) .split(area); @@ -283,19 +303,12 @@ fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) { 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), - )); + 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) - }; + let (fg, bg) = if ok { (Color::Black, Color::Green) } else { (Color::White, C_LABEL) }; Span::styled(format!(" {label} "), Style::default().fg(fg).bg(bg)) } @@ -310,10 +323,7 @@ fn draw_network(frame: &mut ratatui::Frame, area: Rect, state: &crate::gossip::S 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(|t| format!("{}s ago", t.elapsed().as_secs())) - .unwrap_or_else(|| "-".into()); + 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), @@ -347,17 +357,14 @@ fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigI }; let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD), - )) + .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 = a.freq_hz as f64 / 1_000_000.0; + 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() @@ -429,6 +436,7 @@ fn draw_stats(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot, sta // ── 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 "); @@ -472,18 +480,10 @@ fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry]) 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 - }, + 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 - }, + 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)), ), }; @@ -510,15 +510,15 @@ fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry]) PacketAction::DroppedQueueFull => "FULL", PacketAction::DroppedRateLimit => "RATE", }; - let action_span = Span::styled(format!(" {:>4}", action_str), Style::default().fg(action_color)); + 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!("{:>5}", age), Style::default().fg(C_LABEL)), + Span::styled(format!("{age:>5}"), Style::default().fg(C_LABEL)), hash_span, rf_col, net_col, - Span::styled(format!("{:>6}", size_str), Style::default().fg(C_VALUE)), + Span::styled(format!("{size_str:>6}"), Style::default().fg(C_VALUE)), rssi_span, snr_span, action_span, @@ -541,11 +541,7 @@ fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) { let rows = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ]) + .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3)]) .split(inner); let spark_w = inner.width as usize; @@ -556,10 +552,7 @@ fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) { let peak_rx = series.peak_rx(); let peak_tx = series.peak_tx(); - let snr_label = series - .latest_avg_snr() - .map(|s| format!("{s}dB")) - .unwrap_or_else(|| "-".into()); + 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); @@ -590,10 +583,7 @@ fn draw_sparkline_section( ]); 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)); + let spark = Sparkline::default().data(data).bar_set(symbols::bar::NINE_LEVELS).style(Style::default().fg(color)); frame.render_widget(spark, rows[1]); } @@ -619,9 +609,7 @@ fn draw_shutdown_overlay(frame: &mut ratatui::Frame, area: Rect) { 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 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)), @@ -635,20 +623,29 @@ fn draw_shutdown_overlay(frame: &mut ratatui::Frame, area: Rect) { fn panel_block(title: &str) -> Block<'static> { Block::default() - .title(Span::styled( - title.to_string(), - Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD), - )) + .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)) } -fn rssi_color(rssi: i16) -> Color { - if rssi >= -70 { Color::Green } else if rssi >= -90 { C_RATE_DROP } else { C_QUEUE_DROP } +const fn rssi_color(rssi: i16) -> Color { + if rssi >= -70 { + Color::Green + } else if rssi >= -90 { + C_RATE_DROP + } else { + C_QUEUE_DROP + } } -fn snr_color(snr: i16) -> Color { - if snr >= 5 { Color::Green } else if snr >= 0 { 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> { @@ -661,18 +658,496 @@ fn kv_line(label: &str, value: &str, color: Color) -> Line<'static> { 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) } + 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) } + 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); + } + } + } +}