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.
This commit is contained in:
aiongg committed 2026-10-24 21:00:51 +00:00
1 parent 3be6c3d09d
commit 3acc132ff0
14 files changed
+244

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Shared JPEG
on:
push:
branches: [main]
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
pull_request:
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: shared-jpeg-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 10
name: JPEG lint, tests, WASM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: craft-jpeg
- run: cargo fmt -p craft-jpeg --check
- run: cargo clippy -p craft-jpeg --all-targets --locked -- -D warnings
- run: cargo test -p craft-jpeg --locked
- run: cargo check -p craft-jpeg --locked --target wasm32-unknown-unknown
+32
View File
@@ -95,6 +95,38 @@ SOF11 (lossless) falls in that range and returns `Unsupported`.
XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read.
Color management and encoding also stay in the apps.
## Testing
`cargo test -p craft-jpeg` runs:
- unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to
the coefficients that went in, and Huffman streams are compared against `zune-jpeg`.
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
the derived files were made: `tests/data/SOURCE.md`):
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
overwrites, deleted and repeated spans) over the test images and the fuzz seeds. Decoding must
not panic and must stay within small `Limits`.
`.github/workflows/jpeg.yml` runs fmt, clippy, the tests and a `wasm32-unknown-unknown` check on
Linux.
### Fuzzing
`fuzz/` is a cargo-fuzz crate outside the workspace, with one target, `decode`. Its seeds in
`fuzz/corpus/decode` are the test images plus small SOF9 (with restarts), 12-bit SOF9, SOF10
and 12-bit SOF1 streams. With nightly and `cargo install cargo-fuzz`:
```sh
cd crates/jpeg/fuzz
cargo +nightly fuzz run decode
```
Running it adds the inputs it finds to `fuzz/corpus/decode`; commit only seeds that cover
something new.
## Credits
The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft.
+4
View File
@@ -0,0 +1,4 @@
target
artifacts
coverage
Cargo.lock
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "craft-jpeg-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
craft-jpeg = { path = ".." }
# Its own workspace: cargo-fuzz needs nightly and sanitizer flags, so this crate stays out of the
# craft-libs workspace and its lockfile.
[workspace]
[[bin]]
name = "decode"
path = "fuzz_targets/decode.rs"
test = false
doc = false
bench = false
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+23
View File
@@ -0,0 +1,23 @@
#![no_main]
use craft_jpeg::{Limits, Samples, decode, frame};
use libfuzzer_sys::fuzz_target;
const LIMITS: Limits = Limits { max_pixels: 1 << 20, max_alloc: 64 << 20 };
fuzz_target!(|data: &[u8]| {
let _ = frame(data);
// The input length picks the Adobe transform, so every color path is reached without
// changing the stream.
let adobe = [None, Some(0), Some(1), Some(2)][data.len() % 4];
if let Ok(img) = decode(data, &LIMITS, adobe) {
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);
assert!(len * width <= LIMITS.max_alloc);
assert_eq!(len, pixels * u64::from(img.components));
}
});
+125
View File
@@ -0,0 +1,125 @@
//! 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");
}