diff --git a/.github/workflows/build-steps.yml b/.github/workflows/build-steps.yml index f0c2958aab..f1d5c41ddf 100644 --- a/.github/workflows/build-steps.yml +++ b/.github/workflows/build-steps.yml @@ -96,6 +96,18 @@ on: oiio_python_bindings_backend: type: string default: '' + fuzz_format: + type: string + default: '' + description: "If non-empty, run fuzz tests for this format after the build" + fuzz_max_time: + type: string + default: '3600' + description: "Max seconds for the fuzz run (-max_total_time)" + fuzz_corpus_lint: + type: boolean + default: false + description: "If true, verify every compiled-in format has a corpus directory" secrets: PASSED_GITHUB_TOKEN: required: false @@ -260,6 +272,52 @@ jobs: if: inputs.clang_format == '1' shell: bash run: src/build-scripts/run-clang-format.bash + - name: Check ABI + if: inputs.abi_check != '' + shell: bash + run: | + src/build-scripts/ci-abicheck.bash ./build abi_standard/build libOpenImageIO libOpenImageIO_Util + + - name: Checkout oiio-images for fuzz seeding + if: inputs.fuzz_format != '' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: AcademySoftwareFoundation/OpenImageIO-images + path: oiio-images + fetch-depth: 1 + - name: Restore fuzz corpus cache + if: inputs.fuzz_format != '' + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + key: fuzz-corpus-${{ inputs.fuzz_format }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + fuzz-corpus-${{ inputs.fuzz_format }}-${{ github.ref_name }}- + fuzz-corpus-${{ inputs.fuzz_format }}- + path: corpus/${{ inputs.fuzz_format }} + - name: Fuzz + id: fuzz + if: inputs.fuzz_format != '' || inputs.fuzz_corpus_lint + shell: bash + env: + OIIO_FUZZ_FORMAT: ${{ inputs.fuzz_format }} + OIIO_FUZZ_MAX_TIME: ${{ inputs.fuzz_max_time }} + OIIO_FUZZ_CORPUS_LINT: ${{ inputs.fuzz_corpus_lint }} + run: src/build-scripts/ci-fuzztest.bash + - name: Save fuzz corpus cache + if: always() && inputs.fuzz_format != '' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + key: fuzz-corpus-${{ inputs.fuzz_format }}-${{ github.ref_name }}-${{ github.run_id }} + path: corpus/${{ inputs.fuzz_format }} + - name: Upload fuzz crash artifacts + if: always() && inputs.fuzz_format != '' && steps.fuzz.outcome == 'failure' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: fuzz-crashes-${{ inputs.fuzz_format }}-${{ github.run_id }} + path: crash_${{ inputs.fuzz_format }}_* + if-no-files-found: ignore + retention-days: 30 + - name: Code coverage if: inputs.coverage == '1' run: src/build-scripts/ci-coverage.bash @@ -276,11 +334,6 @@ jobs: # sonar-scanner --define sonar.cfamily.compile-commands="${BUILD_WRAPPER_OUT_DIR}/compile_commands.json" time sonar-scanner --define sonar.host.url="${SONAR_SERVER_URL}" --define sonar.cfamily.compile-commands="$BUILD_WRAPPER_OUT_DIR/compile_commands.json" --define sonar.cfamily.gcov.reportsPath="_coverage" --define sonar.cfamily.threads="$PARALLEL" # Consult https://docs.sonarcloud.io/advanced-setup/ci-based-analysis/sonarscanner-cli/ for more information and options - - name: Check ABI - if: inputs.abi_check != '' - shell: bash - run: | - src/build-scripts/ci-abicheck.bash ./build abi_standard/build libOpenImageIO libOpenImageIO_Util - name: Build Docs if: inputs.build_docs == '1' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbaf56c858..6bfaaca4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: # Linux Tests using ASWF-docker containers # linux-aswf: - if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'macos-only') }} + if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'macos-only') && ! contains(github.ref, 'fuzz-only') }} name: "${{matrix.desc}}" uses: ./.github/workflows/build-steps.yml with: @@ -256,7 +256,7 @@ jobs: # Linux Tests using GHA Ubuntu runners directly # linux-ubuntu: - if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'macos-only') }} + if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'macos-only') && ! contains(github.ref, 'fuzz-only') }} name: "${{matrix.desc}}" uses: ./.github/workflows/build-steps.yml with: @@ -572,7 +572,7 @@ jobs: # MacOS Tests # macos: - if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'linux-only') }} + if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'windows-only') && ! contains(github.ref, 'linux-only') && ! contains(github.ref, 'fuzz-only') }} name: "${{matrix.desc}}" uses: ./.github/workflows/build-steps.yml with: @@ -657,7 +657,7 @@ jobs: # Windows Tests # windows: - if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'linux-only') && ! contains(github.ref, 'macos-only') }} + if: ${{ (github.event.repository.fork == false || github.event_name != 'schedule') && ! contains(github.ref, 'linux-only') && ! contains(github.ref, 'macos-only') && ! contains(github.ref, 'fuzz-only') }} name: "${{matrix.desc}}" uses: ./.github/workflows/build-steps.yml with: diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000000..998f5daa25 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,123 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +name: Fuzz + +on: + schedule: + # Nightly at 10:00 UTC — main fork only (filtered via job condition below) + - cron: "0 10 * * *" + push: + # Run on any branch whose name contains "fuzz" + branches: + - '*fuzz*' + paths: + - 'src/**' + - '.github/workflows/fuzz.yml' + - '.github/workflows/build-steps.yml' + workflow_dispatch: + inputs: + format: + description: "Format name to fuzz (leave empty for all)" + required: false + default: "" + +permissions: read-all + +# Allow subsequent pushes to the same PR or REF to cancel any previous jobs. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + + +jobs: + fuzz: + name: "Fuzz ${{ matrix.format }} (tier ${{ matrix.tier }})" + # Scheduled runs only on the canonical fork; push runs anywhere (branch filter above ensures "fuzz" substring) + if: github.event_name != 'schedule' || github.repository == 'AcademySoftwareFoundation/OpenImageIO' + strategy: + fail-fast: false + matrix: + include: + # Tier 1: complex/high-risk formats — 60 min fuzz window each + - { format: openexr, tier: 1, max_total_time: 3600 } + - { format: tiff, tier: 1, max_total_time: 3600 } + - { format: jpeg, tier: 1, max_total_time: 3600 } + - { format: png, tier: 1, max_total_time: 3600 } + - { format: dpx, tier: 1, max_total_time: 3600 } + - { format: psd, tier: 1, max_total_time: 3600 } + - { format: heif, tier: 1, max_total_time: 3600 } + - { format: jpegxl, tier: 1, max_total_time: 3600 } + - { format: jpeg2000, tier: 1, max_total_time: 3600 } + - { format: raw, tier: 1, max_total_time: 3600 } + # Tier 2: simpler formats — 30 min fuzz window each + - { format: bmp, tier: 2, max_total_time: 1800 } + - { format: cineon, tier: 2, max_total_time: 1800 } + - { format: dds, tier: 2, max_total_time: 1800 } + - { format: dicom, tier: 2, max_total_time: 1800 } + - { format: fits, tier: 2, max_total_time: 1800 } + - { format: gif, tier: 2, max_total_time: 1800 } + - { format: hdr, tier: 2, max_total_time: 1800 } + - { format: ico, tier: 2, max_total_time: 1800 } + - { format: iff, tier: 2, max_total_time: 1800 } + - { format: pnm, tier: 2, max_total_time: 1800 } + - { format: rla, tier: 2, max_total_time: 1800 } + - { format: sgi, tier: 2, max_total_time: 1800 } + - { format: softimage, tier: 2, max_total_time: 1800 } + - { format: targa, tier: 2, max_total_time: 1800 } + - { format: ffmpeg, tier: 2, max_total_time: 1800 } + - { format: webp, tier: 2, max_total_time: 1800 } + - { format: zfile, tier: 2, max_total_time: 1800 } + - { format: openvdb, tier: 2, max_total_time: 1800 } + - { format: ptex, tier: 2, max_total_time: 1800 } + uses: ./.github/workflows/build-steps.yml + with: + runner: ubuntu-latest + nametag: fuzz-linux-clang + container: aswf/ci-oiio:2027 + cc_compiler: clang + cxx_compiler: clang++ + build_type: Release + cxx_std: 20 + python_ver: "3.13" + simd: "avx2,f16c" + pybind11_ver: v3.0.0 + skip_tests: '1' + fuzz_format: ${{ matrix.format }} + fuzz_max_time: ${{ matrix.max_total_time }} + setenvs: export SANITIZE=address,undefined + USE_PYTHON=0 + OIIO_BUILD_FUZZ_TARGETS=ON + INSTALL_DOCS=OFF + OIIO_BUILD_TOOLS=OFF + OIIO_BUILD_TESTS=OFF + OpenImageIO_BUILD_LOCAL_DEPS="TIFF,libdeflate" + + # + # Verify every format reported by oiio_fuzz_image --list-formats has a corpus + # directory in src/fuzz/corpora/. Fails with a clear message when a new + # format plugin is added without a corresponding corpus directory. + # + fuzz-corpus-lint: + name: "Fuzz corpus coverage lint" + if: github.event_name != 'schedule' || github.repository == 'AcademySoftwareFoundation/OpenImageIO' + uses: ./.github/workflows/build-steps.yml + with: + runner: ubuntu-latest + nametag: fuzz-corpus-lint + container: aswf/ci-oiio:2027 + cc_compiler: clang + cxx_compiler: clang++ + build_type: Release + cxx_std: 20 + python_ver: "3.13" + simd: "avx2,f16c" + pybind11_ver: v3.0.0 + skip_tests: '1' + fuzz_corpus_lint: true + setenvs: export USE_PYTHON=0 + OIIO_BUILD_FUZZ_TARGETS=ON + INSTALL_DOCS=OFF + OIIO_BUILD_TOOLS=OFF + OIIO_BUILD_TESTS=OFF diff --git a/AGENTS.md b/AGENTS.md index 159302aa56..1ffd19c123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,13 @@ http://github.com/AcademySoftwareFoundation/OpenImageIO - `src/.imageio/` : Per-format ImageInput/ImageOutput plugins. - `src/python/` : Python bindings using pybind11 - `src/` : CLI tools (oiiotool, iinfo, iconvert, maketx, iv) +- `src/fuzz/` : libFuzzer harness (`oiio_fuzz_image`, gated by + `OIIO_BUILD_FUZZ_TARGETS`) and per-format seed corpora; see + `docs/dev/fuzzing.md` - `testsuite/` : End-to-end/regression tests + reference outputs - `src/cmake/`, `CMakeLists.txt` : Build system - `.github/workflows/ci.yml` : GitHub Actions CI +- `.github/workflows/fuzz.yml` : Nightly/on-demand fuzz CI (per-format matrix) - `src/build-scripts` Helper scripts used for build & CI - `src/doc/` : User manual source (+ Doxygen comments in the public headers) - `docs/dev/` : Developer documentation diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d863678c5..df2720a9ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -337,6 +337,11 @@ if (NUKE_FOUND) add_subdirectory (src/nuke/txWriter) endif () +set_option (OIIO_BUILD_FUZZ_TARGETS "Build libFuzzer fuzz targets (requires clang)" OFF) +if (OIIO_BUILD_FUZZ_TARGETS) + add_subdirectory (src/fuzz) +endif () + # install pkgconfig file if (NOT MSVC) configure_file(src/build-scripts/OpenImageIO.pc.in "${CMAKE_BINARY_DIR}/OpenImageIO.pc" @ONLY) diff --git a/docs/dev/Architecture.md b/docs/dev/Architecture.md index 72f81d8907..602c5a5f64 100644 --- a/docs/dev/Architecture.md +++ b/docs/dev/Architecture.md @@ -165,6 +165,18 @@ an image file), `iconvert` (which converts between different file formats), and `maketx` (which generates tiled mipmaps in an efficient arrangement for texture mapping in a renderer). +## Fuzzing + +`src/fuzz/` holds a libFuzzer-based harness (`oiio_fuzz_image`) that exercises +every compiled-in format reader's `ImageInput` implementation against +malformed input, dispatching to a single format per process at runtime rather +than building one binary per format. It shares its per-input read loop +(`OIIO::pvt::test_read_image()` / `test_read_all_images()`, also reachable via +`oiiotool --testread`) with the rest of the library rather than duplicating +read logic in the harness. Gated behind the `OIIO_BUILD_FUZZ_TARGETS` CMake +option (off by default) and requires clang. See `docs/dev/fuzzing.md` for the +developer workflow. + ## Language Bindings The main APIs, and the underlying implementation, are in C++ (currently diff --git a/docs/dev/fuzzing.md b/docs/dev/fuzzing.md new file mode 100644 index 0000000000..e3ea222c86 --- /dev/null +++ b/docs/dev/fuzzing.md @@ -0,0 +1,214 @@ + + + +# Fuzzing OpenImageIO + +OpenImageIO uses [libFuzzer](https://llvm.org/docs/LibFuzzer.html) to +exercise image format readers against malformed input. A single binary, +`oiio_fuzz_image`, covers all compiled-in formats by dispatching at runtime +based on the `OIIO_FUZZ_FORMAT` environment variable. Nightly CI fuzzes every +format in parallel; crash reproducers are uploaded as GitHub Actions +artifacts. + + +## Prerequisites + +- **clang ≥ 14** with libFuzzer support (`-fsanitize=fuzzer`). GCC does not + support libFuzzer and will be rejected by CMake with a clear error. + - On **macOS**, Apple's clang (from Xcode / Command Line Tools) does *not* + ship the libFuzzer runtime, so `-fsanitize=fuzzer` fails to link. Install + upstream LLVM (`brew install llvm`) and point CMake at it: + `-DCMAKE_C_COMPILER=$(brew --prefix llvm)/bin/clang + -DCMAKE_CXX_COMPILER=$(brew --prefix llvm)/bin/clang++`. With AppleClang, + CMake warns and skips the fuzz targets (the rest of OIIO still builds). +- **CMake ≥ 3.18** +- All optional format libraries you want fuzz coverage for (the same ones + used in a normal OIIO build). The `aswf/ci-oiio:2027` container has all + of them pre-installed. + + +## Building the fuzz target + +```bash +cmake -B build -S . \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_BUILD_TYPE=Release \ + -DOIIO_BUILD_FUZZ_TARGETS=ON \ + -DSANITIZE=address,undefined + +cmake --build build --target oiio_fuzz_image -j$(nproc) +``` + +`OIIO_BUILD_FUZZ_TARGETS=ON` is the only new flag. `SANITIZE=address,undefined` +instruments the full OpenImageIO library for maximum bug detection — omitting +it produces a valid binary but with much weaker coverage. + +By default the local fuzzing engine is `-fsanitize=fuzzer`. On OSS-Fuzz the +`LIB_FUZZING_ENGINE` environment variable is set to the engine's `.a` path and +is picked up automatically by `src/fuzz/CMakeLists.txt`. + + +## Listing supported formats + +```bash +build/src/fuzz/oiio_fuzz_image --list-formats +``` + +Prints one format name per line for every format compiled into this build. +This list drives the CI lint check: the build fails if any format returned +here lacks a directory under `src/fuzz/corpora/`. + + +## Running the fuzzer locally + +```bash +# Fuzz JPEG for 60 seconds using the seed corpus as a starting point: +OIIO_FUZZ_FORMAT=jpeg \ + build/src/fuzz/oiio_fuzz_image \ + src/fuzz/corpora/jpeg/ \ + -max_total_time=60 + +# Useful flags: +# -timeout=60 kill any single input that takes longer than 60 s +# -jobs=4 run 4 worker processes in parallel +# -runs=0 process seeds only, no mutation (useful to verify seeds) +# -max_len=65536 cap input size to 64 KB (speeds up throughput) +``` + +The fuzzer writes new interesting inputs into the corpus directory and any +crash reproducers as `crash_` files in the current directory. + + +## Reproducing a CI crash + +1. Download the crash artifact from the failed GitHub Actions run + (named `fuzz-crashes--`). +2. Extract the `crash__` file. +3. Run: + +```bash +OIIO_FUZZ_FORMAT= \ + build/src/fuzz/oiio_fuzz_image \ + crash__ +``` + +The AddressSanitizer or UBSan report appears on stderr. The key fields are +the error type (e.g. `heap-buffer-overflow`), the stack trace at the point of +the violation, and the allocation site. + + +## Minimizing a crash + +Reduce a crash reproducer to the smallest input that still triggers it: + +```bash +OIIO_FUZZ_FORMAT= \ + build/src/fuzz/oiio_fuzz_image \ + -minimize_crash=1 \ + -exact_artifact_path=min_crash \ + crash__ +``` + +Commit `min_crash` to `testsuite/fuzz-/` as a regression test before +merging the fix. + + +## Adding seeds for a new format + +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 + **fail** until `src/fuzz/corpora//` 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): + +```bash +mkdir src/fuzz/corpora// +# copy or generate seed files +cp path/to/sample. src/fuzz/corpora// +# Or generate a small synthetic seed: +oiiotool --create 64x64 3 --ch R,G,B -o src/fuzz/corpora//seed. +``` + +4. Verify the seeds parse cleanly: + +```bash +OIIO_FUZZ_FORMAT= \ + build/src/fuzz/oiio_fuzz_image \ + src/fuzz/corpora// \ + -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// (local dev) +python3 src/fuzz/populate_corpora.py [--format ] + +# Or write directly into a working corpus dir (as CI does): +python3 src/fuzz/populate_corpora.py --format --dest corpus/ +``` + +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. + + +## How format selection works + +`oiio_fuzz_image` resolves the active format in priority order: + +1. `OIIO_FUZZ_FORMAT` environment variable — used by CI matrix jobs. +2. `basename(argv[0])` stripped of `fuzz_` prefix — used by OSS-Fuzz + per-format symlinks (`fuzz_jpeg → oiio_fuzz_image`). +3. `--format=` command-line argument. +4. None set → the binary prints available formats and exits with an error. + + +## Memory limits and false-positive OOMs + +libFuzzer enforces an `rss_limit_mb` (set in +`src/fuzz/oiio_fuzz_image.options`, currently 4096 MB) and reports a crash if +the process exceeds it. OIIO's own decode-bomb guards default to much larger +values (`limits:imagesize_MB` = 32768, `limits:resolution` = 1048576), so a +corrupt header claiming a multi-GB image would trip libFuzzer's OOM kill before +OIIO's guard rejects it — a false positive. + +To keep the two budgets commensurate, `OIIO_FUZZ_INIT` (in +`src/fuzz/fuzz_image.cpp`) lowers OIIO's limits well under the RSS budget: +`limits:imagesize_MB` to 2048 (half the budget, leaving headroom for decode +scratch and process overhead) and `limits:resolution` to 65536. With these in +place OIIO rejects oversized headers through its normal error path instead of +allocating into an OOM kill. If you change `rss_limit_mb`, update these limits +to match. + + +## CI overview + +- **Nightly fuzz** (`.github/workflows/fuzz.yml`): 29-format parallel matrix, + Tier 1 formats run for 1 hour, Tier 2 for 30 minutes. Evolved corpus is + cached per format per branch (see `data-model.md` EvolvedCorpus for the + exact cache-key scheme). +- **Corpus lint** (`fuzz-corpus-lint` job in `.github/workflows/fuzz.yml`): + builds `oiio_fuzz_image` on every PR, runs `--list-formats`, and fails if + any compiled-in format lacks a `src/fuzz/corpora//` directory. + + +## OSS-Fuzz + +Not yet onboarded. No `ossfuzz/` directory exists in the repo yet — this is +deferred future work. The harness already supports what OSS-Fuzz would need +(`$LIB_FUZZING_ENGINE` linkage, `argv[0]`-based per-format dispatch for +`fuzz_` symlinks, `--list-formats`), so onboarding remains a small, +additive step: a `build.sh` would loop over `--list-formats` output to create +per-format symlinks and seed corpus zips automatically, so new formats would +be covered without any `build.sh` changes. See +`specs/001-image-fuzzing/research.md §8` for the intended design. diff --git a/docs/dev/specs/001-image-fuzzing/checklists/implementation.md b/docs/dev/specs/001-image-fuzzing/checklists/implementation.md new file mode 100644 index 0000000000..40b13f3e71 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/checklists/implementation.md @@ -0,0 +1,102 @@ +# Implementation Readiness Checklist: Image Format Fuzzing Infrastructure + +**Purpose**: Validate that requirements are specific enough to implement without ambiguity — one developer reading spec + plan should reach the same design as another +**Created**: 2026-06-24 +**Reconciled against as-built**: 2026-07-12 +**Feature**: [spec.md](../spec.md) | [plan.md](../plan.md) +**Audience**: Implementation author, PR reviewer +**Depth**: Standard + +**Note**: This checklist was written before implementation, when the design was +still "one harness binary per format." The feature shipped with a different +design (single dynamic-dispatch binary), so several items below are answered +by as-built annotations in `spec.md`/`plan.md`/`data-model.md`/`research.md`/ +`contracts/harness-contract.md` rather than by the original pre-implementation +draft text the item cites. Checked items are resolved as of the reconciliation +date above; unchecked items are genuine open gaps (some newly discovered during +this pass — see notes). + +--- + +## Harness Interface Contract + +- [x] CHK001 - Is the exact `LLVMFuzzerTestOneInput` signature (parameter types and return type) specified, or does it reference a standard that defines it unambiguously? [Clarity, Spec §FR-009] — Resolved: `contracts/harness-contract.md` §`LLVMFuzzerTestOneInput Signature` gives the exact `extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size)` signature and return-value rules. +- [x] CHK002 - Is the per-call timeout mechanism specified — which API sets the timeout, what units, and what value? [Clarity, Spec §FR-010, Gap] — Resolved: FR-010's as-built note + `data-model.md` `GHAFuzzWorkflow.per_input_timeout` specify libFuzzer's `-timeout=60` (seconds), enforced externally rather than in harness source. +- [x] CHK003 - Is the `IOMemReader` contract defined — specifically, does it specify that it wraps `Filesystem::IOMemReader` and how raw bytes are passed to `ImageInput::open()`? [Clarity, Plan §Project Structure] — Resolved: `contracts/harness-contract.md` §OIIO API Usage, "Standard formats" bullet. *(Originally asked about `fuzz_utils.h`; that header was folded into `fuzz_image.cpp` — see T063 in `tasks.md` — so the contract now lives there instead.)* +- [x] CHK004 - Are requirements defined for what `LLVMFuzzerTestOneInput` MUST return on graceful failure (non-crash sanitizer finding vs. ordinary open failure)? [Completeness, Spec §FR-003, Gap] — Resolved: `contracts/harness-contract.md` §Error Handling: ordinary failure (`open()` returns null) → return 0, not a test failure; sanitizer findings are caught by the runtime, not via a return value at all. +- [x] CHK005 - Are requirements specified for minimum input size handling — e.g., must a zero-byte input be handled without crashing before any format detection occurs? [Clarity, Spec §Edge Cases] — Resolved: `spec.md` User Story 2, Acceptance Scenario 3 ("fed a zero-byte input or random bytes... returns gracefully"). +- [x] CHK006 - Is the expected behavior when `ImageInput::open()` returns false (legitimate format rejection) vs. when it crashes specified? Both should result in the same harness behavior, but is this stated? [Clarity, Gap] — Resolved: `contracts/harness-contract.md` §Error Handling states the null-open case explicitly; FR-003 states any sanitizer finding MUST cause non-zero exit. The two paths are distinct and both specified. + +--- + +## CMake Build Integration + +- [x] CHK007 - Is the exact CMake option name (`OIIO_BUILD_FUZZ_TARGETS`) and default value (`OFF`) specified, or left to implementation discretion? [Clarity, Spec §FR-007] — Resolved: FR-007 names the option and default explicitly; matches `src/fuzz/CMakeLists.txt` gate in the root `CMakeLists.txt`. +- [x] CHK008 - Are requirements specified for which compiler flags the fuzz build MUST add (e.g., `-fsanitize=address,undefined,fuzzer-no-link`), and whether these conflict with any existing `SANITIZE` mechanism? [Completeness, Plan §Technical Context, Gap] — Resolved: `contracts/harness-contract.md` §Linking gives exact compile/link flags; `plan.md` Summary states the fuzz binary layers its own `-fsanitize=address,undefined` independent of the global `SANITIZE` variable. +- [x] CHK009 - Is the `add_fuzz_target()` CMake macro's interface defined — what arguments it takes, what targets it produces, and how it handles conditional compilation for optional-library formats? [Clarity, Plan §Project Structure, Gap] — Resolved by supersession: `research.md` §5 and `plan.md`'s `all_fuzz_targets` note record that this macro was never built — the single dynamic-dispatch design replaced per-format targets entirely, so no macro interface is needed. +- [x] CHK010 - Are requirements specified for how the fuzz build interacts with existing sanitizer CI jobs — e.g., must it be a separate build type, or can it share the SANITIZE build? [Completeness, Gap] — Resolved: `fuzz.yml` is a fully separate GHA workflow/job (not sharing the main CI sanitizer job); `plan.md` Summary + `data-model.md` `FuzzBuildConfig` document the independent `SANITIZE`/fuzz-flag relationship. +- [x] CHK011 - Is the requirement for `$LIB_FUZZING_ENGINE` linking specified in a way that describes how `CMakeLists.txt` consumes the environment variable (e.g., as a `target_link_libraries` argument)? [Clarity, Spec §FR-009, Gap] — Resolved: `src/fuzz/CMakeLists.txt` reads `ENV{LIB_FUZZING_ENGINE}` into `OIIO_FUZZING_ENGINE`, defaulting to `-fsanitize=fuzzer`, consumed via `target_link_options`; documented in `contracts/harness-contract.md` §Linking. + +--- + +## GHA Workflow Requirements + +- [x] CHK012 - Are the exact Tier 1 and Tier 2 format lists fully enumerated in the spec, or is Tier 2 defined only by example ("BMP, ICO, HDR, PNM, GIF, SGI, and similar")? [Clarity, Spec §FR-005a, Ambiguity] — Resolved: FR-005a enumerates all 10 Tier 1 and 19 Tier 2 formats by name, matching `.github/workflows/fuzz.yml`'s matrix exactly. +- [x] CHK013 - Are the specific job duration values for Tier 1 and Tier 2 specified (e.g., "Tier 1: 45 minutes, Tier 2: 15 minutes"), or is only the relative distinction stated? [Clarity, Spec §FR-005a, Gap] — Resolved: FR-005a states 1 hour/job (Tier 1) and 30 minutes/job (Tier 2), matching the matrix's `max_total_time` values. +- [x] CHK014 - Is the GHA cache key schema specified precisely enough to implement — format: `fuzz-corpus--`, but which sha (branch head, workflow run sha, or something else)? [Clarity, Spec §FR-008a, Ambiguity] — Resolved: `data-model.md` `EvolvedCorpus.cache_key` gives the exact as-built key (`fuzz-corpus---`) and restore-key prefix fallback chain. +- [ ] CHK015 - Are requirements defined for the teardown margin ("5 minutes") — specifically, how the harness or workflow enforces it (e.g., via libFuzzer's `-max_total_time` flag)? [Clarity, Plan §Performance Goals, Gap] — Partially resolved: `plan.md` Performance Goals now notes no separate margin is enforced (both tiers sit far under GHA's default 6h job timeout by construction). Left unchecked because this reframes the requirement rather than answering the original question as posed — there is no "5 minute" value anywhere in the as-built system to point to. +- [x] CHK016 - Is the artifact upload trigger condition specified — does upload occur on any non-zero exit, or only when the output directory contains specific crash artifact files? [Clarity, Spec §FR-006, Gap] — Resolved: upload is gated on `steps.fuzz.outcome == 'failure'` (any non-zero exit of the fuzz step), documented in `tasks.md` T040 and `build-steps.yml`. +- [x] CHK017 - Are requirements defined for the job summary output — what information MUST appear in the GHA job summary when a format crashes? [Completeness, Spec §FR-006, Gap] — Resolved: `src/build-scripts/ci-fuzztest.bash` writes Format/Status/Detail/crash-count fields to `$GITHUB_STEP_SUMMARY`; documented in `tasks.md` T040. +- [ ] CHK018 - Is the `workflow_dispatch` input schema fully specified — what are the valid values for the format input, and what happens when an invalid format name is provided? [Clarity, Spec Scenario 4, Gap] — **Newly discovered gap (2026-07-12)**: `fuzz.yml`'s `workflow_dispatch.inputs.format` is declared but never read anywhere in the workflow (no `github.event.inputs.format` reference) — the matrix always runs every format regardless of what's passed. Spec §User Story 1 Acceptance Scenario 4 ("a specific format name is passed as input, Then only that format's target runs") is currently unimplemented. Left unchecked; this is a real implementation gap, not just a documentation gap. +- [ ] CHK019 - Is the requirement for "automatically including new format targets" (Spec Scenario 3) specified as a failing check or a dynamic matrix generation mechanism? These have very different implementation costs. [Clarity, Spec Scenario 3, Ambiguity] — **Gap clarified, not resolved (2026-07-12)**: harness *coverage* is automatic (runtime format discovery), and the corpus-lint failing check catches a missing `src/fuzz/corpora//` directory — but `fuzz.yml`'s matrix is a static hand-maintained list with no check that a new/corpus-complete format is actually added to it. A new format could pass corpus-lint yet never be nightly-fuzzed. Left unchecked. + +--- + +## Corpus Management + +- [x] CHK020 - Is the corpus directory naming convention specified — are directory names the format plugin name, the file extension, or something else (spec shows `corpora/jpeg/` but plugin is `jpeg.imageio`)? [Clarity, Plan §Project Structure] — Resolved: `data-model.md` `FuzzTarget.format` specifies the OIIO format-registry key (e.g. `openexr`, not `exr`), with the exr→openexr rename (T058) as a concrete worked example. +- [x] CHK021 - Are requirements specified for how the harness discovers and loads seed files — via libFuzzer's `-corpus` flag, explicit file enumeration, or another mechanism? [Clarity, Spec §FR-008, Gap] — Resolved by consistent example: every invocation shown (`contracts/harness-contract.md`, `docs/dev/fuzzing.md`, `research.md`) passes the corpus directory as a positional libFuzzer argument (`oiio_fuzz_image corpus/jpeg/ ...`) — the harness itself does no seed enumeration; libFuzzer's standard corpus-directory loading applies. +- [x] CHK022 - Is the requirement for graceful degradation on missing corpus specified clearly enough to implement — must the harness continue running on random bytes, or must it exit cleanly? [Clarity, Spec §SC-001] — Resolved: `spec.md` US3 Acceptance Scenario 2 and `contracts/harness-contract.md` §Corpus Convention both state an empty/missing corpus dir is valid — the fuzzer starts from random bytes. +- [x] CHK023 - Are there size constraints specified for individual seed files (e.g., max bytes per seed) to prevent CI cache bloat or slow initialization? [Completeness, Gap] — Resolved: `data-model.md` `SeedCorpus` constraints (≤100 KB/file per `tasks.md` T041/T043, ≤5 MB total per format). +- [x] CHK024 - Is the cache restore/save timing specified precisely — must the restore happen before the harness binary runs, and must the save happen even if the harness exits non-zero (crash found)? [Clarity, Spec §FR-008a, Ambiguity] — Resolved: `data-model.md` `EvolvedCorpus` state transitions plus the (now-clarified) `GHAFuzzWorkflow` Invariants state the save step runs unconditionally (`if: always()`), regardless of crash. + +--- + +## Conditional Compilation for Optional Formats + +- [x] CHK025 - Are requirements specified for how optional-library formats signal their absence — e.g., are they simply excluded from the matrix, or do they produce a disabled/skipped job? [Completeness, Spec §FR-001, Gap] — Resolved by supersession: there are no more per-format source files (`fuzz_openvdb.cpp` etc. were never built — see `research.md` §5). `data-model.md` `FuzzTarget` constraints state optional-library formats simply don't appear in `get_extension_map()` when their library isn't compiled in; no CMake guards needed in the single harness source. openvdb/ptex remain in the nightly matrix regardless (marked `†` conditionally-compiled in `data-model.md`'s tier table) and are silently skipped at runtime by `ci-fuzztest.bash`'s `--list-formats` check if genuinely absent. +- [ ] CHK026 - Is the behavior defined for a CI environment where an optional library is present at configure time but absent at link time? [Edge Case, Gap] — Not addressed anywhere in spec/plan/data-model; this is a general OIIO plugin-registration edge case that the fuzzing feature docs don't specifically cover. Left unchecked. +- [x] CHK027 - Are requirements defined for which CI runner image (`aswf/ci-oiio:2026.3`) provides which optional libraries, and whether Tier 2 optional formats are tested in the nightly run or only locally? [Completeness, Plan §Technical Context, Gap] — Resolved: `docs/dev/fuzzing.md` Prerequisites and `research.md` §4 state the `aswf/ci-oiio` container (now `:2027`) has all optional format libraries pre-installed; openvdb/ptex run in the nightly Tier 2 matrix, not just locally. + +--- + +## OSS-Fuzz Compatibility + +- [ ] CHK028 - Is "minimal delta work" for OSS-Fuzz onboarding (FR-011) quantified? The spec says "minimal" but does not define what counts as minimal — is it a specific file count, time estimate, or list of required artifacts? [Clarity, Spec §FR-011, Ambiguity] — Still unquantified; `contracts/harness-contract.md` §OSS-Fuzz Compatibility describes what's needed qualitatively (draft `project.yaml`/`build.sh`) but gives no measurable bound. Left unchecked (User Story 5 also remains deferred/not started). +- [x] CHK029 - Are the OSS-Fuzz corpus directory conventions documented in the spec, or only inferred from the OSS-Fuzz documentation? If the latter, is there a reference? [Completeness, Spec §SC-005, Gap] — Resolved: `contracts/harness-contract.md` §OSS-Fuzz Compatibility gives the exact `build.sh` snippet showing the `fuzz__seed_corpus.zip` naming convention. +- [x] CHK030 - Is the requirement for `project.yaml` and `build.sh` artifacts specified as "must be producible without harness changes" clearly enough to distinguish from "must be committed to the repo in v1"? [Clarity, Spec §FR-011, Ambiguity] — Resolved: `spec.md` Status line + Assumptions explicitly state User Story 5 is deferred and no `ossfuzz/` files exist yet, while the harness-side prerequisites (dispatch, `$LIB_FUZZING_ENGINE`, `--list-formats`) are already built — the "producible without changes" vs. "committed now" distinction is explicit. + +--- + +## Documentation Requirements + +- [ ] CHK031 - Is "within 15 minutes of reading the documentation" (SC-007 / Spec §User Story 4) defined as a measurable requirement on the documentation itself — e.g., maximum step count, or list of prerequisites to state? [Measurability, Spec §SC-007] — Still an unfalsifiable time claim; no step-count or prerequisite-count bound defined anywhere. Left unchecked. +- [x] CHK032 - Are requirements specified for what the local fuzzing documentation MUST include — just build commands, or also: how to interpret ASan output, how to minimize a reproducer, how to submit a corpus seed? [Completeness, Spec §FR-013, Gap] — Resolved: `docs/dev/fuzzing.md` has all of Prerequisites, Building, Listing formats, Running, Reproducing a CI crash (ASan), Minimizing a crash, and Adding seeds sections, matching `tasks.md` T045. +- [ ] CHK033 - Is the target audience for `docs/dev/fuzzing.md` specified — is it OIIO contributors only, or also external security researchers unfamiliar with the codebase? This affects required prerequisite depth. [Clarity, Spec §FR-013, Gap] — `docs/dev/fuzzing.md` has no explicit audience statement (unlike the superseded `quickstart.md` draft, which named "OpenImageIO contributors"). Left unchecked. + +--- + +## Success Criteria Measurability + +- [x] CHK034 - Is "40+ supported image format readers" in SC-001 consistent with "29 formats in scope" stated in FR-001 and the Assumptions section? [Consistency, Spec §SC-001 vs §FR-001, Conflict] — Resolved: SC-001 rewritten to say "all in-scope image format readers" with an as-built note pointing at FR-001's 29-of-32 count, removing the "40+" conflict. +- [x] CHK035 - Is "≥ 1,000 executions/sec sustained" (Plan §Performance Goals) a hard requirement or an aspirational target? If hard, is there a CI check that enforces it? [Measurability, Plan §Performance Goals, Gap] — Resolved: `plan.md` Performance Goals now states explicitly these are aspirational, not CI-enforced (no step measures throughput). +- [x] CHK036 - Is "under 30 minutes" (SC-004) for adding a new harness testable — is there a defined test procedure for measuring this, or is it a developer experience goal without a verification method? [Measurability, Spec §SC-004] — Resolved: SC-004's as-built note explains the claim is now near-trivially true by construction (adding a format is just creating a corpus directory), not something requiring a timed test procedure. +- [ ] CHK037 - Is "within one iteration of feedback" in SC-005 (OSS-Fuzz PR) specific enough to be a testable acceptance criterion, given it depends on OSS-Fuzz reviewer response time? [Measurability, Spec §SC-005, Ambiguity] — Still depends on an external party's response time and User Story 5 remains deferred/not started, so this can't be tested yet. Left unchecked. + +--- + +## Scope Boundaries + +- [x] CHK038 - Is the exclusion of `null`, `term`, and `r3d` from fuzz coverage specified with enough rationale that a reviewer can confirm the exclusion is correct without re-researching? [Clarity, Spec §FR-001, Assumptions] — Resolved: FR-001 gives a one-line reason for each exclusion (no real parsing / output-only / proprietary SDK unavailable in CI). +- [ ] CHK039 - Is the deferral of write fuzzing (`ImageOutput`) specified with a clear boundary — what would trigger adding write fuzzing in v2, and does the v1 structure need to accommodate it in any specific way? [Completeness, Spec §Assumptions] — Partially resolved: Assumptions state write fuzzing is out of scope for v1 and the harness structure "should not preclude" adding it later, but no explicit trigger condition for when v2 work would start is given. Left unchecked. +- [x] CHK040 - Are requirements defined for what happens to fuzz findings after triage — the spec says "triaged by maintainer" but does not specify whether a fix must include a regression test, or what the commit/merge policy is for such fixes? [Completeness, Spec §Assumptions, Gap] — Resolved: `data-model.md` `Reproducer` lifecycle states a regression test MUST be committed to `testsuite/fuzz-/` before the fix merges. diff --git a/docs/dev/specs/001-image-fuzzing/checklists/requirements.md b/docs/dev/specs/001-image-fuzzing/checklists/requirements.md new file mode 100644 index 0000000000..645162f0d1 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Image Format Fuzzing Infrastructure + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-23 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All checklist items pass. Ready for `/speckit-plan`. diff --git a/docs/dev/specs/001-image-fuzzing/contracts/harness-contract.md b/docs/dev/specs/001-image-fuzzing/contracts/harness-contract.md new file mode 100644 index 0000000000..70ffe6edee --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/contracts/harness-contract.md @@ -0,0 +1,196 @@ +# Contract: Fuzz Harness Interface + +**Feature**: 001-image-fuzzing | **Date**: 2026-06-24 (revised) + +There is one fuzz harness binary (`oiio_fuzz_image`) that handles all supported image formats. +It discovers formats at runtime and dispatches based on the active format selection. +This contract specifies what `fuzz_image.cpp` MUST satisfy. *(As-built: the shared +helpers originally split into a separate `fuzz_utils.h` header were later folded +directly into `fuzz_image.cpp` — it was the header's only include, so the split +bought no reuse, just an extra file to open.)* + +--- + +## Format Selection + +The active format for a given run is resolved in priority order: + +1. **`OIIO_FUZZ_FORMAT` environment variable** — used by GHA matrix jobs + (`OIIO_FUZZ_FORMAT=jpeg ./oiio_fuzz_image corpus/jpeg/ ...`) +2. **`basename(argv[0])`** — used by OSS-Fuzz per-format symlinks + (`fuzz_jpeg -> oiio_fuzz_image`; binary reads its own name, strips `fuzz_` prefix) +3. **`--format=` pseudo-argument** — parsed in `LLVMFuzzerInitialize` before + libFuzzer strips its own flags; useful for local one-off runs +4. **None set** — harness MUST abort with a clear error message listing available formats + (running without a format target produces meaningless blended coverage) + +Format selection MUST be performed in `LLVMFuzzerInitialize`, stored in a `static` +variable, and accessed read-only in `LLVMFuzzerTestOneInput`. + +--- + +## `LLVMFuzzerInitialize` Signature + +```cpp +extern "C" int LLVMFuzzerInitialize(int* argc, char*** argv); +``` + +**Requirements**: +- MUST call `OIIO_FUZZ_INIT` (one-time OIIO attribute setup macro defined in + `fuzz_image.cpp`). +- MUST resolve the active format via the priority order above. +- MUST validate the format name against the OIIO format registry (as implemented: + `OIIO::get_extension_map()`, not the `extension_list` string attribute) and abort + if the format is not recognized or not compiled in. +- MUST support a `--list-formats` pseudo-argument that prints all available formats + (from the extension map, excluding `null` and `term`) to stdout and exits 0. + This is used by the CI lint step to detect missing corpus directories. As + implemented, `stdout` is explicitly `fflush()`ed before `exit(0)` — ASan's leak + detector can `_Exit()` from an atexit hook on the way out, which would otherwise + skip the normal stdio auto-flush and truncate the printed list. +- MUST return 0. + +--- + +## `LLVMFuzzerTestOneInput` Signature + +```cpp +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size); +``` + +**Requirements**: +- MUST have C linkage (`extern "C"`). +- MUST accept `const uint8_t*` and `size_t` parameters exactly. +- MUST return `0` (non-zero is undefined behavior in future ABI). +- MUST NOT call `exit()`, `abort()`, or `_exit()` directly. +- MUST NOT retain mutable state between calls beyond the static format selection set + in `LLVMFuzzerInitialize`. + +--- + +## OIIO API Usage + +*(As implemented: the read loop itself lives in `libOpenImageIO`, not in the +harness — see below. Once written for the harness, this logic turned out to be +generally useful for interactively exercising an `ImageInput` outside of fuzzing +too, so it was moved into the library and exposed via `oiiotool --testread` as +well, superseding the original "no production source changes" constraint.)* + +**Standard formats** (single subimage — most formats): +- MUST use `OIIO::Filesystem::IOMemReader` to wrap input bytes — no disk I/O. +- MUST call `ImageInput::open(fake_filename, nullptr, &mem_reader)`. +- `fake_filename` MUST end with the primary extension for the target format + (e.g., `"input.jpg"` for `jpeg`, `"input.tif"` for `tiff`), obtained from + `oiio_format_primary_ext()`. +- MUST hand the open `ImageInput&` to `OIIO::pvt::test_read_all_images(inp, TypeUInt8)` + (declared in `imageio_pvt.h`) rather than calling `read_image()` directly. This + shared helper reads tiled images one full-width row of tiles at a time and + scanline images in bounded chunks — never allocating a buffer sized to the + (possibly adversarial) claimed image dimensions — and stops at the first read + failure instead of continuing through a huge chunk grid. +- MUST call `inp->close()` before returning (or rely on `unique_ptr` RAII). + +**OOM defense** is no longer a fixed per-call pixel-count check in the harness. +Instead, `OIIO_FUZZ_INIT` lowers OIIO's own global guards +(`OIIO::attribute("limits:imagesize_MB", 2048)`, +`OIIO::attribute("limits:resolution", 65536)`) to well under libFuzzer's +`rss_limit_mb` (4096, set in `oiio_fuzz_image.options`), so a corrupt header +claiming a multi-GB image is rejected by OIIO's normal error path before it can +trip libFuzzer's out-of-band RSS-limit kill (which would otherwise register as a +false-positive crash). `test_read_all_images()`'s chunked reads provide a second, +independent bound on top of this. + +**Multi-subimage / MIP formats** (openexr, tiff, and any other format that +declares support): no longer a harness-level branch. `test_read_all_images()` +checks `inp->supports("multiimage")` / `"mipmap"` itself and iterates +automatically; the harness calls the same function for every format. + +**Dispatch plugin formats** (raw, ffmpeg): +- These plugins dispatch to a third-party library that does not support + `IOProxy`-based in-memory reads. Unlike the original plan (which called for + `ImageInput::create()` + `IOMemReader`), the implemented dispatch path writes + the fuzz bytes to a process-unique temp file (reused/overwritten across calls + for throughput) and calls `ImageInput::open(tmppath)` — the extension on the + temp path selects the plugin (`.cr2` → raw, `.mkv` → ffmpeg). +- If `ImageInput::create()` (or the temp-file `open()`) returns null (plugin not + compiled in, or input rejected), return immediately without treating it as a + failure. + +--- + +## Error Handling + +- If `ImageInput::open()` returns null (format rejected the input), return `0` — normal + for most fuzz inputs. +- MUST NOT treat a null open result as a test failure. +- MUST NOT print to stdout or stderr during normal operation. +- Sanitizer findings are caught automatically by the runtime. + +--- + +## Linking + +- MUST link against `OpenImageIO` library. +- MUST link against `$LIB_FUZZING_ENGINE` (resolved from env; default `-fsanitize=fuzzer`). +- MUST be compiled with `-fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer` + (compile-time coverage instrumentation without linking a fuzzer runtime yet; the + runtime comes from `$LIB_FUZZING_ENGINE` at link time) and linked with + `-fsanitize=address,undefined $LIB_FUZZING_ENGINE`. +- MUST be compiled with clang or clang++. As implemented, both gcc (no `-fsanitize=fuzzer` + support) and Apple's Xcode/Command-Line-Tools clang (missing the libFuzzer runtime, + `libclang_rt.fuzzer_osx.a`) are rejected with a CMake warning and the fuzz target is + skipped, rather than the whole build failing — see `data-model.md` FuzzBuildConfig. + +--- + +## Shared Helpers (defined in `fuzz_image.cpp`) + +| Symbol | Purpose | +|--------|---------| +| `OIIO_FUZZ_INIT` | One-time OIIO setup macro: single-threaded mode (`threads`, `exr_threads`) plus `limits:imagesize_MB` / `limits:resolution` tuned under libFuzzer's RSS budget | +| `oiio_fuzz_read(data, size, fake_filename)` | Open via `IOMemReader`, then delegate to `OIIO::pvt::test_read_all_images()` — covers single- and multi-subimage formats alike | +| `oiio_fuzz_read_dispatch(data, size, fake_filename)` | Dispatch-plugin read (raw, ffmpeg): write to a reused temp file, `ImageInput::open(tmppath)`, delegate to `test_read_all_images()` | +| `oiio_format_primary_ext(format_name)` | Returns primary extension for a format name | +| `oiio_format_is_dispatch(format_name)` | True for raw, ffmpeg | + +**Dropped from the original design**: `oiio_fuzz_read_multi()` and +`oiio_format_is_multi()`. Subimage/MIP iteration moved into +`OIIO::pvt::test_read_all_images()` in `libOpenImageIO` itself, so every format +goes through the same call — there is no separate "multi" code path in the +harness to select. + +--- + +## Corpus Convention + +Each format has a corpus directory at `src/fuzz/corpora//`. +- Files MUST be valid images for the format. +- File names MUST be lowercase hexadecimal or descriptive (no spaces). +- The directory MAY be empty (slower but valid — fuzzer starts from random bytes). +- **Presence is mandatory**: the CI lint step (`./oiio_fuzz_image --list-formats`) fails if + any format returned by `extension_list` (excluding `null`, `term`) lacks a + `src/fuzz/corpora//` directory. + +--- + +## OSS-Fuzz Compatibility + +**Status: not yet built.** No `ossfuzz/` directory exists in the repo — this section +remains a forward-looking contract for User Story 5 (P3, deferred). The harness-side +support it depends on (`argv[0]` dispatch, `$LIB_FUZZING_ENGINE` linkage, +`--list-formats`) is already in place, so onboarding remains additive whenever it's +picked up. + +OSS-Fuzz requires separate named binaries per fuzz target. `build.sh` satisfies this by +creating per-format symlinks in `$OUT/`: + +```bash +cp $WORK/build/src/fuzz/oiio_fuzz_image $OUT/ +for fmt in $(./oiio_fuzz_image --list-formats); do + ln -s oiio_fuzz_image $OUT/fuzz_${fmt} + zip -j $OUT/fuzz_${fmt}_seed_corpus.zip src/fuzz/corpora/${fmt}/* 2>/dev/null || true +done +``` + +When OSS-Fuzz invokes `fuzz_jpeg`, the binary reads `basename(argv[0])` = `fuzz_jpeg`, +strips the `fuzz_` prefix, and targets the `jpeg` format. No harness source changes needed. diff --git a/docs/dev/specs/001-image-fuzzing/data-model.md b/docs/dev/specs/001-image-fuzzing/data-model.md new file mode 100644 index 0000000000..209f9dd4a6 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/data-model.md @@ -0,0 +1,191 @@ +# Data Model: Image Format Fuzzing Infrastructure + +**Feature**: 001-image-fuzzing | **Date**: 2026-06-23 + +## Entities + +### FuzzTarget + +Represents one logical fuzz target — a format + its GHA job configuration. All formats +share a single compiled binary (`oiio_fuzz_image`); a FuzzTarget is a runtime configuration, +not a separate binary. + +| Field | Type | Description | +|-------|------|-------------| +| `format` | string | Canonical format name, i.e. the OIIO format-registry key (e.g., `openexr`, `jpeg`, `tiff` — note `openexr`, not `exr`) | +| `tier` | enum {1, 2} | 1 = complex/high-risk (longer window), 2 = simpler (capped) | +| `max_total_time` | int (seconds) | As implemented: Tier 1: 3600 (1h), Tier 2: 1800 (30m) — scaled down from the original 19800/3600 plan to fit CI budget | +| `primary_ext` | string | Primary file extension for this format (e.g., `exr`, `jpg`, `tiff`) | +| `is_dispatch` | bool | True for dispatch plugins that use `ImageInput::create()` (raw, ffmpeg) | +| `source_file` | path | `src/fuzz/fuzz_image.cpp` (same for all formats) | +| `binary_path` | path | `build/src/fuzz/oiio_fuzz_image` (one binary; OSS-Fuzz per-format symlinks are supported by the dispatch logic but no `ossfuzz/` project files exist yet) | +| `corpus_dir` | path | `src/fuzz/corpora//` | + +**Note**: the originally-planned `is_multi_subimage` field was dropped. Multi-subimage +and MIP handling is no longer a harness-level decision — `OIIO::pvt::test_read_all_images()` +(added to `libOpenImageIO` during implementation; see `plan.md` Summary) queries +`inp->supports("multiimage")` / `"mipmap"` itself and iterates automatically for every +format, so the harness calls the same function regardless of format. + +**Constraints**: +- `format` MUST be unique and MUST appear in `OIIO::get_string_attribute("extension_list")`. +- `primary_ext` MUST match one of the format's registered extensions in OIIO. +- A `corpus_dir` MUST exist for every format in `extension_list` (excluding `null`, `term`); + absence is caught by the CI lint step (`./oiio_fuzz_image --list-formats`). +- Formats with optional third-party libraries (heif, jpegxl, jpeg2000, raw, dicom, + ffmpeg, openvdb, ptex) appear in `extension_list` only when the library is compiled in; + no CMake guards needed in the single harness source. + +**Format → tier mapping** (as implemented in `.github/workflows/fuzz.yml`): + +| Tier 1 — 3600s/job | Tier 2 — 1800s/job | +|------------------|-----------------| +| openexr, tiff, jpeg, png, dpx, psd, heif, jpegxl, jpeg2000, raw | bmp, cineon, dds, dicom, fits, gif, hdr, ico, iff, pnm, rla, sgi, softimage, targa, ffmpeg, webp, zfile, openvdb†, ptex† | + +† Conditionally compiled: included only when OpenVDB / Ptex library is found in the build. + +**Note on `raw` and `ffmpeg`**: Both are read-only multi-format dispatch plugins — +`raw.imageio` dispatches to LibRaw (all camera RAW formats), `ffmpeg.imageio` dispatches +to FFmpeg (all video container formats). Neither supports ImageOutput. The fuzzing goal +for both is narrow: verify the OIIO wrapper handles library errors correctly, not +exhaustive sub-format coverage. Both libraries are independently fuzzed by their own +projects. Corpus: 2–3 representative files each (camera RAW from `../oiio-images/raw/`; +video from `testsuite/ffmpeg/ref/`). Harnesses use `ImageInput::create("raw"/"ffmpeg")` +to force the plugin without relying on file extension routing. + +--- + +### SeedCorpus + +A set of small, valid image files for a format used to initialize the fuzzer. + +| Field | Type | Description | +|-------|------|-------------| +| `format` | string | Owning format name | +| `directory` | path | `src/fuzz/corpora//` | +| `files` | list\ | Committed binary image files (target: 1–5 per format, each < 100 KB) | +| `source` | enum | `testsuite` (copied from OIIO test images) or `generated` (synthesized) | + +**As implemented, this entity splits in two**: +- **Committed corpus** (`src/fuzz/corpora//`): only formats with no other + seed source get a committed synthetic file (currently: dpx, fits, hdr, iff, + jpeg2000, jpegxl, openexr, sgi). +- **CI-time corpus** (`corpus//` in the runner workspace, never committed): + for every other format, `populate_corpora.py --format --dest corpus/` runs + as part of the `fuzz` step's `src/build-scripts/ci-fuzztest.bash` script in + `build-steps.yml`, pulling from `testsuite/` and companion repos + (`../oiio-images`, `../fits-images`, `../j2kp4files_v1_5`, + `../dicom-images-pvt`) checked out fresh each run. This avoids committing large + binary sample sets while still giving every format real-world seeds at fuzz time. + +**Constraints**: +- Each file MUST be a valid image parseable by OIIO for the given format. +- Total seed corpus size SHOULD NOT exceed 5 MB per format (prevents slow corpus loading). +- Files MUST NOT contain copyrighted content that precludes Apache-2.0 distribution. + +--- + +### EvolvedCorpus + +The set of inputs generated by the fuzzer during previous runs, stored in GHA cache. + +| Field | Type | Description | +|-------|------|-------------| +| `format` | string | Owning format name | +| `cache_key` | string | As implemented: `fuzz-corpus---` (unique per run, so every run's save is a new cache entry) | +| `cache_restore_keys` | list\ | As implemented: `["fuzz-corpus---", "fuzz-corpus--"]` — prefix match picks up the most recent entry for this format+branch, falling back to any branch, since `restore-keys` matches by prefix | +| `directory` | path | `corpus//` (within GHA runner workspace during fuzz run) | + +**State transitions**: +1. Job start: restore from cache if available → merge with seed corpus into working dir. +2. Fuzz run: libFuzzer writes new interesting inputs into corpus directory. +3. Job end (success or crash): save updated corpus directory back to cache. + +--- + +### Reproducer + +A minimal input file that reliably triggers a specific crash or sanitizer finding. + +| Field | Type | Description | +|-------|------|-------------| +| `format` | string | Affected format | +| `filename` | path | `crash__` (libFuzzer naming convention) | +| `crash_type` | string | e.g., `heap-buffer-overflow`, `use-after-free`, `timeout` | +| `stack_trace` | text | ASan/UBSan report captured from stderr | +| `gha_artifact` | string | GHA artifact name: `fuzz-crashes--` | +| `regression_test` | path? | Path under `testsuite/` once triaged and committed | + +**Lifecycle**: +- Created by libFuzzer when a crash is detected. +- Uploaded as GHA artifact by the fuzz workflow. +- After triage: developer runs `./fuzz_ ` to reproduce locally. +- Committed to `testsuite/fuzz-/` as a regression test before fix merges. + +--- + +### FuzzBuildConfig + +CMake configuration for a fuzz-enabled build. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `OIIO_BUILD_FUZZ_TARGETS` | bool | OFF | Master gate for fuzz target compilation | +| `SANITIZE` | string | `""` | Existing OIIO variable; set to `address,undefined` for fuzz builds | +| `LIB_FUZZING_ENGINE` | env var | `""` | Set by OSS-Fuzz; locally set to `-fsanitize=fuzzer` | +| `CMAKE_C_COMPILER` | string | — | Must be clang (not gcc) for fuzzer instrumentation | +| `CMAKE_CXX_COMPILER` | string | — | Must be clang++ | + +**Constraints**: +- `OIIO_BUILD_FUZZ_TARGETS=ON` MUST be combined with a clang compiler and ASan+UBSan. +- As implemented: a gcc build, or an AppleClang build (Apple's Xcode/Command Line + Tools clang, which lacks the libFuzzer runtime), with `OIIO_BUILD_FUZZ_TARGETS=ON` + emits a CMake `WARNING` and skips `src/fuzz/` via `return()` — it does **not** + `FATAL_ERROR`. The original plan called for a hard failure, but that aborted the + *entire* configure for any tree with the option on and an unsuitable compiler, + breaking the rest of the OIIO build too. The softened behavior lets the rest of + OIIO still configure and build. +- Normal Release/Debug builds with `OIIO_BUILD_FUZZ_TARGETS=OFF` MUST be unaffected. + +--- + +### GHAFuzzWorkflow + +The GitHub Actions workflow that orchestrates nightly fuzzing. + +| Field | Type | Value | +|-------|------|-------| +| `trigger.schedule` | cron | As implemented: `0 10 * * *` (10:00 UTC nightly), gated to the canonical fork | +| `trigger.push` | branches | As implemented: also runs on any push to a branch matching `*fuzz*` (paths-filtered to `src/**`, the workflow file, and `build-steps.yml`) | +| `trigger.workflow_dispatch` | inputs | `format` (optional, string; if set, runs only that format) | +| `matrix.fail-fast` | bool | `false` | +| `container` | string | `aswf/ci-oiio:2027` (was `:2026.3` at spec time) | +| `per_input_timeout` | int (seconds) | 60 (`-timeout=60` passed to libFuzzer) | +| `artifact_retention_days` | int | 30 | + +**Additional libFuzzer flags used at runtime** (beyond what was originally specified): +`-max_len=16777216` (16 MB input cap), `-rss_limit_mb=4096`, `-malloc_limit_mb=2048` +(so a single oversized allocation is caught synchronously with a real reproducer, +rather than the RSS-limit monitor firing out-of-band and writing an empty crash +file), `-detect_leaks=0`, `-jobs=$(nproc) -workers=$(nproc)` (both flags — `-jobs` +alone lets libFuzzer default `-workers` to half the core count). + +**Implementation detail**: `fuzz.yml` itself does not hand-roll the build/cache/run +steps described in earlier drafts of this data model — it delegates to the +`build-steps.yml` reusable workflow (also used by the main CI job) via +`fuzz_format` / `fuzz_max_time` inputs, so fuzz builds get the same ccache and +dependency-install machinery as every other CI job. Within `build-steps.yml`, the +corpus-lint / seed / run / job-summary logic lives in a single step (`id: fuzz`) +that calls `src/build-scripts/ci-fuzztest.bash`, rather than being spread across +several inline-bash steps — this matches how `Build`, `Testsuite`, and +`Benchmarks` each delegate to a script. The crash-artifact-upload step checks +`steps.fuzz.outcome == 'failure'`. The `fuzz-corpus-lint` job itself lives in +`fuzz.yml` (it started out in `ci.yml`, then moved). + +**Invariants**: +- All matrix jobs always run to completion regardless of sibling job failures. +- A crashing job MUST upload its reproducer artifact before exiting. +- Every job, crashing or not, MUST save its evolved corpus to cache before exiting + (`if: always()` on the cache-save step) — a crash-finding run still discovered + new interesting inputs worth keeping, so the save is unconditional, not gated + on a clean exit. diff --git a/docs/dev/specs/001-image-fuzzing/plan.md b/docs/dev/specs/001-image-fuzzing/plan.md new file mode 100644 index 0000000000..1a124122c2 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/plan.md @@ -0,0 +1,162 @@ +# Implementation Plan: Image Format Fuzzing Infrastructure + +**Branch**: `001-image-fuzzing` | **Date**: 2026-06-23 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/001-image-fuzzing/spec.md` + +## Summary + +Add a libFuzzer-based fuzzing infrastructure to OpenImageIO that exercises all supported +image format readers via the `ImageInput` API using in-memory `IOProxy` buffers for +maximum throughput. A new `src/fuzz/` directory holds a single dynamic dispatch harness +(`oiio_fuzz_image`) plus a shared helper and seed corpora. The harness queries +`OIIO::get_extension_map()` at startup to discover every compiled-in +format automatically — new format plugins are covered without any manual harness update. +A new `.github/workflows/fuzz.yml` runs nightly as a parallel matrix (one GHA job per +format), each job setting `OIIO_FUZZ_FORMAT=` to target a single format and its +corpus. For OSS-Fuzz, per-format symlinks (`fuzz_jpeg -> oiio_fuzz_image`) let the binary +read its own name from `argv[0]` to determine the format — the dispatch logic is built +and ready, but the `ossfuzz/` project files themselves are not (deferred, P3). The CMake +build is gated by `OIIO_BUILD_FUZZ_TARGETS=OFF` and layers its own +`-fsanitize=address,undefined` directly on the fuzz binary rather than depending on the +global `SANITIZE` mechanism (so a fuzz build is fully sanitized even if `SANITIZE` isn't +set). Documentation lives in `docs/dev/fuzzing.md`. + +**As-built addition beyond "no production source changes"**: while building the +harness's per-input read loop (chunked scanline/tile reads, subimage/MIP iteration, +first-failure bail-out), it became clear the same logic was generally useful outside +fuzzing — as a way to interactively exercise and debug an `ImageInput` the same way +the fuzzer does. It was moved into the core library as `OIIO::pvt::test_read_image()` +/ `test_read_all_images()` (`src/include/imageio_pvt.h`, +`src/libOpenImageIO/imageinput.cpp`) and exposed via a new `oiiotool --testread` flag, +so the harness and developers share one tested implementation instead of two. This is +a one-time, small addition to the library — not a per-format cost. + +## Technical Context + +**Language/Version**: C++17 (matches project baseline) + +**Primary Dependencies**: OpenImageIO (libOpenImageIO), clang/LLVM (for `-fsanitize=fuzzer,address,undefined`), all optional format libraries (OpenEXR, libtiff, libjpeg-turbo, libpng, etc. — already present in the `aswf/ci-oiio` container; pinned to `:2027` as of landing, was `:2026.3` at spec time) + +**Storage**: Seed corpora in `src/fuzz/corpora//` (checked in, binary image files). Evolved corpus in GHA cache keyed `fuzz-corpus--`. + +**Testing**: CTest not used for fuzz runs; GHA fuzz workflow is self-contained. Fuzz findings produce regression tests committed to `testsuite/`. + +**Target Platform**: Linux (GHA `ubuntu-latest` + `aswf/ci-oiio:2027` container, was `:2026.3` at spec time). Local dev: macOS or Linux with clang ≥ 14 (Apple's own clang is explicitly rejected — see Constraints). + +**Project Type**: Infrastructure / build + CI tooling added to an existing C++ library. + +**Performance Goals**: Tier 1 formats: ≥ 1,000 executions/sec sustained. Tier 2: any positive throughput. *(These are aspirational targets, not CI-enforced gates — no step checks measured throughput against them.)* As implemented, Tier 1 jobs run for 1 hour and Tier 2 for 30 minutes (not the originally-planned 5.5h/1h split — scaled down to fit CI budget after initial runs). *(No separate "teardown margin" is enforced: both budgets sit far under the GHA job's default 6-hour timeout, so the gap between `-max_total_time` and the job timeout is large by construction rather than a value anyone tuned.)* + +**Constraints**: Must not affect Release or Debug builds (`OIIO_BUILD_FUZZ_TARGETS` defaults OFF). Must not require changes to production format plugin source. Harness ABI must satisfy OSS-Fuzz `LLVMFuzzerTestOneInput` convention. + +**Scale/Scope**: One harness binary covering all 29 in-scope formats (32 plugins minus 3 excluded: `null`, `term`, `r3d`) discovered at runtime. `openvdb` and `ptex` included as Tier 2 when their libraries are present. Seed corpus: 1–5 small valid images per format sourced from `testsuite/`. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Format-Agnostic API Integrity | ✅ Pass | Harnesses call `ImageInput::open()` via the public API; no internal helpers bypassed. | +| II. Safety and Input Robustness | ✅ Pass | This feature *is* the safety gate. ASan+UBSan enforced. Any finding is blocking per constitution. | +| III. Fuzz-First Development | ✅ Pass | Harnesses added alongside the infrastructure; pattern established for all future plugin additions. | +| IV. Minimal Footprint | ✅ Pass (scope revised) | `OIIO_BUILD_FUZZ_TARGETS=OFF` default keeps the build gate minimal. A small, shared read-loop helper (`OIIO::pvt::test_read_image`/`test_read_all_images`) was added to `libOpenImageIO` and exposed via `oiiotool --testread` — a deliberate, one-time addition once it was clear the logic was useful beyond fuzzing, not per-format churn. "No production source changes" as originally scoped no longer applies; see Summary. | +| V. Reproducibility | ✅ Pass | Crash reproducers uploaded as GHA artifacts; regression test commitment required before fix merges. | + +All gates pass. Proceeding. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-image-fuzzing/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output (= docs/dev/fuzzing.md) +├── contracts/ +│ └── harness-contract.md # Fuzz harness interface contract +└── tasks.md # Phase 2 output (/speckit-tasks command) +``` + +### Source Code (repository root) + +```text +src/fuzz/ +├── CMakeLists.txt # OIIO_BUILD_FUZZ_TARGETS gate; builds oiio_fuzz_image target +├── fuzz_image.cpp # Single dynamic dispatch harness — one binary for all formats; +│ # IOMemReader wrapper, OOM guard, OIIO init, format dispatch +│ # helpers, and LLVMFuzzerInitialize all live here (as-built: +│ # originally split out into fuzz_utils.h, later folded back +│ # in since it was fuzz_image.cpp's only include) +├── oiio_fuzz_image.options # libFuzzer options (max_len, rss_limit_mb), installed +│ # alongside the binary; ClusterFuzz/OSS-Fuzz key it by +│ # binary basename +└── corpora/ + ├── bmp/ (1–3 seed images per format) + ├── cineon/ + ├── dds/ + ├── dicom/ + ├── dpx/ + ├── openexr/ (named after the OIIO format-registry key, not "exr") + ├── ffmpeg/ + ├── fits/ + ├── gif/ + ├── hdr/ + ├── heif/ + ├── ico/ + ├── iff/ + ├── jpeg/ + ├── jpeg2000/ + ├── jpegxl/ + ├── png/ + ├── pnm/ + ├── psd/ + ├── raw/ + ├── rla/ + ├── sgi/ + ├── softimage/ + ├── targa/ + ├── tiff/ + ├── webp/ + ├── zfile/ + ├── openvdb/ + └── ptex/ + +.github/workflows/fuzz.yml # Nightly parallel matrix fuzz workflow + +docs/dev/fuzzing.md # Local fuzzing quickstart + crash reproduction guide +``` + +**Structure Decision**: Single `src/fuzz/` subtree with one `oiio_fuzz_image` binary that +discovers formats at runtime via `OIIO::get_string_attribute("extension_list")`. The +active format is selected via the `OIIO_FUZZ_FORMAT` environment variable (used by GHA +jobs) or by reading `basename(argv[0])` (used by OSS-Fuzz per-format symlinks). Seed +corpora are committed alongside the harness source. Evolved corpus lives only in GHA +cache (never committed). + +**New format coverage guarantee**: A CI lint step (`./oiio_fuzz_image --list-formats`) diffs +the runtime format list against `src/fuzz/corpora/` directories and fails if any format +is missing a corpus directory. This replaces the manual "remember to add a harness" +burden with an automated check. This lint runs as the `fuzz-corpus-lint` job, via the +shared `build-steps.yml` reusable workflow. *(As-built history: it first landed as a job +in `.github/workflows/ci.yml`, then was relocated to `.github/workflows/fuzz.yml` to keep +all fuzzing-related CI in one workflow file.)* + +**Fuzz step logic lives in a script, not inline YAML**: `build-steps.yml`'s fuzz-related +work (corpus lint, corpus seeding, running the fuzzer, writing the job summary) is a +single step, `id: fuzz`, that calls `src/build-scripts/ci-fuzztest.bash` — matching how +`Build`, `Testsuite`, and `Benchmarks` each delegate to a script rather than inlining +bash in the workflow file. Only the sub-steps that need a marketplace action +(`actions/checkout`, `actions/cache`, `actions/upload-artifact`) remain as separate YAML +steps. + +**Note**: the `all_fuzz_targets` CMake alias mentioned in earlier drafts of this plan was +never wired up (it's present only as a comment in `src/fuzz/CMakeLists.txt`) — there is +only one target, `oiio_fuzz_image`, and nothing currently depends on the alias name. + +## Complexity Tracking + +No constitution violations requiring justification. diff --git a/docs/dev/specs/001-image-fuzzing/quickstart.md b/docs/dev/specs/001-image-fuzzing/quickstart.md new file mode 100644 index 0000000000..7608ef58a4 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/quickstart.md @@ -0,0 +1,37 @@ +# Fuzzing Quickstart (Developer Guide) + +**Status**: Superseded by [`docs/dev/fuzzing.md`](../../docs/dev/fuzzing.md), the +published, maintained version of this document. Read that file for current +build/run/reproduce instructions — this file is kept only as a historical record +of the Phase 1 design draft and a note on how it diverged from what shipped. + +**Audience**: OpenImageIO contributors who want to run fuzzing locally, add a new +harness, or reproduce a crash found by CI. + +--- + +## Why this file is no longer authoritative + +This draft was written against the *original* plan of one harness binary per +format (`fuzz_jpeg`, `fuzz_exr`, ..., built via a per-target `add_fuzz_target()` +CMake macro and an `all_fuzz_targets` aggregate target). During implementation the +design pivoted to a single dynamic-dispatch binary, `oiio_fuzz_image`, that +selects its target format at runtime via `OIIO_FUZZ_FORMAT` (or `argv[0]`, or +`--format=`). See `plan.md` and `contracts/harness-contract.md` for the as-built +contract. + +Concretely, everywhere this draft said: + +| This draft said | The shipped system does | +|---|---| +| `build-fuzz/src/fuzz/fuzz_jpeg`, `fuzz_exr`, `fuzz_tiff`, ... (one binary per format) | `build/src/fuzz/oiio_fuzz_image` (one binary; `OIIO_FUZZ_FORMAT=jpeg ./oiio_fuzz_image ...` selects the format) | +| `cmake --build build-fuzz --target all_fuzz_targets` | `cmake --build build --target oiio_fuzz_image` (`all_fuzz_targets` was never wired up — it exists only as a comment in `src/fuzz/CMakeLists.txt`) | +| Register a new format in `src/fuzz/CMakeLists.txt` via `add_fuzz_target(myformat)` | Nothing to register — any format OIIO compiles in is fuzzable immediately; only a `src/fuzz/corpora//` directory is needed (enforced by the `fuzz-corpus-lint` CI job) | +| Per-format `fuzz_.cpp` harness with its own `LLVMFuzzerTestOneInput` | One `src/fuzz/fuzz_image.cpp`; the read loop was recognized as generally useful and moved into `OIIO::pvt::test_read_image()` / `test_read_all_images()` in `libOpenImageIO` itself, also exposed via `oiiotool --testread` — a deliberate addition, not just an exception made for the harness's sake | +| Seeds committed wholesale for every format after running `populate_corpora.py` | Only formats with no other source commit a synthetic seed (dpx, fits, hdr, iff, jpeg2000, jpegxl, openexr, sgi); all other seeds are pulled fresh at CI time by a `populate_corpora.py` workflow step, not committed | +| OSS-Fuzz local simulation section, implying `projects/openimageio/` groundwork was imminent | No `ossfuzz/` files exist yet — deferred (User Story 5, P3) | + +The harness-selection priority order, `--list-formats`, crash reproduction flow, +and corpus-lint enforcement mechanism described in the rest of this draft are +conceptually correct and now documented accurately (with real command lines, +tier timings, and container versions) in `docs/dev/fuzzing.md`. diff --git a/docs/dev/specs/001-image-fuzzing/research.md b/docs/dev/specs/001-image-fuzzing/research.md new file mode 100644 index 0000000000..0c7661b3f9 --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/research.md @@ -0,0 +1,486 @@ +# Research: Image Format Fuzzing Infrastructure + +**Feature**: 001-image-fuzzing | **Date**: 2026-06-23 + +**Post-implementation note**: This document captures the Phase 0 research and the +decisions made *at that time*. Sections §3 and §5 describe a "one harness binary +per format" design (`add_fuzz_target()` macro, `fuzz_jpeg.cpp` per format) that was +superseded during implementation by a single dynamic-dispatch binary +(`oiio_fuzz_image`) — see the pivot rationale and as-built contract in `plan.md` +and `contracts/harness-contract.md`. §4 and §6-8 have inline notes below where +specific values (tier timings, container version, cron schedule, corpus +committing strategy) changed. The engine-selection, IOProxy, and excluded-formats +decisions (§1, §1a, §2, §7) held as researched. + +## 1. Fuzzing Engine Selection + +**Decision**: Use `$LIB_FUZZING_ENGINE` abstraction (defaults to libFuzzer locally). + +**Rationale**: The harness entry point (`LLVMFuzzerTestOneInput`) is the common ABI shared +by libFuzzer, AFL++, Honggfuzz, and Centipede. Linking against `$LIB_FUZZING_ENGINE` +instead of hardcoding `-fsanitize=fuzzer` means OSS-Fuzz runs all four engines on the +same harness binaries with zero changes. Locally, developers set +`LIB_FUZZING_ENGINE=-fsanitize=fuzzer` (libFuzzer) since it requires only clang. + +Centipede was evaluated and rejected for local use: its README self-describes as +"work-in-progress, tested within a small team on a couple of targets, no stable +interface." AFL++ was evaluated but its fork-server model is slower for the in-process +library fuzzing pattern OIIO uses; it provides value at OSS-Fuzz scale where it runs +automatically. + +**Alternatives considered**: +- libFuzzer hardcoded: functionally identical for v1, but blocks future OSS-Fuzz + engine diversity without a build script change. +- AFL++ only: slower in-process throughput; requires file-based I/O unless using + persistent mode (more complex harness). +- Centipede: experimental, unstable interface, overkill for the scale we target. + +## 1a. FuzzTest vs libFuzzer: Why Not FuzzTest for File Format Harnesses? + +**Decision**: Use raw `LLVMFuzzerTestOneInput` harnesses (not FuzzTest) for file format +fuzzing. FuzzTest is explicitly reserved for future structured API fuzzing. + +**What FuzzTest is**: A C++ testing framework from Google that sits *on top of* a fuzzing +engine (libFuzzer or Centipede). It provides a property-based testing API using a +`FUZZ_TEST` macro that integrates with GoogleTest: + +```cpp +void ReadingImageNeverCrashes(int width, int height, std::vector pixels) { + MyApi(width, height, pixels); +} +FUZZ_TEST(Suite, ReadingImageNeverCrashes) + .WithDomains(InRange(1,16384), InRange(1,16384), Arbitrary>()); +``` + +The same test runs as a normal unit test in regular CI (fast, deterministic seed +replay) and as a full fuzzing campaign when built with fuzzer instrumentation. + +**Why it does not help for file format fuzzing**: FuzzTest's core value is +*structured domain constraints* — generating type-safe, range-bounded inputs rather +than raw bytes. For image format reader fuzzing, the input IS raw bytes (`Arbitrary< +std::vector>()`), which is exactly equivalent to `LLVMFuzzerTestOneInput`. +FuzzTest adds a GoogleTest dependency and build complexity without improving coverage +or throughput for this use case. + +**Why it does NOT conflict with future FuzzTest use**: The two approaches are +completely independent — separate source files, separate CMake targets, separate GHA +jobs, separate OSS-Fuzz target names. FuzzTest harnesses can be added to `src/fuzz/` +(or a new `src/fuzztest/` directory) at any time without modifying any existing +`LLVMFuzzerTestOneInput` harness. OSS-Fuzz supports both in the same project. + +**Where FuzzTest would add clear value** (future work, not v1): +- `ImageBufAlgo` operations with typed parameters (width, height, channel count, + filter type, pixel format) — structured inputs where domain constraints dramatically + reduce wasted iterations. +- `oiiotool` command-line argument fuzzing — structured sequences of valid operations + with random but type-correct arguments. +- `ImageCache`/`TextureSystem` parameter fuzzing — tile sizes, cache sizes, filter + modes, thread counts. + +These are all cases where the input has typed structure and domain invariants that +FuzzTest can exploit. File bytes have none of that structure. + +**Alternatives considered**: +- Use FuzzTest for everything: Adds GoogleTest build dependency. For byte-stream + parsing, provides no coverage improvement. Rejected. +- Use FuzzTest for file formats via `Arbitrary>`: Functionally + identical to `LLVMFuzzerTestOneInput` but adds dependency overhead. Rejected. + +## 2. In-Memory I/O via IOProxy + +**Decision**: Use `OIIO::Filesystem::IOMemReader` to pass raw fuzz bytes directly to +`ImageInput::open()`, avoiding disk I/O entirely. + +**Rationale**: `ImageInput::open(filename, config, ioproxy)` accepts an `IOProxy*` +argument. `IOMemReader` wraps a `const void*, size_t` buffer as a seekable in-memory +stream. This means: +- No temp file creation per fuzz iteration (critical for throughput — disk I/O would + reduce executions/sec by 10–100×). +- No cleanup needed per iteration. +- The format is indicated by passing a fake filename with the correct extension + (e.g., `"input.exr"`) so the format dispatcher routes to the correct plugin. + +The `IOMemReader` approach is already used in OIIO's test infrastructure and is the +canonical way to fuzz in-process readers without disk I/O. + +**Alternatives considered**: +- Write bytes to a temp file per iteration: correct but 10–100× slower; temp file + cleanup is error-prone under sanitizer exits. +- Use `ImageInput::create("formatname")` + `open_memory()`: no such API exists; + `IOProxy` is the correct abstraction. + +## 3. Build System Integration + +> **Superseded**: the `add_fuzz_target(format)` macro and per-format targets +> described below were replaced during implementation with a single +> `add_executable(oiio_fuzz_image fuzz_image.cpp)` target. The CMake option name, +> default, and clang-detection rationale still hold; only the "one target per +> format" mechanics changed. See `src/fuzz/CMakeLists.txt` for the as-built file. +> Two behavioral changes not anticipated here: (1) both gcc *and* Apple's clang +> (missing the libFuzzer runtime) are handled with a CMake `WARNING` + `return()` +> rather than `FATAL_ERROR`, so an unsuitable compiler doesn't break the rest of +> the OIIO build; (2) the fuzz target's ASan/UBSan flags are applied directly to +> `oiio_fuzz_image`, independent of the global `SANITIZE` variable, so the target +> is always sanitized rather than only when `SANITIZE` happens to be set. + +**Decision**: New `OIIO_BUILD_FUZZ_TARGETS` CMake option (default OFF) in a new +`src/fuzz/CMakeLists.txt`. An `add_fuzz_target(format)` macro handles the repetitive +per-format target definition. The existing `SANITIZE` CMake variable handles +`-fsanitize=address,undefined`; fuzzer instrumentation (`-fsanitize=fuzzer` or +`$LIB_FUZZING_ENGINE`) is handled separately as a linker flag on fuzz targets only. + +**Rationale**: OIIO already has `SANITIZE` support in `src/cmake/compiler.cmake` (adds +`-fsanitize=` compile and link options). The fuzz targets need +`-fsanitize=address,undefined` for sanitizer coverage AND `-fsanitize=fuzzer` (or +`$LIB_FUZZING_ENGINE`) for the fuzzer runtime. These must be applied to fuzz target +executables only — not to libOpenImageIO itself — so a dedicated section in +`src/fuzz/CMakeLists.txt` is the right place. + +Key CMake pattern: +```cmake +# Resolve fuzzing engine: OSS-Fuzz sets LIB_FUZZING_ENGINE; local default is -fsanitize=fuzzer +if(DEFINED ENV{LIB_FUZZING_ENGINE}) + set(OIIO_FUZZING_ENGINE "$ENV{LIB_FUZZING_ENGINE}") +else() + set(OIIO_FUZZING_ENGINE "-fsanitize=fuzzer") +endif() + +macro(add_fuzz_target name) + add_executable(fuzz_${name} fuzz_${name}.cpp) + target_link_libraries(fuzz_${name} PRIVATE OpenImageIO) + target_compile_options(fuzz_${name} PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(fuzz_${name} PRIVATE + -fsanitize=address,undefined ${OIIO_FUZZING_ENGINE}) +endmacro() +``` + +Note: `libOpenImageIO` itself is built with ASan+UBSan compile flags (via the `SANITIZE` +cmake variable) but NOT with `-fsanitize=fuzzer` — the fuzzer instrumentation applies +only to fuzz executables. + +**Alternatives considered**: +- Separate fuzz CMake preset: more isolated but duplicates all dependency resolution. +- Makefile targets wrapping cmake: not worth the indirection. + +## 4. GHA Workflow Architecture + +> **As-built deltas**: the container tag moved to `aswf/ci-oiio:2027` (rolling +> forward from `:2026.3` between spec time and landing); the cron schedule became +> `0 10 * * *` (not `0 2 * * *`); tier time budgets were scaled down to 3600s/1800s +> (not 19800s/3600s, and Tier 1 covers exactly 10 formats, not "12", matching +> `data-model.md`'s tier table); `fuzz.yml` doesn't hand-roll build/cache/run steps +> itself — it delegates to the `build-steps.yml` reusable workflow via +> `fuzz_format`/`fuzz_max_time` inputs, gaining ccache and standard dependency +> install for free. The workflow also gained a `push` trigger for any +> `*fuzz*`-named branch, useful for iterating on the workflow itself. +> Within `build-steps.yml`, the corpus-lint / seed / run / summary logic that was +> originally four separate steps of inline bash was later consolidated into one +> step (`id: fuzz`) calling `src/build-scripts/ci-fuzztest.bash`, and the +> `fuzz-corpus-lint` job itself moved from `ci.yml` into `fuzz.yml`. + +**Decision**: `.github/workflows/fuzz.yml` with a parallel matrix (`fail-fast: false`), +two-tier time budgets, GHA cache for evolved corpus, and artifact upload on crash. + +**Key workflow design decisions**: + +### Container +Reuse `aswf/ci-oiio:2026.3` (same as the existing sanitizer CI job). This container +already has clang, all format libraries (OpenEXR, libtiff, libpng, libjpeg-turbo, etc.), +and is the known-good environment for ASan builds. No new container needed. +*(As-built: `aswf/ci-oiio:2027`.)* + +### Matrix definition +```yaml +strategy: + fail-fast: false + matrix: + include: + # Tier 1: complex/high-risk — 5h30m (19800s) + - { format: exr, tier: 1, max_total_time: 19800 } + - { format: tiff, tier: 1, max_total_time: 19800 } + - { format: jpeg, tier: 1, max_total_time: 19800 } + - { format: png, tier: 1, max_total_time: 19800 } + - { format: dpx, tier: 1, max_total_time: 19800 } + - { format: psd, tier: 1, max_total_time: 19800 } + - { format: heif, tier: 1, max_total_time: 19800 } + - { format: webp, tier: 1, max_total_time: 19800 } + - { format: jpegxl, tier: 1, max_total_time: 19800 } + - { format: jpeg2000, tier: 1, max_total_time: 19800 } + - { format: raw, tier: 1, max_total_time: 19800 } + - { format: dicom, tier: 1, max_total_time: 19800 } + # Tier 2: simpler — 1h (3600s) + - { format: bmp, tier: 2, max_total_time: 3600 } + - { format: cineon, tier: 2, max_total_time: 3600 } + - { format: dds, tier: 2, max_total_time: 3600 } + - { format: fits, tier: 2, max_total_time: 3600 } + - { format: gif, tier: 2, max_total_time: 3600 } + - { format: hdr, tier: 2, max_total_time: 3600 } + - { format: ico, tier: 2, max_total_time: 3600 } + - { format: iff, tier: 2, max_total_time: 3600 } + - { format: pnm, tier: 2, max_total_time: 3600 } + - { format: rla, tier: 2, max_total_time: 3600 } + - { format: sgi, tier: 2, max_total_time: 3600 } + - { format: softimage, tier: 2, max_total_time: 3600 } + - { format: targa, tier: 2, max_total_time: 3600 } + - { format: ffmpeg, tier: 2, max_total_time: 3600 } + - { format: zfile, tier: 2, max_total_time: 3600 } + - { format: openvdb, tier: 2, max_total_time: 3600 } # conditional on OPENVDB_FOUND + - { format: ptex, tier: 2, max_total_time: 3600 } # conditional on PTEX_FOUND +``` + +*(As-built matrix: see `data-model.md`'s tier table — 10 Tier-1 formats at 3600s, +19 Tier-2 formats at 1800s, `openexr` not `exr`, `raw` in Tier 1 and `webp` in +Tier 2 rather than the reverse.)* + +### Corpus cache key +`fuzz-corpus-${{ matrix.format }}-${{ github.ref_name }}` + +Restoring from a broader fallback key `fuzz-corpus-${{ matrix.format }}-` ensures the +cache is seeded even when switching branches. This is standard GHA cache pattern. + +*(As-built: the save key also includes `${{ github.run_id }}` so every run writes a +new cache entry, with `restore-keys` doing the prefix-fallback work described here — +see `data-model.md` EvolvedCorpus.)* + +### Per-format fuzz invocation +```bash +./fuzz_${FORMAT} \ + corpus/${FORMAT} \ + -max_total_time=${{ matrix.max_total_time }} \ + -timeout=60 \ + -artifact_prefix=crash_${FORMAT}_ \ + -jobs=$(nproc) +``` + +`-timeout=60` kills any single input that takes more than 60 seconds (prevents hangs). +`-jobs=$(nproc)` uses all available cores within the job for parallel fuzzing workers. + +### Crash detection and artifact upload +libFuzzer writes crash files as `crash__` in the working directory when +it finds a bug. A post-run step checks for `crash_*` files and uploads them; the step +exit code propagates failure. + +**Alternatives considered**: +- Custom time-budgeting script: unnecessary with parallel matrix (each job owns its time). +- ubuntu-latest without container: would need manual installation of all format libs, + fragile and maintenance-heavy. + +## 5. Harness Pattern + +> **Superseded**: this section's per-format harness files (`fuzz_jpeg.cpp`, +> `fuzz_raw.cpp`, ...) and the fixed 64 MP-in-harness OOM cap were replaced during +> implementation. There is one harness file, `fuzz_image.cpp`. The read loop +> (including the OOM defense) moved into `OIIO::pvt::test_read_image()` / +> `test_read_all_images()` in `libOpenImageIO`, which reads in bounded chunks +> (a tile-row at a time, or a fixed number of scanlines at a time) rather than +> allocating a single buffer sized to the claimed image dimensions, and OOM +> defense is now primarily OIIO's own `limits:imagesize_MB` / `limits:resolution` +> attributes, tuned in `OIIO_FUZZ_INIT` to sit under libFuzzer's `rss_limit_mb`. +> See `contracts/harness-contract.md` for the as-built API usage contract. +> The `fuzz_utils.h` split described below (shared helpers in a header, included +> by the harness) was itself later removed: since `fuzz_image.cpp` is the only +> file that includes it, the split added a file with no reuse benefit, so its +> contents were folded directly into `fuzz_image.cpp`. + +**Decision**: Each harness is ~25 lines. Shared logic in `fuzz_utils.h`. + +**Standard harness template**: +```cpp +// fuzz_jpeg.cpp +// SPDX-License-Identifier: Apache-2.0 +#include "fuzz_utils.h" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + oiio_fuzz_read(data, size, "input.jpg"); + return 0; +} +``` + +**`fuzz_utils.h` core function**: +```cpp +inline void oiio_fuzz_read(const uint8_t* data, size_t size, + const char* fake_filename) +{ + OIIO::Filesystem::IOMemReader mem( + const_cast(static_cast(data)), size); + OIIO::ImageSpec config; + auto inp = OIIO::ImageInput::open(fake_filename, &config, &mem); + if (!inp) + return; + OIIO::ImageSpec spec = inp->spec(); + // Cap pixel read to avoid OOM on adversarially large dimensions + if (spec.image_pixels() > 0 && spec.image_pixels() < 64 * 1024 * 1024) { + std::vector buf(spec.image_pixels() * spec.nchannels); + inp->read_image(0, 0, 0, spec.nchannels, OIIO::TypeUInt8, buf.data()); + } + inp->close(); +} +``` + +**OOM guard**: The pixel dimension check caps memory allocation. A fuzzer-generated +header claiming a 1M×1M image would cause a multi-GB allocation without this guard. +64 MP (≈256MB at 4 bytes/pixel) is a reasonable cap; format readers that crash before +`read_image` are still detected. + +**Multi-subimage formats** (EXR, TIFF): The EXR/TIFF harness iterates subimages: +```cpp +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + OIIO::Filesystem::IOMemReader mem(...); + auto inp = OIIO::ImageInput::open("input.exr", nullptr, &mem); + if (!inp) return 0; + do { + auto& spec = inp->spec(); + if (spec.image_pixels() < 64 * 1024 * 1024) { + std::vector buf(spec.image_pixels() * spec.nchannels); + inp->read_image(0, 0, 0, spec.nchannels, OIIO::TypeUInt8, buf.data()); + } + } while (inp->seek_subimage(inp->current_subimage() + 1, 0)); + inp->close(); + return 0; +} +``` + +**Alternatives considered**: +- Calling internal plugin methods directly: violates Principle I, no benefit. +- Writing to disk per iteration: 10–100× slower, rejected. +- Using `ImageBuf` instead of `ImageInput`: adds ImageCache overhead, less direct. + +## 6. Seed Corpus Strategy + +**Decision**: Source seeds from existing test image repositories already checked out for +CI. Copy 1–3 small (< 100KB) files per format into `src/fuzz/corpora//` via a +seed population script. Only ~5 formats require generating synthetic seeds from scratch. + +### Corpus sources (all already present in CI checkout) + +The testsuite and several companion repos live alongside the OIIO source in CI and in +local development. `testsuite/runtest.py` already detects `../oiio-images` at that +relative path. The fuzz seed population script mirrors this convention. + +| Format(s) | Source repo | Path | +|-----------|-------------|------| +| exr, tiff, jpeg, png, bmp, dds, ico, pnm, psd, rla, sgi, tga, openvdb, ptex | **testsuite** (in-repo) | `testsuite//src/` and `testsuite/common/` | +| gif, webp, heif, dpx, cineon, raw†, softimage | **oiio-images** | `../oiio-images//` | +| jpeg2000 | **j2kp4files_v1_5** | `../j2kp4files_v1_5/` | +| fits | **fits-images** | `../fits-images/ftt4b/` | +| dicom | **dicom-images-pvt** | `../dicom-images-pvt/` | +| ffmpeg†† | **testsuite** | `testsuite/ffmpeg/ref/*.mkv` | +| hdr, iff, jpegxl, zfile | **generate** | `oiiotool --create` or format-specific tool | + +† `raw.imageio` is a dispatch plugin over LibRaw that reads all camera RAW formats (CR2, +NEF, RAF, ORF, ARW, PEF, RW2, …). It is read-only in OIIO. Seeds in `../oiio-images/raw/` +cover several camera brands. + +†† `ffmpeg.imageio` is a dispatch plugin over the FFmpeg library that reads all video +container formats (MKV, MP4, MOV, AVI, …). It is read-only in OIIO. Seeds in +`testsuite/ffmpeg/ref/` cover two MKV variants with different color spaces. + +Only the last row (~4 formats) requires generating synthetic seeds. All other formats +have real sample files available in repos already used by CI. + +**Note on crash-* files in testsuite**: Several formats (bmp, dds, ico, psd, rla, tga, +tiff) have existing `crash-*` files in their testsuite directories — past bug reproducers +that were fixed. These are excellent corpus seeds: they test known edge cases and the +fuzzer will mutate around them to find nearby bugs. Include them alongside valid files. + +**Seed population script**: `src/fuzz/populate_corpora.py` — copies seeds from the +source repos into a destination corpus directory. The script is idempotent and +documents exactly where each seed came from. + +*(As-built: the "commit everything" plan changed. Only formats with no other +source (dpx, fits, hdr, iff, jpeg2000, jpegxl, openexr, sgi) commit a synthetic +seed to `src/fuzz/corpora/`. Every other format's seeds are pulled fresh at CI +time — `populate_corpora.py --format --dest corpus/` runs as part of the +`fuzz` step's `src/build-scripts/ci-fuzztest.bash` script in `build-steps.yml`, +against a freshly-checked-out `oiio-images` — rather than being committed to +the repo. This keeps the repo from carrying a large binary sample set while +OSS-Fuzz corpus zip packaging, if User Story 5 is ever picked up, would need +its own seeding step since it can't assume a CI-time checkout of companion +repos.)* + +### Special harness design for multi-format dispatch plugins (raw, ffmpeg) + +Both `raw.imageio` (LibRaw) and `ffmpeg.imageio` (FFmpeg) are read-only dispatch plugins +that hand off parsing to a well-maintained third-party library. Both libraries are +heavily fuzzed by their own projects (LibRaw and FFmpeg each have OSS-Fuzz coverage). + +**Fuzzing goal for these two**: verify that the OIIO plugin wrapper correctly handles +errors and unexpected return values from the underlying library — not to find bugs in +LibRaw or FFmpeg themselves. A small corpus of 2–3 representative files is sufficient. + +**Harness strategy**: use `ImageInput::create("raw")` / `ImageInput::create("ffmpeg")` +to force the specific plugin regardless of file extension, then open via `IOMemReader`. +This avoids the need to fake a specific extension for sub-format routing; LibRaw and +FFmpeg both detect the actual format internally from magic bytes. + +```cpp +// fuzz_raw.cpp +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + OIIO_FUZZ_INIT; + OIIO::Filesystem::IOMemReader mem(const_cast((const void*)data), size); + auto inp = OIIO::ImageInput::create("raw"); + if (!inp) return 0; // plugin not compiled in + OIIO::ImageSpec config; + if (!inp->open("input", config, &mem)) return 0; + // read + close as usual + inp->close(); + return 0; +} +``` + +**Corpus minimization**: libFuzzer's `-merge=1` flag can post-process the evolved corpus +after an extended run to keep only files that add coverage. This is a manual step, +not automated in v1. + +## 7. Excluded Formats + +| Format | Reason | +|--------|--------| +| `null` | Test stub with no real parsing; fuzzing adds no value. | +| `term` | Output-only (terminal display); no `ImageInput` implementation. | +| `r3d` | Requires proprietary RED Digital Cinema SDK; not available in open CI. | + +**openvdb and ptex are included** (Tier 2, conditionally compiled). Initial plan excluded +them with weak reasoning. Both have full `ImageInput` implementations, both accept +binary file data through the standard API, and both are complex enough to harbor parser +bugs. They are wrapped in `if(OPENVDB_FOUND)` / `if(PTEX_FOUND)` CMake guards exactly +like `heif`, `jpegxl`, and `raw`. They are conditionally included in the GHA matrix +only when their libraries are present in the build container. + +## 8. OSS-Fuzz Onboarding Path + +**Status: not started.** US-5 (P3) was deferred after US1–US4 landed; no +`ossfuzz/`, `projects/openimageio/`, or equivalent files exist in the repo. The +plan below is unchanged from research time and remains the intended path +whenever this is picked up — the harness-side prerequisites it depends on +(`$LIB_FUZZING_ENGINE` linkage, `argv[0]` format dispatch, `--list-formats`) +are already built. + +OSS-Fuzz requires three files in `projects/openimageio/`: +1. `project.yaml` — project metadata, language: c++, primary_contact, fuzzing_engines. +2. `Dockerfile` — `FROM gcr.io/oss-fuzz-base/base-builder` + format library installs. +3. `build.sh` — cmake configure + build, then `cp $WORK/build/src/fuzz/fuzz_* $OUT/`. + +The `build.sh` pattern: +```bash +cmake -B $WORK/build $SRC/openimageio \ + -DOIIO_BUILD_FUZZ_TARGETS=ON \ + -DSANITIZE=address,undefined \ + -DCMAKE_C_COMPILER=$CC \ + -DCMAKE_CXX_COMPILER=$CXX +cmake --build $WORK/build --target all_fuzz_targets -j$(nproc) +cp $WORK/build/src/fuzz/fuzz_* $OUT/ +for fmt in exr tiff jpeg png ...; do + zip -j $OUT/fuzz_${fmt}_seed_corpus.zip src/fuzz/corpora/${fmt}/* +done +``` + +OSS-Fuzz sets `LIB_FUZZING_ENGINE`, `CC`, `CXX`, `CFLAGS`, `CXXFLAGS`, `OUT`, `WORK`, +`SRC` as environment variables. Our CMakeLists respects these automatically. + +These three files are **not** part of v1 deliverables (US-5 is P3) but the structure +documented above is what makes them a small incremental step. diff --git a/docs/dev/specs/001-image-fuzzing/spec.md b/docs/dev/specs/001-image-fuzzing/spec.md new file mode 100644 index 0000000000..fa98e44b1c --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/spec.md @@ -0,0 +1,366 @@ +# Feature Specification: Image Format Fuzzing Infrastructure + +**Feature Branch**: `001-image-fuzzing` + +**Created**: 2026-06-23 + +**Status**: Implemented (User Stories 1–4). User Story 5 (OSS-Fuzz onboarding) +is deferred — no `ossfuzz/` files have been created. + +**Implementation note (post-merge)**: The design pivoted during implementation +from "one harness binary per format" to a single dynamic-dispatch binary +(`oiio_fuzz_image`) that selects its target format at runtime. This changes +the letter of User Story 2 and FR-001 below (see inline notes) but not their +intent: every in-scope format still gets fuzzed, and adding a new format still +requires no harness code. See `plan.md` and `contracts/harness-contract.md` +for the as-built contract. + +**Input**: User description: "fuzzing strategy for OpenImageIO image format readers, GHA CI workflow, oss-fuzz compatible" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Automated Nightly Fuzz CI Run (Priority: P1) + +A developer or security engineer wants confidence that OpenImageIO image format readers +are robust against malformed, corrupted, and adversarially crafted inputs. They want a +GitHub Actions workflow that runs on a nightly schedule (and on demand), exercises every +supported image format reader with a fuzzing engine for as long as the job allows, and +fails loudly if any crash or memory safety violation is found — capturing a reproducer +artifact for investigation. + +**Why this priority**: This is the core ask. Without a running CI workflow, the fuzzing +infrastructure has no operational value. It also delivers immediate security value every +night without any developer action. + +**Independent Test**: Trigger the GHA workflow manually on the branch. It should build +fuzz targets, run them against a seed corpus, and complete without crashing. Introduce a +known-bad input (e.g., a file that triggers an out-of-bounds read) and verify the run +fails and uploads a reproducer artifact. + +**Acceptance Scenarios**: + +1. **Given** the nightly schedule fires, **When** all image format fuzz targets run for + their allotted time, **Then** the workflow reports success and no crash artifacts are + uploaded. +2. **Given** a fuzz target finds a crash or sanitizer violation, **When** the run + completes, **Then** the workflow step fails, the reproducer input is uploaded as a + GHA artifact, and the failing format is identified in the job summary. +3. **Given** a developer pushes a new format plugin, **When** the nightly workflow runs, + **Then** that format's fuzz target is included automatically (or a failing check + reminds the developer to add one). +4. **Given** the workflow is triggered manually via `workflow_dispatch`, **When** a + specific format name is passed as input, **Then** only that format's target runs + (useful for re-checking a fix). + +--- + +### User Story 2 - Dynamic-Dispatch Fuzz Harness (Priority: P1) + +*(As implemented: this superseded the originally-scoped "one harness binary per +format" story — see note below.)* + +A developer adding or modifying an image format reader wants fuzz coverage to +appear automatically, without writing or registering a new harness file. A +single harness binary discovers every compiled-in format at runtime and is +pointed at one format per run (via an env var, `argv[0]`, or a flag), so the +harness structure is uniform by construction — there is only one harness. + +**Why this priority**: Without a harness, there is nothing to fuzz. Collapsing +to one dynamically-dispatched binary removes the cost of adding and maintaining +a separate harness file for each of the 29 in-scope format plugins, and means +new format plugins get fuzz coverage the moment they're compiled in — no +harness PR required, only a seed corpus directory (enforced by CI lint). + +**Independent Test**: Build `oiio_fuzz_image` once. Run it with +`OIIO_FUZZ_FORMAT=jpeg` and separately with `OIIO_FUZZ_FORMAT=png` against +their seed corpora for 30 seconds each. Verify both run without crashes and +produce coverage output. Run `--list-formats` and confirm all compiled-in +formats are listed. + +**Acceptance Scenarios**: + +1. **Given** the harness is built, **When** `OIIO_FUZZ_FORMAT` selects a format + at startup, **Then** the harness dispatches every subsequent input to that + format's reader for the life of the process. +2. **Given** a harness is built, **When** it is fed a valid seed image for the + selected format, **Then** it processes the image without crashing. +3. **Given** a harness is built, **When** it is fed a zero-byte input or random + bytes, **Then** it returns gracefully without undefined behavior (crash == + fail). +4. **Given** the harness entry point follows the OSS-Fuzz `LLVMFuzzerTestOneInput` + signature, **When** the OSS-Fuzz build script runs, **Then** it links without + modification. + +**Note on production source changes**: The original constraint that harness +work require "no changes to production source files" was superseded by a +deliberate design decision, not abandoned under pressure. While building the +harness's read-loop (chunked scanline/tile reads, subimage/MIP iteration, +bail-out on first read failure), it became clear the same logic was generally +useful outside of fuzzing — as a way to interactively exercise and debug an +`ImageInput` the same way the fuzzer does, without rebuilding the fuzz binary. +That logic was moved into the core library as `OIIO::pvt::test_read_image()` / +`test_read_all_images()` (`src/include/imageio_pvt.h`, +`src/libOpenImageIO/imageinput.cpp`) and exposed through a new `oiiotool +--testread` flag (`src/oiiotool/oiiotool.cpp`), giving both the harness and +developers one tested implementation instead of two. See FR-003a below. + +--- + +### User Story 3 - Seed Corpus Per Format (Priority: P2) + +A developer wants each format's fuzz target to start from a meaningful seed corpus +(small, valid, representative sample images) rather than random bytes, improving coverage +speed and reducing time-to-first-interesting-mutation. + +**Why this priority**: Seeds dramatically improve fuzzer effectiveness. However, fuzz +targets without seeds still run — they just find bugs more slowly. Seeds are high value +but not blocking for initial deployment. + +**Independent Test**: For three formats (e.g., TIFF, EXR, PNG), verify that a non-empty +seed corpus exists in the designated corpus directory, that each seed file is a valid +image of the correct format, and that the fuzz target processes every seed without +crashing. + +**Acceptance Scenarios**: + +1. **Given** seeds exist in the corpus directory, **When** the fuzz target initializes, + **Then** it loads and processes all seeds before beginning mutation. +2. **Given** the seed corpus for a format is empty or missing, **When** the fuzz target + runs, **Then** it starts from scratch without crashing (graceful degradation). +3. **Given** OIIO's existing test suite contains valid sample images, **When** seeds are + collected, **Then** a subset of those images is reused as seeds to avoid duplicating + storage. + +--- + +### User Story 4 - Local Developer Fuzzing Workflow (Priority: P2) + +A developer working on a format plugin wants to run fuzzing locally against their changes +before pushing — to catch crashes before CI does, to reproduce a specific finding +interactively, or to extend the seed corpus. The build system MUST make local fuzz runs +as easy as `cmake --build . --target fuzz-jpeg` (or equivalent), without requiring +special environment setup beyond the fuzz build configuration. + +**Why this priority**: Without a local workflow, developers cannot act on CI fuzz +findings efficiently. Reproducing a crash requires re-running CI or guessing at the +reproducer. Local fuzzing also enables pre-push validation of format changes. + +**Independent Test**: On a developer workstation with clang and ASan available, configure +the project with the fuzz build option enabled. Build and run the JPEG fuzz target +locally for 60 seconds. Verify it runs, processes seeds, and exits cleanly. Then feed +it a known-crashing input and verify it exits non-zero with an ASan report. + +**Acceptance Scenarios**: + +1. **Given** a developer has a fuzz-enabled build configured, **When** they run the fuzz + target binary for a specific format directly, **Then** it starts fuzzing without + additional setup. +2. **Given** a crash reproducer file, **When** the developer passes it as an argument to + the fuzz target binary, **Then** the crash is reproduced reliably with a full ASan + stack trace. +3. **Given** a developer wants to extend the seed corpus, **When** they add a new image + file to the corpus directory, **Then** the next fuzz run picks it up automatically. +4. **Given** a developer is on macOS or Linux with clang installed, **When** they follow + the documented local fuzzing steps, **Then** they can build and run at least one fuzz + target within 15 minutes of reading the documentation. + +--- + +### User Story 5 - OSS-Fuzz Onboarding Readiness (Priority: P3) + +A project maintainer wants to submit OpenImageIO to OSS-Fuzz so that Google's +infrastructure continuously fuzzes the project at scale. The local fuzzing infrastructure +(harnesses, build configuration, corpus) MUST be structured so that writing an OSS-Fuzz +`project.yaml` and build script requires minimal delta work. + +**Why this priority**: OSS-Fuzz integration is a stated future goal but not required for +the nightly CI value. Getting the structure right from day one means OSS-Fuzz onboarding +is a small incremental step rather than a rework. + +**Independent Test**: Write a draft `project.yaml` and `build.sh` for OSS-Fuzz that +references the existing harnesses and build configuration. Verify that the OSS-Fuzz +`infra/helper.py build_fuzzers openimageio` command succeeds in a local Docker +environment. + +**Acceptance Scenarios**: + +1. **Given** the harness entry points follow the `LLVMFuzzerTestOneInput` ABI, **When** + OSS-Fuzz's `build.sh` compiles them, **Then** all targets link without changes to + harness source. +2. **Given** the corpus structure follows OSS-Fuzz conventions, **When** OSS-Fuzz syncs + the corpus, **Then** seeds are picked up automatically. +3. **Given** a draft OSS-Fuzz PR is submitted, **When** OSS-Fuzz CI validates it, + **Then** the check passes within one iteration of feedback. + +--- + +### Edge Cases + +- What happens when a format reader depends on an optional third-party library that is + not available in the fuzz build? (Harness must degrade gracefully or be conditionally + compiled.) +- How does the system handle a fuzz target that times out rather than crashes? (Timeout + should be treated as a failure and flagged.) +- What happens when the seed corpus grows too large over time and slows down CI? + (Corpus minimization strategy needed.) +- What if a fuzz finding is a hang (infinite loop) rather than a crash? (Harness MUST + set a wall-clock timeout on image open/read operations.) + +## Clarifications + +### Session 2026-06-23 + +- Q: Should GHA fuzz jobs run sequentially (one job, shared time) or in parallel (matrix, one job per format)? → A: Parallel matrix — one job per format so each gets its own full runtime budget. +- Q: Should all formats get equal job duration, or should time be weighted by format complexity/risk? → A: Two tiers — complex/high-risk formats (EXR, TIFF, JPEG, PNG, DPX, PSD, HEIC, WebP, JXL, JPEG2000) get full-duration jobs; simpler formats (BMP, ICO, HDR, PNM, GIF, SGI, etc.) get a shorter capped duration. +- Q: Should the evolved fuzz corpus be discarded after each run, cached between runs, or committed to the repo? → A: Persist via GHA cache keyed per format; corpus grows nightly and is restored at the start of each run. +- Q: When one format job finds a crash, should remaining format jobs be cancelled or continue to completion? → A: Continue all — all format jobs run to completion regardless of other failures (`fail-fast: false`); overall workflow fails if any job failed. +- Q: Should harnesses link against libFuzzer specifically, or use the `$LIB_FUZZING_ENGINE` abstraction? → A: Use `$LIB_FUZZING_ENGINE` — libFuzzer is the local default; OSS-Fuzz automatically runs all four engines (libFuzzer, AFL++, Honggfuzz, Centipede) with no harness changes required. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide fuzz coverage for every supported image format + reader. *(As implemented: one dynamic-dispatch binary, `oiio_fuzz_image`, covers + all formats — not one binary per format. It discovers formats at runtime via + `OIIO::get_extension_map()` and is targeted at a single format per process via + `OIIO_FUZZ_FORMAT`.)* Of the 32 format plugins in `src/*.imageio/`, 29 are in + scope. Excluded: `null` (no real parsing), `term` (output-only), `r3d` + (proprietary SDK unavailable in CI). `openvdb` and `ptex` are included as + Tier 2, conditionally compiled on library availability. +- **FR-003a**: The per-input read logic (subimage/MIP iteration, chunked + scanline/tile reads bounded independent of claimed image size, bail-out on + first read failure) MUST live in one shared, tested implementation rather + than being duplicated in the harness. *(As implemented: `OIIO::pvt::test_read_image()` + / `test_read_all_images()` in `libOpenImageIO`, shared between + `oiio_fuzz_image` and `oiiotool --testread`.)* +- **FR-002**: Each harness MUST accept arbitrary byte sequences as input and attempt to + open and read image data through the standard `ImageInput` API. +- **FR-003**: Harnesses MUST be compiled with AddressSanitizer and UndefinedBehaviorSan + enabled; any sanitizer finding MUST cause a non-zero exit. +- **FR-004**: A GitHub Actions workflow MUST run all fuzz targets on a nightly schedule + and on `workflow_dispatch`. +- **FR-005**: The GHA workflow MUST use a parallel matrix strategy — one job per format + (or per small batch of formats) — so each format receives its own dedicated runtime + budget rather than competing for a shared time slice. +- **FR-005a**: Formats MUST be tiered by complexity and risk: Tier 1 (complex/high-risk: + OpenEXR, TIFF, JPEG, PNG, DPX, PSD, HEIF, JPEG XL, JPEG2000, RAW) runs a longer + duration (1 hour/job as implemented); Tier 2 (the remaining 19 simpler/lower-risk + formats: BMP, Cineon, DDS, DICOM, FITS, GIF, HDR, ICO, IFF, PNM, RLA, SGI, + Softimage, Targa, FFmpeg, WebP, Zfile, OpenVDB, Ptex) runs a shorter capped + duration (30 minutes/job as implemented). Tier membership MUST be explicitly + declared in the workflow configuration. *(WebP and DICOM are Tier 2, not Tier 1, + in the final tiering — WebP's risk was judged lower than RAW's broad + third-party-format surface once tiers were assigned.)* +- **FR-005b**: The matrix MUST use `fail-fast: false` so all format jobs run to + completion regardless of whether other format jobs have failed. The overall workflow + MUST report failure if any individual format job fails. +- **FR-006**: The GHA workflow MUST upload reproducer artifacts (crashing inputs) when a + target crashes or a sanitizer violation is detected. +- **FR-007**: The fuzz build MUST be controlled by a CMake option (e.g., + `OIIO_BUILD_FUZZ_TARGETS`) that defaults to `OFF` and does not affect normal builds. +- **FR-008**: Each format target MUST have a seed corpus directory; the harness MUST + process all seeds before mutation begins. +- **FR-008a**: The GHA fuzz workflow MUST restore a per-format evolved corpus from GHA + cache at the start of each run and save the updated corpus back to cache on completion, + so fuzzer coverage compounds across nightly runs. Seed corpus serves as the fallback + when no cache entry exists (first run or after cache eviction). *(As implemented, only + a handful of formats with no other source — hdr, iff, jpegxl, zfile, dpx, fits, + jpeg2000, openexr, sgi — commit a synthetic seed to `src/fuzz/corpora/`. All other + seeds are pulled at CI time from `testsuite/` and companion image repos by + `populate_corpora.py`, run as a workflow step, rather than being committed wholesale — + this keeps the repo from carrying large binary seed sets while still giving every + format a non-trivial starting corpus.)* +- **FR-009**: Harness entry points MUST use the `LLVMFuzzerTestOneInput(const uint8_t*, + size_t)` signature and MUST link against `$LIB_FUZZING_ENGINE` (not a hardcoded + `-fsanitize=fuzzer` flag) so the same harness binary works with libFuzzer locally and + with all four OSS-Fuzz engines (libFuzzer, AFL++, Honggfuzz, Centipede) without + source changes. +- **FR-010**: The harness MUST enforce a per-call timeout on image read operations to + prevent hangs from appearing as successful runs. *(As implemented: enforced + externally via libFuzzer's `-timeout=60` flag — seconds, passed at invocation + in `ci-fuzztest.bash` / documented locally in `docs/dev/fuzzing.md` — rather + than a timer inside the harness source itself. See `data-model.md` + `GHAFuzzWorkflow.per_input_timeout`.)* +- **FR-011**: The build and harness structure MUST support producing an OSS-Fuzz + `project.yaml` and `build.sh` with minimal additional work. +- **FR-012**: Fuzz target binaries MUST be runnable directly on a developer workstation + (macOS or Linux with clang) without additional tooling beyond the fuzz build + configuration, enabling local reproduce-and-debug workflows. +- **FR-013**: Developer documentation MUST describe how to build and run fuzz targets + locally, including how to reproduce a crash from a given input file. + +### Key Entities + +- **Fuzz Target**: A compiled binary for one image format that accepts raw bytes and + exercises the format's `ImageInput` implementation. +- **Seed Corpus**: A directory of small, valid image files for a specific format used to + seed the fuzzer's initial mutation queue. +- **Reproducer**: A minimal input file that reliably triggers a specific crash or + sanitizer finding; committed to the test suite after triage. +- **Fuzz Build**: A CMake build configuration with sanitizers and fuzzer instrumentation + enabled, separate from Release/Debug builds. +- **GHA Fuzz Workflow**: The GitHub Actions workflow file that orchestrates nightly fuzz + runs, time budgeting, and artifact upload. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All in-scope image format readers have a corresponding fuzz target + that compiles and runs without immediate crashes against their seed corpus. + *(As implemented: 29 of OIIO's 32 format plugins are in scope — see FR-001's + exclusion list. "40+" in earlier drafts of this criterion counted every reader + OIIO ships including the 3 explicitly excluded here; kept in sync with FR-001 + to avoid the two numbers drifting.)* +- **SC-002**: Each format's nightly fuzz job completes within the GitHub Actions per-job + time limit without manual intervention; all format jobs run in parallel via a matrix + strategy. +- **SC-003**: Any crash or sanitizer violation found during a fuzz run causes the GHA + workflow to fail within the same run and upload a reproducer artifact. +- **SC-004**: A developer can add fuzz coverage for a new format in under 30 minutes: + since the single dynamic-dispatch harness already covers any format OIIO can + compile in, this is now just "add a seed corpus directory" — no harness source + change at all. *(The one-time cost of adding `OIIO::pvt::test_read_image()` / + `test_read_all_images()` to `libOpenImageIO` was paid once, up front, for the whole + feature — not per format — and does not recur when a new format plugin is added.)* +- **SC-005**: The harness structure passes OSS-Fuzz's build validation check + (`infra/helper.py build_fuzzers`) without source changes to the harnesses themselves. +- **SC-006**: Seed corpora cover at least the 10 most-used formats (JPEG, PNG, TIFF, + EXR, DPX, BMP, GIF, WebP, HEIC, PSD) at initial rollout. +- **SC-007**: A developer can reproduce a CI-found crash locally by downloading the + reproducer artifact and running the fuzz target binary with it in under 5 minutes, + with no steps beyond the documented local fuzzing setup. + +## Assumptions + +- The initial rollout covers 29 of 32 format plugins. Excluded: `null` (test stub, no + parsing), `term` (output-only), `r3d` (proprietary RED SDK). All optional-dependency + formats (heif, jpegxl, jpeg2000, raw, dicom, ffmpeg, openvdb, ptex) are + conditionally compiled — present when the library is available, silently absent when + not. +- Reading is the primary fuzzing target; writing (ImageOutput) is out of scope for v1 + but the harness structure should not preclude adding write fuzzing later. +- OSS-Fuzz onboarding (User Story 5) was deferred after the P1/P2 stories landed; + no `ossfuzz/project.yaml`, `Dockerfile`, or `build.sh` exist yet. The harness's + `argv[0]`-based format dispatch (for `fuzz_` symlinks) and + `$LIB_FUZZING_ENGINE` linkage were still built in from the start, since they cost + nothing extra and keep this a small incremental step whenever it's picked up. +- Harnesses link against `$LIB_FUZZING_ENGINE` rather than hardcoding libFuzzer. For + local development, libFuzzer (clang's `-fsanitize=fuzzer`) is the default engine. + When onboarded to OSS-Fuzz, the same harnesses run under all four supported engines + (libFuzzer, AFL++, Honggfuzz, Centipede) automatically with no source changes. +- Google's FuzzTest framework was evaluated and explicitly deferred for v1. FuzzTest's + value is structured domain constraints on typed inputs; for raw-byte file format + parsing, it provides no advantage over `LLVMFuzzerTestOneInput`. FuzzTest is the + right tool for future structured API fuzzing (ImageBufAlgo, oiiotool, TextureSystem) + and is fully compatible with these harnesses — both can coexist in the same repo. + See `research.md §1a` for full rationale. +- GHA-hosted runners (Linux, `ubuntu-latest`) are sufficient for the nightly fuzz job; + macOS and Windows fuzz targets are out of scope for v1. +- Fuzz findings discovered by the nightly CI are triaged by the project maintainer; + no automated bug-filing integration (e.g., to GitHub Issues) is required for v1. +- OIIO's existing regression test image files (under `testsuite/`) are suitable as + initial seed corpus material without additional licensing concerns. +- The project's existing CMake build system will be extended (not replaced) to support + the fuzz build configuration. diff --git a/docs/dev/specs/001-image-fuzzing/tasks.md b/docs/dev/specs/001-image-fuzzing/tasks.md new file mode 100644 index 0000000000..3323ea909f --- /dev/null +++ b/docs/dev/specs/001-image-fuzzing/tasks.md @@ -0,0 +1,435 @@ +# Tasks: Image Format Fuzzing Infrastructure + +**Input**: Design documents from `specs/001-image-fuzzing/` + +**Prerequisites**: spec.md ✓, plan.md ✓, research.md ✓, data-model.md ✓, contracts/harness-contract.md ✓ + +**Tests**: Not included (no TDD requested in spec). + +**Organization**: Grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on each other) +- **[Story]**: Which user story this task belongs to (US1–US5) + +--- + +## Phase 1: Setup (Directory Structure) + +**Purpose**: Create the `src/fuzz/` subtree skeleton so all subsequent phases have a home. + +- [x] T001 Create `src/fuzz/` directory and empty placeholder `CMakeLists.txt` +- [x] T002 [P] Create all 29 corpus directories `src/fuzz/corpora//` (one per format: bmp, cineon, dds, dicom, dpx, exr, ffmpeg, fits, gif, hdr, heif, ico, iff, jpeg, jpeg2000, jpegxl, openvdb, png, pnm, psd, ptex, raw, rla, sgi, softimage, targa, tiff, webp, zfile) with a `.gitkeep` in each so git tracks them +- [x] T003 [P] Add `src/fuzz` subdirectory to the root `CMakeLists.txt` inside a `if(OIIO_BUILD_FUZZ_TARGETS)` guard so the option gates compilation + +--- + +## Phase 2: Foundational (Shared Fuzz Infrastructure) + +**Purpose**: `fuzz_utils.h` and `src/fuzz/CMakeLists.txt` — everything Phase 3 depends on. + +**⚠️ CRITICAL**: Phase 3 blocks on this phase. + +- [x] T004 Implement `src/fuzz/fuzz_utils.h` with: + - `OIIO_FUZZ_INIT` macro — calls `OIIO::attribute("imageinput:print_errors", 0)` and + `OIIO::attribute("exr:threads", 1)` once via static flag + - `oiio_fuzz_read(data, size, fake_filename)` — `IOMemReader` wrap → `ImageInput::open()` + → read if `image_pixels() < 64 * 1024 * 1024` → close + - `oiio_fuzz_read_multi(data, size, fake_filename)` — same but loops subimages via + `seek_subimage()` + - `oiio_fuzz_read_dispatch(data, size, plugin_name)` — `ImageInput::create(plugin_name)` + → open via `IOMemReader` → read → close; returns immediately if `create()` returns null + - `oiio_format_primary_ext(string_view format)` → `std::string` — queries + `OIIO::get_string_attribute("extension_list")`, finds the format entry, returns the + first listed extension + - `oiio_format_is_multi(string_view format)` → `bool` — true for exr, tiff (checks + `inp->supports("multiimage")` after a dummy open, or hardcoded set) + - `oiio_format_is_dispatch(string_view format)` → `bool` — true for raw, ffmpeg + - Standard copyright/SPDX header and `#pragma once` + - **Later folded into `fuzz_image.cpp`** (see T063): since `fuzz_image.cpp` was + always the header's only include, the separate file added no reuse benefit. +- [x] T005 Implement `src/fuzz/CMakeLists.txt` (replaces placeholder from T001): + - Early-exit guard: `if(NOT OIIO_BUILD_FUZZ_TARGETS) return() endif()` (option declared + in root CMakeLists) + - Clang detection: `if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") message(FATAL_ERROR + "OIIO_BUILD_FUZZ_TARGETS requires clang") endif()` + - Resolve fuzzing engine: `if(DEFINED ENV{LIB_FUZZING_ENGINE}) set(OIIO_FUZZING_ENGINE + "$ENV{LIB_FUZZING_ENGINE}") else() set(OIIO_FUZZING_ENGINE "-fsanitize=fuzzer") endif()` + - Single target: `add_executable(oiio_fuzz_image fuzz_image.cpp)`; `target_link_libraries(oiio_fuzz_image + PRIVATE OpenImageIO)`; `target_compile_options(-fsanitize=address,undefined + -fno-omit-frame-pointer)`; `target_link_options(-fsanitize=address,undefined + ${OIIO_FUZZING_ENGINE})` + - Alias `oiio_fuzz_image` as the `all_fuzz_targets` custom target (for OSS-Fuzz `build.sh`) + +**Checkpoint**: `cmake -B build -DOIIO_BUILD_FUZZ_TARGETS=ON -DSANITIZE=address,undefined` +configures cleanly; `oiio_fuzz_image` target defined but not yet buildable (source not yet written). + +--- + +## Phase 3: User Story 2 — Dynamic Dispatch Fuzz Harness (Priority: P1) + +**Goal**: One `oiio_fuzz_image` binary covering all formats dynamically, per the contract in +`contracts/harness-contract.md`. New formats are covered automatically with no harness +changes; the CI lint step (`./oiio_fuzz_image --list-formats`) enforces corpus coverage. + +**Independent Test**: Build `oiio_fuzz_image`. Run `OIIO_FUZZ_FORMAT=jpeg ./oiio_fuzz_image +src/fuzz/corpora/jpeg/ -max_total_time=30` and `OIIO_FUZZ_FORMAT=png ./oiio_fuzz_image +src/fuzz/corpora/png/ -max_total_time=30`. Both exit 0. Run `./oiio_fuzz_image --list-formats` +and confirm at least 20 format names are printed. + +- [x] T006 [US2] Implement `src/fuzz/fuzz_image.cpp`: + - `LLVMFuzzerInitialize(int* argc, char*** argv)`: + - Call `OIIO_FUZZ_INIT` + - Scan `(*argv)[0]` (binary name) for `fuzz_` pattern; if matched, use `` + - Check `OIIO_FUZZ_FORMAT` env var (overrides argv[0]) + - Scan remaining args for `--format=` (strip it from argv so libFuzzer doesn't + see it) + - If `--list-formats` found: print all formats from `extension_list` (excluding `null`, + `term`) one per line, then `exit(0)` + - If no format resolved: print error with list of available formats, then `exit(1)` + - Validate resolved format appears in `extension_list`; `exit(1)` if not found + - Store resolved format name in a `static std::string g_format` + - Store resolved primary extension in `static std::string g_ext` via + `oiio_format_primary_ext(g_format)` + - Return 0 + - `LLVMFuzzerTestOneInput(const uint8_t* data, size_t size)`: + - If `oiio_format_is_dispatch(g_format)`: call `oiio_fuzz_read_dispatch(data, size, + g_format.c_str())` + - Else if `oiio_format_is_multi(g_format)`: call `oiio_fuzz_read_multi(data, size, + fake_filename)` where `fake_filename = "input." + g_ext` + - Else: call `oiio_fuzz_read(data, size, fake_filename)` + - Return 0 + +- [x] T007 [US2] Add CI lint step to `.github/workflows/ci.yml` (the existing non-fuzz CI, + not the fuzz workflow): a step that builds `oiio_fuzz_image` in the sanitizer job (or a + dedicated short job), runs `./oiio_fuzz_image --list-formats`, and diffs the output against + the set of `src/fuzz/corpora/` directory names; fails with a clear message if any format + is missing a corpus directory + **As-built history**: this landed as the `fuzz-corpus-lint` job in `ci.yml`, then was + later moved to `.github/workflows/fuzz.yml` (see T062) to keep fuzzing-related CI in + one file. + +**Checkpoint**: `cmake --build build --target oiio_fuzz_image` succeeds. `OIIO_FUZZ_FORMAT=jpeg +./build/src/fuzz/oiio_fuzz_image src/fuzz/corpora/jpeg/ -max_total_time=30` runs for 30 seconds +and exits 0. + +--- + +## Phase 4: User Story 1 — Automated Nightly Fuzz CI Run (Priority: P1) + +**Goal**: `.github/workflows/fuzz.yml` runs nightly, all format jobs in parallel via matrix, +each targeting one format via `OIIO_FUZZ_FORMAT`, uploading crash artifacts, persisting +evolved corpus in GHA cache. + +**Independent Test**: Trigger `workflow_dispatch` on the branch. The workflow builds +`oiio_fuzz_image` once, then matrix jobs each run `OIIO_FUZZ_FORMAT= ./oiio_fuzz_image ...` +without crashing. Introducing a known-bad input causes the matching job to upload a +crash artifact. + +- [x] T035 [US1] Create `.github/workflows/fuzz.yml` with top-level structure: + - `name: Fuzz` + - `on:` block with `schedule: [{cron: "0 2 * * *"}]` and `workflow_dispatch: {inputs: + {format: {description: "Format name to fuzz (leave empty for all)", required: false, + default: ""}}}` + - `jobs: fuzz:` using container `aswf/ci-oiio:2026.3` +- [x] T036 [US1] Add matrix strategy to `fuzz` job in `.github/workflows/fuzz.yml`: + - `strategy: {fail-fast: false, matrix: {include: [...]}}` with all format entries per + `data-model.md` and `research.md §4`. + - **As-built** (not the 12/19800 vs 17/3600 split originally planned here): 10 Tier 1 + formats at `max_total_time: 3600` (openexr, tiff, jpeg, png, dpx, psd, heif, jpegxl, + jpeg2000, raw), 19 Tier 2 formats at `max_total_time: 1800`. Matrix entry format is + `{format: openexr, tier: 1, max_total_time: 3600}` — `openexr`, not `exr`. + - Job-level condition: as-built uses the job-level `if:` guard on + `github.repository == 'AcademySoftwareFoundation/OpenImageIO'` for the schedule + trigger (matrix context isn't available in a job-level `if:`, so per-format + `workflow_dispatch` filtering happens inside `build-steps.yml`'s steps instead of + the job condition originally planned here). +- [x] T037 [US1] Add build step to `fuzz` job in `.github/workflows/fuzz.yml`: + - **As-built**: `fuzz.yml` does not hand-roll checkout/configure/build steps itself — + it calls the `build-steps.yml` reusable workflow (`uses: ./.github/workflows/build-steps.yml`) + with `setenvs: SANITIZE=address,undefined OIIO_BUILD_FUZZ_TARGETS=ON ...`, gaining the + same ccache and dependency-install machinery as the main CI job. `build-steps.yml` + gained two new inputs, `fuzz_format` and `fuzz_max_time`, gating all fuzz-specific + steps so every other caller is unaffected. +- [x] T038 [US1] Add corpus cache restore step (now inside `build-steps.yml`, gated on + `inputs.fuzz_format != ''`): + - `actions/cache@v4` with **as-built** key + `fuzz-corpus-${{ inputs.fuzz_format }}-${{ github.ref_name }}-${{ github.run_id }}` + (unique per run) and `restore-keys` prefix fallback + `["fuzz-corpus-${{ inputs.fuzz_format }}-${{ github.ref_name }}-", "fuzz-corpus-${{ inputs.fuzz_format }}-"]`, + `path: corpus/${{ inputs.fuzz_format }}` + - Seeding step runs `populate_corpora.py --format --dest corpus/` against a + freshly checked-out `oiio-images`, then merges in any committed synthetic seed from + `src/fuzz/corpora//` — not a plain `cp -rn` of a fully-committed corpus as + originally planned, since most corpora are no longer committed (see T042). +- [x] T039 [US1] Add fuzz run step (now inside `build-steps.yml`): + - `env: {OIIO_FUZZ_FORMAT: inputs.fuzz_format}` + - **As-built** flags (beyond the original plan): `-max_len=16777216 -rss_limit_mb=4096 + -malloc_limit_mb=2048` (catches oversized single allocations synchronously so a real + reproducer is written, instead of libFuzzer's out-of-band RSS monitor producing an + empty crash file), `-detect_leaks=0`, `-jobs=$(nproc) -workers=$(nproc)` (both flags — + `-jobs` alone lets libFuzzer default `-workers` to half the cores). Also skips + gracefully (not a failure) via `--list-formats | grep -qx` when the format isn't + compiled into this build (e.g. optional-library formats absent from the container). +- [x] T040 [US1] Add corpus save and crash artifact upload steps (`if: always()` on both, + now inside `build-steps.yml`): + - Corpus save: `actions/cache@v4` save with the T038 key. + - Crash upload: `actions/upload-artifact@v4`, gated on `steps.fuzz.outcome == + 'failure'` (as-built: only uploads on failure, not unconditionally, to reduce + artifact clutter on clean runs; step id renamed from `run_fuzzer` to `fuzz` when + the steps were consolidated — see T061). + - Job summary: as-built uses `find` (not `ls`) to count `crash_*` files, since `ls` + exits nonzero with no matches under `bash -e -o pipefail` and would fail the step + on a clean run. + +**Checkpoint**: Trigger `workflow_dispatch` manually. All matrix jobs start, build `oiio_fuzz_image`, +and run with `OIIO_FUZZ_FORMAT` set. Tier-2 short-duration jobs complete first. + +--- + +## Phase 5: User Story 3 — Seed Corpus Per Format (Priority: P2) + +**Goal**: Every format's corpus directory has 1–5 valid seed files sourced from existing test repositories; formats without existing files get synthetic seeds. + +**Independent Test**: For TIFF, EXR, and PNG, verify `src/fuzz/corpora//` contains at least one valid image (parseable by `oiiotool --info`). Verify `OIIO_FUZZ_FORMAT=tiff ./oiio_fuzz_image src/fuzz/corpora/tiff/ -runs=0` etc. exit 0 for all three. + +- [x] T041 [US3] Create `src/fuzz/populate_corpora.py` — idempotent Python script that: + - Defines a source map: each format → list of glob patterns relative to known repo roots (`testsuite/`, `../oiio-images/`, `../j2kp4files_v1_5/`, `../fits-images/`, `../dicom-images-pvt/`, `testsuite/ffmpeg/ref/`) per the table in `research.md §6` + - Copies up to 5 files per format (preferring smaller files, max 100 KB each) into `src/fuzz/corpora//` + - Includes existing `crash-*` files from testsuite dirs as seeds (for bmp, dds, ico, psd, rla, tga, tiff) per `research.md §6` note + - Prints a report of what was copied and what was missing + - Accepts `--format ` arg to update only one format +- [x] T042 [US3] **Changed from original plan**: rather than running `populate_corpora.py` + once and committing the full result, the repo commits only a synthetic seed per format + with no other source (dpx, fits, hdr, iff, jpeg2000, jpegxl, openexr, sgi — 8 formats, + not the ~4 originally estimated). Every other format's seeds are populated at CI time + by a `build-steps.yml` step running `populate_corpora.py --format --dest corpus/` + against a fresh `oiio-images` checkout, and are never committed. This trades + reproducibility-from-a-single-commit for a much smaller repo footprint. +- [x] T043 [P] [US3] Generate synthetic seeds for the formats with no other source + (dpx, fits, hdr, iff, jpeg2000, jpegxl, openexr, sgi) using `oiiotool --create 64x64 3 + --ch R,G,B -o ` or format-specific tools; add to `src/fuzz/corpora//`. + **Note**: the OpenEXR corpus directory is `src/fuzz/corpora/openexr/`, matching the OIIO + format-registry key — an earlier `exr` directory name was renamed to fix a corpus-lint + failure (`fix(fuzz): rename corpus dir exr -> openexr to match OIIO format name`). +- [ ] T044 [US3] Verify all seed corpora: for each format, run `OIIO_FUZZ_FORMAT= + ./build/src/fuzz/oiio_fuzz_image src/fuzz/corpora// -runs=0` (process seeds, no + mutation); all must exit 0 + +**Checkpoint**: 29 corpus directories all non-empty. `OIIO_FUZZ_FORMAT=jpeg +./build/src/fuzz/oiio_fuzz_image src/fuzz/corpora/jpeg/ -runs=0` exits 0 in under 5 seconds. + +--- + +## Phase 6: User Story 4 — Local Developer Fuzzing Workflow (Priority: P2) + +**Goal**: `docs/dev/fuzzing.md` lets a developer with clang build, run, and reproduce a crash within 15 minutes of reading it. + +**Independent Test**: A developer with no prior context follows `docs/dev/fuzzing.md` step by step on macOS or Linux with clang ≥ 14; they build `oiio_fuzz_image`, run it for 60 seconds targeting JPEG, then reproduce a known crash input. + +- [x] T045 [US4] Create `docs/dev/fuzzing.md` with: + - Prerequisites: clang ≥ 14, CMake ≥ 3.15; note gcc not supported + - Build: cmake configure with `OIIO_BUILD_FUZZ_TARGETS=ON`, `SANITIZE=address,undefined`, + `CMAKE_C_COMPILER=clang`, `CMAKE_CXX_COMPILER=clang++`; `cmake --build build --target + oiio_fuzz_image`; note `LIB_FUZZING_ENGINE=-fsanitize=fuzzer` default for local use + - Listing formats: `./build/src/fuzz/oiio_fuzz_image --list-formats` + - Running a format: `OIIO_FUZZ_FORMAT=jpeg ./build/src/fuzz/oiio_fuzz_image + src/fuzz/corpora/jpeg/ -max_total_time=60`; explain `-timeout`, `-jobs`, `-runs=0` + - Reproducing a CI crash: download artifact, run `OIIO_FUZZ_FORMAT= + ./build/src/fuzz/oiio_fuzz_image `; explain ASan output + - Adding corpus seeds for a new format: create `src/fuzz/corpora//`, add seed + files (≤100 KB each); the lint check enforces this automatically + - What happens when a new format is added to OIIO: it appears in `--list-formats` + automatically; the lint step fails until a corpus dir is created (this is intentional) + - Minimizing a crash: `OIIO_FUZZ_FORMAT= ./oiio_fuzz_image -minimize_crash=1 + -exact_artifact_path=min_crash crash_file` + - Link to `specs/001-image-fuzzing/quickstart.md` for more detail + +**Checkpoint**: The document exists, all commands are copy-pasteable, and a cold reader can follow it to a running fuzz target. + +--- + +## Phase 7: User Story 5 — OSS-Fuzz Onboarding Readiness (Priority: P3) + +**Status: not started.** None of T046–T049 have been done; no `ossfuzz/` directory +exists in the repo. This phase was deferred after US1–US4 (Phases 1–6) landed. The +harness-side prerequisites (`$LIB_FUZZING_ENGINE` linkage, `argv[0]` dispatch, +`--list-formats`) are already in place, so this remains a small incremental step +whenever it's picked back up. + +**Goal**: Draft OSS-Fuzz project files that will allow `infra/helper.py build_fuzzers openimageio` to succeed with no harness source changes. + +**Independent Test**: In a local Docker environment with OSS-Fuzz cloned, run `python infra/helper.py build_fuzzers openimageio` using the draft files. It should link all fuzz targets. + +- [ ] T046 [P] [US5] Create `ossfuzz/project.yaml`: + - `homepage`, `language: c++`, `primary_contact`, `auto_ccs` + - `fuzzing_engines: [libfuzzer, afl, honggfuzz, centipede]` + - `sanitizers: [address, undefined, memory]` +- [ ] T047 [P] [US5] Create `ossfuzz/Dockerfile`: + - `FROM gcr.io/oss-fuzz-base/base-builder` + - `apt-get` installs for all format libraries (libopenexr-dev, libtiff-dev, libjpeg-turbo8-dev, libpng-dev, etc.) + - `COPY . $SRC/openimageio` + - `WORKDIR $SRC/openimageio` +- [ ] T048 [US5] Create `ossfuzz/build.sh` per `research.md §8` and `contracts/harness-contract.md §OSS-Fuzz`: + - cmake configure using `$CC`, `$CXX`, `$SRC`, `$WORK`; `-DOIIO_BUILD_FUZZ_TARGETS=ON`, + `-DSANITIZE=address,undefined` + - `cmake --build $WORK/build --target oiio_fuzz_image -j$(nproc)` + - `cp $WORK/build/src/fuzz/oiio_fuzz_image $OUT/` + - Symlink loop and corpus zip using `--list-formats` output: + ```bash + for fmt in $($OUT/oiio_fuzz_image --list-formats); do + ln -sf oiio_fuzz_image $OUT/fuzz_${fmt} + zip -j $OUT/fuzz_${fmt}_seed_corpus.zip $SRC/openimageio/src/fuzz/corpora/${fmt}/* 2>/dev/null || true + done + ``` + - This loop is self-updating: adding a new format to OIIO automatically creates its + OSS-Fuzz target and corpus zip with no `build.sh` changes required +- [ ] T049 [US5] Verify draft OSS-Fuzz files pass offline by running `python infra/helper.py + build_image openimageio && python infra/helper.py build_fuzzers openimageio` in a local + OSS-Fuzz clone; fix any build script issues + +**Checkpoint**: `infra/helper.py build_fuzzers openimageio` exits 0 and `$OUT/` contains +`oiio_fuzz_image` plus per-format symlinks `fuzz_jpeg`, `fuzz_exr`, etc. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +- [x] T050 [P] Run `make clang-format` and verify `src/fuzz/fuzz_image.cpp` and + `src/fuzz/fuzz_utils.h` pass the `.clang-format` rules enforced by CI +- [x] T051 [P] Confirm copyright + SPDX header present in both `src/fuzz/*.cpp` and + `src/fuzz/*.h` files +- [x] T052 Add `src/fuzz/` mention to `CLAUDE.md` repo map and to `docs/dev/Architecture.md` + (done as part of the spec/docs reconciliation pass, not in the original PR) +- [x] T053 End-to-end smoke test: covered continuously by the nightly/`*fuzz*`-branch + `fuzz.yml` matrix and the `fuzz-corpus-lint` CI job, rather than as a one-off manual + step. + +--- + +## Phase 9: Additional work not tracked in the original task breakdown + +**Purpose**: Record implementation work that shipped but has no corresponding task +above, so this file stays an accurate record of what's in the codebase. + +- [x] T054 Move per-input read logic (subimage/MIP iteration, chunked tile-row / + 16-scanline reads bounded independent of claimed image size, bail-out on first read + failure) out of the fuzz harness and into `OIIO::pvt::test_read_image()` / + `test_read_all_images()` in `src/include/imageio_pvt.h` + + `src/libOpenImageIO/imageinput.cpp`, so it's shared rather than duplicated. +- [x] T055 Expose the same read-test logic to developers via a new `oiiotool --testread + FILENAME` flag (`src/oiiotool/oiiotool.cpp`), useful for interactively debugging a + fuzz-found crash without rebuilding the fuzz binary. +- [x] T056 Tune `OIIO_FUZZ_INIT` to lower OIIO's own `limits:imagesize_MB` (2048) and + `limits:resolution` (65536) under libFuzzer's `rss_limit_mb` (4096), so corrupt + headers claiming huge images are rejected by OIIO's normal error path rather than + triggering a false-positive OOM kill. +- [x] T057 Add `src/fuzz/oiio_fuzz_image.options` (`max_len=16777216`, `rss_limit_mb=4096`), + installed alongside the binary so libFuzzer/ClusterFuzz/OSS-Fuzz pick it up + automatically by binary-basename convention. +- [x] T058 Rename the corpus directory and format key from `exr` to `openexr` to match + `OIIO::get_extension_map()`'s registry key, fixing a `fuzz-corpus-lint` failure. + `populate_corpora.py`'s `FORMAT_SOURCES` dict initially missed this rename (still + keyed OpenEXR's source-repo mapping as `"exr"`, so CI-time seeding via + `--format openexr` silently found no sources beyond the committed synthetic seed); + fixed during the spec/docs reconciliation pass. +- [x] T059 Reject AppleClang (Xcode/Command-Line-Tools clang, which lacks the libFuzzer + runtime) in `src/fuzz/CMakeLists.txt` with the same warn-and-skip pattern as the gcc + guard, rather than failing to link. +- [x] T060 Rename the fuzz binary from `fuzz_image` to `oiio_fuzz_image` so an + accidentally-installed binary is clearly identifiable as belonging to OIIO (the + source file keeps the shorter `fuzz_image.cpp` name). +- [x] T061 Consolidate the four inline-bash fuzz steps in `build-steps.yml` + ("Check fuzz corpus coverage", "Seed fuzz corpus", "Run fuzzer", "Fuzz job summary") + into a single step, `id: fuzz`, calling `src/build-scripts/ci-fuzztest.bash`. Matches + how `Build`, `Testsuite`, and `Benchmarks` each delegate to a script instead of + inlining bash in the workflow YAML. Steps that need a marketplace action + (`actions/checkout`, `actions/cache`, `actions/upload-artifact`) remain separate. + The script is controlled by `OIIO_FUZZ_FORMAT`, `OIIO_FUZZ_MAX_TIME`, and + `OIIO_FUZZ_CORPUS_LINT` env vars set from the corresponding workflow inputs; the + crash-artifact-upload step's outcome check was updated from `steps.run_fuzzer.outcome` + to `steps.fuzz.outcome` to match the renamed step id. +- [x] T062 Move the `fuzz-corpus-lint` job from `.github/workflows/ci.yml` to + `.github/workflows/fuzz.yml`, since it's fuzzing infrastructure and belongs with + the rest of the fuzz CI rather than the general CI workflow. +- [x] T063 Fold `src/fuzz/fuzz_utils.h` into `src/fuzz/fuzz_image.cpp` and delete + the header. `fuzz_image.cpp` was the header's only include, so the split added + a file to open with no reuse benefit. Updated all doc references + (`contracts/harness-contract.md`, `plan.md`, `research.md`, `docs/dev/fuzzing.md`) + accordingly; `src/fuzz/CMakeLists.txt` needed no change (it only lists + `fuzz_image.cpp` as a source; the header was picked up via `#include`). + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Requires Phase 1 complete — blocks Phases 3 and 4 +- **US2 Harness (Phase 3)**: Requires Phase 2 (`fuzz_utils.h` and `CMakeLists.txt` done) +- **US1 GHA Workflow (Phase 4)**: Requires Phase 3 (`oiio_fuzz_image` must exist and build) +- **US3 Corpus (Phase 5)**: Requires Phase 3 (needs `oiio_fuzz_image` to verify seeds with `-runs=0`) +- **US4 Docs (Phase 6)**: Requires Phase 3 and 4 to be functionally complete +- **US5 OSS-Fuzz (Phase 7)**: Requires Phase 3 (`oiio_fuzz_image` and `--list-formats` must work) +- **Polish (Phase 8)**: Requires all implementation phases + +### User Story Dependencies + +- **US2 (P1)**: Unblocked after Phase 2 — no dependency on other stories +- **US1 (P1)**: Depends on US2 (`oiio_fuzz_image` binary must exist) +- **US3 (P2)**: Depends on US2 (needs compiled `oiio_fuzz_image` for `-runs=0` verification) +- **US4 (P2)**: Depends on US1 and US2 (documents the working system) +- **US5 (P3)**: Depends on US2 (`--list-formats` drives the symlink loop in `build.sh`) + +### Within Each Phase + +- T004 and T005 in Phase 2 can be written in parallel (different files), but T005 build + only succeeds once T006 (fuzz_image.cpp) exists +- T006 (Phase 3) and T007 (lint CI step) can be written in parallel; T007 only runs + usefully once T006 compiles + +### Parallel Opportunities + +``` +Phase 1: T001 → T002, T003 in parallel (already done) +Phase 2: T004, T005 in parallel (different files) +Phase 3: T006 → T007 (T007 can be drafted while T006 compiles) +Phase 4: T035→T036→T037→T038→T039→T040 (sequential — one workflow file) +Phase 5: T041→T042; T043 in parallel with T042 → T044 +Phase 7: T046, T047 in parallel → T048 → T049 +``` + +--- + +## Implementation Strategy + +### MVP (User Stories 2 + 1 — delivers nightly CI value) + +1. Phase 1: Setup (done ✓) +2. Phase 2: Foundational (`fuzz_utils.h` + `CMakeLists.txt`) +3. Phase 3: US2 — single `oiio_fuzz_image` binary with dynamic dispatch +4. Phase 4: US1 — GHA workflow with `OIIO_FUZZ_FORMAT` per matrix job +5. **STOP and VALIDATE**: Trigger `workflow_dispatch`; all matrix jobs run; no infrastructure failures +6. Merge — nightly fuzzing is live + +### Incremental Delivery + +1. Phase 1 + 2 → Build system works; `oiio_fuzz_image` target defined +2. Phase 3 (US2) → `oiio_fuzz_image` compiles, any format fuzzable locally → Core value +3. Phase 4 (US1) → Nightly CI live → **MVP delivered** +4. Phase 5 (US3) → Seeds populated → Fuzzer efficiency dramatically improved +5. Phase 6 (US4) → Docs → Developers can self-serve locally +6. Phase 7 (US5) → OSS-Fuzz ready → Scalable cloud fuzzing, symlinks auto-generated + +--- + +## Notes + +- [P] tasks operate on different files with no shared state — safe to run concurrently +- The harness is ~80 lines total (`fuzz_utils.h` + `fuzz_image.cpp`); most complexity is in dispatch logic, not format-specific code +- New formats added to OIIO automatically appear in `--list-formats`; lint step (T007) fails until `src/fuzz/corpora//` is created — this is the enforcement mechanism +- After finding and fixing a fuzz crash, add the reproducer to `testsuite/fuzz-/` before merging the fix +- Corpus `.gitkeep` files may be removed once real seeds are committed in Phase 5 +- The `all_fuzz_targets` CMake alias (T005) + `--list-formats` loop in `build.sh` (T048) together mean OSS-Fuzz also auto-covers new formats diff --git a/src/build-scripts/ci-fuzztest.bash b/src/build-scripts/ci-fuzztest.bash new file mode 100755 index 0000000000..60443f1f7a --- /dev/null +++ b/src/build-scripts/ci-fuzztest.bash @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Fuzz testing step for CI: optionally lints the fuzz corpus directory +# coverage, then (if OIIO_FUZZ_FORMAT is set) seeds, runs, and summarizes a +# libFuzzer session for that format. Controlled by: +# OIIO_FUZZ_CORPUS_LINT "true" to run the corpus coverage lint +# OIIO_FUZZ_FORMAT format name to fuzz (empty = skip fuzz run) +# OIIO_FUZZ_MAX_TIME seconds for -max_total_time (default 3600) + +set -e + +FUZZ_BIN="$OpenImageIO_ROOT/bin/oiio_fuzz_image" + +# +# Verify every format reported by `oiio_fuzz_image --list-formats` has a +# corpus directory in src/fuzz/corpora/. +# +if [[ "${OIIO_FUZZ_CORPUS_LINT}" == "true" ]]; then + "$FUZZ_BIN" --list-formats | sort > /tmp/formats.txt + ls src/fuzz/corpora/ | sort > /tmp/corpus_dirs.txt + missing=$(comm -23 /tmp/formats.txt /tmp/corpus_dirs.txt) + if [[ -n "$missing" ]]; then + echo "ERROR: Missing corpus director(ies) for compiled-in format(s):" + echo "$missing" | sed 's/^/ src\/fuzz\/corpora\//' + echo "" + echo "Add src/fuzz/corpora// with at least a .gitkeep for each." + exit 1 + fi + count=$(wc -l < /tmp/formats.txt | tr -d ' ') + echo "OK: all ${count} compiled-in formats have corpus directories." +fi + +# +# Seed the corpus, run the fuzzer, and write a job summary for one format. +# +if [[ -n "${OIIO_FUZZ_FORMAT}" ]]; then + 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 + + if [[ ! -x "$FUZZ_BIN" ]]; then + echo "::error::$FUZZ_BIN not found — was OIIO_BUILD_FUZZ_TARGETS=ON passed to cmake?" + exit 1 + fi + + skipped=0 + fuzz_status=0 + if ! ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}detect_leaks=0" \ + "$FUZZ_BIN" --list-formats | grep -qx "$OIIO_FUZZ_FORMAT"; then + echo "::notice::Format '${OIIO_FUZZ_FORMAT}' not compiled in this build; skipping fuzz run." + skipped=1 + else + set +e + "$FUZZ_BIN" "$corpus_dir" \ + -max_total_time="${OIIO_FUZZ_MAX_TIME:-3600}" \ + -max_len=16777216 \ + -rss_limit_mb=4096 \ + -malloc_limit_mb=2048 \ + -timeout=60 \ + -detect_leaks=0 \ + -artifact_prefix="crash_${OIIO_FUZZ_FORMAT}_" \ + -jobs=$(nproc) -workers=$(nproc) + fuzz_status=$? + set -e + fi + + crash_count=$(find . -maxdepth 1 -name "crash_${OIIO_FUZZ_FORMAT}_*" | wc -l | tr -d ' ') + if [[ "$fuzz_status" -ne 0 ]]; then + { + echo "Format: ${OIIO_FUZZ_FORMAT}" + echo "Status: FAILED" + echo "Detail: fuzzer ran and found a problem (crash/timeout/OOM); see uploaded crash artifacts" + echo "Crash artifacts found: ${crash_count}" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$skipped" -eq 1 ]]; then + { + echo "Format: ${OIIO_FUZZ_FORMAT}" + echo "Status: SKIPPED" + echo "Detail: fuzzing was not run because this format is not compiled/implemented in this build" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + fi + + exit "$fuzz_status" +fi diff --git a/src/cmake/compiler.cmake b/src/cmake/compiler.cmake index f975b24909..30b77026e3 100644 --- a/src/cmake/compiler.cmake +++ b/src/cmake/compiler.cmake @@ -520,9 +520,13 @@ if (${PROJ_NAME}_HARDENING GREATER_EQUAL 1) # Defining _FORTIFY_SOURCE provides buffer overflow checks in modern gcc & # clang with some compiler-assisted deduction of buffer lengths) for the # many C functions such as memcpy, strcpy, sprintf, etc. But it requires - # optimization, so we don't do it for debug builds. + # optimization, so we don't do it for debug builds. It is also incompatible + # with the address and memory sanitizers, which predefine _FORTIFY_SOURCE=0 + # themselves; defining it again would be ineffective and trigger a + # -Wmacro-redefined error, so we skip it when such a sanitizer is enabled. if ((CMAKE_COMPILER_IS_CLANG OR (GCC_VERSION VERSION_GREATER_EQUAL 14)) - AND NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + AND NOT CMAKE_BUILD_TYPE STREQUAL "Debug" + AND NOT SANITIZE MATCHES "address|memory") add_compile_definitions (_FORTIFY_SOURCE=${${PROJ_NAME}_HARDENING}) endif () endif () diff --git a/src/cmake/dependency_utils.cmake b/src/cmake/dependency_utils.cmake index 879580a46b..17b698f7f8 100644 --- a/src/cmake/dependency_utils.cmake +++ b/src/cmake/dependency_utils.cmake @@ -488,7 +488,7 @@ macro (checked_find_package pkgname) set (${_v} TRUE) endforeach () if (_pkg_RECOMMEND_MIN) - if (${${pkgname}_VERSION} VERSION_LESS ${_pkg_RECOMMEND_MIN}) + if ("${${pkgname}_VERSION}" VERSION_LESS ${_pkg_RECOMMEND_MIN}) message (STATUS "${ColorYellow}Recommend ${pkgname} >= ${_pkg_RECOMMEND_MIN} ${_pkg_RECOMMEND_MIN_REASON} ${ColorReset}") endif () endif () diff --git a/src/cmake/externalpackages.cmake b/src/cmake/externalpackages.cmake index 107b8ad255..33b9cf16ab 100644 --- a/src/cmake/externalpackages.cmake +++ b/src/cmake/externalpackages.cmake @@ -115,6 +115,7 @@ else () endif() # From pythonutils.cmake +set_option (USE_PYTHON "Enable support of Python bindings" ON) if (USE_PYTHON) find_python() endif () diff --git a/src/fuzz/CMakeLists.txt b/src/fuzz/CMakeLists.txt new file mode 100644 index 0000000000..b0da90ea6f --- /dev/null +++ b/src/fuzz/CMakeLists.txt @@ -0,0 +1,81 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# This file is only reached when OIIO_BUILD_FUZZ_TARGETS=ON (guarded in the +# root CMakeLists.txt). + +# libFuzzer requires a capable clang. If the compiler can't build the fuzz +# targets, skip them with a warning rather than failing the whole configure -- +# that lets the rest of OIIO still build in a tree that happens to have +# OIIO_BUILD_FUZZ_TARGETS=ON with an unsuitable compiler. +# +# - GCC does not support libFuzzer at all. +# - Apple's clang (CMAKE_CXX_COMPILER_ID "AppleClang", shipped with Xcode and +# the Command Line Tools) does not ship the libFuzzer runtime +# (libclang_rt.fuzzer_osx.a), so -fsanitize=fuzzer fails to link. Use an +# upstream LLVM clang instead (e.g. `brew install llvm`). +if (NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message (WARNING + "OIIO_BUILD_FUZZ_TARGETS=ON requires clang; skipping fuzz targets " + "(detected ${CMAKE_CXX_COMPILER_ID}). Set " + "CMAKE_CXX_COMPILER=clang++ to build them.") + return () +endif () +if (CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + message (WARNING + "OIIO_BUILD_FUZZ_TARGETS=ON does not work with Apple's clang, which " + "lacks the libFuzzer runtime; skipping fuzz targets. Install upstream " + "LLVM (e.g. 'brew install llvm') and configure with " + "-DCMAKE_CXX_COMPILER=$(brew --prefix llvm)/bin/clang++ " + "-DCMAKE_C_COMPILER=$(brew --prefix llvm)/bin/clang.") + return () +endif () + +# The fuzz targets are always instrumented with the address sanitizer (see the +# target_compile_options below), independently of the global SANITIZE option. +# _FORTIFY_SOURCE is incompatible with ASan (clang predefines it to 0), so strip +# any inherited _FORTIFY_SOURCE define for this directory to avoid a +# -Wmacro-redefined error when OIIO's hardening defined it. +get_directory_property (_fuzz_defs COMPILE_DEFINITIONS) +list (FILTER _fuzz_defs EXCLUDE REGEX "^_FORTIFY_SOURCE") +set_directory_properties (PROPERTIES COMPILE_DEFINITIONS "${_fuzz_defs}") + +# Resolve the fuzzing engine. +# - Local dev: defaults to -fsanitize=fuzzer (links the libFuzzer runtime). +# - OSS-Fuzz: sets LIB_FUZZING_ENGINE to a .a path for the chosen engine. +if (DEFINED ENV{LIB_FUZZING_ENGINE}) + set (OIIO_FUZZING_ENGINE "$ENV{LIB_FUZZING_ENGINE}") +else () + set (OIIO_FUZZING_ENGINE "-fsanitize=fuzzer") +endif () + +# Executable is named oiio_fuzz_image (not just fuzz_image) so that if it is +# ever accidentally built and installed, the name makes clear which package it +# belongs to. The source file keeps its shorter fuzz_image.cpp name. +add_executable (oiio_fuzz_image fuzz_image.cpp) + +target_link_libraries (oiio_fuzz_image PRIVATE OpenImageIO) + +# -fsanitize=fuzzer-no-link instruments code for coverage tracing without +# linking the fuzzer runtime; the runtime comes from OIIO_FUZZING_ENGINE at +# link time. address+undefined catch memory errors and UB respectively. +target_compile_options (oiio_fuzz_image PRIVATE + -fsanitize=fuzzer-no-link,address,undefined + -fno-omit-frame-pointer +) + +target_link_options (oiio_fuzz_image PRIVATE + ${OIIO_FUZZING_ENGINE} + -fsanitize=address,undefined +) + +# all_fuzz_targets is the conventional build alias used by OSS-Fuzz build.sh +# and local "cmake --build . --target all_fuzz_targets". +# add_custom_target (all_fuzz_targets DEPENDS oiio_fuzz_image) +install_targets (oiio_fuzz_image) + +# Install the libFuzzer options file alongside the binary. OSS-Fuzz/ClusterFuzz +# keys the options file to the binary basename, so it is named to match: +# oiio_fuzz_image.options. +install (FILES oiio_fuzz_image.options DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/src/fuzz/corpora/bmp/.gitkeep b/src/fuzz/corpora/bmp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/cineon/.gitkeep b/src/fuzz/corpora/cineon/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/dds/.gitkeep b/src/fuzz/corpora/dds/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/dicom/.gitkeep b/src/fuzz/corpora/dicom/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/dpx/.gitkeep b/src/fuzz/corpora/dpx/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/dpx/seed.dpx b/src/fuzz/corpora/dpx/seed.dpx new file mode 100644 index 0000000000..fb2704e871 Binary files /dev/null and b/src/fuzz/corpora/dpx/seed.dpx differ diff --git a/src/fuzz/corpora/ffmpeg/.gitkeep b/src/fuzz/corpora/ffmpeg/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/fits/.gitkeep b/src/fuzz/corpora/fits/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/fits/seed.fits b/src/fuzz/corpora/fits/seed.fits new file mode 100644 index 0000000000..98b3668520 Binary files /dev/null and b/src/fuzz/corpora/fits/seed.fits differ diff --git a/src/fuzz/corpora/gif/.gitkeep b/src/fuzz/corpora/gif/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/hdr/.gitkeep b/src/fuzz/corpora/hdr/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/hdr/seed.hdr b/src/fuzz/corpora/hdr/seed.hdr new file mode 100644 index 0000000000..18e89e0d4c Binary files /dev/null and b/src/fuzz/corpora/hdr/seed.hdr differ diff --git a/src/fuzz/corpora/heif/.gitkeep b/src/fuzz/corpora/heif/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/ico/.gitkeep b/src/fuzz/corpora/ico/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/iff/.gitkeep b/src/fuzz/corpora/iff/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/iff/seed.iff b/src/fuzz/corpora/iff/seed.iff new file mode 100644 index 0000000000..b0f3dd8ade Binary files /dev/null and b/src/fuzz/corpora/iff/seed.iff differ diff --git a/src/fuzz/corpora/jpeg/.gitkeep b/src/fuzz/corpora/jpeg/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/jpeg2000/.gitkeep b/src/fuzz/corpora/jpeg2000/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/jpeg2000/seed.j2k b/src/fuzz/corpora/jpeg2000/seed.j2k new file mode 100644 index 0000000000..cea7a03bd1 Binary files /dev/null and b/src/fuzz/corpora/jpeg2000/seed.j2k differ diff --git a/src/fuzz/corpora/jpegxl/.gitkeep b/src/fuzz/corpora/jpegxl/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/jpegxl/seed.jxl b/src/fuzz/corpora/jpegxl/seed.jxl new file mode 100644 index 0000000000..60ca183153 Binary files /dev/null and b/src/fuzz/corpora/jpegxl/seed.jxl differ diff --git a/src/fuzz/corpora/openexr/.gitkeep b/src/fuzz/corpora/openexr/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/openexr/seed.exr b/src/fuzz/corpora/openexr/seed.exr new file mode 100644 index 0000000000..a41b863960 Binary files /dev/null and b/src/fuzz/corpora/openexr/seed.exr differ diff --git a/src/fuzz/corpora/openvdb/.gitkeep b/src/fuzz/corpora/openvdb/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/png/.gitkeep b/src/fuzz/corpora/png/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/pnm/.gitkeep b/src/fuzz/corpora/pnm/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/psd/.gitkeep b/src/fuzz/corpora/psd/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/ptex/.gitkeep b/src/fuzz/corpora/ptex/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/raw/.gitkeep b/src/fuzz/corpora/raw/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/rla/.gitkeep b/src/fuzz/corpora/rla/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/sgi/.gitkeep b/src/fuzz/corpora/sgi/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/sgi/seed.sgi b/src/fuzz/corpora/sgi/seed.sgi new file mode 100644 index 0000000000..6f4894a39c Binary files /dev/null and b/src/fuzz/corpora/sgi/seed.sgi differ diff --git a/src/fuzz/corpora/softimage/.gitkeep b/src/fuzz/corpora/softimage/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/targa/.gitkeep b/src/fuzz/corpora/targa/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/tiff/.gitkeep b/src/fuzz/corpora/tiff/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/webp/.gitkeep b/src/fuzz/corpora/webp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/corpora/zfile/.gitkeep b/src/fuzz/corpora/zfile/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fuzz/fuzz_image.cpp b/src/fuzz/fuzz_image.cpp new file mode 100644 index 0000000000..f09de4656f --- /dev/null +++ b/src/fuzz/fuzz_image.cpp @@ -0,0 +1,233 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "imageio_pvt.h" + +using OIIO::Strutil::print; +using OIIO::Strutil::starts_with; + + +// One-time OIIO setup: single-threaded mode plus memory limits sized to the +// fuzzer's own RSS budget. +// +// libFuzzer's rss_limit_mb (see oiio_fuzz_image.options, currently 4096 MB) +// kills the process if it exceeds that resident size. OIIO's default +// decode-bomb guards (limits:imagesize_MB = 32768, limits:resolution = +// 1048576) are far larger than that budget, so a corrupt header claiming a +// multi-GB image would trip the fuzzer's OOM kill *before* OIIO's own guard +// rejects it -- a false positive crash. Lowering OIIO's limits well under the +// RSS budget lets OIIO reject such headers cleanly via the normal error path. +// imagesize_MB is set to half the RSS budget to leave headroom for decode +// scratch, the input buffer, and process overhead; resolution caps any single +// dimension to a value no real image reaches but that bounds per-scanline +// allocations. +// +// Safe to call from LLVMFuzzerInitialize; idempotent via static flag. +#define OIIO_FUZZ_INIT \ + do { \ + static bool _inited = false; \ + if (!_inited) { \ + _inited = true; \ + OIIO::attribute("threads", 1); \ + OIIO::attribute("exr_threads", 1); \ + OIIO::attribute("limits:imagesize_MB", 2048); \ + OIIO::attribute("limits:resolution", 65536); \ + } \ + } while (0) + + + +inline void +oiio_fuzz_read(const uint8_t* data, size_t size, const char* fake_filename) +{ + OIIO::Filesystem::IOMemReader mem(data, size); + auto inp = OIIO::ImageInput::open(fake_filename, nullptr, &mem); + if (!inp) { + (void)OIIO::geterror(); // discard any errors + return; + } + OIIO::pvt::test_read_all_images(*inp, OIIO::TypeUInt8); + inp->close(); +} + + + +// Dispatch-plugin read for formats (raw, ffmpeg) whose underlying libraries +// do not support in-memory IOProxy reads. Writes fuzz data to a +// process-unique temp file, opens it, reads, and closes. The temp file is +// reused across calls (overwritten each time) for throughput. +// +// The fake_filename extension selects the right plugin: ".cr2" → raw.imageio +// (LibRaw handles sub-format dispatch internally), ".mkv" → ffmpeg.imageio. +inline void +oiio_fuzz_read_dispatch(const uint8_t* data, size_t size, + const char* fake_filename) +{ + // Compute a process-unique temp path once; reuse across calls. + static std::string tmppath; + if (tmppath.empty()) { + // unique_path() substitutes '%%%%' with random hex digits. + std::string ext = OIIO::Filesystem::extension(fake_filename); + std::string base = OIIO::Filesystem::unique_path( + OIIO::Filesystem::temp_directory_path() + "/oiio_fuzz_%%%%"); + tmppath = base + ext; + } + + // Write fuzz data. + OIIO::Filesystem::write_binary_file(tmppath, OIIO::make_cspan(data, size)); + + // Open and read via the normal public API. + auto inp = OIIO::ImageInput::open(tmppath); + if (!inp) { + (void)OIIO::geterror(); // discard any errors + return; + } + OIIO::pvt::test_read_all_images(*inp, OIIO::TypeUInt8); + inp->close(); +} + + + +// Return the primary file extension for a named format (e.g., "jpeg" → +// "jpg", "tiff" → "tif"). Uses the live extension_list so newly registered +// formats are covered automatically. Returns "" if the format is unknown. +inline std::string +oiio_format_primary_ext(OIIO::string_view format) +{ + auto extmap = OIIO::get_extension_map(); + auto it = extmap.find(std::string(format)); + if (it == extmap.end() || it->second.empty()) + return {}; + return it->second[0]; +} + + + +// True for dispatch plugins (raw, ffmpeg) whose underlying libraries do not +// support IOProxy and require a temp-file read path instead. +inline bool +oiio_format_is_dispatch(OIIO::string_view format) +{ + return format == "raw" || format == "ffmpeg"; +} + + + +// Active format for this process, set once in LLVMFuzzerInitialize. +static std::string format_name; // e.g., "jpeg" +static std::string fake_name; // fake filename for open, e.g., "input.jpg" +static bool is_dispatch; // true for raw, ffmpeg (no IOProxy support) + + +// Return all known format names sorted, excluding internal pseudo-formats. +static std::vector +all_formats() +{ + std::vector result; + for (auto& [fmt, exts] : OIIO::get_extension_map()) { + if (fmt != "null" && fmt != "term") + result.push_back(fmt); + } + std::sort(result.begin(), result.end()); + return result; +} + + + +extern "C" int +LLVMFuzzerInitialize(int* argc, char*** argv) +{ + OIIO_FUZZ_INIT; + + std::string format; + + // Priority 1: OIIO_FUZZ_FORMAT env var (used by GHA matrix jobs). + if (const char* env = getenv("OIIO_FUZZ_FORMAT")) + format = env; + + // Priority 2: argv[0] basename stripped of "fuzz_" prefix. + // OSS-Fuzz invokes per-format symlinks: fuzz_jpeg -> fuzz_image. + if (format.empty()) { + std::string base = OIIO::Filesystem::filename((*argv)[0]); + if (starts_with(base, "fuzz_")) + format = base.substr(5); + if (format == "image") // canonical binary name, not a format + format.clear(); + } + + // Priority 3: --format= pseudo-arg. Also handle --list-formats. + // Strip our pseudo-args before returning so libFuzzer doesn't reject them. + for (int i = 1; i < *argc;) { + OIIO::string_view arg((*argv)[i]); + if (arg == "--list-formats") { + for (auto& fmt : all_formats()) + print("{}\n", fmt); + // Flush before exit(): ASan's LSAN atexit hook calls _Exit() when + // it finds leaks, skipping stdio auto-flush. Flushing first + // ensures the list is on the pipe regardless of the atexit order. + fflush(stdout); + exit(0); + } + if (starts_with(arg, "--format=")) { + if (format.empty()) + format = std::string(arg.substr(9)); + for (int j = i; j < *argc - 1; ++j) + (*argv)[j] = (*argv)[j + 1]; + --*argc; + continue; + } + ++i; + } + + if (format.empty()) { + print(stderr, "fuzz_image: no format specified.\n" + " Set OIIO_FUZZ_FORMAT=, use --format=, " + "or invoke as fuzz_.\n" + " Available formats:\n"); + for (auto& fmt : all_formats()) + print(stderr, " {}\n", fmt); + exit(1); + } + + // Validate: format must be compiled into this build. + auto extmap = OIIO::get_extension_map(); + if (extmap.find(format) == extmap.end()) { + print(stderr, + "fuzz_image: unknown or unsupported format '{}'\n" + " Available formats:\n", + format); + for (auto& fmt : all_formats()) + print(stderr, " {}\n", fmt); + exit(1); + } + + format_name = format; + is_dispatch = oiio_format_is_dispatch(format_name); + fake_name = "input." + oiio_format_primary_ext(format_name); + + return 0; +} + + + +extern "C" int +LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + if (is_dispatch) + oiio_fuzz_read_dispatch(data, size, fake_name.c_str()); + else + oiio_fuzz_read(data, size, fake_name.c_str()); + return 0; +} diff --git a/src/fuzz/oiio_fuzz_image.options b/src/fuzz/oiio_fuzz_image.options new file mode 100644 index 0000000000..f8386be8af --- /dev/null +++ b/src/fuzz/oiio_fuzz_image.options @@ -0,0 +1,3 @@ +[libfuzzer] +max_len = 16777216 +rss_limit_mb = 4096 diff --git a/src/fuzz/populate_corpora.py b/src/fuzz/populate_corpora.py new file mode 100644 index 0000000000..4c85905a7c --- /dev/null +++ b/src/fuzz/populate_corpora.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO +""" +Populate src/fuzz/corpora/ with seed files for each image format. + +Run from the repository root. Companion image repos are expected as siblings +of the repo root (e.g. ../oiio-images, ../fits-images, etc.). + +Usage: + python src/fuzz/populate_corpora.py [--format ] [--dry-run] +""" + +import argparse +import os +import shutil +import sys +from pathlib import Path + + +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 + +# Companion image repos may live: +# (a) as siblings of the repo root (local dev or fuzz CI checkout) +# (b) under build/testsuite/ (fetched by oiio_setup_test_data during ctest) +# Walk candidates in priority order; first hit wins. +def _find_images_root() -> Path: + # CI checks out oiio-images inside the workspace (path: oiio-images) + if (REPO_ROOT / "oiio-images").is_dir(): + return REPO_ROOT + # Local dev: companion repos are siblings of the repo root (or worktree parent) + for candidate in (REPO_ROOT.parent, REPO_ROOT.parent.parent): + if (candidate / "oiio-images").is_dir(): + return candidate + # Regular cmake build: oiio_setup_test_data fetches here + build_ts = REPO_ROOT / "build" / "testsuite" + if (build_ts / "oiio-images").is_dir(): + return build_ts + return REPO_ROOT.parent # fallback; companion repos may just be absent + +IMAGES_ROOT = _find_images_root() + + +# Source map: format -> list of (path_relative_to_repo_root, [extensions]) +# '../foo' means a sibling of the repo root. +# Crash files from testsuite are included — they exercise edge-case parser +# paths and are ideal fuzz seeds. +FORMAT_SOURCES = { + "bmp": [ + ("testsuite/bmp/src", ["bmp", "BMP"]), + ("../oiio-images/bmp", ["bmp", "BMP"]), + ("../bmpsuite", ["bmp", "BMP"]), + ], + "cineon": [ + ("../oiio-images/cineon", ["cin"]), + ("../oiio-images", ["cin"]), + ], + "dds": [ + ("testsuite/dds/src", ["dds"]), + ("../oiio-images/dds", ["dds"]), + ], + "dicom": [ + ("../dicom-images-pvt", ["dcm"]), + ("testsuite/dicom/src", ["dcm"]), + ], + "dpx": [ + ("../oiio-images/dpx", ["dpx"]), + ("../dpx-images-spi", ["dpx"]), + ], + "openexr": [ + ("../oiio-images", ["exr"]), + ("testsuite/oiio-images", ["exr"]), + ], + "ffmpeg": [ + ("testsuite/ffmpeg/ref", ["mkv", "mov", "mp4", "avi"]), + ], + "fits": [ + ("../fits-images/ftt4b", ["fits", "fit"]), + ("../fits-images/pg93", ["fits", "fit"]), + ], + "gif": [ + ("../oiio-images/gif", ["gif"]), + ], + # hdr: synthetic seed committed; no further sources needed + "hdr": [], + "heif": [ + ("../oiio-images/heif", ["heif", "heic", "avif"]), + ("../heif-images", ["heif", "heic", "avif"]), + ], + "ico": [ + ("testsuite/ico/src", ["ico"]), + ("../oiio-images/ico", ["ico"]), + ], + # iff: synthetic seed committed; no further sources needed + "iff": [], + "jpeg": [ + ("../oiio-images/jpeg", ["jpg", "jpeg"]), + ("testsuite/jpeg/src", ["jpg", "jpeg"]), + ("../oiio-images", ["jpg", "jpeg"]), + ], + "jpeg2000": [ + ("../oiio-images/jpeg2000", ["jp2", "j2k"]), + ("../j2kp4files_v1_5/codestreams_profile0", ["j2k"]), + ], + # jpegxl: synthetic seed committed; no further sources needed + "jpegxl": [], + "openvdb": [ + ("testsuite/openvdb/src", ["vdb"]), + ], + "png": [ + ("../oiio-images/png", ["png"]), + ], + "pnm": [ + ("testsuite/pnm/src", ["ppm", "pgm", "pbm", "pnm"]), + ("../oiio-images/pnm", ["ppm", "pgm", "pbm", "pnm"]), + ], + "psd": [ + ("testsuite/psd/src", ["psd"]), + ("../oiio-images/psd", ["psd"]), + ], + "ptex": [ + ("testsuite/ptex/src", ["ptx", "ptex"]), + ], + "raw": [ + ("../oiio-images/raw", ["cr2", "nef", "arw", "raf", "rw2", "orf", + "CR2", "NEF", "ARW", "RAF", "RW2", "ORF"]), + ], + "rla": [ + ("testsuite/rla/src", ["rla"]), + ("../oiio-images/rla", ["rla"]), + ], + # sgi: synthetic seed committed; no further sources needed + "sgi": [], + "softimage": [ + ("testsuite/softimage/src", ["pic"]), + ("../oiio-images/softimage", ["pic"]), + ], + "targa": [ + ("testsuite/targa/src", ["tga", "TGA"]), + ("../oiio-images/targa", ["tga", "TGA"]), + ], + "tiff": [ + ("../oiio-images", ["tif", "tiff"]), + ("../oiio-images/libtiffpic",["tif", "tiff"]), + ("testsuite/tiff-suite/src", ["tif", "tiff"]), + ], + "webp": [ + ("../oiio-images/webp", ["webp"]), + ], + # zfile: use the reference output produced by the testsuite (a valid zfile) + "zfile": [ + ("testsuite/zfile/ref", ["zfile"]), + ], +} + + +def resolve(rel: str) -> Path: + """Resolve a path; '../x' resolves relative to IMAGES_ROOT (companion repos).""" + if rel.startswith("../"): + return (IMAGES_ROOT / rel[3:]).resolve() + return (REPO_ROOT / rel).resolve() + + +def candidate_files(sources: list) -> list[Path]: + """Collect all candidate files from all sources, sorted by size ascending.""" + candidates = [] + for rel_dir, exts in sources: + d = resolve(rel_dir) + if not d.is_dir(): + continue + for f in d.iterdir(): + if f.suffix.lstrip(".").lower() in [e.lower() for e in exts]: + try: + sz = f.stat().st_size + if sz > 0: + candidates.append((sz, f)) + except OSError: + pass + candidates.sort() # ascending by size — smallest first + return [f for _, f in candidates] + + +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//). + Returns (files_copied, missing_reasons). + """ + dest = dest if dest is not None else CORPUS_ROOT / fmt + sources = FORMAT_SOURCES.get(fmt, []) + missing = [] + + if not sources: + missing.append("no source configured — add a synthetic seed manually") + return 0, missing + + files = candidate_files(sources) + small = [f for f in files if f.stat().st_size <= MAX_BYTES] + + if not small: + if files: + missing.append( + f"found {len(files)} file(s) but all exceed {MAX_BYTES // 1024} KB" + ) + else: + dirs_checked = [resolve(r) for r, _ in sources] + missing.append( + "no source files found; checked: " + + ", ".join(str(d) for d in dirs_checked if not d.exists()) + + " (dirs missing)" + if any(not resolve(r).exists() for r, _ in sources) + else "no source files found (dirs present but empty or wrong exts)" + ) + return 0, missing + + chosen = small[:MAX_FILES] + if not dry_run: + dest.mkdir(parents=True, exist_ok=True) + copied = 0 + for src in chosen: + dst = dest / src.name + if dst.exists(): + continue # idempotent — skip existing + if not dry_run: + shutil.copy2(src, dst) + copied += 1 + + return copied, missing + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--format", metavar="NAME", + help="Populate only this format (default: all)") + parser.add_argument("--dest", metavar="DIR", type=Path, + help="Write seeds into DIR// instead of src/fuzz/corpora//") + parser.add_argument("--dry-run", action="store_true", + help="Show what would be copied without copying") + args = parser.parse_args() + + formats = [args.format] if args.format else sorted(FORMAT_SOURCES) + + total_copied = 0 + needs_synthetic = [] + ok = [] + warnings = [] + + for fmt in formats: + dest_dir = (args.dest / fmt) if args.dest else None + corpus_dir = dest_dir if dest_dir is not None else (CORPUS_ROOT / fmt) + if not corpus_dir.exists() and not args.dry_run: + corpus_dir.mkdir(parents=True, exist_ok=True) + + copied, missing = populate_format(fmt, args.dry_run, dest_dir) + total_copied += copied + prefix = "[dry-run] " if args.dry_run else "" + + existing = (len(list(corpus_dir.glob("*"))) + - (1 if (corpus_dir / ".gitkeep").exists() else 0) + ) if corpus_dir.exists() else 0 + + if missing: + needs_synthetic.append((fmt, missing)) + total_in_dir = existing + print(f" {prefix}{fmt}: NEEDS MANUAL SEED — {'; '.join(missing)}" + + (f" ({total_in_dir} existing)" if total_in_dir else "")) + else: + ok.append(fmt) + print(f" {prefix}{fmt}: +{copied} copied" + + (f" ({existing} already present)" if existing > 0 else "")) + + print() + print(f"Done. {total_copied} file(s) {'would be ' if args.dry_run else ''}copied.") + if needs_synthetic: + print(f"\nFormats needing manual seeds ({len(needs_synthetic)}):") + for fmt, reasons in needs_synthetic: + print(f" {fmt}: {'; '.join(reasons)}") + print("\nGenerate with: oiiotool --create 64x64 3 --ch R,G,B -o src/fuzz/corpora//seed.") + if warnings: + print(f"\nMissing corpus dirs (run T002 first): {', '.join(warnings)}") + + +if __name__ == "__main__": + main() diff --git a/src/include/imageio_pvt.h b/src/include/imageio_pvt.h index f47432868b..ee350d4e75 100644 --- a/src/include/imageio_pvt.h +++ b/src/include/imageio_pvt.h @@ -289,4 +289,30 @@ device_free(void* mem); OIIO_NAMESPACE_END + +OIIO_NAMESPACE_3_1_BEGIN +namespace pvt { +/// Test harness for reading an image, analogous to calling +/// ImageInput::read_image(), but it doesn't return pixels and does as little +/// extraneous allocation as possible. What is this good for? (1) Benchmarking +/// just the read itself; (2) Fuzz testing; (3) Debugging the code path where +/// fuzz testing revealed a specific error. +OIIO_API bool +test_read_image(ImageInput& inp, int subimage, int miplevel, + TypeDesc format = TypeUInt8); + +/// Read all subimage and MIP levels of the open file. +OIIO_API bool +test_read_all_images(ImageInput& inp, TypeDesc format = TypeUInt8); +} // namespace pvt +OIIO_NAMESPACE_3_1_END + +OIIO_NAMESPACE_BEGIN +namespace pvt { +using v3_1::pvt::test_read_all_images; +using v3_1::pvt::test_read_image; +} // namespace pvt +OIIO_NAMESPACE_END + + #endif // OPENIMAGEIO_IMAGEIO_PVT_H diff --git a/src/libOpenImageIO/imageinput.cpp b/src/libOpenImageIO/imageinput.cpp index d96dd8e2b4..51d638a40f 100644 --- a/src/libOpenImageIO/imageinput.cpp +++ b/src/libOpenImageIO/imageinput.cpp @@ -1269,6 +1269,99 @@ ImageInput::read_image(int subimage, int miplevel, int chbegin, int chend, +// Read all pixels of one subimage/miplevel in small chunks, exercising the +// decode path without ever allocating a buffer proportional to the whole +// image. No pixels are returned; this is just meant to fully read the image +// from the file. Tiled images are read one row of tiles at a time; scanline +// images are read 16 rows at a time. This keeps the resident buffer tiny so a +// corrupt-but-large image does not trip the fuzzer's RSS limit with a false +// positive. A read error stops the scan immediately (matching how +// iconvert/oiiotool bail out on the first read failure). +bool +pvt::test_read_image(ImageInput& inp, int subimage, int miplevel, + TypeDesc format) +{ + const ImageSpec spec = inp.spec_dimensions(subimage, miplevel); + if (spec.image_pixels() <= 0 || spec.nchannels <= 0) + return false; + const int nch = spec.nchannels; + + bool native = (format == TypeUnknown); + OIIO_CONTRACT_ASSERT(!native); // for now + stride_t channel_bytes = native ? stride_t(spec.format.size()) + : stride_t(format.size()); + stride_t pixel_bytes = native ? stride_t(spec.pixel_bytes(0, nch, true)) + : stride_t(format.size() * nch); + + if (spec.tile_width > 0) { + // Tiled: read a full-width row of tiles (one tile high, one tile + // deep) per iteration. Tile extents are clamped to the image so a + // corrupt header claiming a giant tile cannot force a giant buffer; + // read_tiles accepts the image edge in place of a tile boundary. + const int th = std::min(spec.tile_height > 0 ? spec.tile_height : 1, + spec.height); + const int td = std::min(spec.tile_depth > 0 ? spec.tile_depth : 1, + spec.depth); + const int xbegin = spec.x; + const int xend = spec.x + spec.width; + std::vector buf(size_t(spec.width) * th * td * pixel_bytes); + for (int z = spec.z; z < spec.z + spec.depth; z += td) { + const int zend = std::min(z + td, spec.z + spec.depth); + for (int y = spec.y; y < spec.y + spec.height; y += th) { + const int yend = std::min(y + th, spec.y + spec.height); + image_span ispan(buf.data(), nch, xend - xbegin, + yend - y, zend - z, AutoStride, + AutoStride, AutoStride, AutoStride, + channel_bytes); + if (!inp.read_tiles(subimage, miplevel, xbegin, xend, y, yend, + z, zend, 0, nch, format, ispan)) + return false; + } + } + } else { + // Scanline: read 16 rows at a time. read_scanlines is 2D-oriented, so + // depth is treated as 1 (volumetric data is tiled). + const int chunk = 16; + std::vector buf(size_t(spec.width) * chunk * pixel_bytes); + for (int y = spec.y; y < spec.y + spec.height; y += chunk) { + const int yend = std::min(y + chunk, spec.y + spec.height); + image_span ispan(buf.data(), nch, spec.width, yend - y, + 1, AutoStride, AutoStride, AutoStride, + AutoStride, channel_bytes); + if (!inp.read_scanlines(subimage, miplevel, y, yend, 0, nch, format, + ispan)) + return false; + } + } + return true; +} + + + +bool +pvt::test_read_all_images(ImageInput& inp, TypeDesc format) +{ + bool supports_mipmap = inp.supports("mipmap"); + bool supports_subimages = inp.supports("multiimage"); + bool ok = true; + for (int s = 0; ok; ++s) { + for (int m = 0; ok; ++m) { + ok &= test_read_image(inp, s, m, format); + if (ok) { + if (!supports_mipmap || !inp.seek_subimage(s, m + 1)) + break; + } + } + if (ok) { + if (!supports_subimages || !inp.seek_subimage(s + 1, 0)) + break; + } + } + return ok && !inp.has_error(); +} + + + bool ImageInput::read_native_deep_scanlines(int /*subimage*/, int /*miplevel*/, int /*ybegin*/, int /*yend*/, int /*z*/, diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index 51dbf281cd..1b2d8006eb 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -5691,6 +5691,45 @@ input_file(Oiiotool& ot, cspan argv) +// --testread +static int +test_read(Oiiotool& ot, cspan argv) +{ + // ot.total_readtime.start(); + string_view command = ot.express(argv[0]); + string_view filename = ot.express(argv[1]); + auto fileoptions = ot.extract_options(command); + TypeDesc input_dataformat(fileoptions.get_string("type", "uint8")); + + OTScopedTimer timer(ot, command); + + bool ok = true; + auto inp = OIIO::ImageInput::open(filename, ot.input_config_set + ? &ot.input_config + : nullptr); + if (inp) + ok = OIIO::pvt::test_read_all_images(*inp, input_dataformat); + if (!ok || !inp) { + ot.error("testread", + ot.format_read_error(filename, inp ? inp->geterror() + : OIIO::geterror())); + } else { + OIIO::print("OK {}\n", filename); + } + ot.printed_info = true; + + // Everything past this point should be credited to other ops, so stop + // the input timer. + timer.stop(); + + ot.process_pending(); + ot.check_peak_memory(); + // ot.total_readtime.stop(); + return 0; +} + + + static void prep_texture_config(Oiiotool& ot, ImageSpec& configspec, ParamValueList& fileoptions) @@ -6949,6 +6988,9 @@ Oiiotool::getargs(int argc, char* argv[]) .OTACTION(set_input_attribute); ap.arg("--missingfile %s:OPTION", &ot.missingfile_policy) .help("Set policy for missing input files: 'error' (default), 'black', 'checker'"); + ap.arg("--testread %s:FILENAME") + .help("Test file read (do not keep image; used for debugging)") + .OTACTION(test_read); ap.separator("Commands that write images:"); ap.arg("-o %s:FILENAME")