Skip to content

Commit 7d5f26d

Browse files
committed
test: add fuzzing (cargo-fuzz targets + stable robustness suite)
libFuzzer targets + a stable 300k-iteration robustness suite; nightly fuzz CI.
1 parent e8937a3 commit 7d5f26d

8 files changed

Lines changed: 242 additions & 0 deletions

File tree

.github/workflows/fuzz.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Fuzz
2+
3+
# Short fuzzing bursts on every push to main, plus a longer weekly run. The
4+
# stable `cargo test` suite (tests/robustness.rs) already smoke-fuzzes the
5+
# decoders; this runs the libFuzzer targets under the nightly toolchain.
6+
on:
7+
push:
8+
branches: [main]
9+
schedule:
10+
- cron: "0 3 * * 1"
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
fuzz:
18+
name: cargo-fuzz ${{ matrix.target }}
19+
runs-on: ubuntu-latest
20+
strategy:
21+
fail-fast: false
22+
matrix:
23+
target: [decode_total, roundtrip, sentinel]
24+
steps:
25+
- uses: actions/checkout@v7
26+
- uses: dtolnay/rust-toolchain@nightly
27+
- name: Install cargo-fuzz
28+
run: cargo install cargo-fuzz --locked
29+
- name: Fuzz ${{ matrix.target }}
30+
run: |
31+
time=$([ "${{ github.event_name }}" = "schedule" ] && echo 300 || echo 60)
32+
cargo fuzz run ${{ matrix.target }} -- -max_total_time=$time

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ documentation = "https://docs.rs/cobs_codec_rs"
1212
readme = "README.md"
1313
keywords = ["cobs", "serial", "framing", "no-std", "embedded"]
1414
categories = ["encoding", "embedded", "no-std"]
15+
# The fuzz crate is a dev-only nested cargo-fuzz workspace; keep it out of the
16+
# published crate.
17+
exclude = ["/fuzz"]
1518

1619
[package.metadata.docs.rs]
1720
# Document every feature, and load KaTeX so the overhead math renders. KaTeX is

fuzz/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/target
2+
/corpus
3+
/artifacts
4+
/coverage
5+
Cargo.lock

fuzz/Cargo.toml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[package]
2+
name = "cobs_codec_rs-fuzz"
3+
version = "0.0.0"
4+
publish = false
5+
edition = "2021"
6+
7+
[package.metadata]
8+
cargo-fuzz = true
9+
10+
[dependencies]
11+
libfuzzer-sys = "0.4"
12+
13+
[dependencies.cobs_codec_rs]
14+
path = ".."
15+
16+
# Detach from the parent crate so `cargo build`/`cargo test` on the library never
17+
# tries to build the fuzz targets (which require the nightly toolchain).
18+
[workspace]
19+
20+
[[bin]]
21+
name = "decode_total"
22+
path = "fuzz_targets/decode_total.rs"
23+
test = false
24+
doc = false
25+
bench = false
26+
27+
[[bin]]
28+
name = "roundtrip"
29+
path = "fuzz_targets/roundtrip.rs"
30+
test = false
31+
doc = false
32+
bench = false
33+
34+
[[bin]]
35+
name = "sentinel"
36+
path = "fuzz_targets/sentinel.rs"
37+
test = false
38+
doc = false
39+
bench = false

fuzz/fuzz_targets/decode_total.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#![no_main]
2+
//! Decoding arbitrary bytes must never panic, and the slice decoder must agree
3+
//! with the in-place decoder on both the accept/reject decision and the output.
4+
5+
use cobs_codec_rs::cobs;
6+
use libfuzzer_sys::fuzz_target;
7+
8+
fuzz_target!(|data: &[u8]| {
9+
let mut out = vec![0u8; data.len() + 1];
10+
let slice = cobs::decode(data, &mut out);
11+
12+
let mut buf = data.to_vec();
13+
let in_place = cobs::decode_in_place(&mut buf);
14+
15+
match (slice, in_place) {
16+
(Ok(m), Ok(n)) => {
17+
assert_eq!(m, n, "length mismatch");
18+
assert_eq!(&out[..m], &buf[..n], "byte mismatch");
19+
}
20+
(Err(_), Err(_)) => {}
21+
(a, b) => panic!("slice/in-place disagree: {a:?} vs {b:?}"),
22+
}
23+
});

fuzz/fuzz_targets/roundtrip.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#![no_main]
2+
//! Any payload must round-trip through encode -> decode for both COBS and
3+
//! COBS/R, and the encoding must never contain a `0x00` byte.
4+
5+
use cobs_codec_rs::{cobs, cobsr};
6+
use libfuzzer_sys::fuzz_target;
7+
8+
fuzz_target!(|data: &[u8]| {
9+
let encoded = cobs::encode_to_vec(data);
10+
assert!(!encoded.contains(&0), "COBS output contains 0x00");
11+
assert_eq!(cobs::decode_to_vec(&encoded).unwrap(), data);
12+
13+
let encoded = cobsr::encode_to_vec(data);
14+
assert!(!encoded.contains(&0), "COBS/R output contains 0x00");
15+
assert_eq!(cobsr::decode_to_vec(&encoded).unwrap(), data);
16+
});

fuzz/fuzz_targets/sentinel.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#![no_main]
2+
//! The configurable-sentinel codec must round-trip any payload, and the
3+
//! encoding must never contain the chosen sentinel byte.
4+
5+
use cobs_codec_rs::cobs;
6+
use libfuzzer_sys::fuzz_target;
7+
8+
fuzz_target!(|data: &[u8]| {
9+
// Use the first byte as the sentinel, the rest as the payload.
10+
let Some((&sentinel, payload)) = data.split_first() else {
11+
return;
12+
};
13+
14+
let encoded = cobs::encode_to_vec_with_sentinel(payload, sentinel);
15+
if sentinel != 0 {
16+
assert!(!encoded.contains(&sentinel), "sentinel byte in output");
17+
}
18+
assert_eq!(
19+
cobs::decode_to_vec_with_sentinel(&encoded, sentinel).unwrap(),
20+
payload,
21+
);
22+
});

tests/robustness.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Robustness / smoke-fuzz suite that runs under plain `cargo test` (no nightly).
2+
// It hammers the decoders with arbitrary, mostly-malformed input to prove they
3+
// are *total* (never panic, only Ok/Err) and that the decode variants agree.
4+
// Deeper coverage lives in `fuzz/` (cargo-fuzz).
5+
#![allow(clippy::pedantic, missing_docs)]
6+
7+
use cobs_codec_rs::{cobs, cobsr};
8+
9+
/// Tiny deterministic xorshift PRNG (keeps the crate dependency-free).
10+
struct Rng(u64);
11+
impl Rng {
12+
fn new(seed: u64) -> Self {
13+
Self(seed)
14+
}
15+
fn next(&mut self) -> u64 {
16+
let mut x = self.0;
17+
x ^= x << 13;
18+
x ^= x >> 7;
19+
x ^= x << 17;
20+
self.0 = x;
21+
x
22+
}
23+
fn byte(&mut self) -> u8 {
24+
(self.next() & 0xFF) as u8
25+
}
26+
// A byte biased toward the values that stress COBS code bytes: 0x00, 0xFF,
27+
// and small counts, with the occasional fully-random byte.
28+
fn stress_byte(&mut self) -> u8 {
29+
match self.next() % 4 {
30+
0 => 0x00,
31+
1 => 0xFF,
32+
2 => (self.next() % 6) as u8,
33+
_ => self.byte(),
34+
}
35+
}
36+
fn stress_bytes(&mut self, len: usize) -> Vec<u8> {
37+
(0..len).map(|_| self.stress_byte()).collect()
38+
}
39+
}
40+
41+
#[test]
42+
fn decode_is_total_on_arbitrary_input() {
43+
// Arbitrary (mostly invalid) bytes must never panic, and basic-COBS decode
44+
// must agree with in-place decode on both the accept/reject decision and the
45+
// decoded bytes.
46+
let mut rng = Rng::new(0xF0FA_D00D_1111_2222);
47+
let mut scratch = vec![0u8; 1024];
48+
for _ in 0..200_000 {
49+
let len = (rng.next() as usize) % 512;
50+
let input = rng.stress_bytes(len);
51+
52+
// COBS/R decode must also never panic.
53+
let _ = cobsr::decode(&input, &mut scratch);
54+
// A custom sentinel decode path must never panic either.
55+
let s = rng.byte();
56+
let _ = cobs::decode_with_sentinel(&input, &mut scratch, s);
57+
58+
// Slice decode vs in-place decode must always agree.
59+
let slice = cobs::decode(&input, &mut scratch);
60+
let mut buf = input.clone();
61+
let in_place = cobs::decode_in_place(&mut buf);
62+
match (slice, in_place) {
63+
(Ok(m), Ok(n)) => {
64+
assert_eq!(m, n, "length mismatch on {input:02x?}");
65+
assert_eq!(&scratch[..m], &buf[..n], "byte mismatch on {input:02x?}");
66+
}
67+
(Err(_), Err(_)) => {}
68+
(a, b) => panic!("slice/in-place disagree on {input:02x?}: {a:?} vs {b:?}"),
69+
}
70+
}
71+
}
72+
73+
#[test]
74+
fn encode_decode_round_trips_arbitrary_payloads() {
75+
// Any payload round-trips through encode -> decode for COBS, COBS/R, and the
76+
// sentinel variants; the encoding never contains the (sentinel) delimiter.
77+
let mut rng = Rng::new(0xC0FF_EE00_3333_4444);
78+
let mut enc = vec![0u8; 8192];
79+
let mut dec = vec![0u8; 8192];
80+
for _ in 0..100_000 {
81+
let len = (rng.next() as usize) % 700;
82+
let src = rng.stress_bytes(len);
83+
84+
let n = cobs::encode(&src, &mut enc);
85+
assert!(!enc[..n].contains(&0), "COBS output contains 0x00");
86+
let m = cobs::decode(&enc[..n], &mut dec).unwrap();
87+
assert_eq!(&dec[..m], &src[..]);
88+
89+
let n = cobsr::encode(&src, &mut enc);
90+
assert!(!enc[..n].contains(&0), "COBS/R output contains 0x00");
91+
let m = cobsr::decode(&enc[..n], &mut dec).unwrap();
92+
assert_eq!(&dec[..m], &src[..]);
93+
94+
let s = rng.byte();
95+
let n = cobs::encode_with_sentinel(&src, &mut enc, s);
96+
if s != 0 {
97+
assert!(!enc[..n].contains(&s), "sentinel byte {s:#04x} in output");
98+
}
99+
let m = cobs::decode_with_sentinel(&enc[..n], &mut dec, s).unwrap();
100+
assert_eq!(&dec[..m], &src[..]);
101+
}
102+
}

0 commit comments

Comments
 (0)