From 7f52a6f3ba6b43cdaa5283c390aaaf280dca7789 Mon Sep 17 00:00:00 2001 From: osobh Date: Sat, 26 Sep 2026 01:16:44 -0500 Subject: [PATCH] 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) --- .../clawhdf5-format/src/filters_bitshuffle.rs | 61 +++++++ crates/clawhdf5-format/src/filters_blosc.rs | 62 ++++++++ crates/clawhdf5-format/src/filters_bzip2.rs | 22 ++- crates/clawhdf5-format/src/filters_lzf.rs | 28 ++++ crates/clawhdf5-format/src/lib.rs | 10 ++ crates/clawhdf5-format/src/test_fuzz.rs | 149 ++++++++++++++++++ 6 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 crates/clawhdf5-format/src/test_fuzz.rs diff --git a/crates/clawhdf5-format/src/filters_bitshuffle.rs b/crates/clawhdf5-format/src/filters_bitshuffle.rs index 83bab22..d20835e 100644 --- a/crates/clawhdf5-format/src/filters_bitshuffle.rs +++ b/crates/clawhdf5-format/src/filters_bitshuffle.rs @@ -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 = (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 = (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); + } + } } diff --git a/crates/clawhdf5-format/src/filters_blosc.rs b/crates/clawhdf5-format/src/filters_blosc.rs index 1bf1d15..625676b 100644 --- a/crates/clawhdf5-format/src/filters_blosc.rs +++ b/crates/clawhdf5-format/src/filters_blosc.rs @@ -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 { + 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 = (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()) + } + }); + } } diff --git a/crates/clawhdf5-format/src/filters_bzip2.rs b/crates/clawhdf5-format/src/filters_bzip2.rs index 2a78074..93e33a3 100644 --- a/crates/clawhdf5-format/src/filters_bzip2.rs +++ b/crates/clawhdf5-format/src/filters_bzip2.rs @@ -37,7 +37,10 @@ pub(crate) fn bzip2_decode(input: &[u8], ctx: &FilterContext<'_>) -> Result= 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 = (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)); + } } diff --git a/crates/clawhdf5-format/src/filters_lzf.rs b/crates/clawhdf5-format/src/filters_lzf.rs index 5657339..acb9763 100644 --- a/crates/clawhdf5-format/src/filters_lzf.rs +++ b/crates/clawhdf5-format/src/filters_lzf.rs @@ -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> = [ + 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) + }); + } } diff --git a/crates/clawhdf5-format/src/lib.rs b/crates/clawhdf5-format/src/lib.rs index 3e31686..905ce84 100644 --- a/crates/clawhdf5-format/src/lib.rs +++ b/crates/clawhdf5-format/src/lib.rs @@ -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; diff --git a/crates/clawhdf5-format/src/test_fuzz.rs b/crates/clawhdf5-format/src/test_fuzz.rs new file mode 100644 index 0000000..41515db --- /dev/null +++ b/crates/clawhdf5-format/src/test_fuzz.rs @@ -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 { + (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 { + 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], + iters: usize, + limit: usize, + mut decode: impl FnMut(&[u8]) -> Result, 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()); + } + } +}