1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
name: Fuzz burst
# The exploring half of the fuzzing program; `fuzz-replay.yml` is the gate. Every push to `main`
# gives every target a short mutation burst, folds what it found into that target's corpus, and
# saves the corpus for the replay to read. Never a gate: nothing depends on this workflow, so a
# random find turns this run red rather than the push.
#
# This replaced a nightly (05:00 UTC, three 5h shards on `parse` plus 1h on each property target),
# retired 2026-09-26. Every real find came in a target's first days, most on the first local run;
# the nightly then ran seven weeks without another, and its later red nights were job timeouts
# (`config_load` overrunning `timeout-minutes`), not findings. A burst per push follows the code:
# new code is fuzzed the day it lands, which is when fuzzing finds things.
#
# Deeper run: `workflow_dispatch` with a larger `max_total_time` after a big change to a fuzzed
# surface, which also renders the `parse` coverage report.
on:
push:
branches:
# Nothing fuzzed reads Markdown or the mdbook sources (no include_str! of either, no .md
# corpus inputs), so a docs-only push has no new code to explore.
paths-ignore:
workflow_dispatch:
inputs:
max_total_time:
description: "Mutation seconds per target (default 180). Raise for a deliberate deeper run; keep it under ~5h so the job completes before GitHub's 6h cap."
default: "180"
# Queue rather than cancel: a cancelled job swallows its "Fail on crashes" step, so a real find
# would vanish behind a superseding push.
concurrency:
group: fuzz-burst
cancel-in-progress: false
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
TRIPLE: x86_64-unknown-linux-gnu
BUDGET: ${{ github.event.inputs.max_total_time || '180' }}
jobs:
# `cargo fuzz run` and every `-merge=1` would each rebuild, so build every target ONCE here and
# hand the binaries on. A cargo-fuzz output is a standalone libFuzzer executable; the burst jobs
# exec it directly with the flags cargo-fuzz would have passed.
build:
name: Build fuzz targets
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
# nightly + rust-src: cargo-fuzz builds std with the sanitizer instrumented.
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
# --target is pinned to the gnu host triple: cargo-fuzz is installed as a prebuilt musl static
# binary and otherwise defaults the fuzz target to its own musl triple, whose static libc can't
# carry AddressSanitizer ("sanitizer is incompatible with statically linked libc").
# No target name: builds every [[bin]] in fuzz/Cargo.toml, so this list cannot drift.
- name: Build
run: |
set -euo pipefail
cargo +nightly fuzz build --target "$TRIPLE"
mkdir -p bin
for t in $(cargo +nightly fuzz list); do
cp "fuzz/target/$TRIPLE/release/$t" bin/
done
ls -l bin
- name: Upload fuzz binaries
uses: actions/upload-artifact@v7
with:
name: fuzz-binaries
path: bin
if-no-files-found: error
# Registry-derived seeds + dictionary for `parse`. Byte mutation barely reaches the
# per-command grammars (~26% region); the examples_safe/denied invocations as seeds plus the
# command/flag vocabulary as a dictionary more than DOUBLE it (measured ~61% combined).
# Generated fresh each run so new commands are covered automatically; the burst's merge folds
# the seeds into the saved corpus, so the replay gate holds them too.
- name: Generate seeds + dictionary
run: |
cargo +nightly run --bin gen-fuzz-corpus --features fuzz-gen
test -s fuzz/dict/parse.dict
test "$(find fuzz/corpus/parse -name 'gen-*' | wc -l)" -gt 0
- name: Upload seeds + dictionary
uses: actions/upload-artifact@v7
with:
name: fuzz-seeds
path: |
fuzz/dict/parse.dict
fuzz/corpus/parse
if-no-files-found: error
burst:
name: Burst (${{ matrix.target }})
needs: build
runs-on: ubuntu-latest
# Budget + corpus load + merge. The dispatch input can raise the budget; keep it under 6h, or
# GitHub cancels the job and the cancellation hides the crash check.
timeout-minutes: 350
strategy:
fail-fast: false # one target finding a crash must not cancel the others
matrix:
# EVERY target, kept in step with fuzz/Cargo.toml and fuzz-replay.yml by
# `tests/fuzz_targets_wired.rs`.
target:
steps:
- uses: actions/checkout@v7
- name: Download fuzz binaries
uses: actions/download-artifact@v8
with:
name: fuzz-binaries
path: bin
# Artifact upload does not preserve the executable bit.
- name: Make executable
run: chmod +x bin/*
# The corpus the previous burst saved. The key prefix is the one fuzz-replay.yml restores.
- name: Restore corpus
uses: actions/cache/restore@v6
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-
restore-keys: fuzz-corpus-${{ matrix.target }}-
# Downloading into fuzz/ unions the gen-* seeds with the restored corpus and adds the dict.
- name: Download seeds + dictionary
if: matrix.target == 'parse'
uses: actions/download-artifact@v8
with:
name: fuzz-seeds
path: fuzz
# fuzz/burst.sh: one process, not fork mode (fork mode reloads the whole corpus in every child;
# a nominal 60s fork-mode burst measured ~275s against a 12.6k corpus). The budget clock starts
# once the corpus has loaded, since libFuzzer's -max_total_time counts the load and a large
# corpus on a runner can take longer to load than the whole budget. New units go to
# fuzz/new/<target>, then `-merge=1` folds [restored corpus + seeds, new] into a minimized
# union. The merge runs after a crash too; a successful one sets `merged=true`. The script
# picks up fuzz/dict/<target>.dict itself, so `parse` gets the dictionary downloaded above.
#
# `continue-on-error` so the save and upload still run after a find; "Fail on crashes" turns
# the job red.
- name: Fuzz and merge
id: fuzz
continue-on-error: true
run: bash fuzz/burst.sh "./bin/${{ matrix.target }}" "${{ matrix.target }}" "$BUDGET"
# Unique (always-miss) key, so the next restore's prefix match pulls the newest. Only a
# completed merge is saved, so a half-merged corpus never becomes canonical.
- name: Save corpus
if: ${{ !cancelled() && steps.fuzz.outputs.merged == 'true' }}
uses: actions/cache/save@v6
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload findings
if: always()
uses: actions/upload-artifact@v7
with:
name: fuzz-findings-${{ matrix.target }}
path: fuzz/artifacts/
if-no-files-found: ignore
# The Fuzz and merge step is continue-on-error, so surface findings explicitly.
# crash/timeout/oom are fatal; slow-unit is uploaded above for triage but is contention-sensitive, so not fatal.
# A burst that failed with NO artifact (missing binary, leak report, bad flag) is red too,
# or continue-on-error would turn a broken burst green.
- name: Fail on crashes
if: always()
env:
FUZZ_OUTCOME: ${{ steps.fuzz.outcome }}
run: |
hits=$(find fuzz/artifacts -type f \( -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' \) 2>/dev/null || true)
if [ -n "$hits" ]; then
echo "::error::fuzzing saved crash/timeout/oom artifacts (see fuzz-findings-${{ matrix.target }})"
printf '%s\n' "$hits"
exit 1
fi
if [ "$FUZZ_OUTCOME" != success ]; then
echo "::error::the Fuzz and merge step ended '$FUZZ_OUTCOME' without a crash/timeout/oom artifact; read its log"
exit 1
fi
echo "no crash/timeout/oom artifacts"
# What the crash/timeout signal CANNOT tell you: which parts of the classifier the corpus never
# reaches. A green burst only means "no panic in the region explored" — this job measures that
# region. Replays the freshly-merged corpus under instrumentation and reports per-file coverage, so
# an unreached module is visible as a coverage hole (either dead code, or a grammar the byte-level
# mutator cannot stumble into and that wants a dictionary / structure-aware target). Informational:
# it gates nothing, but it is the input to deciding where fuzzing effort should go next.
#
# Dispatch only: its own instrumented build plus a full replay would triple a per-push burst, and
# coverage moves slowly. Run it with the deeper dispatch run, after a large change.
coverage:
name: Coverage report
needs: burst
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
# llvm-tools-preview is REQUIRED: without it `cargo fuzz coverage` runs the corpus fine and then
# dies at "Merging raw coverage data" because llvm-profdata is absent from the nightly sysroot.
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src, llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
workspaces: fuzz
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
# The parse burst saved the merged corpus under this run's id, so the prefix match pulls it.
- name: Restore merged corpus
uses: actions/cache/restore@v6
with:
path: fuzz/corpus/parse
key: fuzz-corpus-parse-
restore-keys: fuzz-corpus-parse-
# A separate build: coverage instrumentation differs from the sanitizer/fuzzing build, so this
# one cannot reuse the shared binary.
- name: Generate coverage data
run: cargo +nightly fuzz coverage parse --target "$TRIPLE"
# Don't hardcode cargo-fuzz's layout — it differs with/without --target and has moved between
# releases. The `coverage` build lands under the WORKSPACE-ROOT `target/`, NOT `fuzz/target/`,
# in a nested `<triple>/coverage/<triple>/release/` path (verified on this runner). Search both
# roots for the coverage build, then PROBE each candidate and keep the first llvm-cov accepts —
# the probe, not the path, is the real check (guards against a stale non-instrumented binary).
- name: Render report
run: |
set -euo pipefail
LLVM_COV="$(rustc +nightly --print sysroot)/lib/rustlib/$TRIPLE/bin/llvm-cov"
PROF=fuzz/coverage/parse/coverage.profdata
BIN=""
for cand in $(find target fuzz/target -path '*coverage*release*' -name parse -type f 2>/dev/null); do
if "$LLVM_COV" report "$cand" -instr-profile="$PROF" >/dev/null 2>&1; then
BIN="$cand"; break
fi
done
if [ -z "$BIN" ]; then
echo "::error::no INSTRUMENTED parse binary matched $PROF (cargo-fuzz layout changed?)"
find target fuzz/target -name parse -type f 2>/dev/null || true
exit 1
fi
echo "llvm-cov: $LLVM_COV"
echo "instrumented binary: $BIN"
# Report safe-chains' OWN authored source only — not deps, std, the fuzz shim, or
# build-script-GENERATED files (build/*/out/*, e.g. the compiled command registry).
IGNORE='(/\.cargo/|/rustc/|/fuzz/|/out/)'
{
echo '## Fuzz coverage (`parse` target)'
echo
echo "Corpus: $(find fuzz/corpus/parse -type f | wc -l) inputs"
echo
echo '```'
"$LLVM_COV" report "$BIN" -instr-profile="$PROF" -ignore-filename-regex="$IGNORE"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
"$LLVM_COV" show "$BIN" -instr-profile="$PROF" -ignore-filename-regex="$IGNORE" \
-format=html -output-dir=coverage-html
- name: Upload HTML coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: fuzz-coverage-html
path: coverage-html
if-no-files-found: warn