Files
protocol-rs/tests/mutation_killers.rs
swaits 4cccb65124 feat!: protocol v2 wire format and module restructure
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.
2026-04-22 09:21:11 -06:00

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);
}