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
@@ -374,4 +374,65 @@ mod tests {
ctx.max_output = 100;
assert!(bitshuffle_decode(&enc, &ctx).is_err());
}
/// Random and mutated chunks, in every mode, and hostile `cd_values`:
/// errors are fine, panics are not.
#[cfg(feature = "bitshuffle")]
#[test]
fn fuzzed_chunks_never_panic() {
use crate::test_fuzz::{Rng, fuzz_decoder};
let data: Vec<u8> = (0..3001u32)
.flat_map(|i| ((i / 7) as u16).to_le_bytes())
.collect();
for (comp, block) in [(0, 0), (0, 16), (2, 0), (2, 64), (3, 0), (3, 1024)] {
let f = ctx_for(vec![0, 4, 2, block, comp]);
let ctx = FilterContext {
filter: &f,
element_size: 2,
max_output: data.len(),
};
let seeds = vec![
bitshuffle_encode(&data, &ctx).unwrap(),
bitshuffle_encode(&data[..34], &ctx).unwrap(),
bitshuffle_encode(&data[..512], &ctx).unwrap(),
];
fuzz_decoder(
0xb5 + comp as u64 * 7 + block as u64,
&seeds,
4_000,
data.len(),
|s| bitshuffle_decode(s, &ctx),
);
}
// Hostile filter parameters on a valid chunk.
let mut rng = Rng::new(0xcd);
let good = ctx_for(vec![0, 4, 2, 0, 2]);
let enc = bitshuffle_encode(
&data,
&FilterContext {
filter: &good,
element_size: 2,
max_output: data.len(),
},
)
.unwrap();
for _ in 0..3_000 {
let cd: Vec<u32> = (0..rng.below(7))
.map(|_| match rng.below(4) {
0 => rng.below(5) as u32,
1 => u32::MAX - rng.below(4) as u32,
2 => 1 << rng.below(32),
_ => rng.next_u64() as u32,
})
.collect();
let f = ctx_for(cd);
let ctx = FilterContext {
filter: &f,
element_size: 2,
max_output: data.len(),
};
let _ = bitshuffle_decode(&enc, &ctx);
let _ = bitshuffle_decode(&data, &ctx);
}
}
}
@@ -622,4 +622,66 @@ mod tests {
assert!(blosc_decompress(&frame, 1000).is_err(), "cbytes={cbytes}");
}
}
/// A BloscLZ frame (our encoder cannot write one): a single block,
/// one stream, no shuffle.
fn blosclz_frame() -> Vec<u8> {
let stream = [2, b'a', b'b', b'c', (6 << 5), 2, 0, b'Z'];
let mut f = vec![2u8, 1, 0, 1];
for v in [12u32, 12, (HEADER + 4 + 4 + stream.len()) as u32] {
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&((HEADER + 4) as u32).to_le_bytes());
f.extend_from_slice(&(stream.len() as u32).to_le_bytes());
f.extend_from_slice(&stream);
f
}
/// Random and mutated frames, every codec and shuffle: errors are fine,
/// panics are not.
#[test]
fn fuzzed_frames_never_panic() {
let limit = 6000;
let data: Vec<u8> = (0..1500u32).flat_map(|i| (i / 5).to_le_bytes()).collect();
let mut seeds = vec![blosclz_frame()];
for codec in [1u32, 3, 4, 5] {
for shuffle in [0u32, 1, 2] {
for (ts, n) in [(4usize, data.len()), (4, 520), (1, 300), (2, 4)] {
let f = desc(vec![2, 2, ts as u32, 0, 5, shuffle, codec]);
let ctx = FilterContext {
filter: &f,
element_size: ts,
max_output: n,
};
seeds.push(blosc_encode(&data[..n], &ctx).unwrap());
}
}
}
// Stored raw.
let f = desc(vec![2, 2, 4, 0, 0, 1, 1]);
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: 64,
};
seeds.push(blosc_encode(&data[..64], &ctx).unwrap());
crate::test_fuzz::fuzz_decoder(0xb10, &seeds, 30_000, limit, |s| {
blosc_decompress(s, limit)
});
}
/// BloscLZ streams on their own, random and mutated.
#[test]
fn fuzzed_blosclz_streams_never_panic() {
let seed = blosclz_frame()[HEADER + 8..].to_vec();
let mut out = [0u8; 64];
crate::test_fuzz::fuzz_decoder(0xb11, &[seed], 30_000, 64, |s| {
let n = blosclz_decompress(s, &mut out);
if n == 0 {
Err(err("malformed"))
} else {
Ok(out[..n].to_vec())
}
});
}
}
+21 -1
View File
@@ -37,7 +37,10 @@ pub(crate) fn bzip2_decode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<
return Ok(out);
}
if out.len() == out.capacity() {
let grow = out.capacity().min(max_capacity - out.capacity()).max(1);
let grow = out
.capacity()
.min(max_capacity.saturating_sub(out.capacity()))
.max(1);
out.try_reserve_exact(grow)
.map_err(|_| err("cannot allocate the output buffer"))?;
} else if dec.total_in() as usize >= input.len()
@@ -109,4 +112,21 @@ mod tests {
assert!(bzip2_decode(&enc, &small).is_err());
}
}
/// Random and mutated streams: errors are fine, panics are not.
#[test]
fn fuzzed_streams_never_panic() {
let f = desc(9);
let data: Vec<u8> = (0..4000u32).flat_map(|i| (i % 91).to_le_bytes()).collect();
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: data.len(),
};
let seeds = vec![
bzip2_encode(&data, &ctx).unwrap(),
bzip2_encode(&data[..40], &ctx).unwrap(),
];
crate::test_fuzz::fuzz_decoder(0xb2, &seeds, 3_000, data.len(), |s| bzip2_decode(s, &ctx));
}
}
+28
View File
@@ -229,4 +229,32 @@ mod tests {
let c = lzf_compress(&[1u8; 100]);
assert!(lzf_decompress(&c, 10, 99).is_err());
}
/// Random and mutated streams: errors are fine, panics are not.
#[test]
fn fuzzed_streams_never_panic() {
let seeds: Vec<Vec<u8>> = [
b"hello hello hello hello".to_vec(),
vec![7u8; 3000],
(0..2000u32).flat_map(|i| (i % 37).to_le_bytes()).collect(),
(0..500u32)
.map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
.collect(),
]
.iter()
.map(|d| lzf_compress(d))
.collect();
for limit in [0usize, 23, 4096, 8000] {
crate::test_fuzz::fuzz_decoder(
0x1f2 + limit as u64,
&seeds[..1],
5_000,
limit.max(23),
|s| lzf_decompress(s, limit, limit.max(23)),
);
}
crate::test_fuzz::fuzz_decoder(0x1f3, &seeds, 20_000, 8000, |s| {
lzf_decompress(s, 8000, 8000)
});
}
}
+10
View File
@@ -117,6 +117,16 @@ pub mod shared_message;
pub mod signature;
pub mod superblock;
pub mod symbol_table;
#[cfg(all(
test,
any(
feature = "lzf",
feature = "bitshuffle",
feature = "bzip2",
feature = "blosc"
)
))]
mod test_fuzz;
pub mod type_builders;
pub mod vds;
pub mod vl_data;
+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());
}
}
}