test: proptest suite for protocol and COBS framing
Ten properties covering: RadioConfig roundtrip and validate/resolve determinism; no-panic on arbitrary bytes for both Command::from_bytes and RadioConfig::from_bytes; bounded-capacity for Transmit payloads (guards the heapless::Vec size); Command roundtrip via a test-only encoder (the library only decodes commands); COBS reversibility; and two streaming CobsDecoder properties (no-panic on random byte streams with random chunk boundaries, and a full encode→feed→decode roundtrip). Adds Clone to Command and Response — benign public-API expansion that unblocks proptest's Just strategy.
This commit is contained in:
2 files changed
+336
-2
No files matched your search
+2
-2
@@ -156,7 +156,7 @@ impl RadioConfig {
|
||||
|
||||
/// Host → firmware commands.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Ping,
|
||||
GetConfig,
|
||||
@@ -220,7 +220,7 @@ impl Command {
|
||||
|
||||
/// Firmware → host responses.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Property tests for the wire protocol and COBS framing.
|
||||
//!
|
||||
//! These cover three flavours of invariant:
|
||||
//!
|
||||
//! 1. **Roundtrips** — serialize then parse equals identity on well-formed
|
||||
//! values.
|
||||
//! 2. **No-panic** — parsers never panic on arbitrary byte input. This is
|
||||
//! the cheap version of the fuzz suite and runs on every `cargo test`.
|
||||
//! 3. **Bounded capacity** — oversized inputs are discarded, never allocate,
|
||||
//! never overflow the `heapless::Vec` backing store.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use donglora_protocol::framing::{CobsDecoder, MAX_FRAME, cobs_encode_response};
|
||||
use donglora_protocol::{
|
||||
Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig,
|
||||
Response, TX_POWER_MAX,
|
||||
};
|
||||
use heapless::Vec as HVec;
|
||||
use proptest::prelude::*;
|
||||
|
||||
// ── Strategies ──────────────────────────────────────────────────────
|
||||
|
||||
fn arb_bandwidth() -> impl Strategy<Value = Bandwidth> {
|
||||
prop_oneof![
|
||||
Just(Bandwidth::Khz7),
|
||||
Just(Bandwidth::Khz10),
|
||||
Just(Bandwidth::Khz15),
|
||||
Just(Bandwidth::Khz20),
|
||||
Just(Bandwidth::Khz31),
|
||||
Just(Bandwidth::Khz41),
|
||||
Just(Bandwidth::Khz62),
|
||||
Just(Bandwidth::Khz125),
|
||||
Just(Bandwidth::Khz250),
|
||||
Just(Bandwidth::Khz500),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_radio_config() -> impl Strategy<Value = RadioConfig> {
|
||||
(
|
||||
any::<u32>(),
|
||||
arb_bandwidth(),
|
||||
any::<u8>(),
|
||||
any::<u8>(),
|
||||
any::<u16>(),
|
||||
any::<i8>(),
|
||||
any::<u16>(),
|
||||
any::<u8>(),
|
||||
)
|
||||
.prop_map(
|
||||
|(freq_hz, bw, sf, cr, sync_word, tx_power_dbm, preamble_len, cad)| RadioConfig {
|
||||
freq_hz,
|
||||
bw,
|
||||
sf,
|
||||
cr,
|
||||
sync_word,
|
||||
tx_power_dbm,
|
||||
preamble_len,
|
||||
cad,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Payload bytes, bounded by MAX_PAYLOAD so the heapless Vec never overflows.
|
||||
fn arb_payload() -> impl Strategy<Value = HVec<u8, MAX_PAYLOAD>> {
|
||||
prop::collection::vec(any::<u8>(), 0..=MAX_PAYLOAD).prop_map(|v| {
|
||||
let mut out = HVec::new();
|
||||
let _ = out.extend_from_slice(&v);
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
fn arb_error_code() -> impl Strategy<Value = ErrorCode> {
|
||||
prop_oneof![
|
||||
Just(ErrorCode::InvalidConfig),
|
||||
Just(ErrorCode::RadioBusy),
|
||||
Just(ErrorCode::TxTimeout),
|
||||
Just(ErrorCode::NotConfigured),
|
||||
Just(ErrorCode::NoDisplay),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_response() -> impl Strategy<Value = Response> {
|
||||
prop_oneof![
|
||||
Just(Response::Pong),
|
||||
arb_radio_config().prop_map(Response::Config),
|
||||
(any::<i16>(), any::<i16>(), arb_payload())
|
||||
.prop_map(|(rssi, snr, payload)| Response::RxPacket { rssi, snr, payload }),
|
||||
Just(Response::TxDone),
|
||||
Just(Response::Ok),
|
||||
arb_error_code().prop_map(Response::Error),
|
||||
any::<[u8; 6]>().prop_map(Response::MacAddress),
|
||||
]
|
||||
}
|
||||
|
||||
/// Test-only Command encoder. The firmware only ever decodes commands, so
|
||||
/// there is no `Command::write_to` in the library itself — we roll the wire
|
||||
/// layout here to exercise the roundtrip.
|
||||
fn encode_command(cmd: &Command, out: &mut [u8]) -> usize {
|
||||
match cmd {
|
||||
Command::Ping => {
|
||||
out[0] = 0;
|
||||
1
|
||||
}
|
||||
Command::GetConfig => {
|
||||
out[0] = 1;
|
||||
1
|
||||
}
|
||||
Command::SetConfig(cfg) => {
|
||||
out[0] = 2;
|
||||
cfg.write_to(&mut out[1..]);
|
||||
1 + RADIO_CONFIG_SIZE
|
||||
}
|
||||
Command::StartRx => {
|
||||
out[0] = 3;
|
||||
1
|
||||
}
|
||||
Command::StopRx => {
|
||||
out[0] = 4;
|
||||
1
|
||||
}
|
||||
Command::Transmit { config, payload } => {
|
||||
out[0] = 5;
|
||||
let mut pos = 1;
|
||||
if let Some(cfg) = config {
|
||||
out[pos] = 1;
|
||||
pos += 1;
|
||||
cfg.write_to(&mut out[pos..]);
|
||||
pos += RADIO_CONFIG_SIZE;
|
||||
} else {
|
||||
out[pos] = 0;
|
||||
pos += 1;
|
||||
}
|
||||
out[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
pos += 2;
|
||||
out[pos..pos + payload.len()].copy_from_slice(payload);
|
||||
pos + payload.len()
|
||||
}
|
||||
Command::DisplayOn => {
|
||||
out[0] = 6;
|
||||
1
|
||||
}
|
||||
Command::DisplayOff => {
|
||||
out[0] = 7;
|
||||
1
|
||||
}
|
||||
Command::GetMac => {
|
||||
out[0] = 8;
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn arb_command() -> impl Strategy<Value = Command> {
|
||||
prop_oneof![
|
||||
Just(Command::Ping),
|
||||
Just(Command::GetConfig),
|
||||
arb_radio_config().prop_map(Command::SetConfig),
|
||||
Just(Command::StartRx),
|
||||
Just(Command::StopRx),
|
||||
(prop::option::of(arb_radio_config()), arb_payload())
|
||||
.prop_map(|(config, payload)| Command::Transmit { config, payload }),
|
||||
Just(Command::DisplayOn),
|
||||
Just(Command::DisplayOff),
|
||||
Just(Command::GetMac),
|
||||
]
|
||||
}
|
||||
|
||||
// ── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
/// RadioConfig roundtrip: write_to ∘ from_bytes is identity for any
|
||||
/// value produced by the struct's public fields.
|
||||
#[test]
|
||||
fn radio_config_roundtrip(cfg in arb_radio_config()) {
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
let n = cfg.write_to(&mut buf);
|
||||
prop_assert_eq!(n, RADIO_CONFIG_SIZE);
|
||||
prop_assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
/// RadioConfig::validate is deterministic: the same config against the
|
||||
/// same power range produces the same verdict across N invocations.
|
||||
/// (Tautological but catches any accidental interior mutability.)
|
||||
#[test]
|
||||
fn validate_is_deterministic(
|
||||
cfg in arb_radio_config(),
|
||||
power_range in (-20i8..=0i8, 10i8..=30i8),
|
||||
) {
|
||||
let v1 = cfg.validate(power_range);
|
||||
let v2 = cfg.validate(power_range);
|
||||
prop_assert_eq!(v1, v2);
|
||||
}
|
||||
|
||||
/// Resolve never turns sentinels into out-of-range values: after resolve,
|
||||
/// `tx_power_dbm` sits inside the power range and `preamble_len` ≥ 6.
|
||||
#[test]
|
||||
fn resolve_clears_sentinels(
|
||||
cfg in arb_radio_config(),
|
||||
power_range in (-20i8..=0i8, 10i8..=30i8),
|
||||
) {
|
||||
let r = cfg.resolve(power_range);
|
||||
prop_assert_ne!(r.tx_power_dbm, TX_POWER_MAX);
|
||||
prop_assert_ne!(r.preamble_len, PREAMBLE_DEFAULT);
|
||||
}
|
||||
|
||||
/// `Command::from_bytes` never panics on arbitrary input. This is the
|
||||
/// cheap version of the fuzz target — proptest runs it on every
|
||||
/// `cargo test` with ~256 iterations; cargo-fuzz runs it for hours.
|
||||
#[test]
|
||||
fn command_from_bytes_no_panic(data in prop::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let _ = Command::from_bytes(&data);
|
||||
}
|
||||
|
||||
/// RadioConfig::from_bytes never panics on arbitrary input.
|
||||
#[test]
|
||||
fn radio_config_from_bytes_no_panic(data in prop::collection::vec(any::<u8>(), 0..64)) {
|
||||
let _ = RadioConfig::from_bytes(&data);
|
||||
}
|
||||
|
||||
/// Bounded-capacity invariant: a `Transmit` command parsed from bytes
|
||||
/// never yields a payload longer than MAX_PAYLOAD. (The parser truncates
|
||||
/// silently via `extend_from_slice`; this check guards that assumption.)
|
||||
#[test]
|
||||
fn transmit_payload_bounded(
|
||||
has_config in any::<bool>(),
|
||||
cfg in arb_radio_config(),
|
||||
len_field in 0u16..=2000, // deliberately oversized
|
||||
data in prop::collection::vec(any::<u8>(), 0..2000),
|
||||
) {
|
||||
let mut buf = [0u8; 2100];
|
||||
buf[0] = 5; // Transmit tag
|
||||
let mut pos = 1;
|
||||
if has_config {
|
||||
buf[pos] = 1;
|
||||
pos += 1;
|
||||
cfg.write_to(&mut buf[pos..]);
|
||||
pos += RADIO_CONFIG_SIZE;
|
||||
} else {
|
||||
buf[pos] = 0;
|
||||
pos += 1;
|
||||
}
|
||||
buf[pos..pos + 2].copy_from_slice(&len_field.to_le_bytes());
|
||||
pos += 2;
|
||||
let copy_len = data.len().min(buf.len() - pos);
|
||||
buf[pos..pos + copy_len].copy_from_slice(&data[..copy_len]);
|
||||
let total = pos + copy_len;
|
||||
|
||||
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(&buf[..total]) {
|
||||
prop_assert!(payload.len() <= MAX_PAYLOAD);
|
||||
}
|
||||
}
|
||||
|
||||
/// Command roundtrip via the test-only encoder. Catches any parser/encoder
|
||||
/// drift if either side is edited without the other.
|
||||
#[test]
|
||||
fn command_roundtrip(cmd in arb_command()) {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = encode_command(&cmd, &mut buf);
|
||||
let parsed = Command::from_bytes(&buf[..n]).unwrap();
|
||||
prop_assert_eq!(parsed, cmd);
|
||||
}
|
||||
|
||||
/// COBS reversibility: encode-then-decode a response yields the same
|
||||
/// bytes that `write_to` produced.
|
||||
#[test]
|
||||
fn cobs_response_roundtrip(resp in arb_response()) {
|
||||
let mut write = [0u8; MAX_FRAME];
|
||||
let mut encode = [0u8; MAX_FRAME];
|
||||
|
||||
// Expected raw bytes before COBS.
|
||||
let raw_len = {
|
||||
let mut raw = [0u8; MAX_FRAME];
|
||||
let n = resp.clone().write_to(&mut raw);
|
||||
let raw_vec: HVec<u8, MAX_FRAME> = HVec::from_slice(&raw[..n]).unwrap();
|
||||
raw_vec
|
||||
};
|
||||
|
||||
let framed = cobs_encode_response(resp, &mut write, &mut encode)
|
||||
.expect("non-overflowing response should encode");
|
||||
|
||||
// Last byte is the 0x00 sentinel; the preceding bytes are the COBS frame.
|
||||
prop_assert_eq!(*framed.last().unwrap(), 0x00);
|
||||
let mut decoded = [0u8; MAX_FRAME];
|
||||
let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap();
|
||||
prop_assert_eq!(&decoded[..n], raw_len.as_slice());
|
||||
}
|
||||
|
||||
/// CobsDecoder never panics and never yields a command longer than the
|
||||
/// bounded payload for arbitrary byte streams delivered in arbitrary
|
||||
/// chunk boundaries.
|
||||
#[test]
|
||||
fn cobs_decoder_no_panic(
|
||||
stream in prop::collection::vec(any::<u8>(), 0..4096),
|
||||
chunk_splits in prop::collection::vec(1usize..=128, 0..32),
|
||||
) {
|
||||
let mut decoder = CobsDecoder::new();
|
||||
let mut cursor = 0;
|
||||
for &split in &chunk_splits {
|
||||
if cursor >= stream.len() {
|
||||
break;
|
||||
}
|
||||
let end = (cursor + split).min(stream.len());
|
||||
decoder.feed(&stream[cursor..end], |cmd| {
|
||||
if let Command::Transmit { payload, .. } = cmd {
|
||||
assert!(payload.len() <= MAX_PAYLOAD);
|
||||
}
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
// Deliver any remainder as one chunk.
|
||||
if cursor < stream.len() {
|
||||
decoder.feed(&stream[cursor..], |_| {});
|
||||
}
|
||||
}
|
||||
|
||||
/// COBS round-trip through the streaming decoder: encoding a real
|
||||
/// command, appending the 0x00 sentinel, feeding the result to
|
||||
/// `CobsDecoder::feed` yields exactly that command.
|
||||
#[test]
|
||||
fn cobs_decoder_roundtrip(cmd in arb_command()) {
|
||||
let mut raw = [0u8; 1024];
|
||||
let n = encode_command(&cmd, &mut raw);
|
||||
|
||||
let mut framed = [0u8; 1100];
|
||||
let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap();
|
||||
framed[enc_n] = 0x00;
|
||||
|
||||
let mut decoder = CobsDecoder::new();
|
||||
let mut got: Option<Command> = None;
|
||||
decoder.feed(&framed[..enc_n + 1], |c| got = Some(c));
|
||||
prop_assert_eq!(got, Some(cmd));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user