blob: ae0a8908140e6f5846aa757803134ae2f29aba0d [file]
#!/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.
import argparse
import concurrent.futures
import json
import logging
import os
import subprocess
import sys
from typing import Dict, List, Set
script_dir = os.path.dirname(os.path.realpath(__file__))
chromium_src_dir = os.path.dirname(os.path.dirname(script_dir))
gn_args_libfuzzer = """
dcheck_always_on = false
enable_mojom_fuzzer = true
ffmpeg_branding = "ChromeOS"
is_component_build = true
is_debug = false
optimize_for_fuzzing = true
pdf_enable_xfa = true
proprietary_codecs = true
target_cpu = "x64"
target_os = "linux"
use_libfuzzer = true
use_reclient = false
use_remoteexec = true
use_siso = true
"""
# TODO: Support Centipede fuzzers too.
# https://buganizer.corp.google.com/issues/522382682
def generate_gn_build_dir(out_dir: str):
"""Generates the GN graph in the output directory."""
logging.info(f'Generating GN graph in {out_dir}...')
args_str = gn_args_libfuzzer.replace('\n', ' ')
command = ['gn', 'gen', out_dir, f'--args={args_str}']
try:
subprocess.run(command, cwd=chromium_src_dir, check=True, env=os.environ)
except subprocess.CalledProcessError as e:
logging.error(f'Error running gn gen: {e}')
raise
def run_gn_command(args: List[str]) -> List[str]:
"""Runs a gn command and returns stdout lines as a list."""
try:
command = ['gn'] + args
logging.debug(f'Running command: {" ".join(command)}')
# Explicitly pass os.environ to ensure that GN inherits crucial environment
# variables set by swarming bots or the local environment (e.g., PATH,
# DEPOT_TOOLS_PATH, toolchain paths, and RBE/Reclient variables). GN cannot
# locate compilers, SDKs, or tools without these environment variables.
result = subprocess.run(command,
cwd=chromium_src_dir,
capture_output=True,
text=True,
check=True,
env=os.environ)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
except subprocess.CalledProcessError as e:
logging.error(
f'Error running gn command: {e}\nStdout: {e.stdout}\nStderr: {e.stderr}'
)
raise
except FileNotFoundError:
logging.error('\'gn\' command not found. '
'Ensure depot_tools is in your PATH.')
raise
def find_all_fuzzer_targets(out_dir: str) -> List[str]:
"""Finds all fuzz targets which are executable that depend on
:fuzzing_engine.
"""
logging.info(f'Finding all fuzzer targets in {out_dir}...')
args = [
'refs', out_dir, '//testing/libfuzzer:fuzzing_engine', '--all',
'--type=executable', '--as=label', '-q'
]
return run_gn_command(args)
def find_reverse_deps(out_dir: str, gn_file_path: str) -> List[str]:
"""Finds all targets that depend on the given GN file path."""
logging.debug(f'Finding reverse dependencies for {gn_file_path}...')
args = ['refs', out_dir, '--all', '--as=label', '-q', gn_file_path]
return run_gn_command(args)
def get_modified_files() -> List[str]:
"""Detects modified C/C++ files against the base revision."""
# TODO: Address the issue that if a chain of CLs is uploaded
# (HEAD -> A -> B -> C) and the CQ is run on C, this might discover
# changes in A or B and then cause fuzzers to run, even if C didn't
# have any relevant changes.
# https://buganizer.corp.google.com/issues/522387539
logging.info('Detecting modified files against HEAD~...')
command = ['git', 'diff', '--name-only', '--diff-filter=d', 'HEAD~']
result = subprocess.run(command,
cwd=chromium_src_dir,
capture_output=True,
text=True,
check=True)
return [
f.strip() for f in result.stdout.splitlines()
if f.strip().endswith(('.cc', '.cpp', '.c', '.h', '.m', '.mm'))
]
def get_affected_fuzzers(modified_files: List[str], out_dir: str,
all_fuzzers: List[str]) -> Dict[str, List[str]]:
"""Maps modified files to the fuzzer targets they affect."""
affected_map: Dict[str, List[str]] = {}
all_fuzzers_set = set(all_fuzzers)
def process_file(file_path: str):
rel_path = file_path.replace(os.sep, '/')
# Prefix with '//' so that file paths in the output map are formatted
# consistently with GN target labels which are needed to query fuzzing
# coverage APIs.
gn_path = f'//{rel_path}'
try:
reverse_deps = find_reverse_deps(out_dir, gn_path)
affected = [f for f in reverse_deps if f in all_fuzzers_set]
return gn_path, affected
except subprocess.CalledProcessError as e:
logging.warning(f'Failed to find reverse deps for {gn_path}: {e}. '
'Fuzzing coverage for this file will be missing.')
return gn_path, []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
future_to_file = {
executor.submit(process_file, f): f
for f in modified_files
}
for future in concurrent.futures.as_completed(future_to_file):
gn_path, affected = future.result()
if affected:
logging.info(f'File "{gn_path}" affects {len(affected)} fuzzers.')
affected_map[gn_path] = affected
else:
logging.debug(f'File "{gn_path}" affects no fuzzers.')
affected_map[gn_path] = []
return affected_map
def main():
parser = argparse.ArgumentParser(
description='Discover affected fuzzer targets.')
parser.add_argument('-v',
'--verbose',
action='store_true',
help='Verbose logging.')
parser.add_argument(
'--out-dir',
required=True,
help='Build directory. GN args will be replaced in this directory.')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format='%(levelname)s: %(message)s',
stream=sys.stderr)
# Detect modified files
modified_files = get_modified_files()
if not modified_files:
logging.info('No relevant source files modified against HEAD~.')
return 0
generate_gn_build_dir(args.out_dir)
# Get all possible fuzzer targets
all_fuzzers = find_all_fuzzer_targets(args.out_dir)
if not all_fuzzers:
logging.warning(
'No fuzzer targets found. Ensure "use_libfuzzer = true" in GN args.')
return 1
affected_fuzzers_map = get_affected_fuzzers(modified_files, args.out_dir,
all_fuzzers)
final_output = {'affected_fuzzers': affected_fuzzers_map}
print(json.dumps(final_output, indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
sys.exit(main())