1#!/usr/bin/env python2.7
2#
3# Copyright 2018 The Chromium Authors. All rights reserved.
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_SEARCH_STRING = 'third_party' + os.sep
23
24
25def GetAuthorFromGitBlame(blame_output):
26  """Return author from git blame output."""
27  for line in blame_output.splitlines():
28    m = AUTHOR_REGEX.match(line)
29    if m:
30      return m.group(1)
31
32  return None
33
34
35def GetGitCommand():
36  """Returns a git command that does not need to be executed using shell=True.
37  On non-Windows platforms: 'git'. On Windows: 'git.bat'.
38  """
39  return 'git.bat' if sys.platform == 'win32' else 'git'
40
41
42def GetOwnersIfThirdParty(source):
43  """Return owners using OWNERS file if in third_party."""
44  match_index = source.find(THIRD_PARTY_SEARCH_STRING)
45  if match_index == -1:
46    # Not in third_party, skip.
47    return None
48
49  match_index_with_library = source.find(
50      os.sep, match_index + len(THIRD_PARTY_SEARCH_STRING))
51  if match_index_with_library == -1:
52    # Unable to determine library name, skip.
53    return None
54
55  owners_file_path = os.path.join(source[:match_index_with_library],
56                                  OWNERS_FILENAME)
57  if not os.path.exists(owners_file_path):
58    return None
59
60  return open(owners_file_path).read()
61
62
63def GetOwnersForFuzzer(sources):
64  """Return owners given a list of sources as input."""
65  if not sources:
66    return
67
68  for source in sources:
69    full_source_path = os.path.join(CHROMIUM_SRC_DIR, source)
70    if not os.path.exists(full_source_path):
71      continue
72
73    with open(full_source_path, 'r') as source_file_handle:
74      source_content = source_file_handle.read()
75
76    if SubStringExistsIn(
77        ['FuzzOneInput', 'LLVMFuzzerTestOneInput', 'PROTO_FUZZER'],
78        source_content):
79      # Found the fuzzer source (and not dependency of fuzzer).
80
81      git_dir = os.path.join(CHROMIUM_SRC_DIR, '.git')
82      git_command = GetGitCommand()
83      is_git_file = bool(subprocess.check_output(
84          [git_command, '--git-dir', git_dir, 'ls-files', source],
85          cwd=CHROMIUM_SRC_DIR))
86      if not is_git_file:
87        # File is not in working tree. Return owners for third_party.
88        return GetOwnersIfThirdParty(full_source_path)
89
90      # git log --follow and --reverse don't work together and using just
91      # --follow is too slow. Make a best estimate with an assumption that
92      # the original author has authored line 1 which is usually the
93      # copyright line and does not change even with file rename / move.
94      blame_output = subprocess.check_output(
95          [git_command, '--git-dir', git_dir, 'blame', '--porcelain', '-L1,1',
96           source], cwd=CHROMIUM_SRC_DIR)
97      return GetAuthorFromGitBlame(blame_output)
98
99  return None
100
101
102def FindGroupsAndDepsInDeps(deps_list, build_dir):
103  """Return list of groups, as well as their deps, from a list of deps."""
104  groups = []
105  deps_for_groups = {}
106  for deps in deps_list:
107    output = subprocess.check_output(
108        [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps])
109    needle = 'Type: '
110    for line in output.splitlines():
111      if needle and not line.startswith(needle):
112        continue
113      if needle == 'Type: ':
114        if line != 'Type: group':
115          break
116        groups.append(deps)
117        assert deps not in deps_for_groups
118        deps_for_groups[deps] = []
119        needle = 'Direct dependencies'
120      elif needle == 'Direct dependencies':
121        needle = ''
122      else:
123        assert needle == ''
124        if needle == line:
125          break
126        deps_for_groups[deps].append(line.strip())
127
128  return groups, deps_for_groups
129
130
131def TraverseGroups(deps_list, build_dir):
132  """Filter out groups from a deps list. Add groups' direct dependencies."""
133  full_deps_set = set(deps_list)
134  deps_to_check = full_deps_set.copy()
135
136  # Keep track of groups to break circular dependendies, if any.
137  seen_groups = set()
138
139  while deps_to_check:
140    # Look for groups from the deps set.
141    groups, deps_for_groups = FindGroupsAndDepsInDeps(deps_to_check, build_dir)
142    groups = set(groups).difference(seen_groups)
143    if not groups:
144      break
145
146    # Update sets. Filter out groups from the full deps set.
147    full_deps_set.difference_update(groups)
148    deps_to_check.clear()
149    seen_groups.update(groups)
150
151    # Get the direct dependencies, and filter out known groups there too.
152    for group in groups:
153      deps_to_check.update(deps_for_groups[group])
154    deps_to_check.difference_update(seen_groups)
155    full_deps_set.update(deps_to_check)
156  return list(full_deps_set)
157
158
159def GetSourcesFromDeps(deps_list, build_dir):
160  """Return list of sources from parsing deps."""
161  if not deps_list:
162    return None
163
164  full_deps_list = TraverseGroups(deps_list, build_dir)
165  all_sources = []
166  for deps in full_deps_list:
167    output = subprocess.check_output(
168        [GNPath(), 'desc', '--fail-on-unused-args', build_dir, deps, 'sources'])
169    for source in output.splitlines():
170      if source.startswith('//'):
171        source = source[2:]
172      all_sources.append(source)
173
174  return all_sources
175
176
177def GNPath():
178  if sys.platform.startswith('linux'):
179    subdir, exe = 'linux64', 'gn'
180  elif sys.platform == 'darwin':
181    subdir, exe = 'mac', 'gn'
182  else:
183    subdir, exe = 'win', 'gn.exe'
184
185  return os.path.join(CHROMIUM_SRC_DIR, 'buildtools', subdir, exe)
186
187
188def SubStringExistsIn(substring_list, string):
189  """Return true if one of the substring in the list is found in |string|."""
190  return any([substring in string for substring in substring_list])
191
192
193def main():
194  parser = argparse.ArgumentParser(description='Generate fuzzer owners file.')
195  parser.add_argument('--owners', required=True)
196  parser.add_argument('--build-dir')
197  parser.add_argument('--deps', nargs='+')
198  parser.add_argument('--sources', nargs='+')
199  args = parser.parse_args()
200
201  # Generate owners file.
202  with open(args.owners, 'w') as owners_file:
203    # If we found an owner, then write it to file.
204    # Otherwise, leave empty file to keep ninja happy.
205    owners = GetOwnersForFuzzer(args.sources)
206    if owners:
207      owners_file.write(owners)
208      return
209
210    # Could not determine owners from |args.sources|.
211    # So, try parsing sources from |args.deps|.
212    deps_sources = GetSourcesFromDeps(args.deps, args.build_dir)
213    owners = GetOwnersForFuzzer(deps_sources)
214    if owners:
215      owners_file.write(owners)
216
217
218if __name__ == '__main__':
219  main()
220