From adbad81e19a7bae1641d8d9c552687c3b236a787 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 13 Apr 2026 10:10:07 -0600 Subject: [PATCH] test: 23 proptests across two budget tiers (1M+ cases per run) Scaled the proptest suite hard and added 13 new properties: - cobs_decoder_multi_frame: N frames decode to N commands in order. - cobs_decoder_chunk_invariant: arbitrary chunk splits yield the same sequence as feed-all-at-once. - cobs_decoder_byte_by_byte_equivalent: stronger form of chunking. - cobs_decoder_recovers_after_garbage: decoder is not poisoned by a leading garbage frame. - cobs_decoder_ignores_sentinel_runs: arbitrary leading/trailing 0x00 runs do not affect decoding. - cobs_decoder_oversized_discards: frames > MAX_FRAME are discarded; subsequent frames still decode. - response_full_roundtrip: encode + COBS + decode + re-parse yields the original Response (via a test-only decode_response helper). - response_encoding_deterministic: byte-identical output across runs. - cobs_encoded_has_single_trailing_zero: COBS invariant check. - bandwidth_u8_is_bijection: sweeps the whole u8 space. - radio_config_write_to_stays_in_bounds: buffer-write sanity. - resolve_then_validate_consistent: cross-field contract. Split into two proptest! blocks: lightweight at 100,000 cases each, and heavyweight (MAX_FRAME-sized buffers, multi-frame streams) at 10,000. Total ~1.13M test cases per suite run, finishing in ~20 s. --- tests/proptest.rs | 370 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 366 insertions(+), 4 deletions(-) diff --git a/tests/proptest.rs b/tests/proptest.rs index 3f941ce..bb915d0 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -166,9 +166,73 @@ fn arb_command() -> impl Strategy { ] } -// ── Properties ────────────────────────────────────────────────────── +/// 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] @@ -204,9 +268,9 @@ proptest! { 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. + /// `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); @@ -218,6 +282,76 @@ proptest! { 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.) @@ -331,4 +465,232 @@ proptest! { 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); }