Skip to content

Commit 391ae30

Browse files
scopasottile
authored andcommitted
Add check for executability of scripts with shebangs
Closes #543
1 parent 9e7cd9f commit 391ae30

File tree

5 files changed

+151
-0
lines changed

5 files changed

+151
-0
lines changed

.pre-commit-hooks.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@
5252
entry: check-json
5353
language: python
5454
types: [json]
55+
- id: check-shebang-scripts-are-executable
56+
name: Check that scripts with shebangs are executable
57+
description: Ensures that (non-binary) files with a shebang are executable.
58+
entry: check-shebang-scripts-are-executable
59+
language: python
60+
types: [text]
61+
stages: [commit, push, manual]
5562
- id: pretty-format-json
5663
name: Pretty format JSON
5764
description: This hook sets a standard for formatting JSON files.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ Attempts to load all json files to verify syntax.
5858
#### `check-merge-conflict`
5959
Check for files that contain merge conflict strings.
6060

61+
#### `check-shebang-scripts-are-executable`
62+
Checks that scripts with shebangs are executable.
63+
6164
#### `check-symlinks`
6265
Checks for symlinks which do not point to anything.
6366

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Check that text files with a shebang are executable."""
2+
import argparse
3+
import shlex
4+
import sys
5+
from typing import List
6+
from typing import Optional
7+
from typing import Sequence
8+
from typing import Set
9+
10+
from pre_commit_hooks.check_executables_have_shebangs import EXECUTABLE_VALUES
11+
from pre_commit_hooks.check_executables_have_shebangs import git_ls_files
12+
from pre_commit_hooks.check_executables_have_shebangs import has_shebang
13+
14+
15+
def check_shebangs(paths: List[str]) -> int:
16+
# Cannot optimize on non-executability here if we intend this check to
17+
# work on win32 -- and that's where problems caused by non-executability
18+
# (elsewhere) are most likely to arise from.
19+
return _check_git_filemode(paths)
20+
21+
22+
def _check_git_filemode(paths: Sequence[str]) -> int:
23+
seen: Set[str] = set()
24+
for ls_file in git_ls_files(paths):
25+
is_executable = any(b in EXECUTABLE_VALUES for b in ls_file.mode[-3:])
26+
if not is_executable and has_shebang(ls_file.filename):
27+
_message(ls_file.filename)
28+
seen.add(ls_file.filename)
29+
30+
return int(bool(seen))
31+
32+
33+
def _message(path: str) -> None:
34+
print(
35+
f'{path}: has a shebang but is not marked executable!\n'
36+
f' If it is supposed to be executable, try: '
37+
f'`chmod +x {shlex.quote(path)}`\n'
38+
f' If it not supposed to be executable, double-check its shebang '
39+
f'is wanted.\n',
40+
file=sys.stderr,
41+
)
42+
43+
44+
def main(argv: Optional[Sequence[str]] = None) -> int:
45+
parser = argparse.ArgumentParser(description=__doc__)
46+
parser.add_argument('filenames', nargs='*')
47+
args = parser.parse_args(argv)
48+
49+
return check_shebangs(args.filenames)
50+
51+
52+
if __name__ == '__main__':
53+
exit(main())

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ console_scripts =
4343
check-executables-have-shebangs = pre_commit_hooks.check_executables_have_shebangs:main
4444
check-json = pre_commit_hooks.check_json:main
4545
check-merge-conflict = pre_commit_hooks.check_merge_conflict:main
46+
check-shebang-scripts-are-executable = pre_commit_hooks.check_executables_have_shebangs:main_reverse
4647
check-symlinks = pre_commit_hooks.check_symlinks:main
4748
check-toml = pre_commit_hooks.check_toml:main
4849
check-vcs-permalinks = pre_commit_hooks.check_vcs_permalinks:main
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import os
2+
3+
import pytest
4+
5+
from pre_commit_hooks.check_shebang_scripts_are_executable import \
6+
_check_git_filemode
7+
from pre_commit_hooks.check_shebang_scripts_are_executable import main
8+
from pre_commit_hooks.util import cmd_output
9+
10+
11+
def test_check_git_filemode_passing(tmpdir):
12+
with tmpdir.as_cwd():
13+
cmd_output('git', 'init', '.')
14+
15+
f = tmpdir.join('f')
16+
f.write('#!/usr/bin/env bash')
17+
f_path = str(f)
18+
cmd_output('chmod', '+x', f_path)
19+
cmd_output('git', 'add', f_path)
20+
cmd_output('git', 'update-index', '--chmod=+x', f_path)
21+
22+
g = tmpdir.join('g').ensure()
23+
g_path = str(g)
24+
cmd_output('git', 'add', g_path)
25+
26+
files = [f_path, g_path]
27+
assert _check_git_filemode(files) == 0
28+
29+
# this is the one we should trigger on
30+
h = tmpdir.join('h')
31+
h.write('#!/usr/bin/env bash')
32+
h_path = str(h)
33+
cmd_output('git', 'add', h_path)
34+
35+
files = [h_path]
36+
assert _check_git_filemode(files) == 1
37+
38+
39+
def test_check_git_filemode_passing_unusual_characters(tmpdir):
40+
with tmpdir.as_cwd():
41+
cmd_output('git', 'init', '.')
42+
43+
f = tmpdir.join('mañana.txt')
44+
f.write('#!/usr/bin/env bash')
45+
f_path = str(f)
46+
cmd_output('chmod', '+x', f_path)
47+
cmd_output('git', 'add', f_path)
48+
cmd_output('git', 'update-index', '--chmod=+x', f_path)
49+
50+
files = (f_path,)
51+
assert _check_git_filemode(files) == 0
52+
53+
54+
def test_check_git_filemode_failing(tmpdir):
55+
with tmpdir.as_cwd():
56+
cmd_output('git', 'init', '.')
57+
58+
f = tmpdir.join('f').ensure()
59+
f.write('#!/usr/bin/env bash')
60+
f_path = str(f)
61+
cmd_output('git', 'add', f_path)
62+
63+
files = (f_path,)
64+
assert _check_git_filemode(files) == 1
65+
66+
67+
@pytest.mark.parametrize(
68+
('content', 'mode', 'expected'),
69+
(
70+
pytest.param('#!python', '+x', 0, id='shebang with executable'),
71+
pytest.param('#!python', '-x', 1, id='shebang without executable'),
72+
pytest.param('', '+x', 0, id='no shebang with executable'),
73+
pytest.param('', '-x', 0, id='no shebang without executable'),
74+
),
75+
)
76+
def test_git_executable_shebang(temp_git_dir, content, mode, expected):
77+
with temp_git_dir.as_cwd():
78+
path = temp_git_dir.join('path')
79+
path.write(content)
80+
cmd_output('git', 'add', str(path))
81+
cmd_output('chmod', mode, str(path))
82+
cmd_output('git', 'update-index', f'--chmod={mode}', str(path))
83+
84+
# simulate how identify chooses that something is executable
85+
filenames = [path for path in [str(path)] if os.access(path, os.X_OK)]
86+
87+
assert main(filenames) == expected

0 commit comments

Comments
 (0)