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