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.
This commit is contained in:
swaits committed 2026-04-22 09:21:11 -06:00
1 parent 35866edac4
commit 4cccb65124
29 files changed
+7115 -2285

No files matched your search

-41
View File
@@ -1,41 +0,0 @@
//! Chunk-boundary invariance: the same input stream fed into two
//! decoders — one in arbitrary chunks, one all-at-once — must produce
//! the same command sequence.
#![no_main]
use donglora_protocol::Command;
use donglora_protocol::framing::CobsDecoder;
use libfuzzer_sys::fuzz_target;
fn drain(stream: &[u8], chunk_sched: &[u8]) -> Vec<Command> {
let mut out: Vec<Command> = Vec::new();
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
let mut idx = 0;
while cursor < stream.len() {
let split = if idx < chunk_sched.len() {
((chunk_sched[idx] as usize) % 64) + 1
} else {
16
};
idx += 1;
let end = (cursor + split).min(stream.len());
decoder.feed(&stream[cursor..end], |c| out.push(c));
cursor = end;
}
out
}
fuzz_target!(|data: &[u8]| {
let split = 32.min(data.len());
let (sched, stream) = data.split_at(split);
let whole: Vec<Command> = {
let mut v = Vec::new();
CobsDecoder::new().feed(stream, |c| v.push(c));
v
};
let chunked = drain(stream, sched);
assert_eq!(whole, chunked);
});
-36
View File
@@ -1,36 +0,0 @@
//! `CobsDecoder::feed` must never panic or yield an oversized payload
//! when fed arbitrary bytes in arbitrary chunks.
//!
//! The input is split into chunks driven by a leading length-prefix
//! stream so the fuzzer can exercise boundary handling.
#![no_main]
use donglora_protocol::MAX_PAYLOAD;
use donglora_protocol::framing::CobsDecoder;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
// Use the first byte (if any) as a pseudo-chunk-schedule: low nibble
// picks a split size 1..=16, high nibble is skipped. Subsequent
// leading bytes drive subsequent splits.
let mut sched_idx = 0;
while cursor < data.len() {
let split = if sched_idx < data.len() {
((data[sched_idx] & 0x0F) as usize) + 1
} else {
16
};
sched_idx = sched_idx.saturating_add(1);
let end = (cursor + split).min(data.len());
decoder.feed(&data[cursor..end], |cmd| {
if let donglora_protocol::Command::Transmit { payload, .. } = cmd {
assert!(payload.len() <= MAX_PAYLOAD);
}
});
cursor = end;
}
});
@@ -1,42 +0,0 @@
//! Feed arbitrary bytes to `CobsDecoder::feed` using an arbitrary
//! chunking strategy driven by the fuzz input, and confirm:
//! - the decoder never panics,
//! - every emitted command respects the bounded payload,
//! - the total number of commands never exceeds the number of 0x00
//! sentinels in the stream.
#![no_main]
use donglora_protocol::framing::CobsDecoder;
use donglora_protocol::{Command, MAX_PAYLOAD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Split the input: first 32 bytes (if available) drive chunk sizes,
// the rest is the stream.
let split = 32.min(data.len());
let (sched, stream) = data.split_at(split);
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
let mut sched_idx = 0;
let mut count = 0;
while cursor < stream.len() {
let split_size = if sched_idx < sched.len() {
((sched[sched_idx] as usize) % 64) + 1
} else {
16
};
sched_idx += 1;
let end = (cursor + split_size).min(stream.len());
decoder.feed(&stream[cursor..end], |cmd| {
count += 1;
if let Command::Transmit { payload, .. } = cmd {
assert!(payload.len() <= MAX_PAYLOAD);
}
});
cursor = end;
}
let sentinel_count = stream.iter().filter(|&&b| b == 0).count();
assert!(count <= sentinel_count);
});
-126
View File
@@ -1,126 +0,0 @@
//! Structure-aware COBS roundtrip: build an arbitrary `Response`, encode
//! it through `cobs_encode_response`, decode the framed bytes via
//! `ucobs::decode`, and confirm the decoded payload matches the raw
//! `write_to` output.
#![no_main]
use arbitrary::{Arbitrary, Unstructured};
use donglora_protocol::framing::{MAX_FRAME, cobs_encode_response};
use donglora_protocol::{Bandwidth, ErrorCode, RadioConfig, Response};
use heapless::Vec as HVec;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
enum ArbResponse {
Pong,
Config(ArbConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8>,
},
TxDone,
Ok,
Error(ArbError),
MacAddress([u8; 6]),
}
#[derive(Debug, Arbitrary)]
struct ArbConfig {
freq_hz: u32,
bw: u8,
sf: u8,
cr: u8,
sync_word: u16,
tx_power_dbm: i8,
preamble_len: u16,
cad: u8,
}
#[derive(Debug, Arbitrary, Clone, Copy)]
enum ArbError {
InvalidConfig,
RadioBusy,
TxTimeout,
NotConfigured,
NoDisplay,
}
fn into_error(a: ArbError) -> ErrorCode {
match a {
ArbError::InvalidConfig => ErrorCode::InvalidConfig,
ArbError::RadioBusy => ErrorCode::RadioBusy,
ArbError::TxTimeout => ErrorCode::TxTimeout,
ArbError::NotConfigured => ErrorCode::NotConfigured,
ArbError::NoDisplay => ErrorCode::NoDisplay,
}
}
fn into_config(c: &ArbConfig) -> RadioConfig {
let bw = match c.bw % 10 {
0 => Bandwidth::Khz7,
1 => Bandwidth::Khz10,
2 => Bandwidth::Khz15,
3 => Bandwidth::Khz20,
4 => Bandwidth::Khz31,
5 => Bandwidth::Khz41,
6 => Bandwidth::Khz62,
7 => Bandwidth::Khz125,
8 => Bandwidth::Khz250,
_ => Bandwidth::Khz500,
};
RadioConfig {
freq_hz: c.freq_hz,
bw,
sf: c.sf,
cr: c.cr,
sync_word: c.sync_word,
tx_power_dbm: c.tx_power_dbm,
preamble_len: c.preamble_len,
cad: c.cad,
}
}
fn into_response(a: ArbResponse) -> Response {
match a {
ArbResponse::Pong => Response::Pong,
ArbResponse::Config(c) => Response::Config(into_config(&c)),
ArbResponse::RxPacket { rssi, snr, payload } => {
let mut p = HVec::<u8, { donglora_protocol::MAX_PAYLOAD }>::new();
let take = payload.len().min(donglora_protocol::MAX_PAYLOAD);
let _ = p.extend_from_slice(&payload[..take]);
Response::RxPacket {
rssi,
snr,
payload: p,
}
}
ArbResponse::TxDone => Response::TxDone,
ArbResponse::Ok => Response::Ok,
ArbResponse::Error(e) => Response::Error(into_error(e)),
ArbResponse::MacAddress(m) => Response::MacAddress(m),
}
}
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let Ok(arb) = ArbResponse::arbitrary(&mut u) else {
return;
};
let resp = into_response(arb);
let mut raw = [0u8; MAX_FRAME];
let raw_n = resp.clone().write_to(&mut raw);
let mut write = [0u8; MAX_FRAME];
let mut encode = [0u8; MAX_FRAME];
let Some(framed) = cobs_encode_response(resp, &mut write, &mut encode) else {
return;
};
assert_eq!(*framed.last().unwrap(), 0x00);
let mut decoded = [0u8; MAX_FRAME];
let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap();
assert_eq!(&decoded[..n], &raw[..raw_n]);
});
-157
View File
@@ -1,157 +0,0 @@
//! Structure-aware Command roundtrip: construct an arbitrary Command,
//! hand-encode it using the same wire layout the library parses, and
//! confirm `Command::from_bytes` recovers the original value exactly.
//!
//! The library itself is asymmetric (firmware only decodes commands),
//! so this fuzz target carries a matching encoder.
#![no_main]
use arbitrary::{Arbitrary, Unstructured};
use donglora_protocol::{Bandwidth, Command, MAX_PAYLOAD, RADIO_CONFIG_SIZE, RadioConfig};
use heapless::Vec as HVec;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
enum ArbCommand {
Ping,
GetConfig,
SetConfig(ArbConfig),
StartRx,
StopRx,
Transmit {
config: Option<ArbConfig>,
payload: Vec<u8>,
},
DisplayOn,
DisplayOff,
GetMac,
}
#[derive(Debug, Arbitrary)]
struct ArbConfig {
freq_hz: u32,
bw: u8,
sf: u8,
cr: u8,
sync_word: u16,
tx_power_dbm: i8,
preamble_len: u16,
cad: u8,
}
fn into_config(c: &ArbConfig) -> RadioConfig {
let bw = match c.bw % 10 {
0 => Bandwidth::Khz7,
1 => Bandwidth::Khz10,
2 => Bandwidth::Khz15,
3 => Bandwidth::Khz20,
4 => Bandwidth::Khz31,
5 => Bandwidth::Khz41,
6 => Bandwidth::Khz62,
7 => Bandwidth::Khz125,
8 => Bandwidth::Khz250,
_ => Bandwidth::Khz500,
};
RadioConfig {
freq_hz: c.freq_hz,
bw,
sf: c.sf,
cr: c.cr,
sync_word: c.sync_word,
tx_power_dbm: c.tx_power_dbm,
preamble_len: c.preamble_len,
cad: c.cad,
}
}
fn into_command(a: &ArbCommand) -> Command {
match a {
ArbCommand::Ping => Command::Ping,
ArbCommand::GetConfig => Command::GetConfig,
ArbCommand::SetConfig(c) => Command::SetConfig(into_config(c)),
ArbCommand::StartRx => Command::StartRx,
ArbCommand::StopRx => Command::StopRx,
ArbCommand::Transmit { config, payload } => {
let cfg = config.as_ref().map(into_config);
let mut p: HVec<u8, MAX_PAYLOAD> = HVec::new();
let take = payload.len().min(MAX_PAYLOAD);
let _ = p.extend_from_slice(&payload[..take]);
Command::Transmit {
config: cfg,
payload: p,
}
}
ArbCommand::DisplayOn => Command::DisplayOn,
ArbCommand::DisplayOff => Command::DisplayOff,
ArbCommand::GetMac => Command::GetMac,
}
}
fn encode(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
}
}
}
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let Ok(arb) = ArbCommand::arbitrary(&mut u) else {
return;
};
let cmd = into_command(&arb);
let mut buf = [0u8; 1024];
let n = encode(&cmd, &mut buf);
let parsed = Command::from_bytes(&buf[..n]).expect("encoded command must parse");
assert_eq!(parsed, cmd);
});
+16
View File
@@ -0,0 +1,16 @@
//! `FrameDecoder::feed` must never panic on arbitrary input, and any
//! yielded payload slice must fit the documented bounds.
#![no_main]
use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = FrameDecoder::new();
decoder.feed(data, |res| {
if let FrameResult::Ok { payload, .. } = res {
assert!(payload.len() <= MAX_PAYLOAD_FIELD);
}
});
});
+44
View File
@@ -0,0 +1,44 @@
//! An arbitrary well-formed `(type_id, tag, payload)` must survive a
//! frame encode → decode round trip byte-for-byte.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, encode_frame};
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
tag: u16,
payload: &'a [u8],
}
fuzz_target!(|input: Input| {
if input.payload.len() > MAX_PAYLOAD_FIELD {
return;
}
let mut wire = [0u8; MAX_WIRE_FRAME];
let Ok(n) = encode_frame(input.type_id, input.tag, input.payload, &mut wire) else {
return;
};
let mut decoder = FrameDecoder::new();
let mut matched = false;
decoder.feed(&wire[..n], |res| {
if let FrameResult::Ok {
type_id,
tag,
payload,
} = res
{
assert_eq!(type_id, input.type_id);
assert_eq!(tag, input.tag);
assert_eq!(payload, input.payload);
matched = true;
} else {
panic!("encode→decode produced a frame error: {:?}", res);
}
});
assert!(matched, "encoded frame did not decode");
});
@@ -0,0 +1,50 @@
//! `LoRaConfig` round-trips through its wire encoding for every
//! construction derived via `Arbitrary`.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input {
freq_hz: u32,
sf: u8,
bw_raw: u8,
cr_raw: u8,
preamble_len: u16,
sync_word: u16,
tx_power_dbm: i8,
header_raw: u8,
payload_crc: bool,
iq_invert: bool,
}
fuzz_target!(|input: Input| {
let Some(bw) = LoRaBandwidth::from_u8(input.bw_raw & 0x0F) else {
return;
};
let Some(cr) = LoRaCodingRate::from_u8(input.cr_raw & 0x03) else {
return;
};
let Some(header_mode) = LoRaHeaderMode::from_u8(input.header_raw & 0x01) else {
return;
};
let cfg = LoRaConfig {
freq_hz: input.freq_hz,
sf: input.sf,
bw,
cr,
preamble_len: input.preamble_len,
sync_word: input.sync_word,
tx_power_dbm: input.tx_power_dbm,
header_mode,
payload_crc: input.payload_crc,
iq_invert: input.iq_invert,
};
let mut buf = [0u8; 32];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(LoRaConfig::decode(&buf[..n]).unwrap(), cfg);
});
+13 -7
View File
@@ -1,14 +1,20 @@
//! `Command::from_bytes` must never panic on arbitrary input.
//!
//! Any accepted command must also respect the heapless payload bound.
//! `Command::parse` must never panic on arbitrary `(type_id, payload)`
//! input. Accepted commands must respect the OTA payload bound.
#![no_main]
use donglora_protocol::{Command, MAX_PAYLOAD};
use arbitrary::Arbitrary;
use donglora_protocol::{Command, MAX_OTA_PAYLOAD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(data) {
assert!(payload.len() <= MAX_PAYLOAD);
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
payload: &'a [u8],
}
fuzz_target!(|input: Input| {
if let Ok(Command::Tx { data, .. }) = Command::parse(input.type_id, input.payload) {
assert!(data.len() <= MAX_OTA_PAYLOAD);
}
});
+18
View File
@@ -0,0 +1,18 @@
//! `DeviceMessage::parse` must never panic on arbitrary input.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::DeviceMessage;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
payload: &'a [u8],
originating_cmd: Option<u8>,
}
fuzz_target!(|input: Input| {
let _ = DeviceMessage::parse(input.type_id, input.payload, input.originating_cmd);
});
-10
View File
@@ -1,10 +0,0 @@
//! `RadioConfig::from_bytes` must never panic on arbitrary input.
#![no_main]
use donglora_protocol::RadioConfig;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = RadioConfig::from_bytes(data);
});