11
11
import re
12
12
import sys
13
13
from subprocess import check_output
14
- from typing import Optional , NoReturn
14
+ from typing import Dict , Optional , NoReturn
15
15
16
16
CMD_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" ]
19
18
CMD_SHEBANG_FILES = ["git" , "grep" , "--full-name" , "--line-number" , "-I" , "^#!" ]
20
19
20
+ ALL_SOURCE_FILENAMES_REGEXP = r"^.*\.(cpp|h|py|sh)$"
21
21
ALLOWED_FILENAME_REGEXP = "^[a-zA-Z0-9/_.@][a-zA-Z0-9/_.@-]*$"
22
22
ALLOWED_SOURCE_FILENAME_REGEXP = "^[a-z0-9_./-]+$"
23
23
ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP = (
24
24
"^src/(secp256k1/|minisketch/|univalue/|test/fuzz/FuzzedDataProvider.h)"
25
25
)
26
- ALLOWED_PERMISSION_NON_EXECUTABLES = 644
27
- ALLOWED_PERMISSION_EXECUTABLES = 755
26
+ ALLOWED_PERMISSION_NON_EXECUTABLES = 0o644
27
+ ALLOWED_PERMISSION_EXECUTABLES = 0o755
28
28
ALLOWED_EXECUTABLE_SHEBANG = {
29
29
"py" : [b"#!/usr/bin/env python3" ],
30
30
"sh" : [b"#!/usr/bin/env bash" , b"#!/bin/sh" ],
31
31
}
32
32
33
33
34
34
class 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
37
44
38
45
@property
39
46
def extension (self ) -> Optional [str ]:
@@ -61,20 +68,24 @@ def full_extension(self) -> Optional[str]:
61
68
except IndexError :
62
69
return None
63
70
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 :])
70
71
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
71
82
72
- def check_all_filenames () -> int :
83
+ def check_all_filenames (files ) -> int :
73
84
"""
74
85
Checks every file in the repository against an allowed regexp to make sure only lowercase or uppercase
75
86
alphanumerics (a-zA-Z0-9), underscores (_), hyphens (-), at (@) and dots (.) are used in repository filenames.
76
87
"""
77
- filenames = check_output ( CMD_ALL_FILES ). decode ( "utf8" ). rstrip ( " \0 " ). split ( " \0 " )
88
+ filenames = files . keys ( )
78
89
filename_regex = re .compile (ALLOWED_FILENAME_REGEXP )
79
90
failed_tests = 0
80
91
for filename in filenames :
@@ -86,14 +97,14 @@ def check_all_filenames() -> int:
86
97
return failed_tests
87
98
88
99
89
- def check_source_filenames () -> int :
100
+ def check_source_filenames (files ) -> int :
90
101
"""
91
102
Checks only source files (*.cpp, *.h, *.py, *.sh) against a stricter allowed regexp to make sure only lowercase
92
103
alphanumerics (a-z0-9), underscores (_), hyphens (-) and dots (.) are used in source code filenames.
93
104
94
105
Additionally there is an exception regexp for directories or files which are excepted from matching this regexp.
95
106
"""
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 )]
97
108
filename_regex = re .compile (ALLOWED_SOURCE_FILENAME_REGEXP )
98
109
filename_exception_regex = re .compile (ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP )
99
110
failed_tests = 0
@@ -106,24 +117,22 @@ def check_source_filenames() -> int:
106
117
return failed_tests
107
118
108
119
109
- def check_all_file_permissions () -> int :
120
+ def check_all_file_permissions (files ) -> int :
110
121
"""
111
122
Checks all files in the repository match an allowed executable or non-executable file permission octal.
112
123
113
124
Additionally checks that for executable files, the file contains a shebang line
114
125
"""
115
- filenames = check_output (CMD_ALL_FILES ).decode ("utf8" ).rstrip ("\0 " ).split ("\0 " )
116
126
failed_tests = 0
117
- for filename in filenames :
118
- file_meta = FileMeta (filename )
127
+ for filename , file_meta in files .items ():
119
128
if file_meta .permissions == ALLOWED_PERMISSION_EXECUTABLES :
120
129
with open (filename , "rb" ) as f :
121
130
shebang = f .readline ().rstrip (b"\n " )
122
131
123
132
# For any file with executable permissions the first line must contain a shebang
124
133
if not shebang .startswith (b"#!" ):
125
134
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."""
127
136
)
128
137
failed_tests += 1
129
138
@@ -146,14 +155,14 @@ def check_all_file_permissions() -> int:
146
155
continue
147
156
else :
148
157
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)."""
150
159
)
151
160
failed_tests += 1
152
161
153
162
return failed_tests
154
163
155
164
156
- def check_shebang_file_permissions () -> int :
165
+ def check_shebang_file_permissions (files_meta ) -> int :
157
166
"""
158
167
Checks every file that contains a shebang line to ensure it has an executable permission
159
168
"""
@@ -165,7 +174,7 @@ def check_shebang_file_permissions() -> int:
165
174
166
175
failed_tests = 0
167
176
for filename in filenames :
168
- file_meta = FileMeta ( filename )
177
+ file_meta = files_meta [ filename ]
169
178
if file_meta .permissions != ALLOWED_PERMISSION_EXECUTABLES :
170
179
# These file types are typically expected to be sourced and not executed directly
171
180
if file_meta .full_extension in ["bash" , "init" , "openrc" , "sh.in" ]:
@@ -179,7 +188,7 @@ def check_shebang_file_permissions() -> int:
179
188
continue
180
189
181
190
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)."""
183
192
)
184
193
failed_tests += 1
185
194
return failed_tests
@@ -188,11 +197,14 @@ def check_shebang_file_permissions() -> int:
188
197
def main () -> NoReturn :
189
198
root_dir = check_output (CMD_TOP_LEVEL ).decode ("utf8" ).strip ()
190
199
os .chdir (root_dir )
200
+
201
+ files = get_git_file_metadata ()
202
+
191
203
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 )
196
208
197
209
if failed_tests :
198
210
print (
0 commit comments