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:
24 files changed
+3049
-564
No files matched your search
+260
-17
@@ -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<i8> {
|
||||
}
|
||||
|
||||
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<RadioConfig> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
|
||||
/// Load config from a TOML file. Returns None-passphrase error if not set.
|
||||
pub fn load_config(path: &Path) -> Result<Config> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user