name: fuzz
run-name: Fuzz
env:
  OWNER_RDPATH: ./source                      # Rel path to the dir that contains the fuzzing infra (contains "fuzz" dir).
  DURATION_SEC: 7200                          # Fuzzing run duration in seconds.
  STDERR_LOG_FNAME: fuzz.stderr.log           # File name to redirect the fuzzing run's stderr to.
  TMIN_LOG_FNAME: fuzz.tmin.log               # File name to redirect the fuzzing input minimization log to.
  GH_ISSUE_TEMPLATE_RFPATH: .github/ISSUE_TEMPLATE/fuzz_bug_report.md
                                              # GitHub issue template rel file path.
  ARTIFACTS_RDPATH: fuzz/artifacts            # Fuzzing artifacts rel dir path.
  SEEDS_RDPATH: fuzz/seed_inputs              # Fuzzing seed inputs rel dir path.
  SEEDS_FNAME: list.txt                       # Fuzzing seed inputs list file name.
on:
  workflow_dispatch:                          # Manual runs.
  push:
    branches:
      - main                                  # Development runs against main branch.
    paths:
      - 'source/compiler/**'                  # Run if the compiler was changed.
      - 'source/fuzz/**'                      # Run if the fuzzing infra was changed.
      - '.github/ISSUE_TEMPLATE/fuzz_bug_report.md'
                                              # Run if the GitHub issue template was changed.
      - '.github/workflows/fuzz.yml'          # Run if the workflow itself was changed.
      - '!source/compiler/qsc_eval/**'        # Exclude the qsc_eval dir.
      - '!source/compiler/qsc_codegen/**'     # Exclude the qsc_codegen dir.

jobs:
  fuzz:
    name: Fuzzing
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest]                   # Fuzzing is not supported on Win. The macos is temporarily removed
                                              # because of low availability.
        target_name: [qsharp, qasm]

    runs-on: ${{ matrix.os }}
    permissions:
      issues: write
    steps:
      - name: Install and Configure Tools
        run: |
          rustup install nightly              # Install nightly toolchain.
          rustup default nightly              # Make nightly toolchain default.
          cargo install cargo-fuzz            # Install cargo-fuzz (fuzzing tool).

      - name: Checkout the Repo
        uses: actions/checkout@v3
        with:
          submodules: "true"

      - name: Gather the Seed Inputs
        if: matrix.target_name == 'qsharp'
        working-directory: ${{ env.OWNER_RDPATH }}
        run: |
          # Clone the submodules of QDK:
          REPOS="Quantum Quantum-NC QuantumKatas QuantumLibraries iqsharp qdk-python qsharp-compiler qsharp-runtime"
          for REPO in $REPOS ; do
            git clone --depth 1 --single-branch --no-tags --recurse-submodules --shallow-submodules --jobs 4 \
              https://github.com/microsoft/$REPO.git $SEEDS_RDPATH/${{ matrix.target_name }}/$REPO
          done

          # Build a comma-separated list of all the .qs files in $SEEDS_FNAME file:
          find $SEEDS_RDPATH/${{ matrix.target_name }} -name "*.qs" | tr "\n" "," > \
            $SEEDS_RDPATH/${{ matrix.target_name }}/$SEEDS_FNAME

      - name: Gather the Seed Inputs (qasm)
        if: matrix.target_name == 'qasm'
        working-directory: ${{ env.OWNER_RDPATH }}
        run: |
          # Clone openqasm repo for samples:
          git clone --depth 1 --single-branch --no-tags --recurse-submodules --shallow-submodules --jobs 4 \
            https://github.com/openqasm/openqasm.git $SEEDS_RDPATH/${{ matrix.target_name }}/openqasm


          # Build a comma-separated list of all the .qasm and .inc files in $SEEDS_FNAME file:
          find $SEEDS_RDPATH/${{ matrix.target_name }} -name "*.qasm" | tr "\n" "," > \
            $SEEDS_RDPATH/${{ matrix.target_name }}/$SEEDS_FNAME
          find $SEEDS_RDPATH/${{ matrix.target_name }} -name "*.inc" | tr "\n" "," > \
            $SEEDS_RDPATH/${{ matrix.target_name }}/$SEEDS_FNAME

      - name: Build and Run the Fuzz Target
        working-directory: ${{ env.OWNER_RDPATH }}
        run: |
          cargo fuzz build  --fuzz-dir ./fuzz --release --sanitizer=none --features do_fuzz ${{ matrix.target_name }}  # Build the fuzz target.

          # Run fuzzing for specified number of seconds and redirect the `stderr` to a file
          # whose name is specified by the STDERR_LOG_FNAME env var:
          RUST_BACKTRACE=1 cargo fuzz run  --fuzz-dir ./fuzz --release --sanitizer=none --features do_fuzz ${{ matrix.target_name }} -- \
            -seed_inputs=@$SEEDS_RDPATH/${{ matrix.target_name }}/$SEEDS_FNAME \
            -max_total_time=$DURATION_SEC \
            -rss_limit_mb=4096 \
            -max_len=20000 \
            2>$STDERR_LOG_FNAME
            # The `-rss_limit_mb` and `-max_len` work around running out of memory.

      - name: "If Fuzzing Failed: Collect Failure Info"
        if: failure()
        working-directory: ${{ env.OWNER_RDPATH }}
        run: |
          # Extract from stderr log the panic message:
          PANIC_MESSAGE=`cat $STDERR_LOG_FNAME |
            grep "panicked at" | sed "s|thread '<unnamed>' panicked at '\([^']*\).*|\1|"`
            # Explanation:
            # `cat $STDERR_LOG_FNAME |`: Display the contents of the stderr log file and pass the contents
            #     to the next command.
            # `grep "panicked at" |`: Filter out (drop) all the lines except the ones containing "panicked at",
            #     the script expects that there is only one such line, pass that line to the next command. Line example:
            # thread '<unnamed>' panicked at 'global item should have type', . . ./compiler/qsc_frontend/src/typeck/rules.rs:300:26
            # `sed "s|thread '<unnamed>' panicked at '\([^']*\).*|\1|"`: `sed` - stream editor.
            #     `s` after quote: search command. After `s` there are two sections, each between a pair of '|'.
            #     First section:
            #     In the incoming stream search for a sequence starting with "thread '<unnamed>' panicked at '"
            #     (sequence from the beginning of the line until after the apostrophe where the panic message starts),
            #     followed by zero or more ('*' after ']') non-apostrophe chars (`[^']`)
            #     and memorize ( `\(`, `\)` ) that sequence of non-apostrophe chars (between apostrophes -
            #     "global item should have type") as a memory item 1;
            #     followed by zero or more ('*' after '.') arbitrary chars ('.') till the end of the line.
            #     Second section (`\1`):
            #     If the sequence specified by the first section is found, then replace that sequence (the whole line)
            #     with the memory item 1 (`\1`), ending up in a panic message between the apostrophes.
            # PANIC_MESSAGE=`. . .`: The output of the command(s) between the backticks ('`') is saved in the
            #     env var PANIC_MESSAGE.
          # If the failure is not panic-based then extract any ERROR message(s):
          if [ "$PANIC_MESSAGE" == "" ]; then
            PANIC_MESSAGE=`cat $STDERR_LOG_FNAME | grep "ERROR"`
          fi
          echo "PANIC_MESSAGE: '$PANIC_MESSAGE'"                # Output the PANIC_MESSAGE var value to the log
                                                                # (optional, for workflow failure analysis and sanity check).
          echo "PANIC_MESSAGE=$PANIC_MESSAGE" >> "$GITHUB_ENV"  # Save the PANIC_MESSAGE var in the env, will be used in
                                                                # the subsequent `run:` and `uses:` steps.

          # Determine the name of a file containing the input of interest (that triggers the panic/crash):
          if [ -e $ARTIFACTS_RDPATH/${{ matrix.target_name }}/crash-* ]; then    # Panic and Stack Overflow Cases.
            TO_MINIMIZE_FNAME=crash-*;
          elif [ -e $ARTIFACTS_RDPATH/${{ matrix.target_name }}/oom-* ]; then    # Out-of-Memory Case.
            TO_MINIMIZE_FNAME=oom-*;
          else
            echo -e "File to minimize not found.\nContents of artifacts dir \"$ARTIFACTS_RDPATH/${{ matrix.target_name }}/\":"
            ls $ARTIFACTS_RDPATH/${{ matrix.target_name }}/
          fi

          if [ "$TO_MINIMIZE_FNAME" != "" ]; then
            echo "TO_MINIMIZE_FNAME: $TO_MINIMIZE_FNAME"

            # Minimize the input:
            ( cargo fuzz --fuzz-dir ./fuzz tmin --release --sanitizer=none --features do_fuzz -r 10000 ${{ matrix.target_name }} $ARTIFACTS_RDPATH/${{ matrix.target_name }}/$TO_MINIMIZE_FNAME 2>&1 ) > \
              $TMIN_LOG_FNAME || MINIMIZATION_FAILED=1

            # Get the minimized input relative faile path:
            if [ "$MINIMIZATION_FAILED" == "1" ]; then
              # Minimization failed, get the latest successful minimized input relative faile path:
              MINIMIZED_INPUT_RFPATH=`
                cat $TMIN_LOG_FNAME | grep "CRASH_MIN: minimizing crash input: " | tail -n 1 |
                sed "s|^.*\($ARTIFACTS_RDPATH/${{ matrix.target_name }}/[^\']*\).*|\1|"`
            else
              # Minimization Succeeded, get the reported minimized input relative faile path::
              MINIMIZED_INPUT_RFPATH=`
                cat $TMIN_LOG_FNAME | grep "failed to minimize beyond" |
                sed "s|.*\($ARTIFACTS_RDPATH/${{ matrix.target_name }}/[^ ]*\).*|\1|" `
            fi
            echo "MINIMIZED_INPUT_RFPATH: $MINIMIZED_INPUT_RFPATH"
            echo "MINIMIZED_INPUT_RFPATH=$MINIMIZED_INPUT_RFPATH" >> "$GITHUB_ENV"

            # Extract the minimized input:
            MINIMIZED_INPUT=`cat $MINIMIZED_INPUT_RFPATH | tr "\n" "\r"`
              # Display the contents of the minimized input file and replace all the occurrences of '\n' with '\r'
              # so that the potentially multiline sequence can be "serialized" into the env var,
              # while preserving the information about the line breaks.
          else
            MINIMIZED_INPUT="(Input minimization failed, see the workflow logs and artifacts)"
          fi
          echo "MINIMIZED_INPUT: '$MINIMIZED_INPUT'"
          echo "MINIMIZED_INPUT=$MINIMIZED_INPUT" >> "$GITHUB_ENV"

          # Get the workflow agent system info:
          WF_AGENT_SYS_INFO="`uname -a`"
          echo "WF_AGENT_SYS_INFO: $WF_AGENT_SYS_INFO"
          echo "WF_AGENT_SYS_INFO=$WF_AGENT_SYS_INFO" >> "$GITHUB_ENV"
          echo "WF_AGENT_OS=${{ matrix.os }}" >> "$GITHUB_ENV"

          # Get the branch info:
          BRANCH_INFO=`git branch | grep '*'`
          echo "BRANCH_INFO: '$BRANCH_INFO'"
          echo "BRANCH_INFO=$BRANCH_INFO" >> "$GITHUB_ENV"

          # Get the commit info:
          COMMIT_INFO=`git log -1 | tr "\n" "\r"`
          echo "COMMIT_INFO: '$COMMIT_INFO'"
          echo "COMMIT_INFO=$COMMIT_INFO" >> "$GITHUB_ENV"

          # Get the last N bytes of the fuzzing stderr log into the env var
          # (N is such that the subsequent GitHub issue reporting does not overflow):
          STDERR_LOG=`tail -c 63488 $STDERR_LOG_FNAME | tr "\n" "\r"`
          echo "STDERR_LOG: '$STDERR_LOG'"
          echo "STDERR_LOG=$STDERR_LOG" >> "$GITHUB_ENV"

      - name: "If Fuzzing Failed: Upload Failure Artifacts"
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.target_name }}-fuzz-failure-artifacts
          path: |
            ${{ env.OWNER_RDPATH }}/${{ env.STDERR_LOG_FNAME }}
            ${{ env.OWNER_RDPATH }}/${{ env.TMIN_LOG_FNAME }}
            ${{ env.OWNER_RDPATH }}/${{ env.ARTIFACTS_RDPATH }}/${{ matrix.target_name }}/*
            ${{ env.OWNER_RDPATH }}/${{ env.SEEDS_RDPATH }}/${{ matrix.target_name }}/${{ env.SEEDS_FNAME }}
          if-no-files-found: error

      - name: "If Fuzzing Failed: Report GutHub Issue"
        if: failure()
        uses: JasonEtco/create-an-issue@v2
        env:
          GITHUB_TOKEN:     ${{ secrets.GITHUB_TOKEN }}
          WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        with:
          filename: ${{ env.GH_ISSUE_TEMPLATE_RFPATH }}
          # This issue template file uses a number of env vars collected above.
        id: create-issue

      - name: "If Fuzzing Failed: Log Issue Info"
        if: failure()
        run: |
          echo "Created issue #${{ steps.create-issue.outputs.number }} ${{ steps.create-issue.outputs.url }}"
