//! 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 { 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 { ( any::(), arb_bandwidth(), any::(), any::(), any::(), any::(), any::(), any::(), ) .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> { prop::collection::vec(any::(), 0..=MAX_PAYLOAD).prop_map(|v| { let mut out = HVec::new(); let _ = out.extend_from_slice(&v); out }) } fn arb_error_code() -> impl Strategy { prop_oneof![ Just(ErrorCode::InvalidConfig), Just(ErrorCode::RadioBusy), Just(ErrorCode::TxTimeout), Just(ErrorCode::NotConfigured), Just(ErrorCode::NoDisplay), ] } fn arb_response() -> impl Strategy { prop_oneof![ Just(Response::Pong), arb_radio_config().prop_map(Response::Config), (any::(), any::(), 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 { 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), ] } /// Decode a raw (non-COBS) Response buffer back into a `Response`. The /// library is intentionally asymmetric — firmware encodes, host decodes — /// so this lives in the test crate only. fn decode_response(buf: &[u8]) -> Option { let tag = *buf.first()?; let rest = &buf[1..]; match tag { 0 => Some(Response::Pong), 1 => Some(Response::Config(RadioConfig::from_bytes(rest)?)), 2 => { if rest.len() < 6 { return None; } let rssi = i16::from_le_bytes([rest[0], rest[1]]); let snr = i16::from_le_bytes([rest[2], rest[3]]); let len = u16::from_le_bytes([rest[4], rest[5]]) as usize; if rest.len() < 6 + len { return None; } let mut payload = HVec::new(); let _ = payload.extend_from_slice(&rest[6..6 + len]); Some(Response::RxPacket { rssi, snr, payload }) } 3 => Some(Response::TxDone), 4 => Some(Response::Ok), 5 => { let code = *rest.first()?; let err = match code { 0 => ErrorCode::InvalidConfig, 1 => ErrorCode::RadioBusy, 2 => ErrorCode::TxTimeout, 4 => ErrorCode::NotConfigured, 5 => ErrorCode::NoDisplay, _ => return None, }; Some(Response::Error(err)) } 6 => { if rest.len() < 6 { return None; } let mut mac = [0u8; 6]; mac.copy_from_slice(&rest[..6]); Some(Response::MacAddress(mac)) } _ => None, } } /// COBS-frame a raw byte slice with trailing 0x00 sentinel. fn cobs_frame(raw: &[u8], out: &mut [u8]) -> usize { let n = ucobs::encode(raw, out).unwrap(); out[n] = 0x00; n + 1 } // ── Properties — lightweight (100_000 cases) ──────────────────────── proptest! { // Cheap properties: tiny allocations, fast inner loops. 100k cases // keeps the whole suite under a few seconds. #![proptest_config(ProptestConfig { cases: 100_000, max_shrink_iters: 10_000, .. ProptestConfig::default() })] /// 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. Proptest /// runs this on every `cargo test`; cargo-fuzz runs the same code /// for hours in CI. #[test] fn command_from_bytes_no_panic(data in prop::collection::vec(any::(), 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::(), 0..64)) { let _ = RadioConfig::from_bytes(&data); } /// Bandwidth bijection: every accepted on-wire value round-trips. /// Complements the enum-exhaustive vector test by sweeping the full /// u8 space and confirming accept/reject follow the spec. #[test] fn bandwidth_u8_is_bijection(v in any::()) { let mut buf = [0u8; RADIO_CONFIG_SIZE]; RadioConfig { freq_hz: 915_000_000, bw: Bandwidth::Khz125, sf: 7, cr: 5, sync_word: 0, tx_power_dbm: 0, preamble_len: 16, cad: 0, } .write_to(&mut buf); buf[4] = v; let parsed = RadioConfig::from_bytes(&buf); if v <= 9 { let decoded = parsed.unwrap(); let mut back = [0u8; RADIO_CONFIG_SIZE]; decoded.write_to(&mut back); prop_assert_eq!(back[4], v); } else { prop_assert!(parsed.is_none()); } } /// write_to touches exactly RADIO_CONFIG_SIZE bytes — no surrounding /// memory is clobbered. #[test] fn radio_config_write_to_stays_in_bounds(cfg in arb_radio_config()) { let mut buf = [0xAAu8; RADIO_CONFIG_SIZE + 16]; let n = cfg.write_to(&mut buf[..RADIO_CONFIG_SIZE]); prop_assert_eq!(n, RADIO_CONFIG_SIZE); for (i, &b) in buf[RADIO_CONFIG_SIZE..].iter().enumerate() { prop_assert_eq!(b, 0xAA, "byte at RADIO_CONFIG_SIZE+{} was clobbered", i); } } /// After `resolve(pr)` clears both sentinels, `validate(pr)` accepts /// the remaining fields iff they are independently in range. #[test] fn resolve_then_validate_consistent( cfg in arb_radio_config(), power_range in (-20i8..=0i8, 10i8..=30i8), ) { let r = cfg.resolve(power_range); if r.tx_power_dbm < power_range.0 || r.tx_power_dbm > power_range.1 { prop_assert!(r.validate(power_range).is_err()); } if r.preamble_len < 6 { prop_assert!(r.validate(power_range).is_err()); } } } // ── Properties — heavyweight (10_000 cases) ──────────────────────── proptest! { // Properties that touch MAX_FRAME-sized buffers or multi-frame // streams; ~10k cases keeps total runtime reasonable. #![proptest_config(ProptestConfig { cases: 10_000, max_shrink_iters: 10_000, .. ProptestConfig::default() })] /// 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::(), cfg in arb_radio_config(), len_field in 0u16..=2000, // deliberately oversized data in prop::collection::vec(any::(), 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 = 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::(), 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 = None; decoder.feed(&framed[..enc_n + 1], |c| got = Some(c)); prop_assert_eq!(got, Some(cmd)); } /// Multi-frame streaming: N concatenated COBS frames decode to N /// commands in the same order. #[test] fn cobs_decoder_multi_frame(cmds in prop::collection::vec(arb_command(), 1..8)) { let mut stream: HVec = HVec::new(); let mut raw = [0u8; 1024]; let mut framed = [0u8; 1100]; for cmd in &cmds { let n = encode_command(cmd, &mut raw); let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); } let mut decoder = CobsDecoder::new(); let mut got: HVec = HVec::new(); decoder.feed(&stream, |c| { got.push(c).unwrap(); }); prop_assert_eq!(got.as_slice(), cmds.as_slice()); } /// Chunk-boundary invariance: the same byte stream fed in arbitrary /// chunk splits yields the same command sequence as feeding it all /// at once. #[test] fn cobs_decoder_chunk_invariant( cmds in prop::collection::vec(arb_command(), 0..6), splits in prop::collection::vec(1usize..=64, 0..64), ) { let mut stream: HVec = HVec::new(); let mut raw = [0u8; 1024]; let mut framed = [0u8; 1100]; for cmd in &cmds { let n = encode_command(cmd, &mut raw); let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); } let mut all_at_once: HVec = HVec::new(); CobsDecoder::new().feed(&stream, |c| { all_at_once.push(c).unwrap(); }); let mut chunked: HVec = HVec::new(); let mut decoder = CobsDecoder::new(); let mut cursor = 0; for &split in &splits { if cursor >= stream.len() { break; } let end = (cursor + split).min(stream.len()); decoder.feed(&stream[cursor..end], |c| { chunked.push(c).unwrap(); }); cursor = end; } if cursor < stream.len() { decoder.feed(&stream[cursor..], |c| { chunked.push(c).unwrap(); }); } prop_assert_eq!(chunked, all_at_once); } /// Garbage-then-valid: a leading oversized/garbage frame is discarded /// but does not poison the decoder; the next valid frame decodes. #[test] fn cobs_decoder_recovers_after_garbage( garbage in prop::collection::vec(1u8..=255, 1..2 * MAX_FRAME), cmd in arb_command(), ) { let mut stream: HVec = HVec::new(); // Feed garbage followed by a sentinel (no valid COBS structure). stream.extend_from_slice(&garbage).unwrap(); stream.push(0x00).unwrap(); // Then a legitimate framed 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(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); let mut decoder = CobsDecoder::new(); let mut got: HVec = HVec::new(); decoder.feed(&stream, |c| { got.push(c).unwrap(); }); // The recovered command must be last. Garbage may or may not // parse as Some command — we only assert that the real one did. prop_assert!(got.contains(&cmd), "valid frame after garbage must decode"); prop_assert_eq!(got.last(), Some(&cmd)); } /// Sentinel padding: arbitrary runs of 0x00 between frames are /// treated as empty frames (no command emitted) and do not disturb /// subsequent decoding. #[test] fn cobs_decoder_ignores_sentinel_runs( leading_zeros in 0usize..32, cmd in arb_command(), trailing_zeros in 0usize..32, ) { let mut stream: HVec = HVec::new(); for _ in 0..leading_zeros { stream.push(0x00).unwrap(); } 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(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); for _ in 0..trailing_zeros { stream.push(0x00).unwrap(); } let mut decoder = CobsDecoder::new(); let mut got: HVec = HVec::new(); decoder.feed(&stream, |c| { got.push(c).unwrap(); }); prop_assert!(got.contains(&cmd)); } /// Oversized frame (more than MAX_FRAME bytes before a sentinel) is /// discarded; a subsequent valid frame decodes. #[test] fn cobs_decoder_oversized_discards( overflow_bytes in (MAX_FRAME + 1)..(3 * MAX_FRAME), cmd in arb_command(), ) { let mut stream: HVec = HVec::new(); for _ in 0..overflow_bytes { stream.push(0x01).unwrap(); // non-zero filler } stream.push(0x00).unwrap(); 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(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); let mut decoder = CobsDecoder::new(); let mut got: Option = None; decoder.feed(&stream, |c| got = Some(c)); prop_assert_eq!(got, Some(cmd)); } /// Full Response roundtrip: write_to + COBS encode + COBS decode + /// bytes-to-Response yields the original value. #[test] fn response_full_roundtrip(resp in arb_response()) { let mut write = [0u8; MAX_FRAME]; let mut encode = [0u8; MAX_FRAME]; let framed = cobs_encode_response(resp.clone(), &mut write, &mut encode).unwrap(); let mut decoded = [0u8; MAX_FRAME]; let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap(); prop_assert_eq!(decode_response(&decoded[..n]), Some(resp)); } /// Encoding is deterministic: the same Response yields byte-identical /// output across repeated invocations. #[test] fn response_encoding_deterministic(resp in arb_response()) { let mut a_write = [0u8; MAX_FRAME]; let mut a_enc = [0u8; MAX_FRAME]; let mut b_write = [0u8; MAX_FRAME]; let mut b_enc = [0u8; MAX_FRAME]; let a_len = cobs_encode_response(resp.clone(), &mut a_write, &mut a_enc).unwrap().len(); let b_len = cobs_encode_response(resp, &mut b_write, &mut b_enc).unwrap().len(); prop_assert_eq!(a_len, b_len); prop_assert_eq!(&a_enc[..a_len], &b_enc[..b_len]); } /// `cobs_encode_response` output has exactly one 0x00 — at the end. /// The COBS layer's entire contract is that no zero appears inside. #[test] fn cobs_encoded_has_single_trailing_zero(resp in arb_response()) { let mut write = [0u8; MAX_FRAME]; let mut encode = [0u8; MAX_FRAME]; let framed = cobs_encode_response(resp, &mut write, &mut encode).unwrap(); let zero_count = framed.iter().filter(|&&b| b == 0).count(); prop_assert_eq!(zero_count, 1); prop_assert_eq!(*framed.last().unwrap(), 0x00); } /// COBS-framed stream: feed-byte-by-byte is equivalent to feed-all-at-once. #[test] fn cobs_decoder_byte_by_byte_equivalent(cmds in prop::collection::vec(arb_command(), 0..4)) { let mut stream: HVec = HVec::new(); let mut raw = [0u8; 1024]; let mut framed = [0u8; 1100]; for cmd in &cmds { let n = encode_command(cmd, &mut raw); let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); stream.extend_from_slice(&framed[..enc_n]).unwrap(); stream.push(0x00).unwrap(); } let mut whole: HVec = HVec::new(); CobsDecoder::new().feed(&stream, |c| whole.push(c).unwrap()); let mut per_byte: HVec = HVec::new(); let mut decoder = CobsDecoder::new(); for &b in &stream { decoder.feed(&[b], |c| per_byte.push(c).unwrap()); } prop_assert_eq!(per_byte, whole); } } // Keep the framer helper exercised so unused-code lints on `cobs_frame` // stay quiet even if a future edit removes its last caller. #[test] fn cobs_frame_helper_smoke() { let mut out = [0u8; 16]; let n = cobs_frame(&[1, 2, 3], &mut out); assert!(n >= 4); assert_eq!(out[n - 1], 0x00); }