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