diff --git a/.github/workflows/jpeg.yml b/.github/workflows/jpeg.yml new file mode 100644 index 00000000..d10c2598 --- /dev/null +++ b/.github/workflows/jpeg.yml @@ -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 diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 661be5aa..44eaee7c 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -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. diff --git a/crates/jpeg/fuzz/.gitignore b/crates/jpeg/fuzz/.gitignore new file mode 100644 index 00000000..d02230c0 --- /dev/null +++ b/crates/jpeg/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +artifacts +coverage +Cargo.lock diff --git a/crates/jpeg/fuzz/Cargo.toml b/crates/jpeg/fuzz/Cargo.toml new file mode 100644 index 00000000..c4881e66 --- /dev/null +++ b/crates/jpeg/fuzz/Cargo.toml @@ -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 diff --git a/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg new file mode 100644 index 00000000..7c0e6860 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg b/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg new file mode 100644 index 00000000..f29a0a3c Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg new file mode 100644 index 00000000..dd053182 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg new file mode 100644 index 00000000..c165ab46 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg b/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg new file mode 100644 index 00000000..2501c615 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testimgari.jpg b/crates/jpeg/fuzz/corpus/decode/testimgari.jpg new file mode 100644 index 00000000..89664874 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testimgari.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig.jpg b/crates/jpeg/fuzz/corpus/decode/testorig.jpg new file mode 100644 index 00000000..9816a0c6 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig.jpg differ diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12.jpg new file mode 100644 index 00000000..861aff98 Binary files /dev/null and b/crates/jpeg/fuzz/corpus/decode/testorig12.jpg differ diff --git a/crates/jpeg/fuzz/fuzz_targets/decode.rs b/crates/jpeg/fuzz/fuzz_targets/decode.rs new file mode 100644 index 00000000..b03dbfe9 --- /dev/null +++ b/crates/jpeg/fuzz/fuzz_targets/decode.rs @@ -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)); + } +}); diff --git a/crates/jpeg/tests/mutation.rs b/crates/jpeg/tests/mutation.rs new file mode 100644 index 00000000..a23a706b --- /dev/null +++ b/crates/jpeg/tests/mutation.rs @@ -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 { + (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 { + 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"); +}