#!/usr/bin/env vpython3
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Provides a local HTML report of the ClusterFuzz explorations
by a given fuzzer.

  * Example usage: vpython3 view_fuzz_coverage.py --target my_fuzzer_binary
    --fuzzer-type libfuzzer
"""

import argparse
import os
import subprocess
import sys
import tempfile
import pathlib

script_dir = os.path.dirname(os.path.realpath(__file__))
chromium_src_dir = os.path.dirname(os.path.dirname(script_dir))

XVFB_PATH = os.path.join(chromium_src_dir, 'testing/xvfb.py')

# These may evolve over time, so if this script doesn't work, you may
# need to adjust these. In an ideal world we'd look these up from LUCI
# infrastructure but we're intentionally making a local script somewhat
# equivalent to LUCI infrastructure, so for now let's not rely on that.
gn_args_libfuzzer = """
dcheck_always_on = false
enable_mojom_fuzzer = true
ffmpeg_branding = "ChromeOS"
is_component_build = false
is_debug = false
pdf_enable_xfa = true
proprietary_codecs = true
use_clang_coverage = true
use_clang_modules = false
use_libfuzzer = true
use_remoteexec = true
symbol_level = 2
"""

gn_args_blackbox = """
dcheck_always_on = false
is_asan = true
is_component_build = false
is_debug = false
use_clang_coverage = true
use_clang_modules = false
use_remoteexec = true
symbol_level = 2
"""


def _ParseCommandArguments():
  """Adds and parses relevant arguments for tool comands.

  Returns:
    A dictionary representing the arguments.
  """
  arg_parser = argparse.ArgumentParser()
  arg_parser.usage = __doc__

  arg_parser.add_argument(
    '--fuzzer-type',
    choices=['libfuzzer', 'centipede', 'fuzzilli', 'blackbox'],
    default='libfuzzer',
    help='The type of fuzzer to analyze.',
  )
  arg_parser.add_argument(
    '--target',
    type=str,
    required=True,
    help='The fuzzer binary name. For blackbox fuzzers, '
    'the target binary to use, e.g. Chrome or v8',
  )
  arg_parser.add_argument(
    '--build-dir',
    default=os.path.join(chromium_src_dir, 'out', 'coverage'),
    help='Where to build fuzzers.',
  )
  arg_parser.add_argument(
    '--html-dir',
    default=os.path.join(chromium_src_dir, 'out', 'coverage-html'),
    help='Where to put HTML report.',
  )
  arg_parser.add_argument(
    '--corpora-dir',
    help='The directory containing the fuzzing corpora. Required when the '
    'fuzzer type is blackbox, where it is the directory of html or js files '
    'to be run with the target.',
  )
  arg_parser.add_argument(
    '--retain-build-dir',
    action='store_true',
    help='Avoid cleaning the build dir (may result in multiple fuzzers being '
    'analyzed).',
  )
  arg_parser.add_argument(
    '--testcase-timeout',
    type=int,
    default=60,
    help='Timeout in seconds for each testcase. Defaults to 60 seconds. For '
    'blackbox fuzzers, this corresponds to the TEST_TIMEOUT env variable for '
    'the bot running the fuzzer. Many blackbox fuzzers require a timeout '
    'because test cases can\'t signal when they are finished.',
  )
  args = arg_parser.parse_args()
  return args


def step(name):
  """Print a banner for the upcoming task.."""
  print("==== " + name + " ====:")


def check_call(args, *, cwd=None, shell=False):
  """Equivalent to subprocess.check_call but logs command."""
  print(" ".join(args))
  subprocess.check_call(args, cwd=cwd, shell=shell)


def Main():
  args = _ParseCommandArguments()

  os.makedirs(args.build_dir, exist_ok=True)
  os.makedirs(args.html_dir, exist_ok=True)

  step("Writing gn args")
  gn_args_file = os.path.join(args.build_dir, "args.gn")
  with open(gn_args_file, "w") as f:
    if args.fuzzer_type == 'blackbox':
      f.write(gn_args_blackbox)
    else:
      f.write(gn_args_libfuzzer)

  if not args.retain_build_dir:
    step("gn clean")
    check_call(["gn", "clean", args.build_dir], cwd=chromium_src_dir)
  step("gn gen")
  check_call(["gn", "gen", args.build_dir], cwd=chromium_src_dir)

  step("autoninja")
  check_call(["autoninja", "-C", args.build_dir, args.target])

  is_blackbox_fuzzer = args.fuzzer_type == 'blackbox'

  if is_blackbox_fuzzer:
    if not args.corpora_dir:
      print("--corpora-dir is required for blackbox fuzzer coverage.")
      return 1

    corpora_dir_path = args.corpora_dir
  else:
    temp_corpora_dir = tempfile.TemporaryDirectory()
    corpora_dir_path = temp_corpora_dir.name
    step("Download corpora")
    check_call(
      [
        sys.executable,
        os.path.join(script_dir, "download_fuzz_corpora.py"),
        "--download-dir",
        corpora_dir_path,
        "--build-dir",
        args.build_dir,
        "--corpora-type",
        args.fuzzer_type,
      ]
    )

  individual_profdata_dir = tempfile.TemporaryDirectory()
  step(
    'Running fuzzers (can take a while - NB you might need a valid DISPLAY '
    'set for some fuzzers)'
  )

  run_all_fuzzers_cmd = [
    sys.executable,
    XVFB_PATH,
    os.path.join(script_dir, "run_all_fuzzers.py"),
    "--fuzzer-binaries-dir",
    args.build_dir,
    "--fuzzer-corpora-dir",
    corpora_dir_path,
    "--profdata-outdir",
    individual_profdata_dir.name,
    "--fuzzer",
    args.fuzzer_type,
    "--testcase-timeout",
    str(args.testcase_timeout),
  ]
  if is_blackbox_fuzzer:
    run_all_fuzzers_cmd += ["--target", args.target]

  check_call(run_all_fuzzers_cmd)

  step("Merging profdata")
  merged_profdata_dir = tempfile.TemporaryDirectory()
  merged_profdata_file = os.path.join(merged_profdata_dir.name, "out.profdata")
  llvm_dir = os.path.join(
    chromium_src_dir, "third_party", "llvm-build", "Release+Asserts", "bin"
  )
  check_call(
    [
      sys.executable,
      os.path.join(script_dir, "merge_all_profdata.py"),
      "--profdata-dir",
      individual_profdata_dir.name,
      "--outfile",
      merged_profdata_file,
      "--llvm-profdata",
      os.path.join(llvm_dir, "llvm-profdata"),
    ]
  )

  step("Generating HTML")
  target_path = os.path.join(args.build_dir, args.target)
  check_call(
    [
      os.path.join(llvm_dir, "llvm-cov"),
      "show",
      target_path,
      "--format=html",
      "--instr-profile",
      merged_profdata_file,
      "--output-dir",
      args.html_dir,
      "--compilation-dir",
      args.build_dir,
    ]
  )

  uri = pathlib.Path(
    os.path.abspath(os.path.join(args.html_dir, "index.html"))
  ).as_uri()
  print("Report URI " + uri)
  step("Opening HTML in Chrome")
  check_call(["google-chrome-stable", uri])


if __name__ == '__main__':
  sys.exit(Main())
