commit 874f7c08f758db93b4305bd0ea216d0b09fdc214
parent b55537669126af9a1694f1983d58e98b712b666a
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 29 Jul 2026 13:27:42 +0200
fuzzing update
Diffstat:
5 files changed, 537 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -80,6 +80,20 @@ src/main/test_ipc
src/main/test_plugin_load_multi
src/main/test_plugin_loading
src/main/test_trivial
+src/fuzz/.deps/
+src/fuzz/.libs/
+src/fuzz/Makefile
+src/fuzz/Makefile.in
+src/fuzz/*.o
+src/fuzz/*.lo
+src/fuzz/*.la
+src/fuzz/*.log
+src/fuzz/*.trs
+src/fuzz/test-suite.log
+src/fuzz/fuzz_*
+!src/fuzz/fuzz_*.c
+!src/fuzz/fuzz_*.h
+src/fuzz/crashes/
src/plugins/.deps/
src/plugins/.libs/
src/plugins/Makefile
diff --git a/contrib/oss-fuzz/run_campaign.sh b/contrib/oss-fuzz/run_campaign.sh
@@ -0,0 +1,273 @@
+#!/bin/bash
+#
+# Run a weighted libextractor fuzzing campaign.
+#
+# This file is in the public domain.
+#
+# The weights are not guesses. They come from a measured 12-core hour on
+# the 2026-07-29 tree: every target was given an identical 1200 s slice,
+# the resulting corpora were replayed through a coverage build, and each
+# target was credited with the library regions still uncovered in the
+# source files it covers better than any other target. See
+# ../../src/fuzz/CAMPAIGN.md for the data and for why a flat allocation
+# wastes most of its budget.
+#
+# Re-measure with:
+# src/fuzz/CAMPAIGN.md, section "Reproducing the measurement"
+#
+set -u
+
+BIN="${BIN:-}"
+OUT="${OUT:-./campaign}"
+CORES="${CORES:-$(nproc)}"
+HOURS="${HOURS:-}"
+PROFILE="${PROFILE:-nightly}"
+WEIGHTS_FILE=""
+FLOOR="${FLOOR:-300}"
+DRYRUN=0
+
+usage ()
+{
+ cat <<EOF
+usage: $0 -b BINDIR [-o OUTDIR] [-c CORES] [-t HOURS] [-p PROFILE] [-w FILE]
+
+ -b BINDIR directory holding the built fuzz targets (build.sh's \$OUT)
+ -o OUTDIR where corpora, logs and artifacts go (default ./campaign)
+ -c CORES concurrent targets (default nproc)
+ -t HOURS wall-clock budget; overrides -p
+ -p PROFILE ci | nightly | deep (default nightly)
+ -w FILE weight table "target weight" per line, overrides the built-in
+ -F SECONDS per-target floor (default 300)
+ -n print the schedule and exit; run nothing
+
+Profiles, in wall-clock hours on \$CORES cores:
+
+ ci every target gets the floor and nothing more. On 12 cores
+ this is about 15 minutes and it recovers ~93% of the edge
+ coverage a twenty-minute-per-target run reaches. Cheap
+ enough to run on every push.
+ nightly 2 h. The floor plus a headroom-weighted share.
+ deep 8 h. Past this the fitted curve says each additional 1% of
+ edge coverage costs more than 30 core-hours; spend it on
+ better generators or new targets instead.
+EOF
+ exit "${1:-1}"
+}
+
+while getopts "b:o:c:t:p:w:F:nh" o; do
+ case "$o" in
+ b) BIN="$OPTARG" ;;
+ o) OUT="$OPTARG" ;;
+ c) CORES="$OPTARG" ;;
+ t) HOURS="$OPTARG" ;;
+ p) PROFILE="$OPTARG" ;;
+ w) WEIGHTS_FILE="$OPTARG" ;;
+ F) FLOOR="$OPTARG" ;;
+ n) DRYRUN=1 ;;
+ h) usage 0 ;;
+ *) usage ;;
+ esac
+done
+
+[ -n "${BIN}" ] || usage
+[ -d "${BIN}" ] || { echo "no such directory: ${BIN}" >&2; exit 1; }
+
+if [ -z "${HOURS}" ]; then
+ case "${PROFILE}" in
+ ci) HOURS=0 ;;
+ nightly) HOURS=2 ;;
+ deep) HOURS=8 ;;
+ *) echo "unknown profile: ${PROFILE}" >&2; usage ;;
+ esac
+fi
+
+# Addressable headroom in library regions, measured 2026-07-29.
+# "Addressable" excludes two things that no runtime can reach:
+# plugins/pack.c 89 regions of format codes that neither of its
+# two callers ever asks for (fixed format strings)
+# extractor_logging.c 2 regions live only in a debug build
+# The third-party wrappers (gif jpeg tiff flac ogg archive mime) are
+# damped to 35%: their uncovered regions are almost all error returns
+# from giflib/libjpeg/libtiff/FLAC/libvorbis/libarchive/libmagic, which
+# those projects fuzz themselves, and reaching them from a file input is
+# disproportionately expensive.
+read -r -d '' BUILTIN_WEIGHTS <<'EOF'
+fuzz_ole2 119
+fuzz_unzip 119
+fuzz_msoffice 73
+fuzz_datasource 74
+fuzz_qt 56
+fuzz_rtf 56
+fuzz_png 48
+fuzz_ogg 26
+fuzz_flac 14
+fuzz_mime 10
+fuzz_dvi 10
+fuzz_odf 9
+fuzz_deb 7
+fuzz_convert 6
+fuzz_elf 6
+fuzz_man 4
+fuzz_nsfe 4
+fuzz_ps 3
+fuzz_real 3
+fuzz_sid 2
+fuzz_gif 1
+fuzz_jpeg 1
+fuzz_tiff 1
+fuzz_nsf 1
+fuzz_riff 1
+fuzz_applefile 0
+fuzz_archive 0
+fuzz_ipc 0
+fuzz_it 0
+fuzz_s3m 0
+fuzz_wav 0
+fuzz_xm 0
+fuzz_zip 0
+EOF
+
+if [ -n "${WEIGHTS_FILE}" ]; then
+ WEIGHTS="$(cat "${WEIGHTS_FILE}")"
+else
+ WEIGHTS="${BUILTIN_WEIGHTS}"
+fi
+
+mkdir -p "${OUT}/corpus" "${OUT}/logs" "${OUT}/artifacts"
+export TMPDIR="${OUT}/tmp"
+mkdir -p "${TMPDIR}"
+
+# Only schedule targets that were actually built.
+AVAIL=""
+while read -r t w; do
+ [ -n "${t}" ] || continue
+ [ -x "${BIN}/${t}" ] || continue
+ AVAIL="${AVAIL}${t} ${w}
+"
+done <<EOF
+${WEIGHTS}
+EOF
+
+NTARGETS=$(printf '%s' "${AVAIL}" | grep -c . || true)
+[ "${NTARGETS}" -gt 0 ] || { echo "no targets found in ${BIN}" >&2; exit 1; }
+
+BUDGET=$(awk -v c="${CORES}" -v h="${HOURS}" 'BEGIN{printf "%d", c*h*3600}')
+# No single job may outlast the campaign: with -t 8 a target given nine
+# hours would still be running when everything else has finished, and
+# the run would take nine hours rather than the eight that were asked
+# for. Hence water-filling -- share out the budget by weight, clamp
+# anything over the wall clock, share the remainder among the rest, and
+# repeat until nothing else clamps.
+CAP=$(awk -v h="${HOURS}" 'BEGIN{printf "%d", (h>0 ? h*3600 : 0)}')
+
+: > "${OUT}/jobs.txt"
+printf '%s' "${AVAIL}" | awk -v floor="${FLOOR}" -v budget="${BUDGET}" \
+ -v cap="${CAP}" '
+ { name[NR]=$1; w[NR]=$2; n=NR }
+ END {
+ if (cap <= 0 || cap < floor) cap = floor; # ci profile: floor only
+ for (i=1; i<=n; i++) { s[i]=floor; capped[i]=(floor>=cap) }
+ extra = budget - floor*n
+ if (extra < 0) extra = 0
+ for (round=0; round<64 && extra>0; round++) {
+ tw = 0
+ for (i=1; i<=n; i++) if (!capped[i]) tw += w[i]
+ if (tw <= 0) break
+ spill = 0; moved = 0
+ for (i=1; i<=n; i++) {
+ if (capped[i]) continue
+ add = extra * w[i] / tw
+ if (s[i] + add >= cap) { spill += s[i] + add - cap; s[i] = cap; capped[i]=1 }
+ else { s[i] += add }
+ moved = 1
+ }
+ if (!moved) break
+ extra = spill
+ }
+ for (i=1; i<=n; i++) printf "%s:%d\n", name[i], s[i]
+ }' >> "${OUT}/jobs.txt"
+# Longest first: with a fixed number of slots the long jobs must start
+# early or the tail of the campaign runs on one core.
+sort -t: -k2 -rn -o "${OUT}/jobs.txt" "${OUT}/jobs.txt"
+
+echo "=== libextractor campaign ==="
+echo " targets ${NTARGETS}"
+echo " cores ${CORES}"
+echo " profile ${PROFILE} (${HOURS} h wall => $(awk -v b=${BUDGET} 'BEGIN{printf "%.1f", b/3600}') core-hours)"
+echo " floor ${FLOOR}s"
+echo " out ${OUT}"
+awk -F: -v c="${CORES}" '
+ { printf " %-18s %6d s %2d:%02d\n", $1, $2, $2/3600, ($2%3600)/60; s+=$2 }
+ END { printf " %-18s %6.1f core-hours over %d cores => %.1f h wall (perfect packing)\n",
+ "TOTAL", s/3600, c, s/3600/c }
+' "${OUT}/jobs.txt"
+
+if [ "${DRYRUN}" = "1" ]; then
+ echo "(dry run: nothing executed)"
+ exit 0
+fi
+
+for z in "${BIN}"/*_seed_corpus.zip; do
+ [ -f "${z}" ] || continue
+ t=$(basename "${z}" _seed_corpus.zip)
+ [ -x "${BIN}/${t}" ] || continue
+ mkdir -p "${OUT}/corpus/${t}"
+ unzip -qo "${z}" -d "${OUT}/corpus/${t}" 2>/dev/null
+done
+
+run_one ()
+{
+ local spec="$1" t secs dict maxlen leaks rc
+ t="${spec%%:*}"
+ secs="${spec##*:}"
+ dict=""
+ [ -f "${BIN}/${t}.dict" ] && dict="-dict=${BIN}/${t}.dict"
+ maxlen=262144
+ case "${t}" in
+ fuzz_convert) maxlen=4096 ;;
+ fuzz_ipc) maxlen=65536 ;;
+ esac
+ # ole2 drags in glib, whose one-time allocations LeakSanitizer reports
+ # as leaks on every input. They are not ours and they bury everything
+ # else, so leak detection is off for that target only.
+ leaks=1
+ [ "${t}" = "fuzz_ole2" ] && leaks=0
+ mkdir -p "${OUT}/artifacts/${t}"
+ echo "START ${t} (${secs}s)"
+ ASAN_OPTIONS="allocator_may_return_null=0:detect_stack_use_after_return=1:detect_leaks=${leaks}" \
+ UBSAN_OPTIONS="print_stacktrace=1:report_error_type=1" \
+ "${BIN}/${t}" "${OUT}/corpus/${t}" \
+ -max_total_time="${secs}" -fork=1 \
+ -ignore_crashes=1 -ignore_ooms=1 -ignore_timeouts=1 \
+ -rss_limit_mb=2560 -timeout=25 -max_len="${maxlen}" \
+ -print_final_stats=1 \
+ -artifact_prefix="${OUT}/artifacts/${t}/" \
+ ${dict} > "${OUT}/logs/${t}.log" 2>&1
+ rc=$?
+ echo "DONE ${t} rc=${rc} artifacts=$(ls -1 "${OUT}/artifacts/${t}" 2>/dev/null | wc -l)"
+}
+export -f run_one
+export BIN OUT
+
+date +%s > "${OUT}/started_at"
+xargs -a "${OUT}/jobs.txt" -P "${CORES}" -I{} \
+ bash -c 'run_one "$@"' _ {} 2>&1 | tee "${OUT}/campaign.log"
+date +%s > "${OUT}/finished_at"
+
+echo
+echo "=== campaign finished in $(( $(cat "${OUT}/finished_at") - $(cat "${OUT}/started_at") ))s ==="
+echo "artifacts: $(find "${OUT}/artifacts" -type f | wc -l)"
+cat <<EOF
+
+Now run the replay pass. -fork=1 reads only the child's exit status, so
+a *recovering* UndefinedBehaviorSanitizer report leaves no artifact and
+no trace in the log -- and seven of the twelve defects fixed in this
+library were exactly that. The campaign above finds memory-safety bugs;
+this finds the rest:
+
+ for d in ${OUT}/corpus/*; do
+ t=\$(basename "\$d")
+ UBSAN_OPTIONS=halt_on_error=0 ASAN_OPTIONS=halt_on_error=0 \\
+ ./src/fuzz/\$t --corpus-dir="\$d"
+ done 2>&1 | grep "runtime error"
+EOF
diff --git a/src/fuzz/CAMPAIGN.md b/src/fuzz/CAMPAIGN.md
@@ -0,0 +1,238 @@
+# Running a libextractor fuzzing campaign
+
+This is the measured answer to two questions: **how should a fixed
+number of core-hours be divided across the targets**, and **how long is
+it still worth running**. Both answers come from a controlled 12-core
+hour on the 2026-07-29 tree, not from intuition, and both are
+uncomfortable: a flat allocation wastes most of its budget, and the
+productive length of a campaign is far shorter than it looks.
+
+`../../contrib/oss-fuzz/run_campaign.sh` implements the conclusion.
+
+
+## 1. The measurement
+
+Every target got an **identical** budget, so the curves are comparable
+and the allocation could be derived from the data instead of assumed:
+
+ 36 jobs x 1200 s / 12 cores = 3 full waves = 3715 s wall, 97% busy
+
+32 of the jobs were the targets; 4 were replicates of `fuzz_unzip`,
+`fuzz_msoffice`, `fuzz_rtf` and `fuzz_datasource` with a different PRNG
+seed and their own corpus, to find out how much of any difference is
+just noise.
+
+The tree carried the twelve fixes from `issues.txt`. Result:
+
+| | |
+|---|---|
+| wall clock | 3715 s (62 min), 12 cores, 12.0 core-hours |
+| crashes / leaks / OOM / timeouts | **0** — all 36 jobs reported `0/0/0` |
+| artifacts | **0** |
+| corpus | 1727 seed inputs grew to 6045 |
+| replay of the whole result under ASan+UBSan | **0 sanitizer reports** |
+
+The replay pass is not optional and not redundant. `-fork=1` reads only
+the child's exit status, so a *recovering* UBSan report leaves no
+artifact and nothing in the log — and seven of the twelve fixed defects
+were exactly that.
+
+
+## 2. Feature counts are noise; edge coverage is not
+
+The four replicate pairs differ only in PRNG seed:
+
+| target | run 1 `ft` | run 2 `ft` | Δ | run 1 `cov` | run 2 `cov` | Δ |
+|---|---|---|---|---|---|---|
+| fuzz_rtf | 6608 | 7201 | **+9.0 %** | 1047 | 1072 | +2.4 % |
+| fuzz_msoffice | 5332 | 5739 | **+7.6 %** | 1083 | 1100 | +1.6 % |
+| fuzz_unzip | 4560 | 4430 | −2.9 % | 805 | 804 | −0.1 % |
+| fuzz_datasource | 3254 | 3244 | −0.3 % | 650 | 650 | 0.0 % |
+
+libFuzzer's `ft` counter includes value-profile entries and is unbounded,
+so it keeps rising after the fuzzer has stopped reaching new code. Any
+ranking built on it at this scale is fitting noise. Everything below
+therefore uses **edge coverage** and **llvm-cov region coverage**, both
+of which reproduce to within about 2 %.
+
+
+## 3. How long a target is worth running
+
+Seconds each target needed to reach a given fraction of the edge
+coverage it ended the 1200 s slice with:
+
+| | median over 32 targets |
+|---|---|
+| 90 % of final | **12 s** |
+| 95 % of final | **34 s** |
+| 99 % of final | **93 s** |
+
+Only **4 of 32** targets were still gaining at t = 1200 s
+(`fuzz_unzip`, `fuzz_rtf`, `fuzz_qt`, `fuzz_msoffice`); twelve had
+converged within nine seconds.
+
+Aggregated over all 32 targets, the discovery rate collapses by a factor
+of **340** across a single twenty-minute slice:
+
+| window | new edges | rate |
+|---|---|---|
+| 0 – 75 s | 2026 | 3039 edges/core-hour |
+| 75 – 150 s | 117 | 175 edges/core-hour |
+| 150 – 300 s | 80 | 60 edges/core-hour |
+| 300 – 600 s | 61 | 23 edges/core-hour |
+| 600 – 1200 s | 48 | **9 edges/core-hour** |
+
+87 % of everything the slice found arrived in its first 75 seconds. The
+second half of the run — six of the twelve core-hours — produced 2 % of
+the gain.
+
+Cumulative gain fits `G(t) = 1655 · t^0.050`, which extrapolates to:
+
+| per target | total edges | vs the 20-min slice | cost |
+|---|---|---|---|
+| 20 min | 2332 | — | 10.7 core-hours |
+| 1 h | 2488 | +6.7 % | 32 core-hours |
+| 2 h | 2576 | +10.5 % | 64 core-hours |
+| 8 h | 2760 | +18.3 % | 256 core-hours |
+| 24 h | 2915 | +25.0 % | 768 core-hours |
+| 168 h | 3211 | +37.7 % | 5376 core-hours |
+
+Seventy-two times the budget buys a quarter more coverage. The marginal
+cost per edge roughly doubles with every doubling of runtime: 0.14
+core-hours per edge going from 20 min to 1 h per target, 1.4 at 8 h,
+3.9 at 24 h.
+
+
+## 4. Where the remaining code actually is
+
+The campaign corpora were replayed through a coverage build. Including
+`fuzz_ole2`, which was given its own matching 1200 s slice afterwards,
+the union across all targets is **6488 of 7328 library regions, 88.5 %**.
+The 840 that are left are not evenly spread, and not all of them are
+reachable:
+
+| source file | regions | covered | left | best target |
+|---|---|---|---|---|
+| `common/unzip.c` | 674 | 555 | **119** | fuzz_unzip |
+| `plugins/pack.c` | 128 | 39 | 89 → *unreachable* | fuzz_elf |
+| `plugins/ole2_extractor.c` | 799 | 721 | 78 | fuzz_ole2 |
+| `main/extractor_datasource.c` | 545 | 471 | 74 | fuzz_datasource |
+| `plugins/msoffice_extractor.c` | 548 | 475 | 73 | fuzz_msoffice |
+| `plugins/ogg_extractor.c` | 235 | 162 | 73 | fuzz_ogg |
+| `plugins/qt_extractor.c` | 390 | 334 | 56 | fuzz_qt |
+| `plugins/rtf_extractor.c` | 746 | 690 | 56 | fuzz_rtf |
+| `plugins/png_extractor.c` | 315 | 267 | 48 | fuzz_png |
+| `plugins/msoffice_biff.h` | 181 | 140 | 41 | fuzz_ole2 |
+| `plugins/flac_extractor.c` | 130 | 89 | 41 | fuzz_flac |
+| `plugins/mime_extractor.c` | 52 | 24 | 28 | fuzz_mime |
+| everything else | | | ≤ 10 each | |
+
+Four findings matter more than the table:
+
+**`fuzz_ole2` was the highest-yield target in the tree and had never been
+run.** Its 1200 s slice took `ole2_extractor.c` from 47.6 % to 90.2 %
+— 341 new regions, 1023 regions/core-hour, against 780 for the next best
+(`fuzz_elf`) and 150 for `fuzz_unzip`. It also covers
+`plugins/msoffice_biff.h` better than `fuzz_msoffice` does (41 uncovered
+against 64). It was excluded from `contrib/oss-fuzz/build.sh` because
+the plugin needs libgsf's compiler flags rather than just a `-l`, and
+nobody revisited the exclusion; issue 13 was waiting in it, reachable
+from a file already in `src/plugins/testdata/`. Build it with
+`LE_FUZZ_GSF=1`. Note that most of what it *reports* belongs to libgsf
+and glib rather than to libextractor — see `issues.txt` — so it earns a
+place in local campaigns but not in the OSS-Fuzz default set.
+
+**`plugins/pack.c`'s 89 uncovered regions are dead code.** It is a
+general-purpose (un)packer with sixteen format codes. Its only two
+callers pass fixed format strings — `"4bW16bH"`, `"WWW"`,
+`"hhwwwwwhhhhhh"`, `"wwwwwwwwww"` and their upper-case variants — so no
+input can reach the rest. `fuzz_applefile` looks like it has 102
+regions of headroom and actually has none; do not fund it.
+
+**`fuzz_zip`, `fuzz_odf` and `fuzz_msoffice` do not meaningfully cover
+`unzip.c`.** Their corpora were merged with `fuzz_unzip`'s (961 inputs
+total) and replayed: union coverage of `unzip.c` is 83.4 % against
+`fuzz_unzip`'s 82.3 % alone. Three targets, 743 inputs, seven extra
+regions. `unzip.c` should be funded through `fuzz_unzip` and nowhere
+else; `zip_extractor.c` itself is already at 100 %.
+
+**What is left in `unzip.c` is the interesting part.** The uncovered
+regions concentrate in `parse_current_file_coherency_header` (29) and
+`unzip_open_using_ffd` (18) — the central-directory / local-header
+consistency checks, which is precisely where a ZIP reader gets attacked.
+Reaching them needs a better ZIP generator, not a longer run.
+
+
+## 5. The recommendation
+
+**Balance.** Weight each target by the library regions still uncovered in
+the files it covers better than any other target, excluding the
+unreachable ones, and damp the third-party wrappers (gif, jpeg, tiff,
+flac, ogg, archive, mime) to 35 % — their remaining regions are error
+returns from giflib/libjpeg/libtiff/FLAC/libvorbis/libarchive/libmagic,
+which those projects fuzz themselves. Give every target a 300 s floor so
+that a regression anywhere still gets caught. That table is built into
+`run_campaign.sh`.
+
+**Length.** Three tiers, and the top one is smaller than it looks:
+
+| profile | wall clock on 12 cores | core-hours | what it is for |
+|---|---|---|---|
+| `ci` | ~11 min (300 s floor, every target) | 2.8 | every push; recovers ~93 % of the edges a 20-min-per-target run reaches |
+| `nightly` | 2 h | 24 | floor plus the headroom-weighted share |
+| `deep` | 8 h | 96 | weekly; past here each additional 1 % of edge coverage costs >30 core-hours |
+
+No single job is ever given more than the wall clock, however heavy its
+weight: a target handed nine hours inside an eight-hour campaign would
+still be running when everything else had finished. `run_campaign.sh`
+water-fills instead — share by weight, clamp at the wall clock,
+redistribute the spill, repeat — so `-t 8` really does take eight hours.
+
+Do not run flat campaigns longer than `deep`. Beyond roughly 100
+core-hours per run the fitted curve says the money is better spent on:
+
+1. **A structure-aware ZIP generator**, for the `unzip.c` coherency
+ checks above — 119 regions, and the ones that matter.
+2. **New targets.** `fuzz_ole2` had 419 uncovered regions and one live
+ defect purely because nobody had built it; that is a far better
+ return than another 100 core-hours on the 32 targets that were
+ already running. `fuzz_extract` is still absent from the libFuzzer
+ set for a good reason (it `dlopen()`s uninstrumented modules), but it
+ is worth running under the built-in driver.
+3. **The replay pass after every fix.** Issue 12 existed only because
+ issue 02 fired first on almost every PNG input and masked it; it was
+ found by re-running after patching, not by running longer.
+
+The general shape: on this codebase a campaign is a **corpus-building
+and regression exercise**, not a bug-discovery lottery. Of the twelve
+defects fixed, four were found within 73 seconds and seven more were
+reachable from the seed corpus with *no fuzzing at all* — replaying
+`src/plugins/testdata/` under UBSan finds them. Exactly one needed
+fuzzing time, and only after another defect was unmasked.
+
+
+## 6. Reproducing the measurement
+
+```sh
+# 1. build, out of tree, with gsf so ole2 is included
+rsync -a --exclude=.git /path/to/libextractor/ /tmp/le/libextractor/
+( cd /tmp/le/libextractor && make distclean )
+SRC=/tmp/le WORK=/tmp/w OUT=/tmp/out SANITIZER=address \
+ LE_FUZZ_EXTRA_PLUGINS=1 LE_FUZZ_GSF=1 \
+ /tmp/le/libextractor/contrib/oss-fuzz/build.sh
+
+# 2. flat slice, for comparable curves
+contrib/oss-fuzz/run_campaign.sh -b /tmp/out -o /tmp/camp -c 12 -F 1200 -t 0
+
+# 3. coverage build, replay each corpus, diff against the seed corpus
+SRC=/tmp/le WORK=/tmp/wc OUT=/tmp/cov SANITIZER=coverage \
+ LE_FUZZ_EXTRA_PLUGINS=1 LE_FUZZ_GSF=1 \
+ /tmp/le/libextractor/contrib/oss-fuzz/build.sh
+LLVM_PROFILE_FILE=p.profraw /tmp/cov/fuzz_unzip -runs=0 /tmp/camp/corpus/fuzz_unzip
+llvm-profdata merge -sparse p.profraw -o p.profdata
+llvm-cov report /tmp/cov/fuzz_unzip -instr-profile=p.profdata
+```
+
+The per-job stats lines libFuzzer prints in fork mode
+(`#N: cov: .. ft: .. corp: .. time: Ns job: M`) are a ready-made time
+series; that is where sections 2 and 3 come from.
diff --git a/src/fuzz/Makefile.am b/src/fuzz/Makefile.am
@@ -63,6 +63,7 @@ noinst_HEADERS = \
EXTRA_DIST = \
README \
BUILD-INTEGRATION.md \
+ CAMPAIGN.md \
corpus
CLEANFILES = \
diff --git a/src/fuzz/README b/src/fuzz/README
@@ -221,6 +221,17 @@ fix:
For a real campaign use libFuzzer via `contrib/oss-fuzz/build.sh`; see
`../../contrib/oss-fuzz/README`.
+`CAMPAIGN.md` answers the two questions that come up as soon as the
+campaign is longer than a coffee break -- how to split the budget across
+the targets, and how long it is worth running -- from a measured
+12-core hour rather than from intuition. Both answers are unobvious:
+half the targets reach 99% of their final coverage within 93 seconds,
+and a flat allocation spends most of its budget on targets that finished
+in the first minute. `contrib/oss-fuzz/run_campaign.sh` implements the
+conclusion:
+
+ contrib/oss-fuzz/run_campaign.sh -b /path/to/build.sh-output -p nightly
+
Environment knobs, all read once at startup:
LE_FUZZ_ITERATIONS iterations of the built-in driver