From 0d0dc81879bd1dd0fd32f932cced5892e0aa4a15 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 13 Apr 2026 09:03:58 -0600 Subject: [PATCH] test: cargo-fuzz targets on parser and framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- fuzz/.gitignore | 4 + fuzz/Cargo.toml | 51 ++++++++++ fuzz/fuzz_targets/cobs_decoder_feed.rs | 36 +++++++ fuzz/fuzz_targets/cobs_roundtrip.rs | 126 ++++++++++++++++++++++++ fuzz/fuzz_targets/parse_command.rs | 14 +++ fuzz/fuzz_targets/parse_radio_config.rs | 10 ++ 6 files changed, 241 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/cobs_decoder_feed.rs create mode 100644 fuzz/fuzz_targets/cobs_roundtrip.rs create mode 100644 fuzz/fuzz_targets/parse_command.rs create mode 100644 fuzz/fuzz_targets/parse_radio_config.rs diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..45a01f7 --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/cobs_decoder_feed.rs b/fuzz/fuzz_targets/cobs_decoder_feed.rs new file mode 100644 index 0000000..ccb30bb --- /dev/null +++ b/fuzz/fuzz_targets/cobs_decoder_feed.rs @@ -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; + } +}); diff --git a/fuzz/fuzz_targets/cobs_roundtrip.rs b/fuzz/fuzz_targets/cobs_roundtrip.rs new file mode 100644 index 0000000..c7acd70 --- /dev/null +++ b/fuzz/fuzz_targets/cobs_roundtrip.rs @@ -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, + }, + 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::::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]); +}); diff --git a/fuzz/fuzz_targets/parse_command.rs b/fuzz/fuzz_targets/parse_command.rs new file mode 100644 index 0000000..eee7e65 --- /dev/null +++ b/fuzz/fuzz_targets/parse_command.rs @@ -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); + } +}); diff --git a/fuzz/fuzz_targets/parse_radio_config.rs b/fuzz/fuzz_targets/parse_radio_config.rs new file mode 100644 index 0000000..a16ee23 --- /dev/null +++ b/fuzz/fuzz_targets/parse_radio_config.rs @@ -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); +});