Files
craft-libs/crates/jpeg/tests/mutation.rs
T
aiongg 3acc132ff0 jpeg: mutation test, fuzz target and CI workflow
tests/mutation.rs runs a seeded mutation pass over the test images and
small SOF9, SOF10 and 12-bit seeds, checking that decode never panics
and stays within small limits. fuzz/ is a cargo-fuzz crate (own
workspace) with a decode target and those seeds. jpeg.yml runs fmt,
clippy, tests and a wasm32 check on Linux. The README gains a testing
section.
2026-10-24 21:00:51 +00:00

126 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deterministic mutation run over the test images and fuzz seeds: decoding corrupted input never panics and
//! stays within the caller's limits.
use craft_jpeg::{Limits, Samples, decode, frame};
/// Inputs and mutation counts. The 227×149 test images take tens of milliseconds each to decode
/// in a debug build; the small fuzz seeds (SOF9 with restarts, 12-bit SOF9, SOF10, 12-bit SOF1)
/// take microseconds and get most of the cases.
const INPUTS: [(&str, usize); 8] = [
("tests/data/testorig.jpg", 12),
("tests/data/testimgari.jpg", 12),
("tests/data/testimgari-huffman.jpg", 12),
("tests/data/testorig12.jpg", 12),
("fuzz/corpus/decode/sof9-restart.jpg", 75),
("fuzz/corpus/decode/sof9-12bit.jpg", 75),
("fuzz/corpus/decode/sof10-progressive.jpg", 75),
("fuzz/corpus/decode/sof1-12bit.jpg", 75),
];
/// Room for the 227×149 fixtures, too little for a mutated frame header that grows them much.
const LIMITS: Limits = Limits { max_pixels: 1 << 16, max_alloc: 1 << 20 };
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
// xorshift64*
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn byte(&mut self) -> u8 {
self.next() as u8
}
}
/// Offset of the first SOFn segment's body, if any.
fn sof_body(b: &[u8]) -> Option<usize> {
(2..b.len().saturating_sub(1))
.find(|&i| b[i] == 0xFF && matches!(b[i + 1], 0xC0..=0xCF) && !matches!(b[i + 1], 0xC4 | 0xC8 | 0xCC))
.map(|i| i + 4)
}
fn mutate(rng: &mut Rng, original: &[u8]) -> Vec<u8> {
let mut b = original.to_vec();
match rng.below(6) {
0 => b.truncate(rng.below(original.len())),
1 => {
for _ in 0..1 + rng.below(8) {
let at = rng.below(b.len());
b[at] ^= 1 << rng.below(8);
}
}
2 => {
for _ in 0..1 + rng.below(4) {
let at = rng.below(b.len());
b[at] = rng.byte();
}
}
3 => {
// Frame header fields: precision, height, width, component count and the
// per-component sampling and table selectors.
if let Some(body) = sof_body(&b) {
for _ in 0..1 + rng.below(3) {
let at = body + rng.below(15);
if let Some(v) = b.get_mut(at) {
*v = rng.byte();
}
}
}
}
4 => {
let at = rng.below(b.len());
let len = rng.below(64).min(b.len() - at);
b.drain(at..at + len);
}
_ => {
// Repeat a span, e.g. a marker segment or part of the entropy-coded data.
let at = rng.below(b.len());
let len = rng.below(256).min(b.len() - at);
let span = b[at..at + len].to_vec();
let to = rng.below(b.len());
b.splice(to..to, span);
}
}
b
}
#[test]
fn mutated_streams_never_panic_and_stay_within_limits() {
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let (mut ok, mut errors) = (0, 0);
for (name, cases) in INPUTS {
let path = format!("{}/{name}", env!("CARGO_MANIFEST_DIR"));
let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
assert!(decode(&original, &LIMITS, None).is_ok(), "{name} must decode within the test limits");
for case in 0..cases {
let bytes = mutate(&mut rng, &original);
let _ = frame(&bytes);
match decode(&bytes, &LIMITS, None) {
Ok(img) => {
let pixels = u64::from(img.width) * u64::from(img.height);
let (len, width) = match &img.samples {
Samples::U8(v) => (v.len() as u64, 1),
Samples::U16(v) => (v.len() as u64, 2),
};
assert!(pixels <= LIMITS.max_pixels, "{name} case {case}: {pixels} pixels");
assert!(len * width <= LIMITS.max_alloc, "{name} case {case}: {} bytes", len * width);
assert_eq!(len, pixels * u64::from(img.components), "{name} case {case}");
ok += 1;
}
Err(_) => errors += 1,
}
}
}
eprintln!("{ok} decoded, {errors} rejected");
// A mutation run that only produces errors would no longer reach the entropy decoders.
assert!(ok > 50 && errors > 50, "{ok} decoded, {errors} rejected");
}