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
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: fan out to N parallel shards, each fuzzing 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).
on:
schedule:
- cron: "0 7 * * *" # ~07:00 UTC nightly
workflow_dispatch:
inputs:
max_total_time:
description: "Fuzz duration in seconds, per shard (default 5h)"
default: "18000"
env:
CARGO_TERM_COLOR: always
jobs:
fuzz:
name: Fuzz (parse) shard ${{ matrix.shard }}
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
# nightly + rust-src: cargo-fuzz builds std with the sanitizer instrumented.
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
# 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-
# +nightly is explicit: cargo-fuzz runs cargo from the repo root, so a fuzz/ toolchain file
# would be ignored. (Pin a dated nightly here + in dtolnay above for reproducible runs.)
# --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").
# 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.
# The step stays green even with findings; "Fail on crashes" below turns any into a red run.
- name: Fuzz
run: |
cargo +nightly fuzz run parse --target x86_64-unknown-linux-gnu -- \
-fork=1 -ignore_crashes=1 \
-max_total_time=${{ github.event.inputs.max_total_time || '18000' }} \
-timeout=25 \
-rss_limit_mb=4096
# Hand this shard's corpus to the merge job regardless of outcome — every explored input is
# worth keeping. Files are content-hash named, so the merge unions across shards with no
# collisions.
- name: Upload shard corpus
if: always()
uses: actions/upload-artifact@v4
with:
name: corpus-shard-${{ matrix.shard }}
path: fuzz/corpus/parse
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: fuzz
if: ${{ !cancelled() }} # merge even when a shard went red on a crash, so its corpus is preserved
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
# Seed the merge with the prior canonical corpus, then fold every shard 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-
- name: Download shard corpora
uses: actions/download-artifact@v4
with:
pattern: corpus-shard-*
path: incoming
# Union then minimize: libFuzzer names corpus files by content hash, so copying every shard's
# files into one directory dedups automatically; `cmin` then drops inputs that add no coverage,
# keeping the saved corpus bounded rather than growing without limit across nights.
- name: Union and minimize
run: |
mkdir -p fuzz/corpus/parse
find incoming -type f -exec cp -n {} fuzz/corpus/parse/ \;
echo "Union corpus size: $(find fuzz/corpus/parse -type f | wc -l) inputs"
cargo +nightly fuzz cmin parse --target x86_64-unknown-linux-gnu
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 }}