Skip to content
Merged
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
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 {
// Raw codec fuzzers have no frame-size option. Read dimensions without
// allocating pixels, and still exercise headers that fail to parse.
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
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ use wpd_capi::decoder::{wpd_decode_into, WPDOutputBuffer};
use wpd_capi::frame::{WPDFrame, WPDOutputPlane};
use wpd_capi::options::WPDDecoderOptions;

#[path = "../budget.rs"]
mod budget;

const FORMATS: [Format; 16] = [
Format::Yuv420p,
Format::Yuva420p,
Expand Down Expand Up @@ -40,6 +43,7 @@ fn decode_options(data: &[u8]) -> (Options, bool) {
let flags = byte(data, 1);
let subframe = flags & 4 != 0;
let mut options = Options {
frame_size_limit: budget::MAX_PIXELS,
n_threads: [1, 2, 3, 8][usize::from(flags >> 6)],
bypass_filtering: flags & 8 != 0,
no_fancy_upsampling: flags & 16 != 0,
Expand Down Expand Up @@ -123,6 +127,8 @@ fn decode_external(data: &[u8], options: Options) {
flip: i32::from(options.flip),
reserved: 0,
n_threads: options.n_threads,
reserved2: 0,
frame_size_limit: options.frame_size_limit,
};
let mut frame = WPDFrame {
struct_size: mem::size_of::<WPDFrame>(),
Expand Down Expand Up @@ -154,6 +160,9 @@ fn decode_external(data: &[u8], options: Options) {
}

fuzz_target!(|data: &[u8]| {
if !budget::fits(data) {
return;
}
let Some(&first) = data.first() else {
return;
};
Expand Down
7 changes: 6 additions & 1 deletion fuzz/fuzz_targets/vp8.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@

#![no_main]

use libfuzzer_sys::fuzz_target;
use wpd::vp8::Decoder;

#[path = "../budget.rs"]
mod budget;

fuzz_target!(|data: &[u8]| {
if !budget::fits(data) {
return;
}
let mut decoder = Decoder::new();

let _ = decoder.decode_frame(data);
Expand Down
7 changes: 6 additions & 1 deletion fuzz/fuzz_targets/vp8l.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@

#![no_main]

use libfuzzer_sys::fuzz_target;
use wpd::vp8l::{AlphaDst, Decoder, Target};

#[path = "../budget.rs"]
mod budget;

fuzz_target!(|data: &[u8]| {
if data.len() < 2 {
return;
}
let (head, payload) = data.split_at(2);
if !budget::fits(payload) {
return;
}
let mut decoder = Decoder::new();
let alpha_chunk = head[0] & 1 != 0;

Expand Down
36 changes: 36 additions & 0 deletions fuzz/tests/budget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#[path = "../budget.rs"]
mod budget;

#[test]
fn raw_dimensions_are_bounded_without_decoding_pixels() {
for (width, height, fits) in [
(1024, 1024, true),
(1025, 1024, false),
(16384, 14336, false),
] {
let bits = (width - 1) | (height - 1) << 14;
let mut lossless = vec![0x2f];

lossless.extend_from_slice(&u32::to_le_bytes(bits));
assert_eq!(budget::fits(&lossless), fits);
if width < 16384 {
let mut lossy = vec![0x10, 0, 0, 0x9d, 1, 0x2a];

lossy.extend_from_slice(&(width as u16).to_le_bytes());
lossy.extend_from_slice(&(height as u16).to_le_bytes());
assert_eq!(budget::fits(&lossy), fits);
}
}
}

#[test]
fn malformed_headers_remain_in_the_fuzzing_domain() {
for data in [
&b""[..],
&b"RIFF"[..],
&[0x2f, 0, 0][..],
&b"not a header"[..],
] {
assert!(budget::fits(data));
}
}
2 changes: 1 addition & 1 deletion meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ custom_target(
message('imagewebpdec: enabled (build explicitly with target imagewebpdec)')

wuffs_dep = dependency(
'',
'wuffs',
fallback: ['wuffs', 'wuffs_dep'],
required: get_option('wuffs'),
)
Expand Down
5 changes: 5 additions & 0 deletions scripts/ruff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash -eu

find . -type f -name '*.py' -exec ruff format {} +
find . -type f -name '*.py' -exec ruff check --select I --fix {} +
find . -type f -name '*.py' -exec ruff check --fix {} +
209 changes: 209 additions & 0 deletions scripts/webpcompare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Compare WebP acceptance, frame geometry and RGBA pixels with libwebpdec.

Build the pinned reference with `meson compile -C build libwebpdec`.
Directories contribute their .webp files recursively; explicit files may be
extensionless fuzz artifacts. Outputs stay under wpd-test-data. Exit 1 means an
acceptance, geometry, visible-pixel or abnormal-exit difference; RGB beneath
fully transparent pixels is reported separately and does not fail the run.
"""

import argparse
import hashlib
import json
import math
import subprocess
import tempfile
from collections import Counter
from pathlib import Path


def digest(path):
result = hashlib.sha256()
with path.open("rb") as source:
for data in iter(lambda: source.read(65536), b""):
result.update(data)
return result.hexdigest()


def run(command, log, timeout):
with log.open("wb") as stderr:
try:
status = subprocess.run(
command, stdout=subprocess.DEVNULL, stderr=stderr, timeout=timeout
).returncode
except subprocess.TimeoutExpired:
return "timeout", "wall-clock limit exceeded"
with log.open("rb") as stderr:
stderr.seek(max(0, log.stat().st_size - 4096))
return status, stderr.read().decode("utf-8", "replace").strip()


def header(source):
magic = source.readline(4)
if not magic:
return None
if magic != b"P7\n":
raise ValueError("invalid PAM magic")
fields = {}
for _ in range(16):
line = source.readline(256)
if line == b"ENDHDR\n":
break
key, value = line.rstrip(b"\n").split(b" ", 1)
if key in fields:
raise ValueError("duplicate PAM field")
fields[key] = value
else:
raise ValueError("invalid PAM header")
if (
fields[b"DEPTH"] != b"4"
or fields[b"MAXVAL"] != b"255"
or fields[b"TUPLTYPE"] != b"RGB_ALPHA"
):
raise ValueError("expected straight-RGBA PAM")
width, height = int(fields[b"WIDTH"]), int(fields[b"HEIGHT"])
if not 0 < width <= 16384 or not 0 < height <= 16384:
raise ValueError("invalid PAM dimensions")
return width, height


def compare(a_path, b_path):
frames = visible = transparent = 0
with a_path.open("rb") as a, b_path.open("rb") as b:
while True:
a_size, b_size = header(a), header(b)
if a_size != b_size:
return "geometry", frames, visible, transparent
if a_size is None:
if not frames:
raise ValueError("success without frames")
break
frames += 1
remaining = a_size[0] * a_size[1] * 4
while remaining:
size = min(remaining, 65536)
aa, bb = a.read(size), b.read(size)
if len(aa) != size or len(bb) != size:
raise ValueError("truncated PAM pixels")
remaining -= size
if aa == bb:
continue
for i in range(0, size, 4):
if aa[i : i + 4] == bb[i : i + 4]:
continue
if aa[i + 3] == bb[i + 3] == 0:
transparent += 1
else:
visible += 1
outcome = "visible" if visible else "transparent" if transparent else "identical"
return outcome, frames, visible, transparent


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inputs", nargs="+", type=Path)
parser.add_argument("--wpd", type=Path, default=Path("build/wpd"))
parser.add_argument("--libwebp", type=Path, default=Path("build/libwebpdec"))
parser.add_argument("--timeout", type=float, default=60)
parser.add_argument("--work-dir", type=Path, default=Path("wpd-test-data"))
args = parser.parse_args()
if not math.isfinite(args.timeout) or args.timeout <= 0:
parser.error("timeout must be positive")
tools = [args.wpd.resolve(), args.libwebp.resolve()]
if any(not tool.is_file() for tool in tools):
parser.error("build both wpd and libwebpdec first")
files = set()
for path in args.inputs:
if path.is_file():
files.add(path.resolve())
elif path.is_dir():
files.update(p.resolve() for p in path.rglob("*.webp") if p.is_file())
else:
parser.error(f"input does not exist: {path}")
if not files:
parser.error("no input files")
args.work_dir.mkdir(parents=True, exist_ok=True)
counts = Counter()
versions = None
for path in sorted(files):
record = {"path": str(path), "input_sha256": digest(path)}
with tempfile.TemporaryDirectory(
prefix="webpcompare-", dir=args.work_dir
) as td:
work = Path(td)
outputs = [work / "wpd.pam", work / "libwebp.pam"]
results = [
run(
[
str(tool),
"--fmt",
"rgba",
"--muxer",
"pam",
str(path),
str(output),
],
work / f"{i}.log",
args.timeout,
)
for i, (tool, output) in enumerate(zip(tools, outputs))
]
if versions is None:
versions = [
error.splitlines()[0] if error else "" for _, error in results
]
for name, (status, error) in zip(["wpd", "libwebp"], results):
record[f"{name}_status"] = status
if status:
record[f"{name}_error"] = error
statuses = [status for status, _ in results]
if any(status not in (0, 1) for status in statuses):
outcome = "abnormal"
elif statuses == [0, 0]:
try:
outcome, frames, visible, transparent = compare(*outputs)
record.update(
frames=frames,
visible_pixels=visible,
transparent_rgb_pixels=transparent,
wpd_output_sha256=digest(outputs[0]),
libwebp_output_sha256=digest(outputs[1]),
)
except (KeyError, ValueError) as error:
outcome = "abnormal"
record["output_error"] = str(error)
else:
outcome = (
"wpd_only"
if statuses[0] == 0
else "libwebp_only"
if statuses[1] == 0
else "neither"
)
record["outcome"] = outcome
counts[outcome] += 1
print(json.dumps(record), flush=True)
print(
json.dumps(
{
"summary": dict(counts),
"files": len(files),
"tools": [str(tool) for tool in tools],
"versions": versions,
}
)
)
return int(
any(
counts[key]
for key in ("visible", "geometry", "wpd_only", "libwebp_only", "abnormal")
)
)


if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, subprocess.SubprocessError) as error:
raise SystemExit(str(error))
23 changes: 23 additions & 0 deletions src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ impl Scan {
let tag = rl32(buf, at);
let size = rl32(buf, at + 4);

if self.pos == 12 && !matches!(tag, TAG_VP8X | TAG_VP8 | TAG_VP8L) {
log::error("RIFF must start with VP8, VP8L or VP8X");
return Err(Error::InvalidData);
}
if size == u32::MAX {
self.info.truncated = true;
break;
Expand Down Expand Up @@ -751,6 +755,25 @@ mod tests {
assert_eq!(get_info(b"not a webp file at all"), Err(Error::NotWebp));
}

#[test]
fn an_unknown_first_chunk_cannot_hide_the_extended_header() {
let mut payload = chunk(b"VP9X", &[0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0]);

payload.extend(chunk(b"VP8L", &vp8l_header(1, 1, true)));
let file = riff(&payload);

assert_eq!(get_info(&file), Err(Error::InvalidData));
for split in 12..20 {
let mut scan = Scan::new();

assert_eq!(
scan.headers(&file[..split], 0, true, true),
Err(Error::Truncated)
);
assert_eq!(scan.headers(&file, 0, true, true), Err(Error::InvalidData));
}
}

#[test]
fn oversized_vp8x_dimensions_are_refused_before_allocating() {
let payload = chunk(b"VP8X", &[2, 0, 0, 0, 0, 64, 0, 0, 0, 0]);
Expand Down
Loading