blob: a0c766d8b54628c6a5bb28217146f1721ba44eb9 [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
import time
from typing import Dict, List, NamedTuple, Optional, Set, Tuple
import urllib.error
import urllib.parse
import urllib.request
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_remoteexec = true
use_siso = true
"""
# TODO: Support Centipede fuzzers too.
# https://buganizer.corp.google.com/issues/522382682
COVERAGE_API_URL = 'https://analysis.chromium.org/coverage/p/chromium/file'
MAX_FINDIT_API_RETRIES = 3
FINDIT_API_TIMEOUT = 10.0
class FinditCoverage(NamedTuple):
"""Fuzzing coverage of a file for one fuzzer or all fuzzers combined."""
# The file's lines in the report, in file order: file_lines[i] is line i + 1.
file_lines: List[str]
# Hit count of each line executed at least once, by 1-based line number.
covered_lines: Dict[int, int]
def generate_gn_build_dir(out_dir: str):
"""Generates the GN graph in the output directory."""
logging.info('Generating GN graph in %s...', out_dir)
args_str = gn_args_libfuzzer.replace('\n', ' ')
command = ['gn', 'gen', out_dir, f'--args={args_str}']
try:
# Redirect gn gen's output to stderr so it doesn't get mixed into the
# JSON result this script prints to stdout.
subprocess.run(
command,
cwd=chromium_src_dir,
check=True,
env=os.environ,
stdout=sys.stderr,
)
except subprocess.CalledProcessError as e:
logging.error('Error running gn gen: %s', 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('Running command: %s', ' '.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(
'Error running gn command: %s\nStdout: %s\nStderr: %s',
e,
e.stdout,
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('Finding all fuzzer targets in %s...', 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('Finding reverse dependencies for %s...', gn_file_path)
args = ['refs', out_dir, '--all', '--as=label', '-q', gn_file_path]
return run_gn_command(args)
def get_modified_files(revision: str) -> List[str]:
"""Detects C/C++ files modified by the given commit."""
# Diff against the commit's own parent rather than HEAD~: bot_update
# soft-resets the CL, with any unlanded CLs it's stacked on, onto a main
# commit, so HEAD~ would also pick up those CLs and the last main commit.
logging.info('Detecting files modified by %s...', revision)
command = [
'git',
'diff',
'--name-only',
'--diff-filter=d',
'-z',
f'{revision}~',
revision,
]
result = subprocess.run(
command, cwd=chromium_src_dir, capture_output=True, text=True, check=True
)
return [
f
for f in result.stdout.split('\0')
if f.endswith(('.cc', '.cpp', '.c', '.h', '.m', '.mm'))
]
def find_affected_fuzzers_for_file(
file_path: str, out_dir: str, all_fuzzers: Set[str]
) -> Tuple[str, List[str]]:
"""Finds the fuzzers affected by a single modified file."""
rel_path = file_path.replace(os.sep, '/')
# Prefix with '//' so the file path is formatted as a GN label, which is what
# both the reverse-dep query and the fuzzing coverage API expect.
gn_path = f'//{rel_path}'
try:
reverse_deps = find_reverse_deps(out_dir, gn_path)
except subprocess.CalledProcessError as e:
logging.warning(
'Failed to find reverse deps for %s: %s. '
'Fuzzing coverage for this file will be missing.',
gn_path,
e,
)
return gn_path, []
affected_fuzzers = [f for f in reverse_deps if f in all_fuzzers]
# TODO: Query the Findit fuzzing coverage API for the changed lines of this
# file and drop candidates with no coverage hits.
# https://buganizer.corp.google.com/issues/560236801
# TODO: Before matching, map the changed lines from the CL's parent commit
# to the coverage report's commit, since the file may have changed in
# between. The Findit response has the report's version of the file in
# data.line_to_data to diff the parent's version against.
# https://buganizer.corp.google.com/issues/560236801
# TODO: Include newly added fuzzers as it will not have Findit Fuzzing
# Coverage API hits.
# https://buganizer.corp.google.com/issues/561529566
return gn_path, affected_fuzzers
def get_affected_fuzzers(
modified_files: List[str], out_dir: str, all_fuzzers: List[str]
) -> Dict[str, List[str]]:
"""Maps modified files to the fuzzers they affect."""
affected_map: Dict[str, List[str]] = {}
all_fuzzers_set = set(all_fuzzers)
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [
executor.submit(
find_affected_fuzzers_for_file, f, out_dir, all_fuzzers_set
)
for f in modified_files
]
for future in concurrent.futures.as_completed(futures):
gn_path, affected_fuzzers = future.result()
logging.info(
'File "%s" affects %d fuzzers.', gn_path, len(affected_fuzzers)
)
affected_map[gn_path] = affected_fuzzers
return affected_map
def get_findit_coverage(
file_path: str,
fuzzer_name: str = 'any',
) -> Optional[FinditCoverage]:
"""Fetches fuzzing coverage data for a file from the Findit Coverage API.
Args:
file_path: Path of the source file
fuzzer_name: Name of the fuzzer whose coverage is requested, e.g.
'base_json_reader_fuzzer'. The default, 'any', returns the coverage
aggregated over all LibFuzzer/FUZZ_TEST fuzzers for the file_path.
Returns:
The latest collected fuzzing coverage of the file. Its covered_lines is
empty if the coverage was fetched but the fuzzer doesn't cover the file.
None if the coverage can't be fetched, e.g. because Findit has no report
for the fuzzer, the report doesn't have the file, or the request keeps
failing.
Raises:
AssertionError: if file_path is empty.
"""
assert file_path, 'file_path must be a non-empty path.'
gn_path = (
file_path if file_path.startswith('//') else f'//{file_path.lstrip("/")}'
)
# Findit coverage API uses 'fuzz' for Libfuzzer and FUZZ_TEST coverage.
params = urllib.parse.urlencode(
{
'path': gn_path,
'platform': 'fuzz',
'test_suite_type': fuzzer_name,
'raw': 'true',
}
)
url = f'{COVERAGE_API_URL}?{params}'
req = urllib.request.Request(
url, headers={'User-Agent': 'Chromium-FindAffectedFuzzers/1.0'}
)
for attempt in range(MAX_FINDIT_API_RETRIES):
log_level = logging.WARNING
try:
with urllib.request.urlopen(req, timeout=FINDIT_API_TIMEOUT) as resp:
response = json.loads(resp.read().decode('utf-8'))
return parse_findit_response(response, gn_path)
except urllib.error.HTTPError as e:
# Retry on 429 (rate limit) and on 500, 502, 503 and 504 (transient
# server errors), except on a 500 for a single fuzzer: Findit answers
# 500 when it has no report for the fuzzer, which retrying won't fix.
# TODO(b/571233495): Retry those 500s too once Findit is redeployed and
# returns 404 for a missing report instead.
no_report = e.code == 500 and fuzzer_name != 'any'
should_retry = e.code in (429, 500, 502, 503, 504) and not no_report
if no_report:
# Fuzzers sometimes have no reports e.g. for newly added fuzzers
# or fuzzers tagged no_clusterfuzz.
log_level = logging.INFO
error_msg = 'HTTP error %s fetching %s coverage for %s: %s'
error_args = (e.code, fuzzer_name, gn_path, e.reason)
except (urllib.error.URLError, TimeoutError) as e:
should_retry = True
error_msg = 'Network error fetching coverage for %s: %s'
error_args = (gn_path, e)
except (json.JSONDecodeError, KeyError, TypeError) as e:
# The request succeeded but the response isn't JSON or doesn't have the
# expected fields, e.g. because the API changed and retrying won't help.
should_retry = False
error_msg = 'Malformed coverage response for %s: %r'
error_args = (gn_path, e)
except Exception as e:
should_retry = False
error_msg = 'Unexpected error fetching coverage for %s: %s'
error_args = (gn_path, e)
if should_retry and attempt < MAX_FINDIT_API_RETRIES - 1:
time.sleep(1.0 * (attempt + 1))
continue
logging.log(log_level, error_msg, *error_args)
break
return None
def parse_findit_response(
response: Dict, file_path: str
) -> Optional[FinditCoverage]:
"""Extracts the coverage of a file from a raw Findit Coverage API response.
'data.line_to_data' is a list of [line_number, {'line': str, 'count': int,
...}] pairs with each line of the file at the report's revision and its hit
count.
Args:
response: The response to a request for the coverage of file_path.
file_path: Source-absolute path of the file, e.g.
'//base/json/json_reader.cc'.
Returns:
The coverage or None. If the report doesn't contain the file, Findit
returns the summary of the root directory, and we return None in this
case. We also return None if Findit couldn't load the file's content.
Raises:
KeyError, TypeError: if the response doesn't have the expected fields.
"""
if response['path'] != file_path:
# TODO(crbug.com/578475336): This is a temporary check. We should expect a
# 404 once Findit returns 404 for files that aren't in the report.
logging.debug('No coverage for %s: %s', file_path, response['warning'])
return None
line_to_data = response['data']['line_to_data']
if line_to_data is None:
# Findit couldn't load the file's content from GCS or Gitiles.
# TODO(crbug.com/578476159): Expect an error instead once Findit returns an
# error code instead of None line_to_data.
logging.warning('Findit has no content for %s', file_path)
return None
file_lines: List[str] = []
covered_lines: Dict[int, int] = {}
for line_number, line_data in line_to_data:
file_lines.append(line_data['line'])
# The count is -1 for non-executable lines (e.g. comments) and 0 for
# executable lines that weren't executed.
if line_data['count'] > 0:
covered_lines[line_number] = line_data['count']
return FinditCoverage(
file_lines=file_lines,
covered_lines=covered_lines,
)
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.',
)
parser.add_argument(
'--revision',
required=True,
help='The CL\'s commit. Only files it changes relative to its parent are '
'considered.',
)
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(args.revision)
if not modified_files:
logging.info('No relevant source files modified by %s.', args.revision)
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())