The wire protocol is re-specified as DongLoRa Protocol v2 (see PROTOCOL.md). Every type_id, tag-correlation rule, and field layout is incompatible with 0.1.0. * Add PROTOCOL.md as the normative spec for the v2 wire format. * Replace framing.rs with a streaming FrameDecoder that emits (type_id, tag, payload) triples on each COBS-delimited, CRC-checked frame. * Split the single-file lib.rs into dedicated modules: frame, commands, events, errors, modulation, info, crc, chip_id. Re-exports preserve convenience imports at the crate root. * Replace RadioConfig / Bandwidth with a Modulation enum covering LoRa, FSK/GFSK, LR-FHSS, and FLRC, each with its own typed parameter struct. * Add TxFlags, RxOrigin, SetConfigResult, OkPayload to model v2 response shapes, and RadioChipId for GET_INFO. * Rewrite fuzz corpus: replace the 4 cobs_* / command_roundtrip targets with decoder_feed, frame_roundtrip, lora_config_roundtrip, and parse_device_message; drop parse_radio_config. * Expand proptest and mutation-killer coverage for the new wire shape. * Rewrite canonical-byte test vectors against the v2 spec.
982 lines
34 KiB
Rust
982 lines
34 KiB
Rust
//! Targeted tests that exercise specific branches of the codec so
|
|
//! surviving mutants surface quickly.
|
|
//!
|
|
//! These complement `vectors.rs` (canonical wire bytes) and
|
|
//! `proptest.rs` (random round-trips) by pinning narrow edge cases that
|
|
//! random strategies tend to miss — e.g. off-by-one comparisons, CRC
|
|
//! byte order, COBS boundary cases, reserved-bit validation.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::panic, clippy::bool_assert_comparison)]
|
|
|
|
use donglora_protocol::{
|
|
Command, CommandEncodeError, CommandParseError, DeviceMessage, DeviceMessageParseError,
|
|
ErrorCode, FrameDecodeError, FrameDecoder, FrameResult, Info, InfoParseError, LoRaBandwidth,
|
|
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD,
|
|
MAX_PAYLOAD_FIELD, MAX_PRE_COBS_FRAME, MAX_RADIO_UID_LEN, MAX_SETCONFIG_OK_PAYLOAD,
|
|
MAX_WIRE_FRAME, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult,
|
|
SetConfigResultCode, TxDonePayload, TxFlags, TxResult, cap, commands, crc::crc16, encode_frame,
|
|
events,
|
|
};
|
|
use heapless::Vec as HVec;
|
|
|
|
// ── CRC boundary vectors ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn crc16_check_value_is_0x29b1() {
|
|
// Spec Appendix B — the canonical CRC-16/CCITT-FALSE check value.
|
|
// Any accidental variant switch (KERMIT, XMODEM, reflected, different
|
|
// init) breaks this before it reaches the wire layer.
|
|
assert_eq!(crc16(b"123456789"), 0x29B1);
|
|
}
|
|
|
|
#[test]
|
|
fn crc16_single_null_byte_not_zero() {
|
|
// A naive "init = 0" implementation would yield 0 for a single null
|
|
// input. The correct CCITT-FALSE init 0xFFFF yields 0xE1F0.
|
|
assert_eq!(crc16(&[0x00]), 0xE1F0);
|
|
}
|
|
|
|
#[test]
|
|
fn crc16_empty_input_is_initial_value() {
|
|
assert_eq!(crc16(&[]), 0xFFFF);
|
|
}
|
|
|
|
// ── Frame CRC trailer positioning ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn encode_then_decode_flipped_crc_byte_fails() {
|
|
// Target: swapping CRC byte order or indexing off-by-one in the
|
|
// CRC trailer placement. Take a valid frame, flip one CRC byte, and
|
|
// confirm the decoder rejects.
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(commands::TYPE_PING, 0x0042, &[], &mut wire).unwrap();
|
|
// Last byte is 0x00 sentinel; the byte before that is part of the
|
|
// COBS-encoded CRC. Flip it to inject CRC corruption.
|
|
wire[n - 2] ^= 0x01;
|
|
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut saw_err = false;
|
|
decoder.feed(&wire[..n], |res| {
|
|
if matches!(res, FrameResult::Err(_)) {
|
|
saw_err = true;
|
|
}
|
|
});
|
|
assert!(saw_err, "CRC-corrupted frame must produce FrameResult::Err");
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_rejects_frame_exactly_4_bytes_post_cobs() {
|
|
// Min valid post-COBS frame is 5 bytes (type + tag + crc). A frame
|
|
// that decodes to exactly 4 bytes post-COBS must be flagged TooShort
|
|
// — catches off-by-one on the minimum-length check.
|
|
// COBS of [0xAA, 0xBB, 0xCC, 0xDD] is [0x05, 0xAA, 0xBB, 0xCC, 0xDD].
|
|
let wire = [0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0x00];
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut saw_too_short = false;
|
|
decoder.feed(&wire, |res| {
|
|
if matches!(res, FrameResult::Err(FrameDecodeError::TooShort)) {
|
|
saw_too_short = true;
|
|
}
|
|
});
|
|
assert!(saw_too_short);
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_accepts_frame_exactly_5_bytes_post_cobs() {
|
|
// The 5-byte boundary must succeed (empty payload: type + tag + crc).
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut saw_ok = false;
|
|
decoder.feed(&wire[..n], |res| {
|
|
if matches!(res, FrameResult::Ok { .. }) {
|
|
saw_ok = true;
|
|
}
|
|
});
|
|
assert!(saw_ok);
|
|
}
|
|
|
|
// ── TX flag boundary ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tx_flags_reserved_bits_each_rejected() {
|
|
// Target: `b & !0b0000_0001 != 0` mutated to `== 0` or flipped
|
|
// elsewhere. Verify every single reserved bit position triggers
|
|
// rejection.
|
|
for bit in 1..8 {
|
|
let byte = 1u8 << bit;
|
|
assert!(
|
|
matches!(
|
|
TxFlags::from_byte(byte),
|
|
Err(CommandParseError::ReservedBitSet)
|
|
),
|
|
"bit {bit} must be rejected",
|
|
);
|
|
}
|
|
// Bit 0 alone is allowed (that's the skip_cad flag).
|
|
assert_eq!(TxFlags::from_byte(0b0000_0001).unwrap().skip_cad, true);
|
|
}
|
|
|
|
// ── Empty-TX rejection ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tx_parse_rejects_only_flags_byte() {
|
|
// Spec §6.4: TX with zero data bytes must be rejected.
|
|
assert!(matches!(
|
|
Command::parse(commands::TYPE_TX, &[0x00]),
|
|
Err(CommandParseError::WrongLength)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tx_parse_accepts_one_data_byte() {
|
|
// Minimum valid TX: flags byte + exactly one data byte.
|
|
let got = Command::parse(commands::TYPE_TX, &[0x00, 0x42]).unwrap();
|
|
match got {
|
|
Command::Tx { flags, data } => {
|
|
assert_eq!(flags.skip_cad, false);
|
|
assert_eq!(data.as_slice(), &[0x42]);
|
|
}
|
|
other => panic!("expected Tx, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tx_parse_rejects_payload_exceeding_max_ota() {
|
|
// Craft MAX_OTA_PAYLOAD + 1 bytes of data (plus the flags byte).
|
|
let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
|
|
bytes.push(0x00).unwrap(); // flags
|
|
for _ in 0..=MAX_OTA_PAYLOAD {
|
|
bytes.push(0x55).unwrap();
|
|
}
|
|
assert!(matches!(
|
|
Command::parse(commands::TYPE_TX, &bytes),
|
|
Err(CommandParseError::WrongLength)
|
|
));
|
|
}
|
|
|
|
// ── COBS run-length boundaries ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cobs_254_byte_nonzero_run() {
|
|
// A long run of nonzero payload bytes forces COBS to emit a 0xFF
|
|
// code byte and start a fresh run — the classic boundary case
|
|
// from `PROTOCOL.md §C.8.3`. We pick a run large enough that the
|
|
// type + tag + payload + CRC span exceeds 254 bytes without any
|
|
// interior zero to break it up.
|
|
let mut big: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
|
|
for i in 1..=253u8 {
|
|
big.push(i).unwrap();
|
|
}
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x04, 0x0101, &big, &mut wire).unwrap();
|
|
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut seen: Option<(u8, u16, HVec<u8, MAX_PAYLOAD_FIELD>)> = None;
|
|
decoder.feed(&wire[..n], |res| {
|
|
if let FrameResult::Ok {
|
|
type_id,
|
|
tag,
|
|
payload,
|
|
} = res
|
|
{
|
|
let mut p = HVec::new();
|
|
p.extend_from_slice(payload).unwrap();
|
|
seen = Some((type_id, tag, p));
|
|
}
|
|
});
|
|
let (ty, tag, payload) = seen.unwrap();
|
|
assert_eq!(ty, 0x04);
|
|
assert_eq!(tag, 0x0101);
|
|
assert_eq!(payload.len(), big.len());
|
|
assert_eq!(payload.as_slice(), big.as_slice());
|
|
}
|
|
|
|
#[test]
|
|
fn decoder_handles_split_feed() {
|
|
// Feed one full frame across two `feed()` calls. Accumulator must
|
|
// preserve the partial state, not reset on each call.
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
|
|
assert!(n >= 3);
|
|
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut count = 0;
|
|
decoder.feed(&wire[..n / 2], |_| {
|
|
count += 1;
|
|
});
|
|
assert_eq!(count, 0, "no 0x00 in first chunk → no frame yet");
|
|
|
|
decoder.feed(&wire[n / 2..n], |res| {
|
|
if matches!(res, FrameResult::Ok { .. }) {
|
|
count += 1;
|
|
}
|
|
});
|
|
assert_eq!(count, 1, "second chunk contains the 0x00 sentinel");
|
|
}
|
|
|
|
// ── Tag little-endian byte order ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn tag_byte_order_is_le() {
|
|
// tag=0x0102 → bytes 02 01 (LE). Any mutant swapping the tag
|
|
// bytes would fail here.
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x01, 0x0102, &[], &mut wire).unwrap();
|
|
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut seen_tag = None;
|
|
decoder.feed(&wire[..n], |res| {
|
|
if let FrameResult::Ok { tag, .. } = res {
|
|
seen_tag = Some(tag);
|
|
}
|
|
});
|
|
assert_eq!(seen_tag, Some(0x0102));
|
|
}
|
|
|
|
// ── Size-constant pins (lib.rs) ─────────────────────────────────────
|
|
//
|
|
// The `+` in `MAX_PRE_COBS_FRAME = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD +
|
|
// FRAME_TRAILER_SIZE` etc. is a prime mutation target. Buffers sized
|
|
// from the mutated constant still work (bigger ≠ failure, smaller
|
|
// masked by existing slack), so exact-value tests are the canonical
|
|
// kill.
|
|
|
|
#[test]
|
|
fn max_pre_cobs_frame_pins_exactly_280() {
|
|
assert_eq!(MAX_PRE_COBS_FRAME, 280);
|
|
}
|
|
|
|
#[test]
|
|
fn max_wire_frame_pins_exactly_284() {
|
|
// 280 pre-COBS + 3 worst-case COBS overhead + 1 sentinel.
|
|
assert_eq!(MAX_WIRE_FRAME, 284);
|
|
}
|
|
|
|
#[test]
|
|
fn max_ok_payload_pins_exactly_85() {
|
|
// 37 fixed + 32 mcu_uid + 16 radio_uid. Any mutation that flips
|
|
// `+` to `*` or `-` crashes this.
|
|
assert_eq!(MAX_OK_PAYLOAD, 85);
|
|
}
|
|
|
|
#[test]
|
|
fn max_setconfig_ok_payload_pins_exactly_27() {
|
|
// 2 (result + owner) + 1 (modulation_id) + 24 (FSK with 8B sync).
|
|
assert_eq!(MAX_SETCONFIG_OK_PAYLOAD, 27);
|
|
}
|
|
|
|
// ── Command::encode_payload / parse boundaries (commands.rs) ────────
|
|
|
|
fn tx_cmd(data_len: usize) -> Command {
|
|
let mut d: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
|
|
for i in 0..data_len {
|
|
d.push(i as u8).unwrap();
|
|
}
|
|
Command::Tx {
|
|
flags: TxFlags::default(),
|
|
data: d,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tx_encode_accepts_exactly_max_ota_payload() {
|
|
// Kills `> MAX_OTA_PAYLOAD` → `>=` (the boundary-inclusive mutant).
|
|
let cmd = tx_cmd(MAX_OTA_PAYLOAD);
|
|
let mut buf = [0u8; MAX_OTA_PAYLOAD + 8];
|
|
assert!(cmd.encode_payload(&mut buf).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn tx_encode_rejects_max_ota_payload_plus_one() {
|
|
// Kills `> MAX_OTA_PAYLOAD` → `==` — a heapless::Vec can't hold
|
|
// more than MAX_OTA_PAYLOAD, so we reach the limit via the parse
|
|
// path with an oversized slice.
|
|
let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
|
|
bytes.push(0x00).unwrap();
|
|
for _ in 0..=MAX_OTA_PAYLOAD {
|
|
bytes.push(0x42).unwrap();
|
|
}
|
|
assert!(matches!(
|
|
Command::parse(commands::TYPE_TX, &bytes),
|
|
Err(CommandParseError::WrongLength)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tx_encode_rejects_buffer_exactly_one_byte_too_small() {
|
|
// Kills `buf.len() < total` → `==` and `<=`.
|
|
let cmd = tx_cmd(5);
|
|
// total = 1 (flags) + 5 (data) = 6
|
|
let mut exact = [0u8; 6];
|
|
assert!(cmd.encode_payload(&mut exact).is_ok());
|
|
|
|
let mut too_small = [0u8; 5];
|
|
assert!(matches!(
|
|
cmd.encode_payload(&mut too_small),
|
|
Err(CommandEncodeError::BufferTooSmall)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tx_parse_body_exactly_max_ota_payload() {
|
|
// Kills `body.len() > MAX_OTA_PAYLOAD` → `==`.
|
|
let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
|
|
bytes.push(0x00).unwrap();
|
|
for _ in 0..MAX_OTA_PAYLOAD {
|
|
bytes.push(0x42).unwrap();
|
|
}
|
|
let cmd = Command::parse(commands::TYPE_TX, &bytes).unwrap();
|
|
match cmd {
|
|
Command::Tx { data, .. } => assert_eq!(data.len(), MAX_OTA_PAYLOAD),
|
|
_ => panic!("expected Tx"),
|
|
}
|
|
}
|
|
|
|
// ── SetConfigResultCode / Owner enum coverage (events.rs) ───────────
|
|
|
|
#[test]
|
|
fn set_config_result_code_as_u8_exact() {
|
|
// Kills `SetConfigResultCode::as_u8 -> u8 with 0` by pinning each
|
|
// non-zero variant's discriminant.
|
|
assert_eq!(SetConfigResultCode::Applied.as_u8(), 0);
|
|
assert_eq!(SetConfigResultCode::AlreadyMatched.as_u8(), 1);
|
|
assert_eq!(SetConfigResultCode::LockedMismatch.as_u8(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn set_config_result_code_from_u8_exact() {
|
|
// Kills match-arm-deletion mutants (1, 2) by asserting each maps.
|
|
assert_eq!(
|
|
SetConfigResultCode::from_u8(0),
|
|
Some(SetConfigResultCode::Applied),
|
|
);
|
|
assert_eq!(
|
|
SetConfigResultCode::from_u8(1),
|
|
Some(SetConfigResultCode::AlreadyMatched),
|
|
);
|
|
assert_eq!(
|
|
SetConfigResultCode::from_u8(2),
|
|
Some(SetConfigResultCode::LockedMismatch),
|
|
);
|
|
assert_eq!(SetConfigResultCode::from_u8(3), None);
|
|
}
|
|
|
|
#[test]
|
|
fn owner_as_u8_exact() {
|
|
// Kills `Owner::as_u8 -> u8 with 1`.
|
|
assert_eq!(Owner::None.as_u8(), 0);
|
|
assert_eq!(Owner::Mine.as_u8(), 1);
|
|
assert_eq!(Owner::Other.as_u8(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn owner_from_u8_exact() {
|
|
// Kills match-arm-deletion mutants (0, 2).
|
|
assert_eq!(Owner::from_u8(0), Some(Owner::None));
|
|
assert_eq!(Owner::from_u8(1), Some(Owner::Mine));
|
|
assert_eq!(Owner::from_u8(2), Some(Owner::Other));
|
|
assert_eq!(Owner::from_u8(3), None);
|
|
}
|
|
|
|
// ── SetConfigResult encode/decode length boundaries ────────────────
|
|
|
|
fn sample_lora_for_scr() -> LoRaConfig {
|
|
LoRaConfig {
|
|
freq_hz: 915_000_000,
|
|
sf: 7,
|
|
bw: LoRaBandwidth::Khz125,
|
|
cr: LoRaCodingRate::Cr4_5,
|
|
preamble_len: 8,
|
|
sync_word: 0x1424,
|
|
tx_power_dbm: 14,
|
|
header_mode: LoRaHeaderMode::Explicit,
|
|
payload_crc: true,
|
|
iq_invert: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_config_result_encode_rejects_exactly_one_byte_buf() {
|
|
// Kills `buf.len() < 2` → `==` / `<=`.
|
|
let r = SetConfigResult {
|
|
result: SetConfigResultCode::Applied,
|
|
owner: Owner::Mine,
|
|
current: Modulation::LoRa(sample_lora_for_scr()),
|
|
};
|
|
let mut one = [0u8; 1];
|
|
assert!(r.encode(&mut one).is_err());
|
|
let mut enough = [0u8; 32];
|
|
assert!(r.encode(&mut enough).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn set_config_result_decode_rejects_exactly_one_byte_buf() {
|
|
// Kills `buf.len() < 2` → `==` / `<=` on the decode side.
|
|
assert!(matches!(
|
|
SetConfigResult::decode(&[]),
|
|
Err(DeviceMessageParseError::TooShort)
|
|
));
|
|
assert!(matches!(
|
|
SetConfigResult::decode(&[0x00]),
|
|
Err(DeviceMessageParseError::TooShort)
|
|
));
|
|
}
|
|
|
|
// ── OkPayload::parse_for dispatches each command cleanly ───────────
|
|
|
|
#[test]
|
|
fn ok_payload_parses_get_info() {
|
|
// Kills `delete match arm commands::TYPE_GET_INFO`.
|
|
let mut info_buf = [0u8; 128];
|
|
let info = sample_info();
|
|
let n = info.encode(&mut info_buf).unwrap();
|
|
let parsed = OkPayload::parse_for(commands::TYPE_GET_INFO, &info_buf[..n]).unwrap();
|
|
assert!(matches!(parsed, OkPayload::Info(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn ok_payload_parses_set_config() {
|
|
// Kills `delete match arm commands::TYPE_SET_CONFIG`.
|
|
let r = SetConfigResult {
|
|
result: SetConfigResultCode::Applied,
|
|
owner: Owner::Mine,
|
|
current: Modulation::LoRa(sample_lora_for_scr()),
|
|
};
|
|
let mut buf = [0u8; 64];
|
|
let n = r.encode(&mut buf).unwrap();
|
|
let parsed = OkPayload::parse_for(commands::TYPE_SET_CONFIG, &buf[..n]).unwrap();
|
|
assert!(matches!(parsed, OkPayload::SetConfig(_)));
|
|
}
|
|
|
|
// ── DeviceMessage::parse covers each type_id ───────────────────────
|
|
|
|
#[test]
|
|
fn device_message_parses_err() {
|
|
// Kills `delete match arm TYPE_ERR` in DeviceMessage::parse.
|
|
let mut err_buf = [0u8; 2];
|
|
events::encode_err_payload(ErrorCode::EBusy, &mut err_buf).unwrap();
|
|
let parsed = DeviceMessage::parse(events::TYPE_ERR, &err_buf, None).unwrap();
|
|
assert!(matches!(parsed, DeviceMessage::Err(ErrorCode::EBusy)));
|
|
}
|
|
|
|
#[test]
|
|
fn device_message_parses_tx_done() {
|
|
// Kills `delete match arm TYPE_TX_DONE`.
|
|
let td = TxDonePayload {
|
|
result: TxResult::Transmitted,
|
|
airtime_us: 42,
|
|
};
|
|
let mut buf = [0u8; 8];
|
|
let n = td.encode(&mut buf).unwrap();
|
|
let parsed = DeviceMessage::parse(events::TYPE_TX_DONE, &buf[..n], None).unwrap();
|
|
assert!(matches!(
|
|
parsed,
|
|
DeviceMessage::TxDone(TxDonePayload { airtime_us: 42, .. })
|
|
));
|
|
}
|
|
|
|
// ── RxPayload::decode length math (events.rs:255) ──────────────────
|
|
|
|
#[test]
|
|
fn rx_payload_decode_subtracts_metadata_size() {
|
|
// `n = buf.len() - METADATA_SIZE` — kills `- -> /`. If `-` is
|
|
// replaced with `/`, the resulting n would be `buf.len() / 20`
|
|
// which for a 21-byte buffer yields 1 (matches the truth) — but
|
|
// for a 40-byte buffer yields 2 (wrong; truth is 20). Use a
|
|
// buffer length that makes the two calculations disagree.
|
|
let mut meta = [0u8; 20];
|
|
meta[0..2].copy_from_slice(&(-100i16).to_le_bytes());
|
|
meta[16] = 1; // crc_valid
|
|
meta[19] = 0; // origin Ota
|
|
let mut buf: HVec<u8, 64> = HVec::new();
|
|
buf.extend_from_slice(&meta).unwrap();
|
|
buf.extend_from_slice(&[0x01u8; 20]).unwrap();
|
|
let decoded = RxPayload::decode(&buf).unwrap();
|
|
assert_eq!(decoded.data.len(), 20);
|
|
}
|
|
|
|
// ── FrameDecoder reset actually clears state ──────────────────────
|
|
|
|
#[test]
|
|
fn frame_decoder_reset_clears_accumulator() {
|
|
// Kills `replace FrameDecoder::reset with ()`.
|
|
let mut decoder = FrameDecoder::new();
|
|
// Feed a half-frame: bytes without a 0x00 terminator.
|
|
decoder.feed(&[0xAA, 0xBB, 0xCC, 0xDD], |_| {});
|
|
decoder.reset();
|
|
// Now feed a complete, valid frame. The accumulator must not carry
|
|
// the stale bytes — if `reset` is a no-op, the decoder prepends
|
|
// them and the CRC / COBS decode fails.
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
|
|
let mut saw_ok = false;
|
|
decoder.feed(&wire[..n], |res| {
|
|
if matches!(res, FrameResult::Ok { .. }) {
|
|
saw_ok = true;
|
|
}
|
|
});
|
|
assert!(saw_ok, "reset() must clear stale bytes");
|
|
}
|
|
|
|
// ── Frame decoder overflow || vs && branch (frame.rs:111) ──────────
|
|
|
|
#[test]
|
|
fn frame_decoder_stray_sentinel_does_not_emit() {
|
|
// `self.overflowed || self.len == 0` mutated to `&&` would emit a
|
|
// spurious error for a stray 0x00 (both `overflowed` and `len == 0`
|
|
// must be true under `&&`, but on a naked `0x00` only `len == 0`
|
|
// holds — so the branch stops triggering and the decoder tries to
|
|
// COBS-decode an empty buffer).
|
|
let mut decoder = FrameDecoder::new();
|
|
let mut saw_any = false;
|
|
decoder.feed(&[0x00], |_| {
|
|
saw_any = true;
|
|
});
|
|
assert!(
|
|
!saw_any,
|
|
"stray 0x00 with empty buffer must not produce any frame"
|
|
);
|
|
}
|
|
|
|
// ── Capability bit values (info.rs) ────────────────────────────────
|
|
//
|
|
// Kills the 14 `<< -> >>` mutants on the cap:: constants by pinning
|
|
// each bit's expected u64 value.
|
|
|
|
#[test]
|
|
fn cap_bit_values_pinned() {
|
|
assert_eq!(cap::LORA, 1);
|
|
assert_eq!(cap::FSK, 2);
|
|
assert_eq!(cap::GFSK, 4);
|
|
assert_eq!(cap::LR_FHSS, 8);
|
|
assert_eq!(cap::FLRC, 16);
|
|
assert_eq!(cap::MSK, 32);
|
|
assert_eq!(cap::GMSK, 64);
|
|
assert_eq!(cap::BLE_COMPATIBLE, 128);
|
|
assert_eq!(cap::CAD_BEFORE_TX, 0x0001_0000);
|
|
assert_eq!(cap::IQ_INVERSION, 0x0002_0000);
|
|
assert_eq!(cap::RANGING, 0x0004_0000);
|
|
assert_eq!(cap::GNSS_SCAN, 0x0008_0000);
|
|
assert_eq!(cap::WIFI_MAC_SCAN, 0x0010_0000);
|
|
assert_eq!(cap::SPECTRAL_SCAN, 0x0020_0000);
|
|
assert_eq!(cap::FULL_DUPLEX, 0x0040_0000);
|
|
assert_eq!(cap::MULTI_CLIENT, 0x0000_0001_0000_0000);
|
|
}
|
|
|
|
// ── Info::encode / decode boundaries ───────────────────────────────
|
|
|
|
fn sample_info() -> Info {
|
|
let mut mcu = [0u8; MAX_MCU_UID_LEN];
|
|
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
|
|
Info {
|
|
proto_major: 1,
|
|
proto_minor: 0,
|
|
fw_major: 0,
|
|
fw_minor: 1,
|
|
fw_patch: 0,
|
|
radio_chip_id: 0x0002,
|
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
|
supported_sf_bitmap: 0x1FE0,
|
|
supported_bw_bitmap: 0x03FF,
|
|
max_payload_bytes: 255,
|
|
rx_queue_capacity: 32,
|
|
tx_queue_capacity: 1,
|
|
freq_min_hz: 150_000_000,
|
|
freq_max_hz: 960_000_000,
|
|
tx_power_min_dbm: -9,
|
|
tx_power_max_dbm: 22,
|
|
mcu_uid_len: 8,
|
|
mcu_uid: mcu,
|
|
radio_uid_len: 0,
|
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn info_encode_rejects_buffer_exactly_one_byte_too_small() {
|
|
// Kills `buf.len() < total` → `==` / `<=` in Info::encode.
|
|
let i = sample_info();
|
|
// 37 fixed + 8 mcu_uid + 0 radio_uid = 45 bytes required.
|
|
let mut exact = [0u8; 45];
|
|
assert!(i.encode(&mut exact).is_ok());
|
|
|
|
let mut too_small = [0u8; 44];
|
|
assert!(i.encode(&mut too_small).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn info_decode_rejects_mcu_uid_len_overrun() {
|
|
// Kills the `<` boundary checks and `+` arithmetic in Info::decode
|
|
// by constructing an `mcu_uid_len` that extends past the buffer.
|
|
let i = sample_info();
|
|
let mut buf = [0u8; 128];
|
|
let n = i.encode(&mut buf).unwrap();
|
|
// Pretend mcu_uid_len is 30 so radio_len_idx is 66, beyond n=45.
|
|
buf[35] = 30;
|
|
assert!(matches!(
|
|
Info::decode(&buf[..n]),
|
|
Err(donglora_protocol::InfoParseError::TooShort)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn info_decode_offsets_are_correct() {
|
|
// Implicitly kills the `+ -> -` and `+ -> *` mutants in
|
|
// Info::decode's offset calculations by roundtripping a
|
|
// non-zero radio_uid_len AND non-zero mcu_uid_len — any arithmetic
|
|
// bug flips the data between the two UIDs.
|
|
let mut i = sample_info();
|
|
let mut radio_uid = [0u8; MAX_RADIO_UID_LEN];
|
|
radio_uid[..4].copy_from_slice(&[0xCA, 0xFE, 0xBA, 0xBE]);
|
|
i.radio_uid_len = 4;
|
|
i.radio_uid = radio_uid;
|
|
|
|
let mut buf = [0u8; 128];
|
|
let n = i.encode(&mut buf).unwrap();
|
|
let decoded = Info::decode(&buf[..n]).unwrap();
|
|
assert_eq!(
|
|
&decoded.mcu_uid[..8],
|
|
&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]
|
|
);
|
|
assert_eq!(&decoded.radio_uid[..4], &[0xCA, 0xFE, 0xBA, 0xBE]);
|
|
}
|
|
|
|
#[test]
|
|
fn info_min_wire_size_pinned() {
|
|
// Kills mutants touching the fixed prefix size.
|
|
assert_eq!(Info::MIN_WIRE_SIZE, 37);
|
|
}
|
|
|
|
// ── FskConfig::encode buffer-size boundary ─────────────────────────
|
|
|
|
#[test]
|
|
fn fsk_encode_rejects_buffer_exactly_one_byte_too_small() {
|
|
// Kills `buf.len() < total` → `==` / `<=` in FskConfig::encode.
|
|
let mut cfg = donglora_protocol::FskConfig {
|
|
freq_hz: 0,
|
|
bitrate_bps: 0,
|
|
freq_dev_hz: 0,
|
|
rx_bw: 0,
|
|
preamble_len: 0,
|
|
sync_word_len: 4,
|
|
sync_word: [0u8; donglora_protocol::MAX_SYNC_WORD_LEN],
|
|
};
|
|
cfg.sync_word[..4].copy_from_slice(&[1, 2, 3, 4]);
|
|
// total = 16 + 4 = 20
|
|
let mut exact = [0u8; 20];
|
|
assert!(cfg.encode(&mut exact).is_ok());
|
|
|
|
let mut too_small = [0u8; 19];
|
|
assert!(cfg.encode(&mut too_small).is_err());
|
|
}
|
|
|
|
// ── Frame encode buffer boundary (frame.rs:68) ─────────────────────
|
|
|
|
#[test]
|
|
fn encode_frame_rejects_output_buffer_exactly_one_byte_too_small() {
|
|
// Kills `out.len() < total` → `<=` in encode_frame.
|
|
//
|
|
// We need to know the exact encoded size for our chosen payload.
|
|
let mut generous = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0x01, 0x0001, b"test", &mut generous).unwrap();
|
|
assert!(n >= 3);
|
|
let mut exact = [0u8; MAX_WIRE_FRAME];
|
|
assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n]).is_ok());
|
|
assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n - 1]).is_err());
|
|
}
|
|
|
|
// ── frame.rs MAX_COBS_ENCODED arithmetic (frame.rs:38) ─────────────
|
|
|
|
#[test]
|
|
fn max_wire_frame_minus_one_leaves_room_for_sentinel() {
|
|
// Indirectly kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` mutants:
|
|
// encoding a maximum-payload frame must fit in exactly
|
|
// MAX_WIRE_FRAME bytes, and no fewer.
|
|
let big = [0x42u8; MAX_PAYLOAD_FIELD];
|
|
let mut wire = [0u8; MAX_WIRE_FRAME];
|
|
let n = encode_frame(0xC0, 0x0000, &big, &mut wire).unwrap();
|
|
assert!(n <= MAX_WIRE_FRAME);
|
|
assert!(n > MAX_PAYLOAD_FIELD);
|
|
}
|
|
|
|
// ── Unknown command type produces UnknownType, not WrongLength ─────
|
|
|
|
#[test]
|
|
fn parse_unknown_type_with_empty_payload() {
|
|
// 0x10 is reserved (not a defined H→D command type). The parser
|
|
// must surface UnknownType so the caller can emit EUNKNOWN_CMD.
|
|
assert!(matches!(
|
|
Command::parse(0x10, &[]),
|
|
Err(CommandParseError::UnknownType)
|
|
));
|
|
}
|
|
|
|
// ── SetConfigResult::encode boundary (events.rs:103) ──────────────
|
|
//
|
|
// `buf.len() < 2` — the guard clamps writes to the result + owner
|
|
// bytes. A test that just flips the boundary to `is_err()` can't tell
|
|
// `<` from `<=` because the downstream `Modulation::encode` also fails.
|
|
// We distinguish by checking that buf[0] / buf[1] ARE written before
|
|
// the modulation encode fails: unmutated writes them; the `<=` mutant
|
|
// returns early and leaves the buffer untouched.
|
|
|
|
#[test]
|
|
fn set_config_result_encode_writes_header_before_modulation_fails() {
|
|
let r = SetConfigResult {
|
|
result: SetConfigResultCode::LockedMismatch,
|
|
owner: Owner::Other,
|
|
current: Modulation::LoRa(sample_lora_for_scr()),
|
|
};
|
|
let mut buf = [0xFFu8; 2];
|
|
let res = r.encode(&mut buf);
|
|
assert!(res.is_err(), "modulation encode needs more than 2 bytes");
|
|
assert_eq!(
|
|
buf[0],
|
|
SetConfigResultCode::LockedMismatch.as_u8(),
|
|
"result byte must be written before modulation encode fails",
|
|
);
|
|
assert_eq!(
|
|
buf[1],
|
|
Owner::Other.as_u8(),
|
|
"owner byte must be written before modulation encode fails",
|
|
);
|
|
}
|
|
|
|
// ── SetConfigResult::decode boundary (events.rs:116) ───────────────
|
|
//
|
|
// Unmutated: `buf.len() < 2` passes for len==2, then `Modulation::decode(&[])`
|
|
// returns `TooShort` → mapped to `WrongLength`. Mutated `<=`: guard trips,
|
|
// returns `TooShort`. Distinguish via the error variant.
|
|
|
|
#[test]
|
|
fn set_config_result_decode_two_byte_buf_is_wrong_length_not_too_short() {
|
|
let res = SetConfigResult::decode(&[0x00, 0x01]);
|
|
assert!(
|
|
matches!(res, Err(DeviceMessageParseError::WrongLength)),
|
|
"decode of a 2-byte buffer must surface WrongLength from the inner \
|
|
Modulation::decode call, not TooShort from the guard",
|
|
);
|
|
}
|
|
|
|
// ── RxPayload::decode uses `n` for the data slice (events.rs:255) ─
|
|
//
|
|
// After the refactor, `n = buf.len() - METADATA_SIZE` is load-bearing:
|
|
// the data slice is `&buf[METADATA_SIZE..METADATA_SIZE + n]`. A mutant
|
|
// that swaps `-` for `/` yields a smaller n and a shorter data slice,
|
|
// which the round-trip observes.
|
|
|
|
#[test]
|
|
fn rx_payload_encode_accepts_exactly_max_ota_payload() {
|
|
// Kills `data.len() > MAX_OTA_PAYLOAD` → `==` in RxPayload::encode.
|
|
// At exactly MAX_OTA_PAYLOAD, the `==` mutant trips
|
|
// PayloadTooLarge where the unmutated path encodes cleanly.
|
|
let mut data: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
|
|
for i in 0..MAX_OTA_PAYLOAD {
|
|
data.push(i as u8).unwrap();
|
|
}
|
|
let rx = RxPayload {
|
|
rssi_tenths_dbm: -735,
|
|
snr_tenths_db: 95,
|
|
freq_err_hz: 0,
|
|
timestamp_us: 0,
|
|
crc_valid: true,
|
|
packets_dropped: 0,
|
|
origin: RxOrigin::Ota,
|
|
data,
|
|
};
|
|
let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD];
|
|
let n = rx.encode(&mut buf).unwrap();
|
|
assert_eq!(n, 20 + MAX_OTA_PAYLOAD);
|
|
}
|
|
|
|
#[test]
|
|
fn rx_payload_encode_accepts_buffer_exactly_equal_to_total() {
|
|
// Kills `buf.len() < total` → `==` in RxPayload::encode. With the
|
|
// `==` mutant, an exactly-sized buffer trips BufferTooSmall instead
|
|
// of succeeding.
|
|
let mut data: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
|
|
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
|
|
let rx = RxPayload {
|
|
rssi_tenths_dbm: -735,
|
|
snr_tenths_db: 95,
|
|
freq_err_hz: -125,
|
|
timestamp_us: 42_000_000,
|
|
crc_valid: true,
|
|
packets_dropped: 0,
|
|
origin: RxOrigin::Ota,
|
|
data,
|
|
};
|
|
// total = 20 metadata + 4 data = 24. Exact fit.
|
|
let mut buf = [0u8; 24];
|
|
let n = rx.encode(&mut buf).unwrap();
|
|
assert_eq!(n, 24);
|
|
}
|
|
|
|
#[test]
|
|
fn rx_payload_decode_accepts_exactly_max_ota_payload() {
|
|
// Kills `n > MAX_OTA_PAYLOAD` → `==` in RxPayload::decode. A buffer
|
|
// with exactly 20 + MAX_OTA_PAYLOAD bytes means n == MAX_OTA_PAYLOAD;
|
|
// the `==` mutant trips and returns WrongLength, unmutated decodes
|
|
// cleanly with full-length data.
|
|
let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD];
|
|
buf[16] = 1; // crc_valid
|
|
buf[19] = 0; // origin Ota
|
|
for (i, b) in buf[20..].iter_mut().enumerate() {
|
|
*b = i as u8;
|
|
}
|
|
let decoded = RxPayload::decode(&buf).unwrap();
|
|
assert_eq!(decoded.data.len(), MAX_OTA_PAYLOAD);
|
|
assert_eq!(decoded.data[0], 0);
|
|
assert_eq!(
|
|
decoded.data[MAX_OTA_PAYLOAD - 1],
|
|
(MAX_OTA_PAYLOAD - 1) as u8
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rx_payload_decode_accepts_exactly_metadata_size_no_data() {
|
|
// Kills both `buf.len() < METADATA_SIZE` → `==` / `<=` mutants on
|
|
// `RxPayload::decode`. A 20-byte buffer (metadata only, zero data)
|
|
// must succeed: unmutated returns an RxEvent with empty `data`;
|
|
// either mutant trips the guard and returns TooShort.
|
|
let mut buf = [0u8; 20];
|
|
buf[16] = 1; // crc_valid
|
|
buf[19] = 0; // origin Ota
|
|
let decoded = RxPayload::decode(&buf).unwrap();
|
|
assert_eq!(decoded.data.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rx_payload_decode_rejects_one_byte_below_metadata_size() {
|
|
// Complements the above: a 19-byte buffer must fail regardless of
|
|
// how the `<` boundary is mutated, since the data slice
|
|
// `buf[METADATA_SIZE..]` would otherwise index out of bounds.
|
|
let buf = [0u8; 19];
|
|
assert!(matches!(
|
|
RxPayload::decode(&buf),
|
|
Err(DeviceMessageParseError::TooShort),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rx_payload_decode_n_is_used_for_data_slice() {
|
|
let mut buf = [0u8; 40];
|
|
buf[16] = 1; // crc_valid
|
|
buf[19] = 0; // origin Ota
|
|
for (i, b) in buf[20..40].iter_mut().enumerate() {
|
|
*b = i as u8;
|
|
}
|
|
let decoded = RxPayload::decode(&buf).unwrap();
|
|
// Unmutated: n = 40 - 20 = 20 → data slice is all 20 bytes.
|
|
// Mutated `-` → `/`: n = 40 / 20 = 2 → data slice is only 2 bytes.
|
|
assert_eq!(decoded.data.len(), 20);
|
|
assert_eq!(decoded.data[0], 0);
|
|
assert_eq!(decoded.data[19], 19);
|
|
}
|
|
|
|
// ── Info sample for the tests below ────────────────────────────────
|
|
|
|
fn info_sample() -> Info {
|
|
let mut mcu = [0u8; MAX_MCU_UID_LEN];
|
|
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
|
|
Info {
|
|
proto_major: 1,
|
|
proto_minor: 0,
|
|
fw_major: 0,
|
|
fw_minor: 1,
|
|
fw_patch: 0,
|
|
radio_chip_id: 0x0002,
|
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
|
supported_sf_bitmap: 0x1FE0,
|
|
supported_bw_bitmap: 0x03FF,
|
|
max_payload_bytes: 255,
|
|
rx_queue_capacity: 32,
|
|
tx_queue_capacity: 1,
|
|
freq_min_hz: 150_000_000,
|
|
freq_max_hz: 960_000_000,
|
|
tx_power_min_dbm: -9,
|
|
tx_power_max_dbm: 22,
|
|
mcu_uid_len: 8,
|
|
mcu_uid: mcu,
|
|
radio_uid_len: 0,
|
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
|
}
|
|
}
|
|
|
|
// ── Info::decode min-size boundary (info.rs:132) ───────────────────
|
|
//
|
|
// A zero-UID Info encodes to exactly MIN_WIRE_SIZE (37) bytes. Decode
|
|
// of a 37-byte buffer must succeed. `<=` mutant makes it return
|
|
// TooShort.
|
|
|
|
#[test]
|
|
fn info_decode_accepts_exactly_min_wire_size() {
|
|
let mut i = info_sample();
|
|
i.mcu_uid_len = 0;
|
|
i.radio_uid_len = 0;
|
|
let mut buf = [0u8; 64];
|
|
let n = i.encode(&mut buf).unwrap();
|
|
assert_eq!(n, 37);
|
|
let decoded = Info::decode(&buf[..n]).unwrap();
|
|
assert_eq!(decoded.mcu_uid_len, 0);
|
|
assert_eq!(decoded.radio_uid_len, 0);
|
|
}
|
|
|
|
// ── Info::decode `radio_len_idx + 1` guard (info.rs:141) ──────────
|
|
//
|
|
// The `+ 1` reserves the byte that holds `radio_uid_len`. A 44-byte
|
|
// buffer with mcu_uid_len=8 has radio_len_idx=44 — the check needs 45
|
|
// bytes, so 44 must produce TooShort. Mutants `+ → -` or `+ → *`
|
|
// either relax the guard and then panic reading buf[44], or simply
|
|
// allow the read to succeed with garbage data.
|
|
|
|
#[test]
|
|
fn info_decode_radio_len_idx_plus_one_guard_is_active() {
|
|
let mut buf = [0u8; 44];
|
|
buf[35] = 8; // mcu_uid_len so radio_len_idx lands at 44
|
|
assert!(matches!(Info::decode(&buf), Err(InfoParseError::TooShort),));
|
|
}
|
|
|
|
// ── Info::decode `expected_total` uses both + (info.rs:149) ───────
|
|
//
|
|
// `Self::MIN_WIRE_SIZE + mcu_n + radio_n` — a mutation to `-` at
|
|
// either `+` produces a smaller expected_total that passes the guard
|
|
// at buf.len()==45 (which unmutated correctly rejects as TooShort).
|
|
// With the guard bypassed, decode panics reading radio_uid from a
|
|
// slice that extends past the buffer.
|
|
|
|
#[test]
|
|
fn info_decode_expected_total_uses_both_plus_signs() {
|
|
let mut i = info_sample();
|
|
i.radio_uid_len = 4;
|
|
let mut big = [0u8; 128];
|
|
let n = i.encode(&mut big).unwrap();
|
|
assert_eq!(n, 49);
|
|
assert!(matches!(
|
|
Info::decode(&big[..45]),
|
|
Err(InfoParseError::TooShort),
|
|
));
|
|
}
|
|
|
|
// ── Info::decode tolerates trailing bytes (info.rs:150) ───────────
|
|
//
|
|
// Per spec §16.4, minor-version extensions may append fields. The
|
|
// decoder's final length guard is `buf.len() < expected_total`; a
|
|
// mutant to `>` trips when buf.len() exceeds expected_total, which
|
|
// breaks forward-compatibility.
|
|
|
|
#[test]
|
|
fn info_decode_tolerates_trailing_bytes() {
|
|
let i = info_sample();
|
|
let mut buf = [0u8; 64];
|
|
let n = i.encode(&mut buf).unwrap();
|
|
assert_eq!(n, 45);
|
|
// Simulate 10 bytes of a future-minor-version extension.
|
|
for b in &mut buf[45..55] {
|
|
*b = 0xAA;
|
|
}
|
|
let decoded = Info::decode(&buf[..55]).unwrap();
|
|
assert_eq!(decoded, i);
|
|
}
|