• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Makes sure files have the right permissions.
7
8Some developers have broken SCM configurations that flip the svn:executable
9permission on for no good reason. Unix developers who run ls --color will then
10see .cc files in green and get confused.
11
12- For file extensions that must be executable, add it to EXECUTABLE_EXTENSIONS.
13- For file extensions that must not be executable, add it to
14  NOT_EXECUTABLE_EXTENSIONS.
15- To ignore all the files inside a directory, add it to IGNORED_PATHS.
16- For file base name with ambiguous state and that should not be checked for
17  shebang, add it to IGNORED_FILENAMES.
18
19Any file not matching the above will be opened and looked if it has a shebang
20or an ELF header. If this does not match the executable bit on the file, the
21file will be flagged.
22
23Note that all directory separators must be slashes (Unix-style) and not
24backslashes. All directories should be relative to the source root and all
25file paths should be only lowercase.
26"""
27
28import json
29import logging
30import optparse
31import os
32import stat
33import string
34import subprocess
35import sys
36
37#### USER EDITABLE SECTION STARTS HERE ####
38
39# Files with these extensions must have executable bit set.
40#
41# Case-sensitive.
42EXECUTABLE_EXTENSIONS = (
43  'bat',
44  'dll',
45  'dylib',
46  'exe',
47)
48
49# These files must have executable bit set.
50#
51# Case-insensitive, lower-case only.
52EXECUTABLE_PATHS = (
53  'chrome/test/data/app_shim/app_shim_32_bit.app/contents/'
54      'macos/app_mode_loader',
55  'chrome/test/data/extensions/uitest/plugins/plugin.plugin/contents/'
56      'macos/testnetscapeplugin',
57  'chrome/test/data/extensions/uitest/plugins_private/plugin.plugin/contents/'
58      'macos/testnetscapeplugin',
59)
60
61# These files must not have the executable bit set. This is mainly a performance
62# optimization as these files are not checked for shebang. The list was
63# partially generated from:
64# git ls-files | grep "\\." | sed 's/.*\.//' | sort | uniq -c | sort -b -g
65#
66# Case-sensitive.
67NON_EXECUTABLE_EXTENSIONS = (
68  '1',
69  '3ds',
70  'S',
71  'am',
72  'applescript',
73  'asm',
74  'c',
75  'cc',
76  'cfg',
77  'chromium',
78  'cpp',
79  'crx',
80  'cs',
81  'css',
82  'cur',
83  'def',
84  'der',
85  'expected',
86  'gif',
87  'grd',
88  'gyp',
89  'gypi',
90  'h',
91  'hh',
92  'htm',
93  'html',
94  'hyph',
95  'ico',
96  'idl',
97  'java',
98  'jpg',
99  'js',
100  'json',
101  'm',
102  'm4',
103  'mm',
104  'mms',
105  'mock-http-headers',
106  'nexe',
107  'nmf',
108  'onc',
109  'pat',
110  'patch',
111  'pdf',
112  'pem',
113  'plist',
114  'png',
115  'proto',
116  'rc',
117  'rfx',
118  'rgs',
119  'rules',
120  'spec',
121  'sql',
122  'srpc',
123  'svg',
124  'tcl',
125  'test',
126  'tga',
127  'txt',
128  'vcproj',
129  'vsprops',
130  'webm',
131  'word',
132  'xib',
133  'xml',
134  'xtb',
135  'zip',
136)
137
138# These files must not have executable bit set.
139#
140# Case-insensitive, lower-case only.
141NON_EXECUTABLE_PATHS = (
142  'build/android/tests/symbolize/liba.so',
143  'build/android/tests/symbolize/libb.so',
144  'chrome/installer/mac/sign_app.sh.in',
145  'chrome/installer/mac/sign_versioned_dir.sh.in',
146  'chrome/test/data/components/ihfokbkgjpifnbbojhneepfflplebdkc/'
147      'ihfokbkgjpifnbbojhneepfflplebdkc_1/a_changing_binary_file',
148  'chrome/test/data/components/ihfokbkgjpifnbbojhneepfflplebdkc/'
149      'ihfokbkgjpifnbbojhneepfflplebdkc_2/a_changing_binary_file',
150  'chrome/test/data/extensions/uitest/plugins/plugin32.so',
151  'chrome/test/data/extensions/uitest/plugins/plugin64.so',
152  'chrome/test/data/extensions/uitest/plugins_private/plugin32.so',
153  'chrome/test/data/extensions/uitest/plugins_private/plugin64.so',
154  'courgette/testdata/elf-32-1',
155  'courgette/testdata/elf-32-2',
156  'courgette/testdata/elf-64',
157)
158
159# File names that are always whitelisted.  (These are mostly autoconf spew.)
160#
161# Case-sensitive.
162IGNORED_FILENAMES = (
163  'config.guess',
164  'config.sub',
165  'configure',
166  'depcomp',
167  'install-sh',
168  'missing',
169  'mkinstalldirs',
170  'naclsdk',
171  'scons',
172)
173
174# File paths starting with one of these will be ignored as well.
175# Please consider fixing your file permissions, rather than adding to this list.
176#
177# Case-insensitive, lower-case only.
178IGNORED_PATHS = (
179  'native_client_sdk/src/build_tools/sdk_tools/third_party/fancy_urllib/'
180      '__init__.py',
181  'out/',
182  # TODO(maruel): Fix these.
183  'third_party/android_testrunner/',
184  'third_party/bintrees/',
185  'third_party/closure_linter/',
186  'third_party/devscripts/licensecheck.pl.vanilla',
187  'third_party/hyphen/',
188  'third_party/jemalloc/',
189  'third_party/lcov-1.9/contrib/galaxy/conglomerate_functions.pl',
190  'third_party/lcov-1.9/contrib/galaxy/gen_makefile.sh',
191  'third_party/lcov/contrib/galaxy/conglomerate_functions.pl',
192  'third_party/lcov/contrib/galaxy/gen_makefile.sh',
193  'third_party/libevent/autogen.sh',
194  'third_party/libevent/test/test.sh',
195  'third_party/libxml/linux/xml2-config',
196  'third_party/libxml/src/ltmain.sh',
197  'third_party/mesa/',
198  'third_party/protobuf/',
199  'third_party/python_gflags/gflags.py',
200  'third_party/sqlite/',
201  'third_party/talloc/script/mksyms.sh',
202  'third_party/tcmalloc/',
203  'third_party/tlslite/setup.py',
204)
205
206#### USER EDITABLE SECTION ENDS HERE ####
207
208assert set(EXECUTABLE_EXTENSIONS) & set(NON_EXECUTABLE_EXTENSIONS) == set()
209assert set(EXECUTABLE_PATHS) & set(NON_EXECUTABLE_PATHS) == set()
210
211VALID_CHARS = set(string.ascii_lowercase + string.digits + '/-_.')
212for paths in (EXECUTABLE_PATHS, NON_EXECUTABLE_PATHS, IGNORED_PATHS):
213  assert all([set(path).issubset(VALID_CHARS) for path in paths])
214
215
216def capture(cmd, cwd):
217  """Returns the output of a command.
218
219  Ignores the error code or stderr.
220  """
221  logging.debug('%s; cwd=%s' % (' '.join(cmd), cwd))
222  env = os.environ.copy()
223  env['LANGUAGE'] = 'en_US.UTF-8'
224  p = subprocess.Popen(
225      cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env)
226  return p.communicate()[0]
227
228
229def get_svn_info(dir_path):
230  """Returns svn meta-data for a svn checkout."""
231  if not os.path.isdir(dir_path):
232    return {}
233  out = capture(['svn', 'info', '.', '--non-interactive'], dir_path)
234  return dict(l.split(': ', 1) for l in out.splitlines() if l)
235
236
237def get_svn_url(dir_path):
238  return get_svn_info(dir_path).get('URL')
239
240
241def get_svn_root(dir_path):
242  """Returns the svn checkout root or None."""
243  svn_url = get_svn_url(dir_path)
244  if not svn_url:
245    return None
246  logging.info('svn url: %s' % svn_url)
247  while True:
248    parent = os.path.dirname(dir_path)
249    if parent == dir_path:
250      return None
251    svn_url = svn_url.rsplit('/', 1)[0]
252    if svn_url != get_svn_url(parent):
253      return dir_path
254    dir_path = parent
255
256
257def get_git_root(dir_path):
258  """Returns the git checkout root or None."""
259  root = capture(['git', 'rev-parse', '--show-toplevel'], dir_path).strip()
260  if root:
261    return root
262
263
264def is_ignored(rel_path):
265  """Returns True if rel_path is in our whitelist of files to ignore."""
266  rel_path = rel_path.lower()
267  return (
268      os.path.basename(rel_path) in IGNORED_FILENAMES or
269      rel_path.lower().startswith(IGNORED_PATHS))
270
271
272def must_be_executable(rel_path):
273  """The file name represents a file type that must have the executable bit
274  set.
275  """
276  return (os.path.splitext(rel_path)[1][1:] in EXECUTABLE_EXTENSIONS or
277          rel_path.lower() in EXECUTABLE_PATHS)
278
279
280def must_not_be_executable(rel_path):
281  """The file name represents a file type that must not have the executable
282  bit set.
283  """
284  return (os.path.splitext(rel_path)[1][1:] in NON_EXECUTABLE_EXTENSIONS or
285          rel_path.lower() in NON_EXECUTABLE_PATHS)
286
287
288def has_executable_bit(full_path):
289  """Returns if any executable bit is set."""
290  permission = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
291  return bool(permission & os.stat(full_path).st_mode)
292
293
294def has_shebang_or_is_elf(full_path):
295  """Returns if the file starts with #!/ or is an ELF binary.
296
297  full_path is the absolute path to the file.
298  """
299  with open(full_path, 'rb') as f:
300    data = f.read(4)
301    return (data[:3] == '#!/', data == '\x7fELF')
302
303
304def check_file(root_path, rel_path):
305  """Checks the permissions of the file whose path is root_path + rel_path and
306  returns an error if it is inconsistent. Returns None on success.
307
308  It is assumed that the file is not ignored by is_ignored().
309
310  If the file name is matched with must_be_executable() or
311  must_not_be_executable(), only its executable bit is checked.
312  Otherwise, the first few bytes of the file are read to verify if it has a
313  shebang or ELF header and compares this with the executable bit on the file.
314  """
315  full_path = os.path.join(root_path, rel_path)
316  def result_dict(error):
317    return {
318      'error': error,
319      'full_path': full_path,
320      'rel_path': rel_path,
321    }
322  try:
323    bit = has_executable_bit(full_path)
324  except OSError:
325    # It's faster to catch exception than call os.path.islink(). Chromium
326    # tree happens to have invalid symlinks under
327    # third_party/openssl/openssl/test/.
328    return None
329
330  if must_be_executable(rel_path):
331    if not bit:
332      return result_dict('Must have executable bit set')
333    return
334  if must_not_be_executable(rel_path):
335    if bit:
336      return result_dict('Must not have executable bit set')
337    return
338
339  # For the others, it depends on the file header.
340  (shebang, elf) = has_shebang_or_is_elf(full_path)
341  if bit != (shebang or elf):
342    if bit:
343      return result_dict('Has executable bit but not shebang or ELF header')
344    if shebang:
345      return result_dict('Has shebang but not executable bit')
346    return result_dict('Has ELF header but not executable bit')
347
348
349def check_files(root, files):
350  gen = (check_file(root, f) for f in files if not is_ignored(f))
351  return filter(None, gen)
352
353
354class ApiBase(object):
355  def __init__(self, root_dir, bare_output):
356    self.root_dir = root_dir
357    self.bare_output = bare_output
358    self.count = 0
359    self.count_read_header = 0
360
361  def check_file(self, rel_path):
362    logging.debug('check_file(%s)' % rel_path)
363    self.count += 1
364
365    if (not must_be_executable(rel_path) and
366        not must_not_be_executable(rel_path)):
367      self.count_read_header += 1
368
369    return check_file(self.root_dir, rel_path)
370
371  def check_dir(self, rel_path):
372    return self.check(rel_path)
373
374  def check(self, start_dir):
375    """Check the files in start_dir, recursively check its subdirectories."""
376    errors = []
377    items = self.list_dir(start_dir)
378    logging.info('check(%s) -> %d' % (start_dir, len(items)))
379    for item in items:
380      full_path = os.path.join(self.root_dir, start_dir, item)
381      rel_path = full_path[len(self.root_dir) + 1:]
382      if is_ignored(rel_path):
383        continue
384      if os.path.isdir(full_path):
385        # Depth first.
386        errors.extend(self.check_dir(rel_path))
387      else:
388        error = self.check_file(rel_path)
389        if error:
390          errors.append(error)
391    return errors
392
393  def list_dir(self, start_dir):
394    """Lists all the files and directory inside start_dir."""
395    return sorted(
396      x for x in os.listdir(os.path.join(self.root_dir, start_dir))
397      if not x.startswith('.')
398    )
399
400
401class ApiSvnQuick(ApiBase):
402  """Returns all files in svn-versioned directories, independent of the fact if
403  they are versionned.
404
405  Uses svn info in each directory to determine which directories should be
406  crawled.
407  """
408  def __init__(self, *args):
409    super(ApiSvnQuick, self).__init__(*args)
410    self.url = get_svn_url(self.root_dir)
411
412  def check_dir(self, rel_path):
413    url = self.url + '/' + rel_path
414    if get_svn_url(os.path.join(self.root_dir, rel_path)) != url:
415      return []
416    return super(ApiSvnQuick, self).check_dir(rel_path)
417
418
419class ApiAllFilesAtOnceBase(ApiBase):
420  _files = None
421
422  def list_dir(self, start_dir):
423    """Lists all the files and directory inside start_dir."""
424    if self._files is None:
425      self._files = sorted(self._get_all_files())
426      if not self.bare_output:
427        print 'Found %s files' % len(self._files)
428    start_dir = start_dir[len(self.root_dir) + 1:]
429    return [
430      x[len(start_dir):] for x in self._files if x.startswith(start_dir)
431    ]
432
433  def _get_all_files(self):
434    """Lists all the files and directory inside self._root_dir."""
435    raise NotImplementedError()
436
437
438class ApiSvn(ApiAllFilesAtOnceBase):
439  """Returns all the subversion controlled files.
440
441  Warning: svn ls is abnormally slow.
442  """
443  def _get_all_files(self):
444    cmd = ['svn', 'ls', '--non-interactive', '--recursive']
445    return (
446        x for x in capture(cmd, self.root_dir).splitlines()
447        if not x.endswith(os.path.sep))
448
449
450class ApiGit(ApiAllFilesAtOnceBase):
451  def _get_all_files(self):
452    return capture(['git', 'ls-files'], cwd=self.root_dir).splitlines()
453
454
455def get_scm(dir_path, bare):
456  """Returns a properly configured ApiBase instance."""
457  cwd = os.getcwd()
458  root = get_svn_root(dir_path or cwd)
459  if root:
460    if not bare:
461      print('Found subversion checkout at %s' % root)
462    return ApiSvnQuick(dir_path or root, bare)
463  root = get_git_root(dir_path or cwd)
464  if root:
465    if not bare:
466      print('Found git repository at %s' % root)
467    return ApiGit(dir_path or root, bare)
468
469  # Returns a non-scm aware checker.
470  if not bare:
471    print('Failed to determine the SCM for %s' % dir_path)
472  return ApiBase(dir_path or cwd, bare)
473
474
475def main():
476  usage = """Usage: python %prog [--root <root>] [tocheck]
477  tocheck  Specifies the directory, relative to root, to check. This defaults
478           to "." so it checks everything.
479
480Examples:
481  python %prog
482  python %prog --root /path/to/source chrome"""
483
484  parser = optparse.OptionParser(usage=usage)
485  parser.add_option(
486      '--root',
487      help='Specifies the repository root. This defaults '
488           'to the checkout repository root')
489  parser.add_option(
490      '-v', '--verbose', action='count', default=0, help='Print debug logging')
491  parser.add_option(
492      '--bare',
493      action='store_true',
494      default=False,
495      help='Prints the bare filename triggering the checks')
496  parser.add_option(
497      '--file', action='append', dest='files',
498      help='Specifics a list of files to check the permissions of. Only these '
499      'files will be checked')
500  parser.add_option('--json', help='Path to JSON output file')
501  options, args = parser.parse_args()
502
503  levels = [logging.ERROR, logging.INFO, logging.DEBUG]
504  logging.basicConfig(level=levels[min(len(levels) - 1, options.verbose)])
505
506  if len(args) > 1:
507    parser.error('Too many arguments used')
508
509  if options.root:
510    options.root = os.path.abspath(options.root)
511
512  if options.files:
513    # --file implies --bare (for PRESUBMIT.py).
514    options.bare = True
515
516    errors = check_files(options.root, options.files)
517  else:
518    api = get_scm(options.root, options.bare)
519    start_dir = args[0] if args else api.root_dir
520    errors = api.check(start_dir)
521
522    if not options.bare:
523      print('Processed %s files, %d files where tested for shebang/ELF '
524            'header' % (api.count, api.count_read_header))
525
526  if options.json:
527    with open(options.json, 'w') as f:
528      json.dump(errors, f)
529
530  if errors:
531    if options.bare:
532      print '\n'.join(e['full_path'] for e in errors)
533    else:
534      print '\nFAILED\n'
535      print '\n'.join('%s: %s' % (e['full_path'], e['error']) for e in errors)
536    return 1
537  if not options.bare:
538    print '\nSUCCESS\n'
539  return 0
540
541
542if '__main__' == __name__:
543  sys.exit(main())
544