#!/usr/bin/env python3
#
# Copyright 2024 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""List all available fuzz tests and create wrapper scripts for each.

Invoked by GN depending on the unit-tests target. Expected to be called
from the root out dir of GN.

Writes the wrappers and 'fuzztests.stamp' into the ./fuzztests/ directory.
"""

import os
import re
import shutil
import stat
import subprocess
import sys

from pathlib import Path

# Set up path to be able to import action_helpers
BASE_DIR = Path(__file__).absolute().parent.parent.parent
sys.path.append(str(BASE_DIR / 'build'))
import action_helpers

CENTIPEDE = 'centipede'
EXECUTABLE = 'v8_unittests'
FUZZ_TEST_DIR = 'fuzztests'
FUZZ_TEST_STAMP = 'fuzztests.stamp'

# When this script is enabled, we expect to find at least the demo tests.
MIN_FUZZTESTS = 2

# This is an arbitrary safeguard. If we run into it and it's legit,
# just double it.
MAX_FUZZTESTS = 100

WRAPPER_HEADER = """
#!/bin/bash

BINARY_DIR="$(cd "${{0%/*}}"/..; pwd)"
cd $BINARY_DIR
""".strip()

CENTIPEDE_WRAPPER = WRAPPER_HEADER + """
echo -e "Executing:\\n$BINARY_DIR/centipede $@\\n"
exec $BINARY_DIR/centipede $@
"""

FUZZTEST_WRAPPER = WRAPPER_HEADER + """
# Normal fuzzing.
if [ "$#" -eq  "0" ]; then
   echo -e "Starting fuzzing session:\\n$BINARY_DIR/v8_unittests --fuzz={test} --corpus_database=\"\"\\n"
   exec $BINARY_DIR/v8_unittests --fuzz={test} --corpus_database=""
# Fuzztest replay.
elif [ "$#" -eq  "1" ]; then
   unset CENTIPEDE_RUNNER_FLAGS
   echo -e "Replaying test case:\\nFUZZTEST_REPLAY=$1 $BINARY_DIR/v8_unittests --fuzz={test} --corpus_database=\"\"\\n"
   FUZZTEST_REPLAY=$1 exec $BINARY_DIR/v8_unittests --fuzz={test} --corpus_database=""
else
  echo -e "Pass 0 arguments for a fuzzing session and 1 argument for replay (you passed $#)"
  exit 1
fi
"""

FUZZER_NAME_RE = re.compile(r'^\w+\.\w+$')


def list_fuzz_tests(executable: Path):
  env = os.environ
  env['ASAN_OPTIONS'] = 'detect_odr_violation=0'
  env['CENTIPEDE_RUNNER_FLAGS'] = 'stack_limit_kb=0:'
  assert executable.exists()
  assert executable.is_absolute()
  test_list = subprocess.check_output(
      [executable, '--list_fuzz_tests=true'],
      env=env,
      cwd=BASE_DIR,
  ).decode('utf-8')
  return sorted(set(re.findall('Fuzz test: (.*)', test_list)))


def fuzz_test_to_file_name(test):
  assert FUZZER_NAME_RE.match(test)
  fuzztest_name = re.sub(r'((f|F)uzz(t|T)est|(t|T)est)', '', test)
  fuzztest_name = re.sub(r'\.', ' ', fuzztest_name)
  fuzztest_name = re.sub('([A-Z]+)', r' \1', fuzztest_name)
  fuzztest_name = re.sub('([A-Z][a-z]+)', r' \1', fuzztest_name)
  splitted = fuzztest_name.split()
  splitted = map(str.lower, splitted)
  splitted = filter(bool, splitted)
  return 'v8_' + '_'.join(splitted) + '_fuzztest'


def create_wrapper(file_name, template, test=''):
  with action_helpers.atomic_output(file_name) as f:
    wrapper = template.format(test=test)
    f.write(wrapper.encode('utf-8'))

  # Make the wrapper world-executable.
  st = os.stat(file_name)
  m = st.st_mode
  os.chmod(file_name, m | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)


def setup_fuzztests_dir(cwd):
  # This script owns the fuzztests subdirectory. Purge everything
  # to enable consistent incremental builds.
  fuzz_test_dir = cwd / FUZZ_TEST_DIR
  shutil.rmtree(fuzz_test_dir, ignore_errors=True)
  fuzz_test_dir.mkdir(exist_ok=True)

  # Centipede is later expected to be side-by-side with the fuzz-test
  # targets. Create a bash redirect so that shared libraries in cwd
  # keep loading.
  create_wrapper(fuzz_test_dir / CENTIPEDE, CENTIPEDE_WRAPPER)

  return fuzz_test_dir


def create_fuzztest_wrapper(fuzz_test_dir, test_name):
  fuzztest_path = fuzz_test_dir / fuzz_test_to_file_name(test_name)
  create_wrapper(fuzztest_path, FUZZTEST_WRAPPER, test_name)


def main():
  # The CWD is expected to be the root out dir of GN.
  cwd = Path(os.getcwd())

  # We expect the unit-test executable present in the root dir.
  executable = cwd / EXECUTABLE
  assert executable.exists()

  fuzz_test_dir = setup_fuzztests_dir(cwd)
  fuzz_tests = list_fuzz_tests(executable)
  assert MIN_FUZZTESTS <= len(fuzz_tests) <= MAX_FUZZTESTS

  for test_name in fuzz_tests:
    create_fuzztest_wrapper(fuzz_test_dir, test_name)

  # This is a place holder telling GN that we're done.
  with action_helpers.atomic_output(fuzz_test_dir / FUZZ_TEST_STAMP) as f:
    f.write('\n'.join(fuzz_tests).encode('utf-8'))


if __name__ == '__main__':
  main()
