test: cargo-fuzz targets on parser and framing

Four libFuzzer targets:
- parse_command: Command::from_bytes never panics; bounded payload.
- parse_radio_config: RadioConfig::from_bytes never panics.
- cobs_decoder_feed: CobsDecoder::feed is panic-free across arbitrary
  byte streams split into arbitrary chunks.
- cobs_roundtrip: structure-aware (via arbitrary crate) Response →
  cobs_encode_response → ucobs::decode yields identical raw bytes.

All four smoke-tested at 50k runs each with no findings. Intended CI
cadence is a weekly cron with a larger time budget; per-PR would be
expensive. Requires nightly + cargo-fuzz.
This commit is contained in:
swaits committed 2026-04-13 09:05:07 -06:00
1 parent 79587f9e22
commit 0d0dc81879
6 files changed
+241

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
+51
View File
@@ -0,0 +1,51 @@
[package]
name = "donglora-protocol-fuzz"
version = "0.0.0"
edition = "2024"
publish = false
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
heapless = "0.9"
ucobs = "0.3"
[dependencies.donglora-protocol]
path = ".."
default-features = false
features = ["std"]
# Prevent this from being picked up by a workspace scan of the parent repo.
[workspace]
members = ["."]
[[bin]]
name = "parse_command"
path = "fuzz_targets/parse_command.rs"
test = false
doc = false
bench = false
[[bin]]
name = "parse_radio_config"
path = "fuzz_targets/parse_radio_config.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_decoder_feed"
path = "fuzz_targets/cobs_decoder_feed.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_roundtrip"
path = "fuzz_targets/cobs_roundtrip.rs"
test = false
doc = false
bench = false
+36
View File
@@ -0,0 +1,36 @@
//! `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;
}
});
+126
View File
@@ -0,0 +1,126 @@
//! 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]);
});
+14
View File
@@ -0,0 +1,14 @@
//! `Command::from_bytes` must never panic on arbitrary input.
//!
//! Any accepted command must also respect the heapless payload bound.
#![no_main]
use donglora_protocol::{Command, MAX_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);
}
});
+10
View File
@@ -0,0 +1,10 @@
//! `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);
});