Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
name: CI

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_BUILD_JOBS: 3
CARGO_TERM_COLOR: always

jobs:
style:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067
with:
toolchain: stable
components: rustfmt,clippy
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y nasm pipx
pipx install clang-format==23.1.1
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Format and lint
run: |
./scripts/stylecheck.sh
git diff --exit-code

tests:
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
asm: ['true', 'false']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: halidecx/wpd-test-data
ref: f8c31341db3ab4400f048a96e7b3736fed303b34
path: wpd-test-data
persist-credentials: false
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067
with:
toolchain: stable
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y pipx ninja-build nasm cmake
pipx install meson==1.12.1
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Tests, C API, parity and checkasm
env:
ASM: ${{ matrix.asm }}
run: |
meson setup build -Denable_asm="$ASM" -Dtrim_dsp=false \
-Dtestdata_tests=true -Dlibwebp=subproject -Dwuffs=disabled
if [ "$ASM" = true ]; then
meson test -C build --list | grep -q checkasm
fi
meson test -C build --print-errorlogs --num-processes 3

scripts:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: halidecx/wpd-test-data
ref: f8c31341db3ab4400f048a96e7b3736fed303b34
path: wpd-test-data
persist-credentials: false
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067
with:
toolchain: stable
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y pipx ninja-build nasm cmake webp
pipx install meson==1.12.1
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build assembly and fallback tools
run: |
meson setup build -Dtrim_dsp=false -Dtestdata_tests=true \
-Dlibwebp=subproject -Dwuffs=disabled
meson compile -C build -j 3 libwebpdec
meson setup build-noasm -Denable_asm=false \
-Dlibwebp=disabled -Dwuffs=disabled
meson compile -C build-noasm -j 3
- name: Existing correctness checks
run: |
./scripts/testdata.sh
./scripts/animcheck.sh
./scripts/threadcheck.sh
./scripts/md5check.sh ./build-noasm/wpd ./build/wpd
./scripts/clicheck.sh ./build-noasm/wpd ./build/wpd
./scripts/rac32.sh --print-errorlogs --num-processes 3

c-sanitizers:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: halidecx/wpd-test-data
ref: f8c31341db3ab4400f048a96e7b3736fed303b34
path: wpd-test-data
persist-credentials: false
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067
with:
toolchain: stable
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y pipx ninja-build nasm cmake
pipx install meson==1.12.1
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: C harnesses and damaged-input smoke
run: |
./scripts/sanitize.sh --print-errorlogs --num-processes 3
./scripts/fuzz.sh 8 wpd-test-data/odd_lossy.webp \
wpd-test-data/odd_a_lossy.webp wpd-test-data/palette_rgb.webp \
wpd-test-data/mixed_codecs.webp

nightly:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
check: [rustsan, tsan, miri, fuzz]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: halidecx/wpd-test-data
ref: f8c31341db3ab4400f048a96e7b3736fed303b34
path: wpd-test-data
persist-credentials: false
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067
with:
toolchain: nightly
components: rust-src,miri
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y pipx ninja-build nasm cmake clang
pipx install meson==1.12.1
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Nightly check
env:
CHECK: ${{ matrix.check }}
run: |
case "$CHECK" in
rustsan)
meson setup build -Dlibwebp=disabled -Dwuffs=disabled
meson compile -C build -j 3
./scripts/rustsan.sh
;;
tsan) ./scripts/tsan.sh ;;
miri) ./scripts/miri.sh --lib container::tests ;;
fuzz)
cargo install cargo-fuzz --version 0.13.2 --locked
./scripts/fuzz-smoke.sh
;;
esac
- name: Save fuzz failures
if: failure() && matrix.check == 'fuzz'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-failures
path: fuzz/artifacts
if-no-files-found: ignore
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Changelog

## Unreleased — 0.2.0

### Build and CI

- Repair the end-to-end fuzz target's decoder options initializer so all four
coverage-guided targets build again.
- Correct the optional Wuffs dependency name in the Meson build.
- Add one GitHub Actions workflow for tests, rustfmt/clippy, fuzz target builds,
seeded smoke runs, and the existing correctness and sanitizer checks.
- Bound fuzz-harness pictures to one megapixel so mutated dimensions fit the
smoke run's memory budget without changing decoder limits.

### libwebp compatibility

- Reject corrupted first-chunk FourCCs instead of silently treating an extended
container as a simple image.
- Add opt-in libwebp still compatibility through Rust, C and `--libwebp-compat`,
retaining strict decoding by default. Match duplicate VP8X chunks, shortened
image chunks, damaged trailing chunks and simple-image final padding.
- Retain the complete compatible still payload until streaming EOF and preserve
animation container validation.
- Match portable libwebp VP8 transforms on non-conforming lossy streams, with
damaged-stream regressions. Save and restore each decoder's strict DSP tables
when toggling the mode; native libwebp CPU output can still differ.
- Accept a final alpha entropy symbol crossing EOF only when compatibility mode
has already produced the complete alpha plane.
- Add a directory comparison script for libwebp 1.6.0 `dwebp` and `anim_dump`,
distinguishing visible differences from transparent RGB.
- Fuzz strict and compatible decoding with public and damaged regression seeds.
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ Test data is maintained at
into the `wpd/` root. `./scripts/testdata.sh` runs end-to-end assembly and
fallback checks.

GitHub Actions runs assembly and fallback tests, checkasm, format/lint checks,
the correctness scripts, C and Rust sanitizers, and a container Miri smoke
check. The corpus revision is pinned in `.github/workflows/ci.yml`. CI compares
the assembly and fallback tools with `md5check.sh` and `clicheck.sh`; comparing
an older release still needs an explicit baseline binary. Timing scripts
(`bench.sh` and `cmpbench.sh`) remain manual because shared CI runners do not
provide stable performance measurements.

`./scripts/fuzz-smoke.sh [seconds-per-target] [corpus-directory]` builds every
fuzz target and runs each for 15 seconds by default. It requires nightly Rust,
Python 3 and cargo-fuzz 0.13.2. It derives container and raw VP8/VP8L seeds from
the test corpus without changing its WebP files, and leaves generated seeds and
failure artifacts under `fuzz/`. Longer fuzzing and the full safe-core Miri
suite (`./scripts/miri.sh`) are useful local checks before releases. The
decoding harnesses bound pictures to 1,048,576 pixels so mutated dimensions fit
the smoke run's memory budget; larger pictures remain in ordinary corpus tests.
This is a harness limit, not a decoder limit.

For libwebp parity testing and benchmarking against alternative WebP decoders,
build the optional third-party test binaries:

Expand Down Expand Up @@ -127,3 +145,35 @@ This project would not be possible without:
- [dav1d](https://www.videolan.org/projects/dav1d.html), the fastest open-source
AV1 decoder
- [rav1d](https://github.com/memorysafety/rav1d), a Rust rewrite of dav1d

## libwebp compatibility

Strict decoding remains the default. `--libwebp-compat` accepts damaged still
containers that libwebp's still decoder accepts, including shortened image
chunks, duplicate VP8X chunks, and damaged trailing chunks. It also selects
libwebp's portable C VP8 transform behaviour for non-conforming lossy streams.
It applies to pixel output and text info. Animation containers retain their
validation.

Set Rust's `Options.libwebp_compat` before opening input. C callers set
`WPDDecoderOptions.libwebp_compat` to 1 and initialize `struct_size` with
`sizeof(WPDDecoderOptions)`; older options structs retain strict decoding.

Compatible still streams produce pixels after `end_of_stream`, retaining
physical input bytes past RIFF's declared end when the codec needs them. Default
still decoding and animation decoding remain incremental. Library callers should
bound incoming bytes; the CLI's `--max-input` and the normal frame and output
limits still apply.

Compare a directory with libwebp 1.6.0's `dwebp` and `anim_dump`:

```sh
python3 scripts/webpcompare.py path/to/corpus --wpd build/wpd \
--libwebp-compat --noasm
```

`--noasm` selects portable C for stills through `dwebp`; `anim_dump` remains
native. Libwebp's CPU-specific code can render malformed lossy streams
differently from its portable code, so this mode does not promise identical
pixels from every native libwebp build on non-conforming input. The comparison
reports visible differences separately from RGB differences under zero alpha.
4 changes: 4 additions & 0 deletions capi/src/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ fn set_options(
|| !flag(options.use_cropping)
|| !flag(options.use_scaling)
|| !flag(options.flip)
|| !flag(options.libwebp_compat)
{
return Err(decoder.fail("invalid decoder options", Error::InvalidArgument));
}
Expand Down Expand Up @@ -299,6 +300,9 @@ entry!(fn wpd_decoder_set_options(decoder, options: *const WPDDecoderOptions) {
local.frame_size_limit =
unsafe { ptr::addr_of!((*options).frame_size_limit).read() };
}
if size >= WPDDecoderOptions::v4() {
local.libwebp_compat = unsafe { ptr::addr_of!((*options).libwebp_compat).read() };
}
reported(set_options(decoder, &local).map(|()| WPD_OK))
});

Expand Down
26 changes: 26 additions & 0 deletions capi/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub struct WPDDecoderOptions {
/// Takes the v2 struct's tail padding, for the reason `reserved` took v1's.
pub reserved2: c_int,
pub frame_size_limit: c_uint,
/// Takes the v3 struct's tail padding.
pub reserved3: c_int,
pub libwebp_compat: c_int,
}

/// What `sizeof` gave the v1 struct: it ended at `flip`, and padded out to the
Expand All @@ -42,6 +45,11 @@ const V2_SIZE: usize =

const _: () = assert!(V2_SIZE < WPDDecoderOptions::v3());

const V3_SIZE: usize =
WPDDecoderOptions::v3().next_multiple_of(mem::align_of::<WPDDecoderOptions>());

const _: () = assert!(V3_SIZE < WPDDecoderOptions::v4());

impl WPDDecoderOptions {
pub(crate) const fn v1() -> usize {
mem::offset_of!(WPDDecoderOptions, flip) + mem::size_of::<c_int>()
Expand All @@ -55,6 +63,10 @@ impl WPDDecoderOptions {
mem::offset_of!(WPDDecoderOptions, frame_size_limit) + mem::size_of::<c_uint>()
}

pub(crate) const fn v4() -> usize {
mem::offset_of!(WPDDecoderOptions, libwebp_compat) + mem::size_of::<c_int>()
}

/// Legacy callers retain serial decoding and serial log callbacks.
pub(crate) fn to_core(&self) -> Options {
Options {
Expand All @@ -79,6 +91,7 @@ impl WPDDecoderOptions {
} else {
0
},
libwebp_compat: self.struct_size >= Self::v4() && self.libwebp_compat != 0,
}
}
}
Expand Down Expand Up @@ -110,4 +123,17 @@ mod tests {
options.struct_size = mem::size_of::<WPDDecoderOptions>();
assert_eq!(options.to_core().frame_size_limit, 64);
}

#[test]
fn older_options_keep_strict_decoding() {
let mut options: WPDDecoderOptions = unsafe { mem::zeroed() };

options.libwebp_compat = 1;
for size in [V1_SIZE, V2_SIZE, V3_SIZE] {
options.struct_size = size;
assert!(!options.to_core().libwebp_compat);
}
options.struct_size = mem::size_of::<WPDDecoderOptions>();
assert!(options.to_core().libwebp_compat);
}
}
13 changes: 13 additions & 0 deletions fuzz/budget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
pub const MAX_PIXELS: u32 = 1 << 20;

pub fn fits(data: &[u8]) -> bool {
// Keep malformed headers in coverage; only a successfully read size can
// exceed the harness budget. Raw codecs have no decoder options limit.
wpd::api::info(data).map_or(true, |info| {
wpd::api::Options {
frame_size_limit: MAX_PIXELS,
..Default::default()
}
.fits(info.width, info.height)
})
}
1 change: 0 additions & 1 deletion fuzz/fuzz_targets/container.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

#![no_main]

use libfuzzer_sys::fuzz_target;
Expand Down
Loading