Skip to content

Commit 46b025e

Browse files
committed
test: add new python linter to check file names and permissions
Replaces the existing tests in the test/lint/lint-filenames.sh and test/lint/lint-shebang.sh linter tests, as well as adding some new and increased testing. Summary of tests: - Checks every file in the repository against an allowed regexp to make sure only lowercase or uppercase alphanumerics (a-zA-Z0-9), underscores (_), hyphens (-), at (@) and dots (.) are used in repository filenames. - Checks only source files (*.cpp, *.h, *.py, *.sh) against a stricter allowed regexp to make sure only lowercase alphanumerics (a-z0-9), underscores (_), hyphens (-) and dots (.) are used in source code filenames. Additionally there is an exception regexp for directories or files which are excepted from matching this regexp (This should replicate the existing test/lint/lint-filenames.sh test) - Checks all files in the repository match an allowed executable or non-executable file permission octal. Additionally checks that for executable files, the file contains a shebang line. - Checks that for executable .py and .sh files, the shebang line used matches an allowable list of shebangs (This should replicate the existing test/lint/lint-shebang.sh test) - Checks every file that contains a shebang line to ensure it has an executable permission Fixes #21729
1 parent 6f6bb3e commit 46b025e

File tree

5 files changed

+215
-49
lines changed

5 files changed

+215
-49
lines changed

test/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Please be aware that on Linux distributions all dependencies are usually availab
273273
Individual tests can be run by directly calling the test script, e.g.:
274274

275275
```
276-
test/lint/lint-filenames.sh
276+
test/lint/lint-files.sh
277277
```
278278

279279
You can run all the shell-based lint tests by running:

test/lint/lint-filenames.sh

Lines changed: 0 additions & 24 deletions
This file was deleted.

test/lint/lint-files.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2021 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
6+
"""
7+
This checks that all files in the repository have correct filenames and permissions
8+
"""
9+
10+
import os
11+
import re
12+
import sys
13+
from subprocess import check_output
14+
from typing import Optional, NoReturn
15+
16+
CMD_ALL_FILES = "git ls-files --full-name"
17+
CMD_SOURCE_FILES = 'git ls-files --full-name -- "*.[cC][pP][pP]" "*.[hH]" "*.[pP][yY]" "*.[sS][hH]"'
18+
CMD_SHEBANG_FILES = "git grep --full-name --line-number -I '^#!'"
19+
ALLOWED_FILENAME_REGEXP = "^[a-zA-Z0-9/_.@][a-zA-Z0-9/_.@-]*$"
20+
ALLOWED_SOURCE_FILENAME_REGEXP = "^[a-z0-9_./-]+$"
21+
ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP = (
22+
"^src/(secp256k1/|univalue/|test/fuzz/FuzzedDataProvider.h)"
23+
)
24+
ALLOWED_PERMISSION_NON_EXECUTABLES = 644
25+
ALLOWED_PERMISSION_EXECUTABLES = 755
26+
ALLOWED_EXECUTABLE_SHEBANG = {
27+
"py": [b"#!/usr/bin/env python3"],
28+
"sh": [b"#!/usr/bin/env bash", b"#!/bin/sh"],
29+
}
30+
31+
32+
class FileMeta(object):
33+
def __init__(self, file_path: str):
34+
self.file_path = file_path
35+
36+
@property
37+
def extension(self) -> Optional[str]:
38+
"""
39+
Returns the file extension for a given filename string.
40+
eg:
41+
'ci/lint_run_all.sh' -> 'sh'
42+
'ci/retry/retry' -> None
43+
'contrib/devtools/split-debug.sh.in' -> 'in'
44+
"""
45+
return str(os.path.splitext(self.file_path)[1].strip(".") or None)
46+
47+
@property
48+
def full_extension(self) -> Optional[str]:
49+
"""
50+
Returns the full file extension for a given filename string.
51+
eg:
52+
'ci/lint_run_all.sh' -> 'sh'
53+
'ci/retry/retry' -> None
54+
'contrib/devtools/split-debug.sh.in' -> 'sh.in'
55+
"""
56+
filename_parts = self.file_path.split(os.extsep, 1)
57+
try:
58+
return filename_parts[1]
59+
except IndexError:
60+
return None
61+
62+
@property
63+
def permissions(self) -> int:
64+
"""
65+
Returns the octal file permission of the file
66+
"""
67+
return int(oct(os.stat(self.file_path).st_mode)[-3:])
68+
69+
70+
def check_all_filenames() -> int:
71+
"""
72+
Checks every file in the repository against an allowed regexp to make sure only lowercase or uppercase
73+
alphanumerics (a-zA-Z0-9), underscores (_), hyphens (-), at (@) and dots (.) are used in repository filenames.
74+
"""
75+
# We avoid using rstrip() to ensure we catch filenames which accidentally include trailing whitespace
76+
filenames = check_output(CMD_ALL_FILES, shell=True).decode("utf8").split("\n")
77+
filenames = [filename for filename in filenames if filename != ""] # removes the trailing empty list element
78+
79+
filename_regex = re.compile(ALLOWED_FILENAME_REGEXP)
80+
failed_tests = 0
81+
for filename in filenames:
82+
if not filename_regex.match(filename):
83+
print(
84+
f"""File "{filename}" does not not match the allowed filename regexp ('{ALLOWED_FILENAME_REGEXP}')."""
85+
)
86+
failed_tests += 1
87+
return failed_tests
88+
89+
90+
def check_source_filenames() -> int:
91+
"""
92+
Checks only source files (*.cpp, *.h, *.py, *.sh) against a stricter allowed regexp to make sure only lowercase
93+
alphanumerics (a-z0-9), underscores (_), hyphens (-) and dots (.) are used in source code filenames.
94+
95+
Additionally there is an exception regexp for directories or files which are excepted from matching this regexp.
96+
"""
97+
# We avoid using rstrip() to ensure we catch filenames which accidentally include trailing whitespace
98+
filenames = check_output(CMD_SOURCE_FILES, shell=True).decode("utf8").split("\n")
99+
filenames = [filename for filename in filenames if filename != ""] # removes the trailing empty list element
100+
101+
filename_regex = re.compile(ALLOWED_SOURCE_FILENAME_REGEXP)
102+
filename_exception_regex = re.compile(ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP)
103+
failed_tests = 0
104+
for filename in filenames:
105+
if not filename_regex.match(filename) and not filename_exception_regex.match(filename):
106+
print(
107+
f"""File "{filename}" does not not match the allowed source filename regexp ('{ALLOWED_SOURCE_FILENAME_REGEXP}'), or the exception regexp ({ALLOWED_SOURCE_FILENAME_EXCEPTION_REGEXP})."""
108+
)
109+
failed_tests += 1
110+
return failed_tests
111+
112+
113+
def check_all_file_permissions() -> int:
114+
"""
115+
Checks all files in the repository match an allowed executable or non-executable file permission octal.
116+
117+
Additionally checks that for executable files, the file contains a shebang line
118+
"""
119+
filenames = check_output(CMD_ALL_FILES, shell=True).decode("utf8").strip().split("\n")
120+
failed_tests = 0
121+
for filename in filenames:
122+
file_meta = FileMeta(filename)
123+
if file_meta.permissions == ALLOWED_PERMISSION_EXECUTABLES:
124+
shebang = open(filename, "rb").readline().rstrip(b"\n")
125+
126+
# For any file with executable permissions the first line must contain a shebang
127+
if shebang[:2] != b"#!":
128+
print(
129+
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."""
130+
)
131+
failed_tests += 1
132+
133+
# For certain file extensions that have been defined, we also check that the shebang conforms to a specific
134+
# allowable set of shebangs
135+
if file_meta.extension in ALLOWED_EXECUTABLE_SHEBANG.keys():
136+
if shebang not in ALLOWED_EXECUTABLE_SHEBANG[file_meta.extension]:
137+
print(
138+
f"""File "{filename}" is missing expected shebang """
139+
+ " or ".join(
140+
[
141+
x.decode("utf-8")
142+
for x in ALLOWED_EXECUTABLE_SHEBANG[file_meta.extension]
143+
]
144+
)
145+
)
146+
failed_tests += 1
147+
148+
elif file_meta.permissions == ALLOWED_PERMISSION_NON_EXECUTABLES:
149+
continue
150+
else:
151+
print(
152+
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)."""
153+
)
154+
failed_tests += 1
155+
156+
return failed_tests
157+
158+
159+
def check_shebang_file_permissions() -> int:
160+
"""
161+
Checks every file that contains a shebang line to ensure it has an executable permission
162+
"""
163+
filenames = check_output(CMD_SHEBANG_FILES, shell=True).decode("utf8").strip().split("\n")
164+
165+
# The git grep command we use returns files which contain a shebang on any line within the file
166+
# so we need to filter the list to only files with the shebang on the first line
167+
filenames = [filename.split(":1:")[0] for filename in filenames if ":1:" in filename]
168+
169+
failed_tests = 0
170+
for filename in filenames:
171+
file_meta = FileMeta(filename)
172+
if file_meta.permissions != ALLOWED_PERMISSION_EXECUTABLES:
173+
# These file types are typically expected to be sourced and not executed directly
174+
if file_meta.full_extension in ["bash", "init", "openrc", "sh.in"]:
175+
continue
176+
177+
# *.py files which don't contain an `if __name__ == '__main__'` are not expected to be executed directly
178+
if file_meta.extension == "py":
179+
file_data = open(filename, "r", encoding="utf8").read()
180+
if not re.search("""if __name__ == ['"]__main__['"]:""", file_data):
181+
continue
182+
183+
print(
184+
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)."""
185+
)
186+
failed_tests += 1
187+
return failed_tests
188+
189+
190+
def main() -> NoReturn:
191+
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()
196+
197+
if failed_tests:
198+
print(
199+
f"ERROR: There were {failed_tests} failed tests in the lint-files.py lint test. Please resolve the above errors."
200+
)
201+
sys.exit(1)
202+
else:
203+
sys.exit(0)
204+
205+
206+
if __name__ == "__main__":
207+
main()

test/lint/lint-files.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
3+
export LC_ALL=C
4+
5+
set -e
6+
cd "$(dirname $0)/../.."
7+
test/lint/lint-files.py

test/lint/lint-shebang.sh

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)