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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
name: Fuzz
# Overnight coverage-guided fuzzing of the command classifier. Runs on nightly Rust (cargo-fuzz
# needs -Zsanitizer); the fuzz crate is a standalone workspace, so this never touches the stable CI.
#
# Shape: build the target ONCE, then fan out to N parallel shards that each fuzz independently from
# the same restored corpus, then a single merge job unions + minimizes all shard corpora back into
# one canonical corpus for the next run to build on. Parallel shards multiply crash-finding; the
# merge is what makes their coverage compound instead of being dropped (only one cache entry is ever
# restored). A final coverage job measures which parts of the classifier the corpus actually reaches.
on:
schedule:
- cron: "0 7 * * *" # ~07:00 UTC nightly
workflow_dispatch:
inputs:
max_total_time:
description: "Fuzz duration in seconds, per shard (default 5h). Use a small value to smoke-test the workflow."
default: "18000"
env:
CARGO_TERM_COLOR: always
TRIPLE: x86_64-unknown-linux-gnu
jobs:
# `cargo fuzz run` and `cargo fuzz cmin` each rebuild the target, so a 3-shard nightly used to
# compile it four times. Build once here and hand the binary to every downstream job: the
# cargo-fuzz output is a standalone libFuzzer executable, so shards and merge can exec it directly
# with the flags cargo-fuzz would have passed. (Coverage still builds its own — that
# instrumentation differs.)
build:
name: Build fuzz target
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
# 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").
- name: Build
run: cargo +nightly fuzz build parse --target "$TRIPLE"
- name: Upload fuzz binary
uses: actions/upload-artifact@v4
with:
name: fuzz-binary
path: fuzz/target/${{ env.TRIPLE }}/release/parse
if-no-files-found: error
# Registry-derived seeds + dictionary. 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 seeds are absorbed into the canonical corpus by the
# merge job, so this compounds rather than repeating work.
- name: Generate seeds + dictionary
run: |
cargo 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@v4
with:
name: fuzz-seeds
path: |
fuzz/dict/parse.dict
fuzz/corpus/parse
if-no-files-found: error
fuzz:
name: Fuzz (parse) shard ${{ matrix.shard }}
needs: build
runs-on: ubuntu-latest
# GitHub-hosted jobs are force-cancelled at a 6h hard cap — which shows as "cancelled" and
# SWALLOWS the "Fail on crashes" red signal, so an 8h budget silently hid crashes every night.
# Keep the fuzz budget (5h) + setup under 6h so the job COMPLETES: findings then surface as a
# red run, and the merge sees clean shard outcomes. This timeout is a backstop below the cap.
timeout-minutes: 350
strategy:
fail-fast: false # one shard finding a crash must not cancel the others (or the merge)
matrix:
shard:
steps:
- uses: actions/checkout@v5
- name: Download fuzz binary
uses: actions/download-artifact@v4
with:
name: fuzz-binary
path: bin
# Artifact upload does not preserve the executable bit.
- name: Make executable
run: chmod +x bin/parse
# Every shard restores the same latest canonical corpus, then diverges (libFuzzer seeds its
# RNG randomly, so shards explore different regions). Restore-only: shards hand their findings
# to the merge job as artifacts, they do not write the cache themselves.
- name: Restore corpus
uses: actions/cache/restore@v4
with:
path: fuzz/corpus/parse
key: fuzz-corpus-parse-
restore-keys: fuzz-corpus-parse-
# Registry seeds (gen-*) land alongside the restored corpus; the dictionary feeds -dict below.
# Downloading into the same corpus dir unions with the cache (both are present when fuzzing).
- name: Download seeds + dictionary
uses: actions/download-artifact@v4
with:
name: fuzz-seeds
path: fuzz
# Fork mode (-fork=1) + -ignore_crashes: a crash/timeout kills only the child; the parent keeps
# fuzzing to -max_total_time and SAVES every finding to fuzz/artifacts/ instead of libFuzzer's
# default of aborting on the first. That is what lets a run enumerate the whole crash set (and
# use its full time budget) rather than dying minutes in on the nearest shallow bug — every shard
# otherwise trips the same shallow crash from the shared corpus and exits, wasting the parallelism.
# -dict feeds the registry vocabulary so the mutator splices real command/flag tokens.
#
# Two corpus dirs: libFuzzer WRITES new units only to the FIRST (`new/`) and reads the rest as
# read-only input, so the shard uploads only what it DISCOVERED — not a copy of the ~14k it
# restored. That keeps the merge's incoming set (and its file I/O) from scaling with shard count.
# The step stays green even with findings; "Fail on crashes" below turns any into a red run.
- name: Fuzz
run: |
mkdir -p fuzz/corpus/parse fuzz/corpus/new fuzz/artifacts/parse
./bin/parse \
-fork=1 -ignore_crashes=1 \
-dict=fuzz/dict/parse.dict \
-max_total_time=${{ github.event.inputs.max_total_time || '18000' }} \
-timeout=25 \
-rss_limit_mb=4096 \
-artifact_prefix=fuzz/artifacts/parse/ \
fuzz/corpus/new fuzz/corpus/parse
# Only this shard's NEW finds. The prior corpus + registry seeds reach the merge via the cache
# and the fuzz-seeds artifact, not re-uploaded per shard. Content-hash named, so no collisions.
- name: Upload shard finds
if: always()
uses: actions/upload-artifact@v4
with:
name: corpus-shard-${{ matrix.shard }}
path: fuzz/corpus/new
if-no-files-found: ignore
# Reproducing inputs land in fuzz/artifacts/parse/ (crash-*, timeout-*, oom-*, slow-unit-*).
# Upload always — in fork mode the fuzz step is green, so failure() would never fire.
- name: Upload crash artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-shard-${{ matrix.shard }}
path: fuzz/artifacts/
if-no-files-found: ignore
# Fork mode swallows the non-zero exit, so surface findings explicitly: any crash/timeout/oom
# makes the shard (and the run) red, so a regression can't hide behind a green fork-mode run.
# slow-unit is uploaded above for triage but is not fatal on its own.
- name: Fail on crashes
if: always()
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 artifacts (see fuzz-crashes-shard-${{ matrix.shard }})"
printf '%s\n' "$hits"
exit 1
fi
echo "no crash/timeout/oom artifacts"
merge:
name: Merge corpus
needs:
# Merge even when a shard went RED on a crash (its corpus is still worth keeping) — but not when
# the build itself failed, since there'd be no binary to merge with.
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Download fuzz binary
uses: actions/download-artifact@v4
with:
name: fuzz-binary
path: bin
- name: Make executable
run: chmod +x bin/parse
# Seed the merge with the prior canonical corpus, then fold the registry seeds and every
# shard's NEW finds on top of it.
- name: Restore prior corpus
uses: actions/cache/restore@v4
with:
path: fuzz/corpus/parse
key: fuzz-corpus-parse-
restore-keys: fuzz-corpus-parse-
# Shards no longer re-upload the seeds, so fold them in here (into the prior corpus dir) — this
# is what bakes the registry-example coverage into the canonical corpus the per-push replay reads.
- name: Download seeds
uses: actions/download-artifact@v4
with:
name: fuzz-seeds
path: fuzz
- name: Download shard finds
uses: actions/download-artifact@v4
with:
pattern: corpus-shard-*
path: incoming
# `-merge=1 DST SRC...` unions and minimizes in ONE step: libFuzzer copies into DST only the
# inputs that add coverage, so starting from an empty DST over [prior corpus + seeds, every
# shard's finds] yields exactly the minimized union — no separate copy-then-cmin pass, no rebuild.
# Incoming is now just the shards' NEW finds, so the file count read scales with discovery, not
# with 3x the whole corpus.
- name: Union and minimize
run: |
set -euo pipefail
mkdir -p fuzz/corpus/parse minimized
echo "Prior corpus + seeds: $(find fuzz/corpus/parse -type f | wc -l) inputs"
echo "Incoming shard finds: $(find incoming -type f 2>/dev/null | wc -l)"
# Unquoted on purpose: each shard dir is a separate SRC argument to -merge=1.
shards=$(find incoming -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true)
./bin/parse -merge=1 minimized fuzz/corpus/parse $shards
rm -rf fuzz/corpus/parse
mv minimized fuzz/corpus/parse
echo "Minimized corpus size: $(find fuzz/corpus/parse -type f | wc -l) inputs"
# Save under a unique (always-miss) key so the merged corpus becomes the newest entry the next
# run's restore-keys prefix match will pull.
- name: Save merged corpus
uses: actions/cache/save@v4
with:
path: fuzz/corpus/parse
key: fuzz-corpus-parse-${{ github.run_id }}
# What the crash/timeout signal CANNOT tell you: which parts of the classifier the corpus never
# reaches. A green night 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.
coverage:
name: Coverage report
needs: merge
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
# 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 merge job saved the canonical corpus under this run's id, so the prefix match pulls it.
- name: Restore merged corpus
uses: actions/cache/restore@v4
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@v4
with:
name: fuzz-coverage-html
path: coverage-html
if-no-files-found: warn
# The two property targets, kept OFF the `parse` pipeline above on purpose. They have a different
# input domain — `equivalence` consumes a flag VALUE and `hook_envelope` a hook envelope — so the
# registry-derived seeds and dictionary that make `parse` effective do not apply, and they need no
# sharding at this size. Self-contained so a change here cannot break the pipeline that works.
#
# What they assert is a PROPERTY of the verdict, not just "did not crash": a semantics-preserving
# respelling must not change it, and a target must never emit a grant it was not asked for. That
# is the class `parse` structurally cannot see, because it discards the verdict.
property-targets:
name: Fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 75
strategy:
fail-fast: false # one target finding a bug must not cancel the other
matrix:
target:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- uses: Swatinem/rust-cache@v2
- name: Install cargo-fuzz
uses: taiki-e/install-action@cargo-fuzz
# Each target keeps its OWN corpus, so the two accumulate coverage independently and adding
# both at once costs nothing beyond the wall-clock of one extra runner.
- name: Restore corpus
uses: actions/cache/restore@v4
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-
restore-keys: fuzz-corpus-${{ matrix.target }}-
- name: Fuzz
env:
DURATION: ${{ github.event.inputs.max_total_time || '3600' }}
run: |
mkdir -p fuzz/corpus/${{ matrix.target }} fuzz/artifacts/${{ matrix.target }}
cargo +nightly fuzz run ${{ matrix.target }} --target "$TRIPLE" -- \
-max_total_time="$DURATION" \
-artifact_prefix=fuzz/artifacts/${{ matrix.target }}/ \
fuzz/corpus/${{ matrix.target }}
- name: Save corpus
if: always()
uses: actions/cache/save@v4
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-findings-${{ matrix.target }}
path: fuzz/artifacts/${{ matrix.target }}
if-no-files-found: ignore