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
+100
-17
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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<u8>) -> 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<u8> {
|
||||
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<Self> {
|
||||
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::<u8>(), 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::<u8>(), 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user