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
name: Fuzz
# The mutation half of the fuzzing program: a short burst on every push to main.
# `fuzz-replay.yml` is the gate; this is not, and nothing depends on it, so a random
# find turns this workflow red rather than the push. Runs on nightly Rust (cargo-fuzz
# needs -Zsanitizer); the fuzz crate is a standalone workspace, so this never touches
# the stable CI in ci.yml.
#
# There is deliberately no nightly: in safe-chains and jjpr the finds all came in a
# target's first days, and seven weeks of nightlies after that found nothing. A burst
# per push follows the code instead: new code is fuzzed the day it lands.
#
# Shape: build every target once, then one job per target that restores the canonical
# corpus, runs fuzz/burst.sh (~180s of mutation counted from the end of corpus loading,
# in a single process, then a merge of its finds) and saves the result under a fresh
# key. Coverage then reports what the merged corpus reaches.
on:
push:
branches:
# Nothing fuzzed reads Markdown or docs (no include_str! of either),
# so a docs-only push has no new code to explore.
paths-ignore:
workflow_dispatch:
inputs:
max_total_time:
description: "Mutation seconds per target, counted after the corpus loads (default 180). Keep under ~2400: the job times out at 60 min."
default: "180"
# Never cancel a running burst: a cancelled job swallows a failing crash check. GitHub
# keeps one pending run per group, so pushes landing mid-burst collapse into the newest,
# which fuzzes the latest code anyway.
concurrency:
group: fuzz-burst
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
TRIPLE: x86_64-unknown-linux-gnu
BUDGET: ${{ github.event.inputs.max_total_time || '180' }}
jobs:
build:
name: Build fuzz targets
runs-on: ubuntu-latest
timeout-minutes: 45
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 ships as a prebuilt musl
# static binary and otherwise defaults the fuzz build to its own musl triple,
# whose static libc cannot carry AddressSanitizer ("sanitizer is incompatible
# with statically linked libc").
- name: Build all targets
run: cargo +nightly fuzz build --target "$TRIPLE"
# `cargo fuzz run` and `-merge=1` would each rebuild. The cargo-fuzz output is a
# standalone libFuzzer executable, so the per-target jobs exec it directly with
# the flags cargo-fuzz would have passed.
- name: Upload fuzz binaries
uses: actions/upload-artifact@v4
with:
name: fuzz-binaries
path: |
fuzz/target/${{ env.TRIPLE }}/release/layout_inject
fuzz/target/${{ env.TRIPLE }}/release/repo_and_tool_text
fuzz/target/${{ env.TRIPLE }}/release/ps_tree
fuzz/target/${{ env.TRIPLE }}/release/encoders
if-no-files-found: error
fuzz:
name: Fuzz ${{ matrix.target }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false # one target finding a crash must not cancel the others
matrix:
target:
steps:
- uses: actions/checkout@v5
- name: Download fuzz binaries
uses: actions/download-artifact@v4
with:
name: fuzz-binaries
path: bin
# Artifact upload does not preserve the executable bit.
- name: Make executable
run: chmod +x bin/*
# The same key scheme fuzz-replay.yml restores: a prefix match on
# `fuzz-corpus-<target>-` pulls the newest entry this workflow saved.
- 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 }}-
# fuzz/burst.sh: one process, not fork mode (fork mode reloads the whole corpus in
# every child, and on a short budget the loading is most of the run). The budget
# clock starts once the corpus has loaded, since libFuzzer's -max_total_time counts
# the load and a large corpus 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 (the crashing input
# is in fuzz/artifacts, not a corpus dir, so it is not merged); a successful one
# sets `merged=true`.
#
# `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"
# A unique (always-miss) key, so the next restore's prefix match pulls this one.
# 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@v4
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload crash artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ matrix.target }}
path: fuzz/artifacts/
if-no-files-found: ignore
# slow-unit is uploaded for triage but is not fatal: it is contention-sensitive.
# A burst that failed with NO artifact (missing binary, leak report, failed merge)
# 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 artifacts for ${{ 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 signal cannot tell you: which parts of workon the corpus never reaches.
# Informational only; it is the input to deciding where the next target or seed goes.
# About 2 minutes per target, in parallel, after the burst.
coverage:
name: Coverage ${{ matrix.target }}
needs:
# Runs after a red burst too (the merged corpus was still saved), but not after a
# failed build, when there is nothing new to measure.
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
target:
steps:
- uses: actions/checkout@v5
# llvm-tools-preview is REQUIRED: without it `cargo fuzz coverage` replays 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
- name: Restore merged corpus
uses: actions/cache/restore@v4
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-
restore-keys: fuzz-corpus-${{ matrix.target }}-
# A separate build: coverage instrumentation differs from the sanitizer build, so
# this one cannot reuse the shared binary.
- name: Generate coverage data
run: cargo +nightly fuzz coverage ${{ matrix.target }} --target "$TRIPLE"
# Don't hardcode cargo-fuzz's layout — it differs with/without --target and has
# moved between releases. Search both target roots, then PROBE each candidate and
# keep the first llvm-cov accepts: the probe, not the path, is the real check, and
# it also rejects a stale non-instrumented binary from an earlier build.
- name: Render report
run: |
set -euo pipefail
T=${{ matrix.target }}
LLVM_COV="$(rustc +nightly --print sysroot)/lib/rustlib/$TRIPLE/bin/llvm-cov"
PROF=fuzz/coverage/$T/coverage.profdata
BIN=""
for cand in $(find target fuzz/target -path '*coverage*release*' -name "$T" -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 $T binary matched $PROF (cargo-fuzz layout changed?)"
find target fuzz/target -name "$T" -type f 2>/dev/null || true
exit 1
fi
# Report workon's OWN authored source only — not deps, std, the fuzz shim, or
# build-script-generated files.
IGNORE='(/\.cargo/|/rustc/|/fuzz/|/out/)'
{
echo "## Fuzz coverage (\`$T\` target)"
echo
echo "Corpus: $(find fuzz/corpus/$T -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-${{ matrix.target }}
path: coverage-html
if-no-files-found: warn