From e4670023ac564dc715c72d379f9cc3f9220443c8 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Thu, 27 Aug 2026 15:12:06 -0300 Subject: [PATCH] add weekly corpus fuzzing CI and corpora script Add a weekly GitHub Actions workflow that fuzzes every target seeded from payjoin/qa-assets, minimizes the output with -merge=1, and opens a PR to qa-assets with the refreshed corpus for review. --- .github/workflows/cron-corpus-fuzzing.yml | 184 ++++++++++++++++++++++ fuzz/corpora.sh | 95 +++++++++++ 2 files changed, 279 insertions(+) create mode 100644 .github/workflows/cron-corpus-fuzzing.yml create mode 100755 fuzz/corpora.sh diff --git a/.github/workflows/cron-corpus-fuzzing.yml b/.github/workflows/cron-corpus-fuzzing.yml new file mode 100644 index 000000000..841340f71 --- /dev/null +++ b/.github/workflows/cron-corpus-fuzzing.yml @@ -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 diff --git a/fuzz/corpora.sh b/fuzz/corpora.sh new file mode 100755 index 000000000..8599627fc --- /dev/null +++ b/fuzz/corpora.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Manages fuzz corpora between rust-payjoin and payjoin/qa-assets. +# +# Subcommands: +# seed +# Copy corpus from qa-assets into fuzz/corpus// so the +# fuzzer starts from the accumulated seeds rather than empty. +# +# refresh +# 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 |refresh |push}" >&2 + exit 1 + ;; +esac