test(format): fuzz every plugin-filter decoder for panics

Audited LZF, bitshuffle, bzip2 and Blosc/BloscLZ for arithmetic on
header fields and unchecked slicing. The only live bug was the Blosc
frame-size underflow fixed in the previous commit; bzip2's output-growth
step now uses a saturating subtraction as well (the allocator may hand
back more capacity than asked for).

src/test_fuzz.rs (tests only) feeds each decoder random bytes, truncated
seeds and one-to-four-edit mutations of valid frames, biased towards
edge-case u32 values in size and offset fields, and asserts no panic and
no output over the limit (tests build with overflow checks and debug
assertions). Per decoder: LZF, bzip2, bitshuffle in all six mode/block
settings plus hostile cd_values, Blosc across four codecs, three shuffles,
stored frames and a hand-built BloscLZ frame, and BloscLZ streams alone.
With the previous commit's check removed, fuzzed_frames_never_panic panics
at the same subtraction. A 100x-iteration soak (different seed) found no
other panic.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 01:16:44 -05:00
co-authored by Claude Opus 5.5
parent 9416c58723
commit 7f52a6f3ba
6 changed files with 331 additions and 1 deletions
+149
View File
@@ -0,0 +1,149 @@
//! Mutation fuzzing for the filter decoders (tests only).
//!
//! A decoder fed a random or mutated frame may fail, but must not panic —
//! tests build with overflow checks and debug assertions, so an unchecked
//! subtraction, multiplication or shift on a header field, or an
//! out-of-range slice, fails the test — and must not return more than its
//! output limit.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::error::FormatError;
/// xorshift64*: deterministic, so a failure reproduces.
pub(crate) struct Rng(u64);
impl Rng {
pub(crate) fn new(seed: u64) -> Rng {
Rng(seed.max(1))
}
pub(crate) fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in `0..n` (`n` > 0).
pub(crate) fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
pub(crate) fn bytes(&mut self, n: usize) -> Vec<u8> {
(0..n).map(|_| self.next_u64() as u8).collect()
}
/// A u32 that tends to hit edge cases in size and offset fields.
fn interesting_u32(&mut self, len: usize) -> u32 {
match self.below(10) {
0 => 0,
1 => 1,
2 => self.below(20) as u32,
3 => 15 + self.below(3) as u32,
4 => u32::MAX - self.below(16) as u32,
5 => 1 << self.below(32),
6 => (len as u32)
.wrapping_add(self.below(9) as u32)
.wrapping_sub(4),
7 => i32::MAX as u32,
_ => self.next_u64() as u32,
}
}
}
/// One to four random edits of `seed`.
pub(crate) fn mutate(rng: &mut Rng, seed: &[u8]) -> Vec<u8> {
let mut v = seed.to_vec();
for _ in 0..1 + rng.below(4) {
let len = v.len();
match rng.below(9) {
0 if len > 0 => {
let i = rng.below(len);
v[i] ^= 1 << rng.below(8);
}
1 if len > 0 => {
let i = rng.below(len);
v[i] = rng.next_u64() as u8;
}
2 if len > 0 => {
let i = rng.below(len);
v[i] = [0, 0xff, 0x7f, 0x80, 0x20, 0x1f][rng.below(6)];
}
// A size or offset field: little- or big-endian, anywhere, but
// most often in the first 32 bytes where headers live.
3 | 4 if len >= 4 => {
let span = if rng.below(2) == 0 { len.min(32) } else { len };
let i = rng.below(span - 3);
let x = rng.interesting_u32(len);
let b = if rng.below(2) == 0 {
x.to_le_bytes()
} else {
x.to_be_bytes()
};
v[i..i + 4].copy_from_slice(&b);
}
5 if len > 0 => v.truncate(rng.below(len)),
6 => {
let n = 1 + rng.below(64);
let extra = rng.bytes(n);
v.extend_from_slice(&extra);
}
7 if len > 1 => {
let a = rng.below(len);
let b = a + rng.below(len - a);
let copy = v[a..b].to_vec();
let at = rng.below(len);
v.splice(at..at, copy);
}
_ if len > 0 => {
let i = rng.below(len);
v[i] = v[i].wrapping_add(1 + rng.below(3) as u8);
}
_ => v.push(rng.next_u64() as u8),
}
}
v
}
/// Feed `iters` inputs to `decode`: mostly mutations of `seeds`, some pure
/// noise and some truncated seeds. Asserts only "no panic, output within
/// `limit`".
pub(crate) fn fuzz_decoder(
seed: u64,
seeds: &[Vec<u8>],
iters: usize,
limit: usize,
mut decode: impl FnMut(&[u8]) -> Result<Vec<u8>, FormatError>,
) {
assert!(!seeds.is_empty());
let mut rng = Rng::new(seed);
for s in seeds {
// The seeds themselves must be valid, or the fuzz explores nothing.
decode(s).expect("seed frame must decode");
}
for _ in 0..iters {
let input = match rng.below(16) {
0 => {
let n = rng.below(96);
rng.bytes(n)
}
1 => {
let s = &seeds[rng.below(seeds.len())];
s[..rng.below(s.len() + 1)].to_vec()
}
_ => {
let s = &seeds[rng.below(seeds.len())];
mutate(&mut rng, s)
}
};
if let Ok(out) = decode(&input) {
assert!(out.len() <= limit, "decoded {} > limit {limit}", out.len());
}
}
}