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.
697 lines
24 KiB
Rust
697 lines
24 KiB
Rust
//! 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),
|
|
]
|
|
}
|
|
|
|
/// 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<Response> {
|
|
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::<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);
|
|
}
|
|
|
|
/// 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::<u8>()) {
|
|
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::<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));
|
|
}
|
|
|
|
/// 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<u8, 16_384> = 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<Command, 8> = 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<u8, 16_384> = 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<Command, 8> = HVec::new();
|
|
CobsDecoder::new().feed(&stream, |c| {
|
|
all_at_once.push(c).unwrap();
|
|
});
|
|
|
|
let mut chunked: HVec<Command, 8> = 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<u8, 4096> = 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<Command, 4> = 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<u8, 2048> = 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<Command, 4> = 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<u8, 4096> = 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<Command> = 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<u8, 8192> = 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<Command, 4> = HVec::new();
|
|
CobsDecoder::new().feed(&stream, |c| whole.push(c).unwrap());
|
|
|
|
let mut per_byte: HVec<Command, 4> = 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);
|
|
}
|