#!/usr/bin/env python3 # # Copyright 2018 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Generates a `foo.owners` file for a `fuzzer_test("foo", ...)` GN target. By default, the closest `OWNERS` file is located and copied, except for `//OWNERS` and `//third_party/OWNERS` for fear of spamming top-level owners with fuzzer bugs they know nothing about. If no such file can be located, then we attempt to use `git blame` to identify the author of the main fuzzer `.cc` file. Note that this does not work for code in git submodules (e.g. most code in `third_party/`), in which case we generate an empty file. Invoked by GN from `fuzzer_test.gni`. """ import argparse import os import re import subprocess import sys from typing import Optional AUTHOR_REGEX = re.compile('author-mail <(.+)>') CHROMIUM_SRC_DIR = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) OWNERS_FILENAME = 'OWNERS' THIRD_PARTY = 'third_party' THIRD_PARTY_SEARCH_STRING = THIRD_PARTY + os.path.sep def GetAuthorFromGitBlame(blame_output): """Return author from git blame output.""" for line in blame_output.decode('utf-8').splitlines(): m = AUTHOR_REGEX.match(line) if m: return m.group(1) return None def GetGitCommand(): """Returns a git command that does not need to be executed using shell=True. On non-Windows platforms: 'git'. On Windows: 'git.bat'. """ return 'git.bat' if sys.platform == 'win32' else 'git' def GetOwnersFromOwnersFile(source: str) -> Optional[str]: """Finds the owners of `source` from the closest OWNERS file. Both //OWNERS or */third_party/OWNERS are ignored so as not to spam top-level owners with unowned fuzzer bugs. Args: source: Relative path from the chromium src directory to the target source file. Returns: The entire contents of the closest OWNERS file. That is, the first OWNERS file encountered while walking up through the ancestor directories of the target source file. """ # TODO(https://crbug.com/1513729): Use `pathlib` instead of `os.path` for # better ergonomics and robustness. dirs = source.split(os.path.sep)[:-1] # Note: We never test for //OWNERS, i.e. when `dirs` is empty. while dirs: # Never return the contents of */third_party/OWNERS, and stop searching. if dirs[-1] == THIRD_PARTY: break owners_file_path = os.path.join(CHROMIUM_SRC_DIR, *dirs, OWNERS_FILENAME) if os.path.exists(owners_file_path): # TODO(https://crbug.com/1513729): OWNERS files can reference others, # have per-file directives, etc. We should be cleverer than this. return open(owners_file_path).read() dirs.pop() return None def GetOwnersForFuzzer(sources): """Return owners given a list of sources as input.""" if not sources: return None for source in sources: full_source_path = os.path.join(CHROMIUM_SRC_DIR, source) if not os.path.exists(full_source_path): continue with open(full_source_path, 'r') as source_file_handle: source_content = source_file_handle.read() if SubStringExistsIn( ['FuzzOneInput', 'LLVMFuzzerTestOneInput', 'PROTO_FUZZER'], source_content): # Found the fuzzer source (and not dependency of fuzzer). # Try finding the closest OWNERS file first. owners = GetOwnersFromOwnersFile(source) if owners: return owners git_dir = os.path.join(CHROMIUM_SRC_DIR, '.git') git_command = GetGitCommand() is_git_file = bool(subprocess.check_output( [git_command, '--git-dir', git_dir, 'ls-files', source], cwd=CHROMIUM_SRC_DIR)) if not is_git_file: # File is not in working tree. If no OWNERS file was found, we cannot # tell who it belongs to. return None # `git log --follow` and `--reverse` don't work together and using just # `--follow` is too slow. Make a best estimate with an assumption that the # original author has authored the copyright block, which (generally) does # not change even with file rename/move. Look at the last line of the # block, as a copyright block update sweep in late 2022 made one person # responsible for changing the first line of every copyright block in the # repo, and it would be best to avoid assigning ownership of every fuzz # issue predating that year to that one person. blame_output = subprocess.check_output( [git_command, '--git-dir', git_dir, 'blame', '--porcelain', '-L3,3', source], cwd=CHROMIUM_SRC_DIR) return GetAuthorFromGitBlame(blame_output) return None def FindGroupsAndDepsInDeps(deps_list, build_dir): """Return list of groups, as well as their deps, from a list of deps.""" groups = [] deps_for_groups = {} for deps in deps_list: output = subprocess.check_output( [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps]).decode( 'utf8') needle = 'Type: ' for line in output.splitlines(): if needle and not line.startswith(needle): continue if needle == 'Type: ': if line != 'Type: group': break groups.append(deps) assert deps not in deps_for_groups deps_for_groups[deps] = [] needle = 'Direct dependencies' elif needle == 'Direct dependencies': needle = '' else: assert needle == '' if needle == line: break deps_for_groups[deps].append(line.strip()) return groups, deps_for_groups def TraverseGroups(deps_list, build_dir): """Filter out groups from a deps list. Add groups' direct dependencies.""" full_deps_set = set(deps_list) deps_to_check = full_deps_set.copy() # Keep track of groups to break circular dependendies, if any. seen_groups = set() while deps_to_check: # Look for groups from the deps set. groups, deps_for_groups = FindGroupsAndDepsInDeps(deps_to_check, build_dir) groups = set(groups).difference(seen_groups) if not groups: break # Update sets. Filter out groups from the full deps set. full_deps_set.difference_update(groups) deps_to_check.clear() seen_groups.update(groups) # Get the direct dependencies, and filter out known groups there too. for group in groups: deps_to_check.update(deps_for_groups[group]) deps_to_check.difference_update(seen_groups) full_deps_set.update(deps_to_check) return list(full_deps_set) def GetSourcesFromDeps(deps_list, build_dir): """Return list of sources from parsing deps.""" if not deps_list: return None full_deps_list = TraverseGroups(deps_list, build_dir) all_sources = [] for deps in full_deps_list: output = subprocess.check_output( [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps, 'sources']) for source in bytes(output).decode('utf8').splitlines(): if source.startswith('//'): source = source[2:] all_sources.append(source) return all_sources def GNPath(): if sys.platform.startswith('linux'): subdir, exe = 'linux64', 'gn' elif sys.platform == 'darwin': subdir, exe = 'mac', 'gn' else: subdir, exe = 'win', 'gn.exe' return os.path.join(CHROMIUM_SRC_DIR, 'buildtools', subdir, exe) def SubStringExistsIn(substring_list, string): """Return true if one of the substring in the list is found in |string|.""" return any(substring in string for substring in substring_list) def main(): parser = argparse.ArgumentParser(description='Generate fuzzer owners file.') parser.add_argument('--owners', required=True) parser.add_argument('--build-dir') parser.add_argument('--deps', nargs='+') parser.add_argument('--sources', nargs='+') args = parser.parse_args() # Generate owners file. with open(args.owners, 'w') as owners_file: # If we found an owner, then write it to file. # Otherwise, leave empty file to keep ninja happy. owners = GetOwnersForFuzzer(args.sources) if owners: owners_file.write(owners) return # Could not determine owners from |args.sources|. # So, try parsing sources from |args.deps|. deps_sources = GetSourcesFromDeps(args.deps, args.build_dir) owners = GetOwnersForFuzzer(deps_sources) if owners: owners_file.write(owners) if __name__ == '__main__': main()