From 5dbec82aa9b861270ae2eadbaf28b9455ca17b11 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 6 Apr 2026 07:04:26 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20publish-ready=20overhaul=20=E2=80=94=20?= =?UTF-8?q?lints,=20tests,=20docs,=20CI,=20fuzzing,=20mutant=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable clippy pedantic + nursery lints; fix all warnings - Add config validation (SF, CR, frequency range checks) - Zeroize passphrase intermediates after key derivation - Convert to lib+bin crate for fuzz target accessibility - Replace Vec with VecDeque for O(1) log/bucket rotation - Add TUI minimum terminal size guard Testing: - 145 unit + property tests (was 15) - 6 proptest groups (frame roundtrip, hash, SNR, air time, peer list, padding) - 2 cargo-fuzz targets (gossip frame decode, peer list decode) - cargo-mutants: 0 missed, 241 caught, 54 unviable Documentation: - README with prerequisites, quick start, config reference, architecture - Module-level and item-level rustdoc on all public items - LICENSE (MIT), rustfmt.toml, .gitignore Infrastructure: - GitHub Actions CI (fmt, clippy, test, audit) - cargo-mutants config (.cargo/mutants.toml) - justfile for common tasks --- .cargo/mutants.toml | 55 + .github/workflows/ci.yml | 44 + .gitignore | 10 + Cargo.lock | 973 ++++++++++++++---- Cargo.toml | 23 +- LICENSE | 21 + README.md | 273 +++++ deny.toml | 41 + fuzz/Cargo.toml | 26 + fuzz/fuzz_targets/fuzz_decode_peer_list.rs | 7 + fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs | 7 + justfile | 60 ++ rustfmt.toml | 3 + screenshot.png | Bin 0 -> 191678 bytes src/config.rs | 277 ++++- src/gossip.rs | 228 ++-- src/lib.rs | 18 + src/main.rs | 114 +- src/packet.rs | 117 ++- src/radio.rs | 216 +++- src/rate_limit.rs | 249 ++++- src/router.rs | 123 ++- src/setup.rs | 65 +- src/tui.rs | 661 ++++++++++-- 24 files changed, 3048 insertions(+), 563 deletions(-) create mode 100644 .cargo/mutants.toml create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deny.toml create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/fuzz_decode_peer_list.rs create mode 100644 fuzz/fuzz_targets/fuzz_gossip_frame_decode.rs create mode 100644 justfile create mode 100644 rustfmt.toml create mode 100644 screenshot.png create mode 100644 src/lib.rs 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..8e0f7e0 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,16 +921,19 @@ 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]] @@ -911,7 +971,7 @@ checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.9.5", "serde", "sha2 0.11.0-rc.2", "signature", @@ -946,7 +1006,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -957,7 +1017,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -973,7 +1033,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 +1063,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand", + "rand 0.9.2", "siphasher", ] @@ -1000,12 +1079,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 +1196,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 +1247,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1300,8 +1402,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 +1461,10 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand", + "rand 0.9.2", "ring", "rustls", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tokio", "tokio-rustls", @@ -1385,11 +1485,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 +1771,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand", + "rand 0.9.2", "tokio", "url", "xmltree", @@ -1717,7 +1817,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1792,7 +1892,7 @@ dependencies = [ "pkcs8", "portable-atomic", "portmapper", - "rand", + "rand 0.9.2", "reqwest", "rustc-hash", "rustls", @@ -1824,7 +1924,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 +1955,7 @@ dependencies = [ "n0-error", "n0-future", "postcard", - "rand", + "rand 0.9.2", "serde", "tokio", "tokio-util", @@ -1887,7 +1987,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1909,7 +2009,7 @@ dependencies = [ "hyper-util", "iroh-base", "iroh-metrics", - "lru 0.16.3", + "lru", "n0-error", "n0-future", "noq", @@ -1918,7 +2018,7 @@ dependencies = [ "pin-project", "pkarr", "postcard", - "rand", + "rand 0.9.2", "reqwest", "rustls", "rustls-pki-types", @@ -1961,7 +2061,7 @@ checksum = "83c1a4b460634aeed6dc01236a0047867de70e30562d91a0ad031dcb3ac33fb4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1972,9 +2072,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 +2097,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2008,7 +2108,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2038,6 +2138,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 +2209,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 +2263,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 +2284,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 +2316,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 +2340,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 +2409,7 @@ checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2350,7 +2498,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2413,6 +2561,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 +2598,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2448,14 +2619,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 +2666,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 +2675,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 +2714,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2625,6 +2807,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 +2876,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 +2929,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 +2998,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2737,7 +3023,7 @@ dependencies = [ "self_cell", "serde", "simple-dns", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2817,7 +3103,7 @@ dependencies = [ "n0-error", "netwatch", "num_enum", - "rand", + "rand 0.9.2", "serde", "smallvec", "socket2", @@ -2851,7 +3137,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2885,7 +3171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2894,7 +3180,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 +3192,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 +3240,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2944,13 +3255,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 +3302,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 +3318,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 +3328,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 +3347,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 +3457,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 +3567,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 +3620,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 +3657,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 +3725,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3320,11 +3743,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 +3775,7 @@ dependencies = [ "io-kit-sys", "libudev", "mach2", - "nix", + "nix 0.26.4", "scopeguard", "unescaper", "windows-sys 0.52.0", @@ -3478,7 +3901,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 +3918,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3543,11 +3966,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 +3984,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 +4003,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3590,6 +4012,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 +4051,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3627,13 +4060,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 +4173,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3740,7 +4269,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3792,7 +4321,7 @@ dependencies = [ "getrandom 0.3.4", "http", "httparse", - "rand", + "rand 0.9.2", "ring", "rustls-pki-types", "simdutf8", @@ -3803,23 +4332,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 +4354,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 +4361,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 +4372,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 +4445,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 +4458,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4003,19 +4512,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 +4553,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 +4621,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 +4687,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 +4780,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4335,6 +4869,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 +5022,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4427,7 +5033,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4649,15 +5255,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 +5294,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4713,7 +5310,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4765,7 +5362,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror", + "thiserror 2.0.18", "windows", "windows-core", ] @@ -4789,7 +5386,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror", + "thiserror 2.0.18", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -4829,7 +5426,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4856,7 +5453,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4876,7 +5473,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4897,7 +5494,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4930,7 +5527,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1765912..3e331e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,13 @@ 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" } @@ -14,10 +21,10 @@ 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..f7d6942 --- /dev/null +++ b/deny.toml @@ -0,0 +1,41 @@ +# 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 = [ + "https://github.com/swaits/donglora", +] 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 0000000000000000000000000000000000000000..09b9af735626b358f2c3294aef5aae48c3dbd189 GIT binary patch literal 191678 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