Skip to content

Commit b0c1dfd

Browse files
authored
Merge pull request #64 from lilith/fuzz-panic-regression
test: regression for derive_burst OOB on bit-flipped input
2 parents f7c14d0 + e6d06c7 commit b0c1dfd

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,9 @@ required-features = ["std"]
9393
name = "end_of_buffer"
9494
required-features = ["alloc"]
9595

96+
[[test]]
97+
name = "fuzz_panic_regression"
98+
required-features = ["alloc"]
99+
96100
[package.metadata.docs.rs]
97101
all-features = true

tests/fuzz_panic_regression.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//! Regression test for an out-of-bounds panic found via single-bit-flip
2+
//! mutation fuzzing of valid LZW streams at `min_code_size = 12`.
3+
//!
4+
//! Before the fixed-array decode table landed
5+
//! (https://github.com/image-rs/weezl/pull/61), a single bit flip in the
6+
//! encoded output of `b"Hello, world"` could steer `Table::derive_burst`
7+
//! into indexing its `depths` vector one past the end, panicking with:
8+
//!
9+
//! index out of bounds: the len is 4098 but the index is 4098
10+
//!
11+
//! The `& MASK` indexing pattern introduced by #61 replaces the OOB with
12+
//! a wrapping read, so corrupt input now produces wrong output or a clean
13+
//! `LzwError::InvalidCode`, but must never panic.
14+
//!
15+
//! Test order is deliberate:
16+
//!
17+
//! 1. First collect the "min invariant": round-trip a set of valid
18+
//! inputs and assert byte-identical output. This is the observable
19+
//! consequence of the reconstruct chain walk's `entry.prev < len`
20+
//! invariant — if any case mismatches, the decoder is broken on
21+
//! valid data and the subsequent corrupt-input check would be
22+
//! meaningless.
23+
//!
24+
//! 2. Only then feed the bit-flipped stream to the decoder. Both `Ok`
25+
//! and `Err` are acceptable; simply returning from `decode()`
26+
//! (rather than unwinding) is the property under test.
27+
28+
use weezl::decode::Decoder;
29+
use weezl::encode::Encoder;
30+
use weezl::BitOrder;
31+
32+
#[test]
33+
fn corrupt_input_does_not_panic_in_derive_burst() {
34+
// --- Phase 1: min invariant — valid round-trips ---------------------
35+
//
36+
// A compact but deliberately diverse set: the exact literal whose
37+
// encoding the fuzz flip targets, a zero run, an all-0xff run, a
38+
// repeating alphabet, and a small byte ramp. Both bit orders, and a
39+
// spread of code sizes including 12 (where the fuzz finding lived).
40+
let inputs: &[(&str, &[u8])] = &[
41+
("hello_world", b"Hello, world"),
42+
("zero_run_4k", &[0u8; 4096]),
43+
("ff_run_4k", &[0xffu8; 4096]),
44+
("alphabet", b"abcdefghijklmnopqrstuvwxyz"),
45+
("ramp_256", &{
46+
let mut r = [0u8; 256];
47+
for (i, b) in r.iter_mut().enumerate() {
48+
*b = i as u8;
49+
}
50+
r
51+
}),
52+
];
53+
54+
for &order in &[BitOrder::Lsb, BitOrder::Msb] {
55+
for &min_code_size in &[8u8, 9, 12] {
56+
for (label, data) in inputs {
57+
let encoded = Encoder::new(order, min_code_size)
58+
.encode(data)
59+
.unwrap_or_else(|e| {
60+
panic!("encode {} {:?}/{}: {:?}", label, order, min_code_size, e)
61+
});
62+
let decoded = Decoder::new(order, min_code_size)
63+
.decode(&encoded)
64+
.unwrap_or_else(|e| {
65+
panic!("decode {} {:?}/{}: {:?}", label, order, min_code_size, e)
66+
});
67+
assert_eq!(
68+
decoded, *data,
69+
"round-trip mismatch for {} at {:?}/{}",
70+
label, order, min_code_size
71+
);
72+
}
73+
}
74+
}
75+
76+
// --- Phase 2: corrupt input must not panic --------------------------
77+
//
78+
// Re-encode the literal the fuzz harness started from, then flip a
79+
// single bit. The flip is byte index 3, bit 3 — the minimal mutation
80+
// that historically panicked `derive_burst`. Both `Ok` and `Err` are
81+
// acceptable outcomes; reaching this line without unwinding is the
82+
// property under test.
83+
let encoded = Encoder::new(BitOrder::Lsb, 12)
84+
.encode(b"Hello, world")
85+
.expect("encode baseline");
86+
assert!(
87+
encoded.len() > 3,
88+
"encoded baseline too short to mutate at byte 3"
89+
);
90+
let mut corrupt = encoded.clone();
91+
corrupt[3] ^= 0x08;
92+
93+
let _ = Decoder::new(BitOrder::Lsb, 12).decode(&corrupt);
94+
}

0 commit comments

Comments
 (0)