1111import re
1212import sys
1313from subprocess import check_output
14- from typing import Optional , NoReturn
14+ from typing import Dict , Optional , NoReturn
1515
1616CMD_TOP_LEVEL = ["git" , "rev-parse" , "--show-toplevel" ]
17- CMD_ALL_FILES = ["git" , "ls-files" , "-z" , "--full-name" ]
18- CMD_SOURCE_FILES = ["git" , "ls-files" , "-z" , "--full-name" , "--" , "*.[cC][pP][pP]" , "*.[hH]" , "*.[pP][yY]" , "*.[sS][hH]" ]
17+ CMD_ALL_FILES = ["git" , "ls-files" , "-z" , "--full-name" , "--stage" ]
1918CMD_SHEBANG_FILES = ["git" , "grep" , "--full-name" , "--line-number" , "-I" , "^#!" ]
2019
20+ ALL_SOURCE_FILENAMES_REGEXP = r"^.*\.(cpp|h|py|sh)$"
2121ALLOWED_FILENAME_REGEXP = "^[a-zA-Z0-9/_.@][a-zA-Z0-9/_.@-]*$"
2222ALLOWED_SOURCE_FILENAME_REGEXP = "^[a-z0-9_./-]+$"
2323ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP = (
2424 "^src/(secp256k1/|minisketch/|univalue/|test/fuzz/FuzzedDataProvider.h)"
2525)
26- ALLOWED_PERMISSION_NON_EXECUTABLES = 644
27- ALLOWED_PERMISSION_EXECUTABLES = 755
26+ ALLOWED_PERMISSION_NON_EXECUTABLES = 0o644
27+ ALLOWED_PERMISSION_EXECUTABLES = 0o755
2828ALLOWED_EXECUTABLE_SHEBANG = {
2929 "py" : [b"#!/usr/bin/env python3" ],
3030 "sh" : [b"#!/usr/bin/env bash" , b"#!/bin/sh" ],
3131}
3232
3333
3434class FileMeta (object ):
35- def __init__ (self , file_path : str ):
36- self .file_path = file_path
35+ def __init__ (self , file_spec : str ):
36+ '''Parse a `git ls files --stage` output line.'''
37+ # 100755 5a150d5f8031fcd75e80a4dd9843afa33655f579 0 ci/test/00_setup_env.sh
38+ meta , self .file_path = file_spec .split ('\t ' , 2 )
39+ meta = meta .split ()
40+ # The octal file permission of the file. Internally, git only
41+ # keeps an 'executable' bit, so this will always be 0o644 or 0o755.
42+ self .permissions = int (meta [0 ], 8 ) & 0o7777
43+ # We don't currently care about the other fields
3744
3845 @property
3946 def extension (self ) -> Optional [str ]:
@@ -61,20 +68,24 @@ def full_extension(self) -> Optional[str]:
6168 except IndexError :
6269 return None
6370
64- @property
65- def permissions (self ) -> int :
66- """
67- Returns the octal file permission of the file
68- """
69- return int (oct (os .stat (self .file_path ).st_mode )[- 3 :])
7071
72+ def get_git_file_metadata () -> Dict [str , FileMeta ]:
73+ '''
74+ Return a dictionary mapping the name of all files in the repository to git tree metadata.
75+ '''
76+ files_raw = check_output (CMD_ALL_FILES ).decode ("utf8" ).rstrip ("\0 " ).split ("\0 " )
77+ files = {}
78+ for file_spec in files_raw :
79+ meta = FileMeta (file_spec )
80+ files [meta .file_path ] = meta
81+ return files
7182
72- def check_all_filenames () -> int :
83+ def check_all_filenames (files ) -> int :
7384 """
7485 Checks every file in the repository against an allowed regexp to make sure only lowercase or uppercase
7586 alphanumerics (a-zA-Z0-9), underscores (_), hyphens (-), at (@) and dots (.) are used in repository filenames.
7687 """
77- filenames = check_output ( CMD_ALL_FILES ). decode ( "utf8" ). rstrip ( " \0 " ). split ( " \0 " )
88+ filenames = files . keys ( )
7889 filename_regex = re .compile (ALLOWED_FILENAME_REGEXP )
7990 failed_tests = 0
8091 for filename in filenames :
@@ -86,14 +97,14 @@ def check_all_filenames() -> int:
8697 return failed_tests
8798
8899
89- def check_source_filenames () -> int :
100+ def check_source_filenames (files ) -> int :
90101 """
91102 Checks only source files (*.cpp, *.h, *.py, *.sh) against a stricter allowed regexp to make sure only lowercase
92103 alphanumerics (a-z0-9), underscores (_), hyphens (-) and dots (.) are used in source code filenames.
93104
94105 Additionally there is an exception regexp for directories or files which are excepted from matching this regexp.
95106 """
96- filenames = check_output ( CMD_SOURCE_FILES ). decode ( "utf8" ). rstrip ( " \0 " ). split ( " \0 " )
107+ filenames = [ filename for filename in files . keys () if re . match ( ALL_SOURCE_FILENAMES_REGEXP , filename , re . IGNORECASE )]
97108 filename_regex = re .compile (ALLOWED_SOURCE_FILENAME_REGEXP )
98109 filename_exception_regex = re .compile (ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP )
99110 failed_tests = 0
@@ -106,24 +117,22 @@ def check_source_filenames() -> int:
106117 return failed_tests
107118
108119
109- def check_all_file_permissions () -> int :
120+ def check_all_file_permissions (files ) -> int :
110121 """
111122 Checks all files in the repository match an allowed executable or non-executable file permission octal.
112123
113124 Additionally checks that for executable files, the file contains a shebang line
114125 """
115- filenames = check_output (CMD_ALL_FILES ).decode ("utf8" ).rstrip ("\0 " ).split ("\0 " )
116126 failed_tests = 0
117- for filename in filenames :
118- file_meta = FileMeta (filename )
127+ for filename , file_meta in files .items ():
119128 if file_meta .permissions == ALLOWED_PERMISSION_EXECUTABLES :
120129 with open (filename , "rb" ) as f :
121130 shebang = f .readline ().rstrip (b"\n " )
122131
123132 # For any file with executable permissions the first line must contain a shebang
124133 if not shebang .startswith (b"#!" ):
125134 print (
126- f"""File "{ filename } " has permission { ALLOWED_PERMISSION_EXECUTABLES } (executable) and is thus expected to contain a shebang '#!'. Add shebang or do "chmod { ALLOWED_PERMISSION_NON_EXECUTABLES } { filename } " to make it non-executable."""
135+ f"""File "{ filename } " has permission { ALLOWED_PERMISSION_EXECUTABLES :03o } (executable) and is thus expected to contain a shebang '#!'. Add shebang or do "chmod { ALLOWED_PERMISSION_NON_EXECUTABLES :03o } { filename } " to make it non-executable."""
127136 )
128137 failed_tests += 1
129138
@@ -146,14 +155,14 @@ def check_all_file_permissions() -> int:
146155 continue
147156 else :
148157 print (
149- f"""File "{ filename } " has unexpected permission { file_meta .permissions } . Do "chmod { ALLOWED_PERMISSION_NON_EXECUTABLES } { filename } " (if non-executable) or "chmod { ALLOWED_PERMISSION_EXECUTABLES } { filename } " (if executable)."""
158+ f"""File "{ filename } " has unexpected permission { file_meta .permissions :03o } . Do "chmod { ALLOWED_PERMISSION_NON_EXECUTABLES :03o } { filename } " (if non-executable) or "chmod { ALLOWED_PERMISSION_EXECUTABLES :03o } { filename } " (if executable)."""
150159 )
151160 failed_tests += 1
152161
153162 return failed_tests
154163
155164
156- def check_shebang_file_permissions () -> int :
165+ def check_shebang_file_permissions (files_meta ) -> int :
157166 """
158167 Checks every file that contains a shebang line to ensure it has an executable permission
159168 """
@@ -165,7 +174,7 @@ def check_shebang_file_permissions() -> int:
165174
166175 failed_tests = 0
167176 for filename in filenames :
168- file_meta = FileMeta ( filename )
177+ file_meta = files_meta [ filename ]
169178 if file_meta .permissions != ALLOWED_PERMISSION_EXECUTABLES :
170179 # These file types are typically expected to be sourced and not executed directly
171180 if file_meta .full_extension in ["bash" , "init" , "openrc" , "sh.in" ]:
@@ -179,7 +188,7 @@ def check_shebang_file_permissions() -> int:
179188 continue
180189
181190 print (
182- f"""File "{ filename } " contains a shebang line, but has the file permission { file_meta .permissions } instead of the expected executable permission { ALLOWED_PERMISSION_EXECUTABLES } . Do "chmod { ALLOWED_PERMISSION_EXECUTABLES } { filename } " (or remove the shebang line)."""
191+ f"""File "{ filename } " contains a shebang line, but has the file permission { file_meta .permissions :03o } instead of the expected executable permission { ALLOWED_PERMISSION_EXECUTABLES :03o } . Do "chmod { ALLOWED_PERMISSION_EXECUTABLES :03o } { filename } " (or remove the shebang line)."""
183192 )
184193 failed_tests += 1
185194 return failed_tests
@@ -188,11 +197,14 @@ def check_shebang_file_permissions() -> int:
188197def main () -> NoReturn :
189198 root_dir = check_output (CMD_TOP_LEVEL ).decode ("utf8" ).strip ()
190199 os .chdir (root_dir )
200+
201+ files = get_git_file_metadata ()
202+
191203 failed_tests = 0
192- failed_tests += check_all_filenames ()
193- failed_tests += check_source_filenames ()
194- failed_tests += check_all_file_permissions ()
195- failed_tests += check_shebang_file_permissions ()
204+ failed_tests += check_all_filenames (files )
205+ failed_tests += check_source_filenames (files )
206+ failed_tests += check_all_file_permissions (files )
207+ failed_tests += check_shebang_file_permissions (files )
196208
197209 if failed_tests :
198210 print (
0 commit comments