Fuzz CI #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Fuzz CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| schedule: | |
| - cron: "0 2 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| profile: | |
| description: "Fuzz profile to run" | |
| required: false | |
| default: "quick" | |
| type: choice | |
| options: | |
| - quick | |
| - nightly | |
| - all | |
| permissions: | |
| contents: read | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUST_BACKTRACE: "1" | |
| jobs: | |
| fuzz-quick: | |
| name: "fuzz-quick (${{ matrix.target }})" | |
| if: >- | |
| github.event_name == 'push' || | |
| github.event_name == 'pull_request' || | |
| (github.event_name == 'workflow_dispatch' && | |
| (inputs.profile == 'quick' || inputs.profile == 'all')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 4 | |
| matrix: | |
| target: | |
| - fuzz_sse_parser | |
| - fuzz_sse_stream | |
| - fuzz_session_jsonl | |
| - fuzz_session_entry | |
| - fuzz_provider_event | |
| - fuzz_message_deser | |
| - fuzz_message_roundtrip | |
| - fuzz_config | |
| - fuzz_config_load | |
| - fuzz_tool_paths | |
| - fuzz_grep_pattern | |
| - fuzz_edit_match | |
| - fuzz_extension_payload | |
| defaults: | |
| run: | |
| working-directory: pi_agent_rust | |
| shell: bash | |
| steps: | |
| - name: Checkout pi_agent_rust | |
| uses: actions/checkout@v4 | |
| with: | |
| path: pi_agent_rust | |
| - name: Install Rust toolchain (nightly) | |
| uses: dtolnay/rust-toolchain@nightly | |
| with: | |
| targets: x86_64-unknown-linux-gnu | |
| - name: Install cargo-fuzz | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-fuzz | |
| - name: Cache cargo artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| pi_agent_rust/fuzz/target/ | |
| key: fuzz-quick-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('pi_agent_rust/fuzz/Cargo.toml', 'pi_agent_rust/fuzz/Cargo.lock') }} | |
| restore-keys: | | |
| fuzz-quick-${{ runner.os }}-${{ matrix.target }}- | |
| fuzz-quick-${{ runner.os }}- | |
| - name: Run quick fuzz target | |
| env: | |
| FUZZ_PROFILE: quick | |
| FUZZ_TARGET: ${{ matrix.target }} | |
| FUZZ_SECONDS: "60" | |
| run: | | |
| set -euo pipefail | |
| cd fuzz | |
| mkdir -p "corpus/$FUZZ_TARGET" "artifacts/$FUZZ_TARGET" reports | |
| start_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| start_ns="$(date +%s%N)" | |
| set +e | |
| # Force gnu target: cargo-binstall ships cargo-fuzz as a musl binary, | |
| # so its default host triple is x86_64-unknown-linux-musl. ASan is | |
| # incompatible with statically-linked libc, so we must build for the | |
| # dynamically-linked gnu target instead. | |
| cargo fuzz run --target x86_64-unknown-linux-gnu "$FUZZ_TARGET" -- \ | |
| -max_total_time="$FUZZ_SECONDS" \ | |
| -artifact_prefix="artifacts/$FUZZ_TARGET/" \ | |
| "corpus/$FUZZ_TARGET" \ | |
| 2>&1 | tee "reports/${FUZZ_TARGET}_${FUZZ_PROFILE}.log" | |
| exit_code=${PIPESTATUS[0]} | |
| set -e | |
| end_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| end_ns="$(date +%s%N)" | |
| duration_ms=$(( (end_ns - start_ns) / 1000000 )) | |
| corpus_entries="$(find "corpus/$FUZZ_TARGET" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')" | |
| artifact_entries="$(find "artifacts/$FUZZ_TARGET" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')" | |
| status="pass" | |
| if [ "$exit_code" -ne 0 ]; then | |
| status="fail" | |
| fi | |
| cat > "reports/${FUZZ_TARGET}_${FUZZ_PROFILE}_summary.json" <<EOF | |
| { | |
| "schema": "pi.fuzz.run.v1", | |
| "profile": "$FUZZ_PROFILE", | |
| "target": "$FUZZ_TARGET", | |
| "max_total_time_s": $FUZZ_SECONDS, | |
| "status": "$status", | |
| "exit_code": $exit_code, | |
| "start_ts": "$start_ts", | |
| "end_ts": "$end_ts", | |
| "duration_ms": $duration_ms, | |
| "corpus_entries": $corpus_entries, | |
| "artifact_entries": $artifact_entries | |
| } | |
| EOF | |
| if [ "$exit_code" -ne 0 ]; then | |
| exit "$exit_code" | |
| fi | |
| - name: Upload quick fuzz reports | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-quick-${{ matrix.target }}-${{ github.run_id }} | |
| path: | | |
| pi_agent_rust/fuzz/reports/${{ matrix.target }}_quick.log | |
| pi_agent_rust/fuzz/reports/${{ matrix.target }}_quick_summary.json | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| - name: Upload crash artifacts (quick) | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-crash-quick-${{ matrix.target }}-${{ github.run_id }} | |
| path: pi_agent_rust/fuzz/artifacts/${{ matrix.target }}/ | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| fuzz-nightly: | |
| name: "fuzz-nightly (${{ matrix.target }})" | |
| if: >- | |
| github.event_name == 'schedule' || | |
| (github.event_name == 'workflow_dispatch' && | |
| (inputs.profile == 'nightly' || inputs.profile == 'all')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 2 | |
| matrix: | |
| target: | |
| - fuzz_sse_parser | |
| - fuzz_provider_event | |
| - fuzz_session_jsonl | |
| - fuzz_message_deser | |
| defaults: | |
| run: | |
| working-directory: pi_agent_rust | |
| shell: bash | |
| steps: | |
| - name: Checkout pi_agent_rust | |
| uses: actions/checkout@v4 | |
| with: | |
| path: pi_agent_rust | |
| - name: Install Rust toolchain (nightly) | |
| uses: dtolnay/rust-toolchain@nightly | |
| with: | |
| targets: x86_64-unknown-linux-gnu | |
| - name: Install cargo-fuzz | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-fuzz | |
| - name: Cache cargo artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| pi_agent_rust/fuzz/target/ | |
| key: fuzz-nightly-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('pi_agent_rust/fuzz/Cargo.toml', 'pi_agent_rust/fuzz/Cargo.lock') }} | |
| restore-keys: | | |
| fuzz-nightly-${{ runner.os }}-${{ matrix.target }}- | |
| fuzz-nightly-${{ runner.os }}- | |
| - name: Restore nightly corpus cache | |
| uses: actions/cache@v4 | |
| with: | |
| path: pi_agent_rust/fuzz/corpus/${{ matrix.target }} | |
| key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} | |
| restore-keys: | | |
| fuzz-corpus-${{ matrix.target }}- | |
| - name: Run nightly fuzz target | |
| env: | |
| FUZZ_PROFILE: nightly | |
| FUZZ_TARGET: ${{ matrix.target }} | |
| FUZZ_SECONDS: "1800" | |
| run: | | |
| set -euo pipefail | |
| cd fuzz | |
| mkdir -p "corpus/$FUZZ_TARGET" "artifacts/$FUZZ_TARGET" reports | |
| start_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| start_ns="$(date +%s%N)" | |
| set +e | |
| # Force gnu target: cargo-binstall ships cargo-fuzz as a musl binary, | |
| # so its default host triple is x86_64-unknown-linux-musl. ASan is | |
| # incompatible with statically-linked libc, so we must build for the | |
| # dynamically-linked gnu target instead. | |
| cargo fuzz run --target x86_64-unknown-linux-gnu "$FUZZ_TARGET" -- \ | |
| -max_total_time="$FUZZ_SECONDS" \ | |
| -artifact_prefix="artifacts/$FUZZ_TARGET/" \ | |
| "corpus/$FUZZ_TARGET" \ | |
| 2>&1 | tee "reports/${FUZZ_TARGET}_${FUZZ_PROFILE}.log" | |
| exit_code=${PIPESTATUS[0]} | |
| set -e | |
| end_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| end_ns="$(date +%s%N)" | |
| duration_ms=$(( (end_ns - start_ns) / 1000000 )) | |
| corpus_entries="$(find "corpus/$FUZZ_TARGET" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')" | |
| artifact_entries="$(find "artifacts/$FUZZ_TARGET" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')" | |
| status="pass" | |
| if [ "$exit_code" -ne 0 ]; then | |
| status="fail" | |
| fi | |
| cat > "reports/${FUZZ_TARGET}_${FUZZ_PROFILE}_summary.json" <<EOF | |
| { | |
| "schema": "pi.fuzz.run.v1", | |
| "profile": "$FUZZ_PROFILE", | |
| "target": "$FUZZ_TARGET", | |
| "max_total_time_s": $FUZZ_SECONDS, | |
| "status": "$status", | |
| "exit_code": $exit_code, | |
| "start_ts": "$start_ts", | |
| "end_ts": "$end_ts", | |
| "duration_ms": $duration_ms, | |
| "corpus_entries": $corpus_entries, | |
| "artifact_entries": $artifact_entries | |
| } | |
| EOF | |
| if [ "$exit_code" -ne 0 ]; then | |
| exit "$exit_code" | |
| fi | |
| - name: Upload nightly fuzz reports | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-nightly-${{ matrix.target }}-${{ github.run_id }} | |
| path: | | |
| pi_agent_rust/fuzz/reports/${{ matrix.target }}_nightly.log | |
| pi_agent_rust/fuzz/reports/${{ matrix.target }}_nightly_summary.json | |
| if-no-files-found: warn | |
| retention-days: 30 | |
| - name: Upload crash artifacts (nightly) | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-crash-nightly-${{ matrix.target }}-${{ github.run_id }} | |
| path: pi_agent_rust/fuzz/artifacts/${{ matrix.target }}/ | |
| if-no-files-found: ignore | |
| retention-days: 30 | |
| - name: Upload corpus snapshot (nightly) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} | |
| path: pi_agent_rust/fuzz/corpus/${{ matrix.target }}/ | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| fuzz-coverage-dashboard: | |
| name: fuzz-coverage-dashboard | |
| if: >- | |
| github.event_name == 'schedule' || | |
| (github.event_name == 'workflow_dispatch' && | |
| (inputs.profile == 'nightly' || inputs.profile == 'all')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| defaults: | |
| run: | |
| working-directory: pi_agent_rust | |
| shell: bash | |
| env: | |
| FUZZ_TARGET: fuzz_sse_parser | |
| FUZZ_COVERAGE_SECONDS: "120" | |
| steps: | |
| - name: Checkout pi_agent_rust | |
| uses: actions/checkout@v4 | |
| with: | |
| path: pi_agent_rust | |
| - name: Install Rust toolchain (nightly) | |
| uses: dtolnay/rust-toolchain@nightly | |
| with: | |
| components: llvm-tools-preview | |
| targets: x86_64-unknown-linux-gnu | |
| - name: Install cargo-fuzz | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-fuzz | |
| - name: Cache cargo artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| pi_agent_rust/fuzz/target/ | |
| key: fuzz-coverage-${{ runner.os }}-${{ hashFiles('pi_agent_rust/fuzz/Cargo.toml', 'pi_agent_rust/fuzz/Cargo.lock') }} | |
| restore-keys: | | |
| fuzz-coverage-${{ runner.os }}- | |
| fuzz-nightly-${{ runner.os }}- | |
| - name: Build fuzz coverage dashboard | |
| run: | | |
| set -euo pipefail | |
| cd fuzz | |
| mkdir -p "corpus/${FUZZ_TARGET}" "coverage/dashboard/html" reports | |
| start_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| start_ns="$(date +%s%N)" | |
| set +e | |
| # Force gnu target — see fuzz-quick job comment. | |
| cargo fuzz coverage --target x86_64-unknown-linux-gnu "${FUZZ_TARGET}" -- \ | |
| -max_total_time="${FUZZ_COVERAGE_SECONDS}" \ | |
| "corpus/${FUZZ_TARGET}" \ | |
| 2>&1 | tee "reports/${FUZZ_TARGET}_coverage.log" | |
| coverage_exit=${PIPESTATUS[0]} | |
| set -e | |
| end_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| end_ns="$(date +%s%N)" | |
| duration_ms=$(( (end_ns - start_ns) / 1000000 )) | |
| if [ "${coverage_exit}" -ne 0 ]; then | |
| echo "coverage run failed for ${FUZZ_TARGET} (exit=${coverage_exit})" >&2 | |
| exit "${coverage_exit}" | |
| fi | |
| host_triple="$(rustc -vV | awk '/host:/ {print $2}')" | |
| llvm_tools_bin="$(rustc --print sysroot)/lib/rustlib/${host_triple}/bin" | |
| llvm_cov="${llvm_tools_bin}/llvm-cov" | |
| llvm_profdata="${llvm_tools_bin}/llvm-profdata" | |
| if [ ! -x "${llvm_cov}" ] || [ ! -x "${llvm_profdata}" ]; then | |
| echo "missing llvm tools in ${llvm_tools_bin}" >&2 | |
| exit 1 | |
| fi | |
| profdata="coverage/${FUZZ_TARGET}/coverage.profdata" | |
| binary_path="$(find target -type f -path '*coverage*' -name "${FUZZ_TARGET}" | head -n 1 || true)" | |
| if [ -z "${binary_path}" ] || [ ! -f "${profdata}" ]; then | |
| echo "coverage artifacts missing (binary='${binary_path}', profdata='${profdata}')" >&2 | |
| exit 1 | |
| fi | |
| raw_summary_json="coverage/dashboard/${FUZZ_TARGET}_raw_summary.json" | |
| dashboard_json="coverage/dashboard/${FUZZ_TARGET}_dashboard.json" | |
| dashboard_md="coverage/dashboard/${FUZZ_TARGET}_dashboard.md" | |
| report_txt="coverage/dashboard/${FUZZ_TARGET}_report.txt" | |
| html_dir="coverage/dashboard/html/${FUZZ_TARGET}" | |
| "${llvm_cov}" export \ | |
| --format=text \ | |
| --summary-only \ | |
| --instr-profile="${profdata}" \ | |
| "${binary_path}" > "${raw_summary_json}" | |
| "${llvm_cov}" report \ | |
| --instr-profile="${profdata}" \ | |
| "${binary_path}" > "${report_txt}" | |
| "${llvm_cov}" show \ | |
| --format=html \ | |
| --output-dir="${html_dir}" \ | |
| --instr-profile="${profdata}" \ | |
| "${binary_path}" | |
| jq \ | |
| --arg schema "pi.fuzz.coverage.dashboard.v1" \ | |
| --arg target "${FUZZ_TARGET}" \ | |
| --arg started "${start_ts}" \ | |
| --arg finished "${end_ts}" \ | |
| --argjson duration_ms "${duration_ms}" \ | |
| --arg run_id "${GITHUB_RUN_ID}" \ | |
| '{ | |
| schema: $schema, | |
| target: $target, | |
| run_id: $run_id, | |
| started_at: $started, | |
| finished_at: $finished, | |
| duration_ms: $duration_ms, | |
| totals: { | |
| lines: (.data[0].totals.lines // {}), | |
| branches: (.data[0].totals.branches // {}), | |
| functions: (.data[0].totals.functions // {}), | |
| regions: (.data[0].totals.regions // {}) | |
| }, | |
| files_lowest_line_coverage: ( | |
| (.data[0].files // []) | |
| | map({ | |
| file: .filename, | |
| lines: (.summary.lines // {}), | |
| branches: (.summary.branches // {}) | |
| }) | |
| | sort_by((.lines.percent // 0)) | |
| | .[:25] | |
| ) | |
| }' "${raw_summary_json}" > "${dashboard_json}" | |
| jq -r ' | |
| "## Fuzz Coverage Dashboard", | |
| "", | |
| "Target: `\(.target)`", | |
| "", | |
| "| Metric | Covered | Count | Percent |", | |
| "|---|---:|---:|---:|", | |
| "| Lines | \(.totals.lines.covered // 0) | \(.totals.lines.count // 0) | \((.totals.lines.percent // 0)|tostring)% |", | |
| "| Branches | \(.totals.branches.covered // 0) | \(.totals.branches.count // 0) | \((.totals.branches.percent // 0)|tostring)% |", | |
| "| Functions | \(.totals.functions.covered // 0) | \(.totals.functions.count // 0) | \((.totals.functions.percent // 0)|tostring)% |", | |
| "| Regions | \(.totals.regions.covered // 0) | \(.totals.regions.count // 0) | \((.totals.regions.percent // 0)|tostring)% |", | |
| "", | |
| "### Lowest line coverage files (top 25)", | |
| "", | |
| "| File | Lines % | Branches % |", | |
| "|---|---:|---:|", | |
| (.files_lowest_line_coverage[] | | |
| "| `\(.file)` | \((.lines.percent // 0)|tostring)% | \((.branches.percent // 0)|tostring)% |") | |
| ' "${dashboard_json}" > "${dashboard_md}" | |
| cat "${dashboard_md}" >> "${GITHUB_STEP_SUMMARY}" | |
| - name: Upload fuzz coverage dashboard artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-coverage-dashboard-${{ env.FUZZ_TARGET }}-${{ github.run_id }} | |
| path: | | |
| pi_agent_rust/fuzz/reports/${{ env.FUZZ_TARGET }}_coverage.log | |
| pi_agent_rust/fuzz/coverage/${{ env.FUZZ_TARGET }}/ | |
| pi_agent_rust/fuzz/coverage/dashboard/${{ env.FUZZ_TARGET }}_raw_summary.json | |
| pi_agent_rust/fuzz/coverage/dashboard/${{ env.FUZZ_TARGET }}_dashboard.json | |
| pi_agent_rust/fuzz/coverage/dashboard/${{ env.FUZZ_TARGET }}_dashboard.md | |
| pi_agent_rust/fuzz/coverage/dashboard/${{ env.FUZZ_TARGET }}_report.txt | |
| pi_agent_rust/fuzz/coverage/dashboard/html/${{ env.FUZZ_TARGET }}/ | |
| if-no-files-found: warn | |
| retention-days: 30 |