test(format): fuzz Blosc2 decoding for peak allocation
The Blosc2 fuzz tests checked only for panics and the size of the output, so the offsets-chunk amplification passed all 40,000 iterations. tests/blosc2_alloc_bounds.rs now measures peak allocation (a counting global allocator) and asserts it stays within 6x the HDF5 chunk size plus twice the input plus 2 MiB (ruzstd's fixed state) for: - 20,000 mutated fixture frames, decoded with their real chunk size as the limit, with edits aimed at the frame's and chunks' size fields; - 20,000 mutated first chunks on their own; - 5,000 frames built from random header sizes, offsets chunks and B2ND shapes (padding chunk and block shapes, chunks larger than the array, NaN, zero and repeated-value chunks). Against the code before this series every test in the file fails (the fuzz tests at frame iteration 3913, a zstd window, and random frame 289, the offsets chunk); now the worst frame peaks at 0.48 of the bound. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -1246,7 +1246,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Random and mutated frames: errors are fine, panics are not, and no
|
/// Random and mutated frames: errors are fine, panics are not, and no
|
||||||
/// output past the limit.
|
/// output past the limit. Peak allocation is fuzzed separately, with a
|
||||||
|
/// counting allocator, in `tests/blosc2_alloc_bounds.rs`.
|
||||||
#[test]
|
#[test]
|
||||||
fn fuzzed_frames_never_panic() {
|
fn fuzzed_frames_never_panic() {
|
||||||
let limit = 20_000;
|
let limit = 20_000;
|
||||||
|
|||||||
@@ -395,3 +395,247 @@ fn zstd_window_is_bounded_by_the_output() {
|
|||||||
vec![7; 32]
|
vec![7; 32]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// xorshift64*: deterministic, so a failure reproduces.
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn next(&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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() % n.max(1) as u64) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A size that tends to the edges: small, a power of two, huge.
|
||||||
|
fn size(&mut self) -> i64 {
|
||||||
|
match self.below(6) {
|
||||||
|
0 => self.below(64) as i64,
|
||||||
|
1 => 1 << self.below(31),
|
||||||
|
2 => i32::MAX as i64 - self.below(4096) as i64,
|
||||||
|
3 => (1i64 << self.below(62)) + self.below(8) as i64,
|
||||||
|
4 => MAX_BLOCK - self.below(3) as i64,
|
||||||
|
_ => self.next() as i32 as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BLOCK: i64 = 536_866_816;
|
||||||
|
|
||||||
|
/// One to four edits: bytes, or a size field written little-endian (chunk
|
||||||
|
/// headers) or big-endian (frame headers), most often at a header's size
|
||||||
|
/// fields.
|
||||||
|
fn mutate(rng: &mut Rng, seed: &[u8], data_at: usize) -> Vec<u8> {
|
||||||
|
let mut v = seed.to_vec();
|
||||||
|
for _ in 0..1 + rng.below(4) {
|
||||||
|
let len = v.len();
|
||||||
|
if len < 16 {
|
||||||
|
v.push(rng.next() as u8);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match rng.below(8) {
|
||||||
|
0 => {
|
||||||
|
let i = rng.below(len);
|
||||||
|
v[i] ^= 1 << rng.below(8);
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let i = rng.below(len);
|
||||||
|
v[i] = rng.next() as u8;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// Frame header: nbytes, cbytes (i64), typesize, chunksize.
|
||||||
|
let x = rng.size();
|
||||||
|
match rng.below(4) {
|
||||||
|
0 if len >= 38 => v[30..38].copy_from_slice(&x.to_be_bytes()),
|
||||||
|
1 if len >= 47 => v[39..47].copy_from_slice(&x.to_be_bytes()),
|
||||||
|
2 if len >= 52 => v[48..52].copy_from_slice(&(x as i32).to_be_bytes()),
|
||||||
|
_ if len >= 62 => v[58..62].copy_from_slice(&(x as i32).to_be_bytes()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 | 4 => {
|
||||||
|
// A chunk header's nbytes, blocksize or cbytes: in the first
|
||||||
|
// data chunk, or anywhere (the offsets chunk comes last).
|
||||||
|
let at = if rng.below(2) == 0 && data_at + 16 <= len {
|
||||||
|
data_at + 4 * (1 + rng.below(3))
|
||||||
|
} else {
|
||||||
|
rng.below(len - 3)
|
||||||
|
};
|
||||||
|
let x = rng.size() as i32;
|
||||||
|
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
|
||||||
|
}
|
||||||
|
5 => v.truncate(rng.below(len)),
|
||||||
|
6 => {
|
||||||
|
let at = rng.below(len);
|
||||||
|
v[at] = [0x10, 0x20, 0x30, 0x40, 0x05, 0x07, 0x02][rng.below(7)];
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let i = rng.below(len - 3);
|
||||||
|
let x = rng.size() as i32;
|
||||||
|
v[i..i + 4].copy_from_slice(&x.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every fixture frame that decodes, with its decoded size.
|
||||||
|
fn seeds() -> Vec<(Vec<u8>, usize)> {
|
||||||
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/blosc2");
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for e in std::fs::read_dir(dir).unwrap() {
|
||||||
|
let p = e.unwrap().path();
|
||||||
|
if p.extension().is_some_and(|x| x == "b2f")
|
||||||
|
&& let Ok(out) = std::fs::read(p.with_extension("out"))
|
||||||
|
{
|
||||||
|
v.push((std::fs::read(&p).unwrap(), out.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.sort();
|
||||||
|
assert!(v.len() >= 20, "fixtures missing");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_len(frame: &[u8]) -> usize {
|
||||||
|
i32::from_be_bytes(frame[11..15].try_into().unwrap()) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutated fixture frames, decoded with their HDF5 chunk size as the
|
||||||
|
/// limit, and their first chunks on their own: whatever they declare, no
|
||||||
|
/// decode holds more than a small multiple of the output and the input.
|
||||||
|
#[test]
|
||||||
|
fn fuzzed_frames_and_chunks_stay_within_the_allocation_bound() {
|
||||||
|
let _g = lock();
|
||||||
|
let seeds = seeds();
|
||||||
|
let mut rng = Rng(0xb2a1);
|
||||||
|
let mut worst = (0.0f64, String::new());
|
||||||
|
for i in 0..20_000 {
|
||||||
|
let (seed, limit) = &seeds[rng.below(seeds.len())];
|
||||||
|
let f = mutate(&mut rng, seed, header_len(seed));
|
||||||
|
let (r, peak) = peak_during(|| blosc2_decompress(&f, *limit));
|
||||||
|
if let Ok(out) = &r {
|
||||||
|
assert!(out.len() <= *limit, "iteration {i}: output past the limit");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
peak <= bound(*limit, &f),
|
||||||
|
"iteration {i}: peak {peak} bytes for a {limit}-byte chunk from {} bytes ({:?})",
|
||||||
|
f.len(),
|
||||||
|
r.map(|v| v.len())
|
||||||
|
);
|
||||||
|
let ratio = peak as f64 / bound(*limit, &f) as f64;
|
||||||
|
if ratio > worst.0 {
|
||||||
|
worst = (
|
||||||
|
ratio,
|
||||||
|
format!(
|
||||||
|
"frame iteration {i}: peak {peak}, limit {limit}, input {}",
|
||||||
|
f.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in 0..20_000 {
|
||||||
|
let (seed, _) = &seeds[rng.below(seeds.len())];
|
||||||
|
let at = header_len(seed);
|
||||||
|
let chunk = &seed[at..];
|
||||||
|
let c = mutate(&mut rng, chunk, 0);
|
||||||
|
let limit = 1 << 16;
|
||||||
|
let (r, peak) = peak_during(|| blosc2_decompress_chunk(&c, limit));
|
||||||
|
assert!(
|
||||||
|
peak <= bound(limit, &c),
|
||||||
|
"chunk iteration {i}: peak {peak} bytes from {} bytes ({:?})",
|
||||||
|
c.len(),
|
||||||
|
r.map(|v| v.len())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!("worst peak / bound: {:.2} ({})", worst.0, worst.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames built from random header sizes, offsets chunks and B2ND shapes
|
||||||
|
/// (chunk and block shapes that pad, special and repeated-value chunks).
|
||||||
|
#[test]
|
||||||
|
fn random_frames_stay_within_the_allocation_bound() {
|
||||||
|
let _g = lock();
|
||||||
|
let mut rng = Rng(0xb2a2);
|
||||||
|
for i in 0..5_000 {
|
||||||
|
let ts = [1usize, 2, 4, 8][rng.below(4)];
|
||||||
|
let ndim = 1 + rng.below(8);
|
||||||
|
let mut shape = Vec::new();
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
for _ in 0..ndim {
|
||||||
|
let s = 1 + rng.below(if ndim > 3 { 4 } else { 40 });
|
||||||
|
let c = if rng.below(8) == 0 {
|
||||||
|
s * (1 + rng.below(4))
|
||||||
|
} else {
|
||||||
|
1 + rng.below(s)
|
||||||
|
};
|
||||||
|
let b = 1 + rng.below(c);
|
||||||
|
shape.push(s as i64);
|
||||||
|
chunks.push(c as i32);
|
||||||
|
blocks.push(b as i32);
|
||||||
|
}
|
||||||
|
let items: usize = shape.iter().product::<i64>() as usize;
|
||||||
|
let limit = items * ts;
|
||||||
|
let meta = nd_meta(&shape, &chunks, &blocks);
|
||||||
|
let ext: usize = ts
|
||||||
|
* chunks
|
||||||
|
.iter()
|
||||||
|
.zip(&blocks)
|
||||||
|
.map(|(&c, &b)| (c as usize).div_ceil(b as usize) * b as usize)
|
||||||
|
.product::<usize>();
|
||||||
|
let nchunks: usize = shape
|
||||||
|
.iter()
|
||||||
|
.zip(&chunks)
|
||||||
|
.map(|(&s, &c)| (s as usize).div_ceil(c as usize))
|
||||||
|
.product();
|
||||||
|
let block_bytes = ts * blocks.iter().product::<i32>() as usize;
|
||||||
|
let chunksize = if rng.below(4) == 0 {
|
||||||
|
rng.size()
|
||||||
|
} else {
|
||||||
|
ext as i64
|
||||||
|
};
|
||||||
|
let nbytes = if rng.below(4) == 0 {
|
||||||
|
rng.size()
|
||||||
|
} else {
|
||||||
|
(nchunks * ext) as i64
|
||||||
|
};
|
||||||
|
let off_n = if rng.below(4) == 0 {
|
||||||
|
rng.size() as i32
|
||||||
|
} else {
|
||||||
|
8 * nchunks as i32
|
||||||
|
};
|
||||||
|
let (data, off) = match rng.below(3) {
|
||||||
|
0 => (Vec::new(), special_offset(1 + rng.below(2) as u8)),
|
||||||
|
_ => {
|
||||||
|
let bs = if rng.below(4) == 0 {
|
||||||
|
rng.size() as i32
|
||||||
|
} else {
|
||||||
|
block_bytes as i32
|
||||||
|
};
|
||||||
|
let value: Vec<u8> = (0..ts).map(|_| rng.next() as u8).collect();
|
||||||
|
let n = if rng.below(4) == 0 {
|
||||||
|
rng.size() as i32
|
||||||
|
} else {
|
||||||
|
ext as i32
|
||||||
|
};
|
||||||
|
(repeated(&value, n, bs), 0i64.to_le_bytes())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let offsets = repeated(&off, off_n, off_n.clamp(1, 8));
|
||||||
|
let meta = (rng.below(4) != 0).then_some(meta.as_slice());
|
||||||
|
let f = frame(meta, nbytes, ts as i32, chunksize as i32, &data, &offsets);
|
||||||
|
let (r, peak) = peak_during(|| blosc2_decompress(&f, limit));
|
||||||
|
assert!(
|
||||||
|
peak <= bound(limit, &f),
|
||||||
|
"iteration {i}: peak {peak} bytes for a {limit}-byte chunk ({:?}, shape {shape:?} \
|
||||||
|
chunks {chunks:?} blocks {blocks:?})",
|
||||||
|
r.map(|v| v.len())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user