feat: publish-ready overhaul — lints, tests, docs, CI, fuzzing, mutant testing

- 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
This commit is contained in:
swaits committed 2026-04-06 14:52:18 -06:00
1 parent b3bd92c5f8
commit 6bae65c464
24 files changed
+3049 -564

No files matched your search

+168 -48
View File
@@ -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<String>,
) -> (mpsc::Receiver<RadioEvent>, mpsc::Sender<Vec<u8>>) {
pub fn spawn(config: RadioConfig, port: Option<String>) -> Result<(mpsc::Receiver<RadioEvent>, mpsc::Sender<Vec<u8>>)> {
let (event_tx, event_rx) = mpsc::channel::<RadioEvent>(256);
let (tx_send, tx_recv) = mpsc::channel::<Vec<u8>>(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<String>,
@@ -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<String>,
port: Option<&str>,
event_tx: &mpsc::Sender<RadioEvent>,
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
) -> 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<T: Transport>(
client: &mut donglora_client::Client<T>,
desired: &RadioConfig,
@@ -251,10 +246,7 @@ fn negotiate_config<T: Transport>(
/// 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::<std::io::Error>() {
matches!(
io_err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
} else {
false
}
e.downcast_ref::<std::io::Error>()
.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"));
}
}