test: three more cargo-fuzz targets + test-fuzz-long recipe
- command_roundtrip: structure-aware (via arbitrary crate) Command encode → parse → equal. Symmetric to cobs_roundtrip. - cobs_decoder_multi_frame: chunked feeding with arbitrary split sizes; asserts bounded payload and that command count never exceeds sentinel count. - cobs_chunk_invariant: same stream through two decoders (chunked vs all-at-once) must produce the same command sequence. Adds test-fuzz-long recipe (600s per target, ~1h 10m total) for CI cron / pre-release. All seven targets smoke-tested at 100k runs each with zero findings.
This commit is contained in:
5 files changed
+274
No files matched your search
@@ -49,3 +49,24 @@ path = "fuzz_targets/cobs_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "command_roundtrip"
|
||||
path = "fuzz_targets/command_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_decoder_multi_frame"
|
||||
path = "fuzz_targets/cobs_decoder_multi_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_chunk_invariant"
|
||||
path = "fuzz_targets/cobs_chunk_invariant.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 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);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 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);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
//! 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);
|
||||
});
|
||||
@@ -44,6 +44,19 @@ test-fuzz:
|
||||
cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run command_roundtrip -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_multi_frame -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_chunk_invariant -- -max_total_time=60
|
||||
|
||||
# long fuzz run: 10 minutes per target. Use for CI cron / pre-release.
|
||||
test-fuzz-long:
|
||||
cd fuzz && cargo +nightly fuzz run parse_command -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run command_roundtrip -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_multi_frame -- -max_total_time=600
|
||||
cd fuzz && cargo +nightly fuzz run cobs_chunk_invariant -- -max_total_time=600
|
||||
|
||||
# run tests under miri (catches UB, requires nightly + miri component)
|
||||
# The proptest suite is skipped — its file-persistence layer calls getcwd
|
||||
|
||||
Reference in new issue
Block a user