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.
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
target
|
||||
artifacts
|
||||
coverage
|
||||
Cargo.lock
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||