| #!/usr/bin/env vpython3 | |
| # Copyright 2026 The Chromium Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Run fuzzilli for the specified duration and generate a coverage report | |
| * Example usage: vpython3 measure_fuzzilli_coverage.py | |
| --build-out-dir ~/chromium/src/out/Fuzzilli | |
| --fuzzilli-dir ~/fuzzilli | |
| --report-out-dir ~/chromium/src/out/report | |
| --profile mojoLockManager | |
| --minutes 20 | |
| Optionally, use --ignore-filename-regex to provide a regular | |
| expression matching all the file paths to be excluded from the report. | |
| Use --filters to explicitly state all the directories or files to get | |
| include in the coverage report. | |
| The following gn args, for use with `js_in_process_fuzzer`, are a mix | |
| of args required for coverage and args that improve fuzzing either by | |
| speed up or increased coverage: | |
| use_sanitizer_coverage = true | |
| is_component_build = false | |
| is_debug = false | |
| symbol_level = 2 | |
| blink_symbol_level = 0 | |
| use_remoteexec = true | |
| dcheck_always_on = false | |
| is_asan = false | |
| use_chromium_fuzzilli = true | |
| v8_fuzzilli = true | |
| v8_static_library = true | |
| v8_dcheck_always_on = true | |
| optimize_for_fuzzing = false | |
| enable_mojom_fuzzer = true | |
| use_clang_coverage = true | |
| """ | |
| import argparse | |
| import glob | |
| import logging | |
| from pathlib import Path | |
| import os | |
| import signal | |
| import subprocess | |
| import sys | |
| import tempfile | |
| DEFAULT_FUZZILLI_FLAGS = [ | |
| '--storagePath=/tmp/fuzzilli_storage', | |
| '--overwrite', | |
| '--engine=hybrid', | |
| ] | |
| SCRIPT_DIR = Path(__file__).resolve().parent | |
| SRC_DIR = SCRIPT_DIR.parents[1] | |
| class ProcessGroupHandler: | |
| """Simple class for handling process groups across the program. | |
| Notably, this class ensures that the associated process group | |
| is killed upon interrupt by providing an interrupt_handler. | |
| """ | |
| def __init__(self): | |
| self.process_group = None | |
| def SetProcessGroup(self, pg): | |
| self.process_group = pg | |
| def KillProcessGroup(self): | |
| """Kill the process group with a SIGTERM signal. Grant 10 seconds | |
| to allow for graceful shutdown, following up with a SIGKILL if | |
| the process group is still active. | |
| """ | |
| os.killpg(os.getpgid(self.process_group.pid), signal.SIGTERM) | |
| try: | |
| self.process_group.wait(timeout=10) | |
| except subprocess.TimeoutExpired: | |
| # Kill the process if still active | |
| os.killpg(os.getpgid(self.process_group.pid), signal.SIGKILL) | |
| def interrupt_handler(self, sig, frame): | |
| """Gracefully handle interrupt by killing children processes. | |
| When running Fuzzilli, many child processes are created. Without | |
| killing the entire process groups, these processes become orphan | |
| processes. | |
| """ | |
| self.KillProcessGroup() | |
| sys.exit(0) | |
| def _ParseCommandArguments(): | |
| """Adds and parses relevant arguments for tool commands. | |
| Returns: | |
| An argparse.Namespace object representing the arguments. | |
| """ | |
| arg_parser = argparse.ArgumentParser() | |
| arg_parser.usage = __doc__ | |
| arg_parser.add_argument( | |
| '-fd', | |
| '--fuzzilli-dir', | |
| type=str, | |
| required=True, | |
| help='The absolute path to the directory containing Fuzzilli.', | |
| ) | |
| arg_parser.add_argument( | |
| '-b', | |
| '--build-out-dir', | |
| type=str, | |
| required=True, | |
| help='The absolute path to the Chrome output directory.', | |
| ) | |
| arg_parser.add_argument( | |
| '-r', | |
| '--report-out-dir', | |
| type=str, | |
| required=True, | |
| help='The absolute path to the directory to which to output the report.', | |
| ) | |
| arg_parser.add_argument( | |
| '-m', | |
| '--minutes', | |
| type=int, | |
| required=True, | |
| help='The number of minutes for which to run Fuzzilli.', | |
| ) | |
| arg_parser.add_argument( | |
| '-f', | |
| '--filters', | |
| action='append', | |
| required=False, | |
| help='Directories or files to get code coverage for, and all files under ' | |
| 'the directories are included recursively.', | |
| ) | |
| arg_parser.add_argument( | |
| '-i', | |
| '--ignore-filename-regex', | |
| type=str, | |
| help='Skip source code files with file paths that match the given ' | |
| 'regular expression. For example, use -i=\'.*/out/.*|.*/third_party/.*\' ' | |
| 'to exclude files in third_party/ and out/ folders from the report.', | |
| ) | |
| arg_parser.add_argument( | |
| '-p', | |
| '--profile', | |
| type=str, | |
| required=True, | |
| help='Provide a profile for Fuzzilli to use while running, identifying' | |
| 'by the key defined in Fuzzilli\'s `profiles` Dictionary.', | |
| ) | |
| args = arg_parser.parse_args() | |
| return args | |
| def _RunFuzzilli( | |
| pg_handler, fuzzilli_dir, build_out_dir, minutes, profile, profraw_dir | |
| ): | |
| """Builds and runs Fuzzilli. | |
| Bypasses the 'swift run' wrapper to ensure signals are handled correctly. | |
| Launches a process group in order to kill all associated processes | |
| after running for the desired duration. | |
| """ | |
| os.environ["LLVM_PROFILE_FILE"] = os.path.join( | |
| profraw_dir, "fuzzilli.%4m%c.profraw" | |
| ) | |
| build_command = ['swift', 'build', '-c', 'release'] | |
| try: | |
| subprocess.run(build_command, check=True, cwd=fuzzilli_dir) | |
| except (subprocess.CalledProcessError, FileNotFoundError) as e: | |
| logging.fatal(e) | |
| return False | |
| # Fuzzilli strips executed programs of any environment variables, setting | |
| # only the environment variables specified in the optionally provided | |
| # profile. To ensure that js_in_process_fuzzer can see the environment | |
| # variable, create a temporary script for Fuzzilli to execute, in which the | |
| # environment variable is set. | |
| temp_wrapper = tempfile.NamedTemporaryFile( | |
| mode='w+', delete=False, suffix='.sh' | |
| ) | |
| try: | |
| temp_wrapper.write( | |
| '#!/bin/bash\n' | |
| f'export LLVM_PROFILE_FILE="{os.environ["LLVM_PROFILE_FILE"]}"\n' | |
| f'exec {os.path.join(build_out_dir, "js_in_process_fuzzer")} "$@"' | |
| ) | |
| temp_wrapper.flush() | |
| temp_wrapper.close() | |
| # mark wrapper as executable | |
| os.chmod(temp_wrapper.name, 0o755) | |
| print(f"The temporary file is located at: {temp_wrapper.name}") | |
| fuzzilli_executable = os.path.join( | |
| fuzzilli_dir, '.build/release/FuzzilliCli' | |
| ) | |
| run_command = [ | |
| fuzzilli_executable, | |
| *DEFAULT_FUZZILLI_FLAGS, | |
| f'--profile={profile}', | |
| temp_wrapper.name, | |
| ] | |
| # Use a process group, as this command will create many child processes that | |
| # continue to live if only the parent is killed | |
| p = subprocess.Popen(run_command, cwd=fuzzilli_dir, start_new_session=True) | |
| pg_handler.SetProcessGroup(p) | |
| try: | |
| timeout = minutes * 60 | |
| p.wait(timeout=timeout) | |
| if p.returncode != 0: | |
| return False | |
| except subprocess.TimeoutExpired: | |
| # Kill the entire process group to ensure no orphan child processes | |
| pg_handler.KillProcessGroup() | |
| finally: | |
| if os.path.exists(temp_wrapper.name): | |
| os.remove(temp_wrapper.name) | |
| return True | |
| def _GenerateCoverageReport( | |
| build_out_dir, report_out_dir, ignore_filename_regex, filters, profraw_dir | |
| ): | |
| if filters is None: | |
| filters = [] | |
| try: | |
| # Create the indexed profile to pass into `coverage.py` | |
| # | |
| # glob.glob expands the wildcard | |
| gen_profile_command = [ | |
| 'llvm-profdata', | |
| 'merge', | |
| '-o', | |
| os.path.join(profraw_dir, 'coverage.profdata'), | |
| ] + glob.glob(os.path.join(profraw_dir, '*.profraw')) | |
| subprocess.run(gen_profile_command, check=True) | |
| coverage_command = [ | |
| f'{SRC_DIR}/tools/code_coverage/coverage.py', | |
| 'js_in_process_fuzzer', | |
| '-b', | |
| build_out_dir, | |
| '-o', | |
| report_out_dir, | |
| '-p', | |
| os.path.join(profraw_dir, 'coverage.profdata'), | |
| '--no-component-view', | |
| ] | |
| for f in filters: | |
| coverage_command.extend(['-f', f]) | |
| if ignore_filename_regex: | |
| coverage_command.extend(['-i', ignore_filename_regex]) | |
| subprocess.run(coverage_command, check=True) | |
| except FileNotFoundError as e: | |
| logging.fatal(e) | |
| return False | |
| except subprocess.CalledProcessError: | |
| # Don't log here to avoid duplicate logs (as both llvm-profdata and | |
| # coverage.py already log). | |
| return False | |
| return True | |
| def Main(): | |
| """Execute script.""" | |
| pg_handler = ProcessGroupHandler() | |
| signal.signal(signal.SIGINT, pg_handler.interrupt_handler) | |
| args = _ParseCommandArguments() | |
| # Create a unique directory for storing the generated *.profraw files | |
| with tempfile.TemporaryDirectory() as profraw_dir: | |
| profraw_path = Path(profraw_dir) | |
| print(f'Using {profraw_path} for profraw files.') | |
| if not _RunFuzzilli( | |
| pg_handler, | |
| args.fuzzilli_dir, | |
| args.build_out_dir, | |
| args.minutes, | |
| args.profile, | |
| profraw_path, | |
| ): | |
| sys.exit('Error: Fuzzilli failed to build or run.') | |
| if not _GenerateCoverageReport( | |
| args.build_out_dir, | |
| args.report_out_dir, | |
| args.ignore_filename_regex, | |
| args.filters, | |
| profraw_path, | |
| ): | |
| sys.exit('Error: Failed to generate coverage report.') | |
| if __name__ == '__main__': | |
| sys.exit(Main()) |