• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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