Skip to content
Open
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
62 changes: 32 additions & 30 deletions docs/dev/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,50 +116,52 @@ merging the fix.

## Adding seeds for a new format

Seeds are **not** hand-curated into `src/fuzz/corpora/` per format. At fuzz
time `ci-fuzztest.bash` gathers every example we have for the format from the
directories listed in `FORMAT_SOURCES` (`src/fuzz/populate_corpora.py`) — the
format's own `testsuite/` fixtures (valid images *and* malformed/regression
files) plus the companion image repos — capped only by a per-file size limit
(`MAX_BYTES`, 5 MB), with **no cap on the number of seeds**. It then runs
`oiio_fuzz_image -merge=1` to distill that pile down to the coverage-increasing
subset before the timed run. Feeding in the full, size-diverse set (not just a
few tiny files) is deliberate: mutating structurally rich real images reaches
the deep decode paths where subtle bugs hide.

When a new format plugin is added to OIIO:

1. The format automatically appears in `--list-formats` (no harness changes
needed).
2. The CI lint job (`fuzz-corpus-lint` in `.github/workflows/ci.yml`) will
2. The CI lint job (`fuzz-corpus-lint` in `.github/workflows/fuzz.yml`) will
**fail** until `src/fuzz/corpora/<format>/` exists. This is intentional —
it enforces that every compiled-in format has at least a corpus directory.
3. Create the directory and add 1–5 representative seed files (each ≤ 100 KB):
it enforces that every compiled-in format has at least a corpus directory
(a `.gitkeep` is enough).
3. Wire the format's seed sources into `FORMAT_SOURCES` in
`src/fuzz/populate_corpora.py`: its `testsuite/<format>/src` (and any
dedicated malformed-fixture dirs, e.g. `testsuite/<format>-corrupt/src`)
and the relevant companion-repo path. Regression fixtures added to
`testsuite/` are then picked up automatically — do **not** also copy them
into `src/fuzz/corpora/`.
4. Only when a format has **no** testsuite fixture or companion source (e.g.
`hdr`, `iff`, `sgi`, `jpegxl`), commit a small synthetic seed directly:

```bash
mkdir src/fuzz/corpora/<format>/
# copy or generate seed files
cp path/to/sample.<ext> src/fuzz/corpora/<format>/
# Or generate a small synthetic seed:
oiiotool --create 64x64 3 --ch R,G,B -o src/fuzz/corpora/<format>/seed.<ext>
```

4. Verify the seeds parse cleanly:
5. Verify the gathered seeds parse cleanly (`-runs=0` processes seeds without
mutating):

```bash
OIIO_FUZZ_FORMAT=<format> \
build/src/fuzz/oiio_fuzz_image \
src/fuzz/corpora/<format>/ \
-runs=0
```

5. Commit both the corpus directory and any seed files.

To populate corpus seeds from testsuite and companion image repos run:

```bash
# Populate src/fuzz/corpora/<format>/ (local dev)
python3 src/fuzz/populate_corpora.py [--format <name>]

# Or write directly into a working corpus dir (as CI does):
python3 src/fuzz/populate_corpora.py --format <name> --dest corpus/
python3 src/fuzz/populate_corpora.py --format <format> --dest /tmp/seeds
OIIO_FUZZ_FORMAT=<format> build/src/fuzz/oiio_fuzz_image /tmp/seeds/<format>/ -runs=0
```

The script auto-detects companion repos at `../oiio-images` (sibling of the
repo root, as checked out by the fuzz CI job) or at `build/testsuite/oiio-images`
(fetched by `oiio_setup_test_data` during a regular test run). Only synthetic
seeds (formats with no existing source files, such as `seed.hdr`) are committed
to `src/fuzz/corpora/`; all other seeds are sourced from `testsuite/` or
companion repos at fuzz time.
`populate_corpora.py` auto-detects companion repos at `../oiio-images` (sibling
of the repo root, as checked out by the fuzz CI job) or at
`build/testsuite/oiio-images` (fetched by `oiio_setup_test_data` during a
regular test run). The committed `src/fuzz/corpora/<format>/` dir holds only
synthetic seeds that have no testsuite fixture; everything else is sourced from
`testsuite/` or companion repos at fuzz time.


## How format selection works
Expand Down
34 changes: 31 additions & 3 deletions src/build-scripts/ci-fuzztest.bash
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,20 @@ fi
# Seed the corpus, run the fuzzer, and write a job summary for one format.
#
if [[ -n "${OIIO_FUZZ_FORMAT}" ]]; then
# corpus_dir persists across runs via the CI cache and accumulates the
# coverage-increasing inputs libFuzzer discovers. seed_dir is gathered
# fresh each run and then minimized into corpus_dir below.
corpus_dir="corpus/${OIIO_FUZZ_FORMAT}"
mkdir -p "$corpus_dir"
python3 src/fuzz/populate_corpora.py --format "$OIIO_FUZZ_FORMAT" --dest corpus
cp -rn "src/fuzz/corpora/${OIIO_FUZZ_FORMAT}/"* "$corpus_dir/" 2>/dev/null || true
seed_dir="seeds/${OIIO_FUZZ_FORMAT}"
mkdir -p "$corpus_dir" "$seed_dir"

# Gather every available seed for this format (testsuite fixtures +
# companion image repos, each within populate_corpora's per-file size cap)
# plus any committed synthetic seeds. There is intentionally no cap on the
# number of seeds -- the -merge step below keeps only the coverage-
# increasing subset, so feeding in the full, size-diverse set is the point.
python3 src/fuzz/populate_corpora.py --format "$OIIO_FUZZ_FORMAT" --dest seeds
cp -rn "src/fuzz/corpora/${OIIO_FUZZ_FORMAT}/"* "$seed_dir/" 2>/dev/null || true

if [[ ! -x "$FUZZ_BIN" ]]; then
echo "::error::$FUZZ_BIN not found — was OIIO_BUILD_FUZZ_TARGETS=ON passed to cmake?"
Expand All @@ -56,6 +66,19 @@ if [[ -n "${OIIO_FUZZ_FORMAT}" ]]; then
skipped=1
else
set +e
# Corpus minimization: add only coverage-increasing seeds from seed_dir
# into the (possibly cached) run corpus. A crash/hang here means a
# committed seed or testsuite fixture itself trips the decoder -- that
# is a finding, surfaced via merge_status below.
"$FUZZ_BIN" -merge=1 \
-rss_limit_mb=4096 \
-malloc_limit_mb=2048 \
-timeout=60 \
-detect_leaks=0 \
-artifact_prefix="crash_${OIIO_FUZZ_FORMAT}_" \
"$corpus_dir" "$seed_dir"
merge_status=$?
# Timed, coverage-guided fuzzing over the minimized corpus.
"$FUZZ_BIN" "$corpus_dir" \
-max_total_time="${OIIO_FUZZ_MAX_TIME:-3600}" \
-max_len=16777216 \
Expand All @@ -67,6 +90,11 @@ if [[ -n "${OIIO_FUZZ_FORMAT}" ]]; then
-jobs=$(nproc) -workers=$(nproc)
fuzz_status=$?
set -e
# If minimization tripped a crash but the timed run was clean, still
# fail the job on the merge-time finding.
if [[ "$merge_status" -ne 0 && "$fuzz_status" -eq 0 ]]; then
fuzz_status=$merge_status
fi
fi

crash_count=$(find . -maxdepth 1 -name "crash_${OIIO_FUZZ_FORMAT}_*" | wc -l | tr -d ' ')
Expand Down
34 changes: 25 additions & 9 deletions src/fuzz/populate_corpora.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
# SPDX-License-Identifier: Apache-2.0
# https://github.com/AcademySoftwareFoundation/OpenImageIO
"""
Populate src/fuzz/corpora/ with seed files for each image format.
Gather fuzz seed files for each image format from its configured sources.

Run from the repository root. Companion image repos are expected as siblings
of the repo root (e.g. ../oiio-images, ../fits-images, etc.).
For every format, FORMAT_SOURCES lists the directories to draw seeds from:
the format's own testsuite fixture dirs (valid images and malformed/regression
files) and, when available, the companion image repos (../oiio-images, etc.).
Every file up to the per-file size cap (MAX_BYTES) is gathered -- there is no
cap on seed count. Coverage-based minimization happens downstream in
ci-fuzztest.bash via `oiio_fuzz_image -merge=1`, which keeps only the
coverage-increasing subset, so it is fine (and intended) to feed in everything.

Run from the repository root. In CI this is invoked with --dest to collect
seeds into a scratch dir for minimization; without --dest it writes into
src/fuzz/corpora/<fmt>/ (which otherwise holds only synthetic seeds that have
no testsuite fixture, e.g. hdr/iff/sgi/jpegxl).

Usage:
python src/fuzz/populate_corpora.py [--format <name>] [--dry-run]
python src/fuzz/populate_corpora.py [--format <name>] [--dest DIR] [--dry-run]
"""

import argparse
Expand All @@ -21,8 +31,14 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CORPUS_ROOT = REPO_ROOT / "src" / "fuzz" / "corpora"
MAX_FILES = 5
MAX_BYTES = 100 * 1024 # 100 KB per file
# Per-file size cap only. There is deliberately no cap on the *number* of
# seeds: we gather every example we have (valid + malformed, all sizes up to
# the cap) so the fuzzer can mutate structurally rich inputs, not just tiny
# degenerate ones. Coverage-based minimization (libFuzzer -merge, run by
# ci-fuzztest.bash) then distills them to the covering subset. The size cap is
# purely a throughput guard -- very large seeds slow mutation without adding
# much reach; 5 MB comfortably covers our test images.
MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file

# Companion image repos may live:
# (a) as siblings of the repo root (local dev or fuzz CI checkout)
Expand Down Expand Up @@ -186,7 +202,7 @@ def candidate_files(sources: list) -> list[Path]:

def populate_format(fmt: str, dry_run: bool, dest: Path | None = None) -> tuple[int, list[str]]:
"""
Copy up to MAX_FILES files ≤ MAX_BYTES into dest (default: src/fuzz/corpora/<fmt>/).
Copy every file ≤ MAX_BYTES into dest (default: src/fuzz/corpora/<fmt>/).
Returns (files_copied, missing_reasons).
"""
dest = dest if dest is not None else CORPUS_ROOT / fmt
Expand All @@ -203,7 +219,7 @@ def populate_format(fmt: str, dry_run: bool, dest: Path | None = None) -> tuple[
if not small:
if files:
missing.append(
f"found {len(files)} file(s) but all exceed {MAX_BYTES // 1024} KB"
f"found {len(files)} file(s) but all exceed {MAX_BYTES // (1024 * 1024)} MB"
)
else:
dirs_checked = [resolve(r) for r, _ in sources]
Expand All @@ -216,7 +232,7 @@ def populate_format(fmt: str, dry_run: bool, dest: Path | None = None) -> tuple[
)
return 0, missing

chosen = small[:MAX_FILES]
chosen = small
if not dry_run:
dest.mkdir(parents=True, exist_ok=True)
copied = 0
Expand Down
Loading