1#!/usr/bin/env python3 2# 3# Copyright 2018 The Chromium Authors 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6"""Generate owners (.owners file) by looking at commit author for 7libfuzzer test. 8 9Invoked by GN from fuzzer_test.gni. 10""" 11 12import argparse 13import os 14import re 15import subprocess 16import sys 17 18AUTHOR_REGEX = re.compile('author-mail <(.+)>') 19CHROMIUM_SRC_DIR = os.path.dirname( 20 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 21OWNERS_FILENAME = 'OWNERS' 22THIRD_PARTY = 'third_party' 23THIRD_PARTY_SEARCH_STRING = THIRD_PARTY + os.path.sep 24 25 26def GetAuthorFromGitBlame(blame_output): 27 """Return author from git blame output.""" 28 for line in blame_output.decode('utf-8').splitlines(): 29 m = AUTHOR_REGEX.match(line) 30 if m: 31 return m.group(1) 32 33 return None 34 35 36def GetGitCommand(): 37 """Returns a git command that does not need to be executed using shell=True. 38 On non-Windows platforms: 'git'. On Windows: 'git.bat'. 39 """ 40 return 'git.bat' if sys.platform == 'win32' else 'git' 41 42 43def GetOwnersIfThirdParty(source): 44 """Return owners using the closest OWNERS file if in third_party.""" 45 match_index = source.find(THIRD_PARTY_SEARCH_STRING) 46 if match_index == -1: 47 # Not in third_party, skip. 48 return None 49 50 path_prefix = source[:match_index + len(THIRD_PARTY_SEARCH_STRING)] 51 path_after_third_party = source[len(path_prefix):].split(os.path.sep) 52 53 # Test all the paths after third_party/<libname>, making sure that we don't 54 # test third_party/OWNERS itself, otherwise we'd default to CCing them for 55 # all fuzzer issues without OWNERS, which wouldn't be nice. 56 while path_after_third_party: 57 owners_file_path = path_prefix + \ 58 os.path.join(*(path_after_third_party + [OWNERS_FILENAME])) 59 60 if os.path.exists(owners_file_path): 61 return open(owners_file_path).read() 62 63 path_after_third_party.pop() 64 65 return None 66 67# pylint: disable=inconsistent-return-statements 68def GetOwnersForFuzzer(sources): 69 """Return owners given a list of sources as input.""" 70 if not sources: 71 return 72 73 for source in sources: 74 full_source_path = os.path.join(CHROMIUM_SRC_DIR, source) 75 if not os.path.exists(full_source_path): 76 continue 77 78 with open(full_source_path, 'r') as source_file_handle: 79 source_content = source_file_handle.read() 80 81 if SubStringExistsIn( 82 ['FuzzOneInput', 'LLVMFuzzerTestOneInput', 'PROTO_FUZZER'], 83 source_content): 84 # Found the fuzzer source (and not dependency of fuzzer). 85 86 git_dir = os.path.join(CHROMIUM_SRC_DIR, '.git') 87 git_command = GetGitCommand() 88 is_git_file = bool(subprocess.check_output( 89 [git_command, '--git-dir', git_dir, 'ls-files', source], 90 cwd=CHROMIUM_SRC_DIR)) 91 if not is_git_file: 92 # File is not in working tree. Return owners for third_party. 93 return GetOwnersIfThirdParty(full_source_path) 94 95 # `git log --follow` and `--reverse` don't work together and using just 96 # `--follow` is too slow. Make a best estimate with an assumption that the 97 # original author has authored the copyright block, which (generally) does 98 # not change even with file rename/move. Look at the last line of the 99 # block, as a copyright block update sweep in late 2022 made one person 100 # responsible for changing the first line of every copyright block in the 101 # repo, and it would be best to avoid assigning ownership of every fuzz 102 # issue predating that year to that one person. 103 blame_output = subprocess.check_output( 104 [git_command, '--git-dir', git_dir, 'blame', '--porcelain', '-L3,3', 105 source], cwd=CHROMIUM_SRC_DIR) 106 return GetAuthorFromGitBlame(blame_output) 107 108 return None 109# pylint: enable=inconsistent-return-statements 110 111def FindGroupsAndDepsInDeps(deps_list, build_dir): 112 """Return list of groups, as well as their deps, from a list of deps.""" 113 groups = [] 114 deps_for_groups = {} 115 for deps in deps_list: 116 output = subprocess.check_output( 117 [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps]).decode( 118 'utf8') 119 needle = 'Type: ' 120 for line in output.splitlines(): 121 if needle and not line.startswith(needle): 122 continue 123 if needle == 'Type: ': 124 if line != 'Type: group': 125 break 126 groups.append(deps) 127 assert deps not in deps_for_groups 128 deps_for_groups[deps] = [] 129 needle = 'Direct dependencies' 130 elif needle == 'Direct dependencies': 131 needle = '' 132 else: 133 assert needle == '' 134 if needle == line: 135 break 136 deps_for_groups[deps].append(line.strip()) 137 138 return groups, deps_for_groups 139 140 141def TraverseGroups(deps_list, build_dir): 142 """Filter out groups from a deps list. Add groups' direct dependencies.""" 143 full_deps_set = set(deps_list) 144 deps_to_check = full_deps_set.copy() 145 146 # Keep track of groups to break circular dependendies, if any. 147 seen_groups = set() 148 149 while deps_to_check: 150 # Look for groups from the deps set. 151 groups, deps_for_groups = FindGroupsAndDepsInDeps(deps_to_check, build_dir) 152 groups = set(groups).difference(seen_groups) 153 if not groups: 154 break 155 156 # Update sets. Filter out groups from the full deps set. 157 full_deps_set.difference_update(groups) 158 deps_to_check.clear() 159 seen_groups.update(groups) 160 161 # Get the direct dependencies, and filter out known groups there too. 162 for group in groups: 163 deps_to_check.update(deps_for_groups[group]) 164 deps_to_check.difference_update(seen_groups) 165 full_deps_set.update(deps_to_check) 166 return list(full_deps_set) 167 168 169def GetSourcesFromDeps(deps_list, build_dir): 170 """Return list of sources from parsing deps.""" 171 if not deps_list: 172 return None 173 174 full_deps_list = TraverseGroups(deps_list, build_dir) 175 all_sources = [] 176 for deps in full_deps_list: 177 output = subprocess.check_output( 178 [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps, 'sources']) 179 for source in bytes(output).decode('utf8').splitlines(): 180 if source.startswith('//'): 181 source = source[2:] 182 all_sources.append(source) 183 184 return all_sources 185 186 187def GNPath(): 188 if sys.platform.startswith('linux'): 189 subdir, exe = 'linux64', 'gn' 190 elif sys.platform == 'darwin': 191 subdir, exe = 'mac', 'gn' 192 else: 193 subdir, exe = 'win', 'gn.exe' 194 195 return os.path.join(CHROMIUM_SRC_DIR, 'buildtools', subdir, exe) 196 197 198def SubStringExistsIn(substring_list, string): 199 """Return true if one of the substring in the list is found in |string|.""" 200 return any(substring in string for substring in substring_list) 201 202 203def main(): 204 parser = argparse.ArgumentParser(description='Generate fuzzer owners file.') 205 parser.add_argument('--owners', required=True) 206 parser.add_argument('--build-dir') 207 parser.add_argument('--deps', nargs='+') 208 parser.add_argument('--sources', nargs='+') 209 args = parser.parse_args() 210 211 # Generate owners file. 212 with open(args.owners, 'w') as owners_file: 213 # If we found an owner, then write it to file. 214 # Otherwise, leave empty file to keep ninja happy. 215 owners = GetOwnersForFuzzer(args.sources) 216 if owners: 217 owners_file.write(owners) 218 return 219 220 # Could not determine owners from |args.sources|. 221 # So, try parsing sources from |args.deps|. 222 deps_sources = GetSourcesFromDeps(args.deps, args.build_dir) 223 owners = GetOwnersForFuzzer(deps_sources) 224 if owners: 225 owners_file.write(owners) 226 227 228if __name__ == '__main__': 229 main() 230