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.
  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.
  PYTHON_VERSION: "3.11"

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/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@v6
        with:
          submodules: "true"
      - uses: actions/setup-python@v6
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - 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@v6
        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()
        id: create-issue
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          python - <<'PY'
          import pathlib
          body_text = """
          The fuzz-testing workflow has detected a bug.

          <details><summary><b>Auto-Minimized Fuzzing Input That Triggers the Bug:</b> Click this line.</summary>
          <b>Note:</b> If the input is multi-line then the end-of-line characters '\n' (0x0A) and '\r' (0x0D)
          may affect the reproducibility of the bug. If you fail to repro the bug with the input shown below
          then you may want to go to the <a href=${{ env.WORKFLOW_RUN_URL }}>workflow</a> that reported this GitHub bug,
          download the artifact, and extract the file with the exact minimized input.

          ```qs
          ${{ env.MINIMIZED_INPUT }}
          ```

          </details>

          <details><summary><b>The branch/commit the bug has been found in:</b> Click.</summary>
          If the developers fail to repro the bug in the latest <code>main</code> then the branch/commit info below can help them to make sure
          that they are using the correct way to repro. If the bug is reproducible in the branch/commit below, but not in latest <code>main</code>,
          then the bug is likely fixed already or is not in the <code>main</code> branch.

          ```log
          Branch: ${{ env.BRANCH_INFO }}

          ${{ env.COMMIT_INFO }}
          ```

          </details>

          **Other Info**

          - [**Workflow**](${{ env.WORKFLOW_RUN_URL }}) (contains the run artifacts).
          - **Workflow Agent System Info:** `${{ env.WF_AGENT_OS }}: ${{ env.WF_AGENT_SYS_INFO }}`.
          """
          pathlib.Path("/tmp/fuzz_issue.md").write_text(body_text)
          PY

          url="$(gh issue create \
            --title "Fuzz: \"${{ env.PANIC_MESSAGE }}\" (${{ env.WF_AGENT_OS }})" \
            --body-file /tmp/fuzz_issue.md \
            --label bug \
            --assignee swernli)"
          number="$(gh issue view "$url" --json number --jq .number)"

          echo "url=$url" >> "$GITHUB_OUTPUT"
          echo "number=$number" >> "$GITHUB_OUTPUT"

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