From ef576ba1ec0a9dde5098f4aeaa1c61decf3d930c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 13 Apr 2026 10:13:35 -0600 Subject: [PATCH] test: three more cargo-fuzz targets + test-fuzz-long recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- fuzz/Cargo.toml | 21 +++ fuzz/fuzz_targets/cobs_chunk_invariant.rs | 41 +++++ fuzz/fuzz_targets/cobs_decoder_multi_frame.rs | 42 +++++ fuzz/fuzz_targets/command_roundtrip.rs | 157 ++++++++++++++++++ justfile | 13 ++ 5 files changed, 274 insertions(+) create mode 100644 fuzz/fuzz_targets/cobs_chunk_invariant.rs create mode 100644 fuzz/fuzz_targets/cobs_decoder_multi_frame.rs create mode 100644 fuzz/fuzz_targets/command_roundtrip.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 45a01f7..1a10dc9 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/cobs_chunk_invariant.rs b/fuzz/fuzz_targets/cobs_chunk_invariant.rs new file mode 100644 index 0000000..33a9047 --- /dev/null +++ b/fuzz/fuzz_targets/cobs_chunk_invariant.rs @@ -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 { + let mut out: Vec = 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 = { + let mut v = Vec::new(); + CobsDecoder::new().feed(stream, |c| v.push(c)); + v + }; + let chunked = drain(stream, sched); + assert_eq!(whole, chunked); +}); diff --git a/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs b/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs new file mode 100644 index 0000000..eda1cb7 --- /dev/null +++ b/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/command_roundtrip.rs b/fuzz/fuzz_targets/command_roundtrip.rs new file mode 100644 index 0000000..da08b07 --- /dev/null +++ b/fuzz/fuzz_targets/command_roundtrip.rs @@ -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, + payload: Vec, + }, + 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 = 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); +}); diff --git a/justfile b/justfile index 1281cac..099949a 100644 --- a/justfile +++ b/justfile @@ -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