workon 0.24.3

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
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: [main]
    # Nothing fuzzed reads Markdown or docs (no include_str! of either),
    # so a docs-only push has no new code to explore.
    paths-ignore: ["**.md", "docs/**"]
  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: [layout_inject, repo_and_tool_text, ps_tree, encoders]
    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: [build, fuzz]
    # 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: [layout_inject, repo_and_tool_text]
    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