safe-chains 0.213.0

Auto-allow safe bash commands in agentic coding tools
Documentation
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: [1, 2, 3]
    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 }}