Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions .github/workflows/cron-corpus-fuzzing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Fuzz every target seeded from its corpus stored in payjoin/qa-assets.
# Output gets minimized and pushed back to the origin repository.

name: Fuzz (corpus)

on:
schedule:
# 5am every Monday UTC
- cron: "00 05 * * 1"
workflow_dispatch:

permissions: {}

env:
CARGO_FUZZ_VERSION: 0.13.2

# Prevents race on the final push
concurrency:
group: update-corpora
cancel-in-progress: false

jobs:
setup:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
targets: ${{ steps.list.outputs.targets }}
source_sha: ${{ steps.list.outputs.source_sha }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/cargo-fuzz
~/.cargo/.crates.toml
~/.cargo/.crates2.json
key: cargo-fuzz-${{ env.CARGO_FUZZ_VERSION }}

- name: Install nightly toolchain
uses: dtolnay/rust-toolchain@nightly

- name: Install cargo-fuzz
run: cargo install --locked --version "$CARGO_FUZZ_VERSION" cargo-fuzz

- name: List fuzz targets
id: list
run: |
echo "targets=$(cd fuzz && cargo fuzz list | jq -R . | jq -cs .)" >> "$GITHUB_OUTPUT"
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

fuzz:
needs: setup
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
fuzz_target: ${{ fromJSON(needs.setup.outputs.targets) }}
env:
TARGET: ${{ matrix.fuzz_target }}
steps:
- name: Checkout qa-assets
uses: actions/checkout@v6
with:
repository: payjoin/qa-assets
persist-credentials: false

- name: Checkout rust-payjoin
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.source_sha }}
path: rust-payjoin
persist-credentials: false

- uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.cargo/.crates.toml
~/.cargo/.crates2.json
rust-payjoin/fuzz/target
rust-payjoin/target
key: fuzz-${{ hashFiles('rust-payjoin/**/Cargo.toml') }}
restore-keys: fuzz-

- name: Install nightly toolchain
uses: dtolnay/rust-toolchain@nightly

- name: Install cargo-fuzz
run: cargo install --locked --version "$CARGO_FUZZ_VERSION" cargo-fuzz

- name: Seed corpus
working-directory: rust-payjoin
run: fuzz/corpora.sh seed "$GITHUB_WORKSPACE" "$TARGET"

- name: Fuzz
working-directory: rust-payjoin/fuzz
run: cargo fuzz run "$TARGET" -- -max_total_time=300 -fork=$(nproc)

- name: Upload corpus
uses: actions/upload-artifact@v4
with:
name: corpus-${{ matrix.fuzz_target }}
path: rust-payjoin/fuzz/corpus/${{ matrix.fuzz_target }}
if-no-files-found: warn

- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: crash-${{ matrix.fuzz_target }}
path: rust-payjoin/fuzz/artifacts/${{ matrix.fuzz_target }}
if-no-files-found: ignore

- name: Report crashes
if: failure()
run: |
echo "Crash found in $TARGET"
ls rust-payjoin/fuzz/artifacts/"$TARGET"/ 2>/dev/null || true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

merge:
needs: [setup, fuzz]
if: ${{ always() && needs.setup.result == 'success' && needs.fuzz.result != 'cancelled' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout qa-assets
uses: actions/checkout@v6
with:
repository: payjoin/qa-assets
token: ${{ secrets.QA_ASSETS_PUSH_TOKEN }}
persist-credentials: true

- name: Checkout rust-payjoin
uses: actions/checkout@v6
with:
path: rust-payjoin
persist-credentials: false

- uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.cargo/.crates.toml
~/.cargo/.crates2.json
rust-payjoin/fuzz/target
rust-payjoin/target
key: fuzz-${{ hashFiles('rust-payjoin/**/Cargo.toml') }}
restore-keys: fuzz-

- name: Install nightly toolchain
uses: dtolnay/rust-toolchain@nightly

- name: Install cargo-fuzz
run: cargo install --locked --version "$CARGO_FUZZ_VERSION" cargo-fuzz

- name: Download corpus artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
pattern: corpus-*
path: incoming

- name: Refresh corpora
env:
TARGETS: ${{ needs.setup.outputs.targets }}
run: |
echo "$TARGETS" | jq -r '.[]' > "$RUNNER_TEMP/targets.txt"
rust-payjoin/fuzz/corpora.sh refresh incoming "$RUNNER_TEMP/targets.txt"

- name: Open PR
env:
SOURCE: ${{ github.repository }}@${{ needs.setup.outputs.source_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_TOKEN: ${{ secrets.QA_ASSETS_PUSH_TOKEN }}
run: rust-payjoin/fuzz/corpora.sh push
95 changes: 95 additions & 0 deletions fuzz/corpora.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Manages fuzz corpora between rust-payjoin and payjoin/qa-assets.
#
# Subcommands:
# seed <qa-assets-dir> <target>
# Copy corpus from qa-assets into fuzz/corpus/<target>/ so the
# fuzzer starts from the accumulated seeds rather than empty.
#
# refresh <incoming-dir> <targets-file>
# Minimise each target's corpus with -merge=1 and write the
# result back into the qa-assets checkout so it can be pushed.
#
# push
# Commit the refreshed corpora and open a PR to origin.
# Expects to be run from inside the qa-assets checkout.
# Requires SOURCE and RUN_URL env vars set by the CI job.

set -euo pipefail

REPO_DIR=$(git rev-parse --show-toplevel)
QA_ASSETS_CORPUS_DIR="fuzz_corpora"

cmd="${1:-}"
shift || true

case "$cmd" in
seed)
qa_assets_dir="$1"
target="$2"
src="$qa_assets_dir/$QA_ASSETS_CORPUS_DIR/$target"
dst="$REPO_DIR/fuzz/corpus/$target"
if [[ -d $src ]]; then
mkdir -p "$dst"
cp "$src"/* "$dst/" 2>/dev/null || true
echo "Seeded $target from $src ($(find "$dst" -maxdepth 1 -type f | wc -l) inputs)"
else
echo "No existing corpus for $target in qa-assets, starting empty"
fi
;;

refresh)
incoming_dir="$1"
targets_file="$2"
while IFS= read -r target; do
incoming="$incoming_dir/corpus-$target/$target"
if [[ ! -d $incoming ]]; then
echo "No incoming corpus for $target, skipping"
continue
fi
dst="$QA_ASSETS_CORPUS_DIR/$target"
mkdir -p "$dst"
# Merge minimises the corpus: only inputs that add coverage
# are kept. Run from the repo root so cargo fuzz can find
# the manifest.
(
cd "$REPO_DIR/fuzz"
cargo fuzz run "$target" -- \
-merge=1 \
"$OLDPWD/$dst" \
"$OLDPWD/$incoming"
)
echo "Refreshed $target: $(find "$dst" -maxdepth 1 -type f | wc -l) inputs"
done <"$targets_file"
;;

push)
source="${SOURCE:-unknown}"
run_url="${RUN_URL:-unknown}"
branch="corpus-refresh-$(date +%Y%m%d)"

git checkout -b "$branch"
git add fuzz_corpora/
if git diff --cached --quiet; then
echo "No corpus changes"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "Refresh fuzz corpora

Source: $source
Run: $run_url"
git push origin "$branch"
gh pr create \
--title "Refresh fuzz corpora $(date +%Y-%m-%d)" \
--body "Automated corpus refresh from $run_url" \
--base master \
--head "$branch"
;;

*)
echo "Usage: $0 {seed <qa-assets-dir> <target>|refresh <incoming-dir> <targets-file>|push}" >&2
exit 1
;;
esac
Loading