pureshellcheck 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pureshellcheck/__init__.py +58 -0
- pureshellcheck/analyzer.py +403 -0
- pureshellcheck/astlib.py +302 -0
- pureshellcheck/checks/__init__.py +6 -0
- pureshellcheck/checks/commands.py +1003 -0
- pureshellcheck/checks/misc.py +233 -0
- pureshellcheck/checks/quoting.py +440 -0
- pureshellcheck/checks/variables.py +348 -0
- pureshellcheck/cli.py +162 -0
- pureshellcheck/parser.py +2050 -0
- pureshellcheck/shast.py +106 -0
- pureshellcheck/varflow.py +584 -0
- pureshellcheck/varscan.py +511 -0
- pureshellcheck-0.1.0.dist-info/METADATA +184 -0
- pureshellcheck-0.1.0.dist-info/RECORD +19 -0
- pureshellcheck-0.1.0.dist-info/WHEEL +5 -0
- pureshellcheck-0.1.0.dist-info/entry_points.txt +2 -0
- pureshellcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- pureshellcheck-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Assorted smaller checks."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..analyzer import node_check
|
|
6
|
+
from ..astlib import word_parts
|
|
7
|
+
from ..parser import literal_text, quoted_literal_text
|
|
8
|
+
from ..shast import ancestors, walk
|
|
9
|
+
from .commands import first_word_basename, is_condition, word_approx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# SC2007: deprecated $[..]
|
|
13
|
+
|
|
14
|
+
@node_check("T_DollarArithmetic")
|
|
15
|
+
def check_dollar_brackets(ctx, node):
|
|
16
|
+
if node.get("deprecated"):
|
|
17
|
+
ctx.style(node, 2007, "Use $((..)) instead of deprecated $[..].")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# SC2035: glob that may become an option
|
|
21
|
+
|
|
22
|
+
@node_check("T_SimpleCommand")
|
|
23
|
+
def check_globs_as_options(ctx, node):
|
|
24
|
+
if len(node.words) < 2:
|
|
25
|
+
return
|
|
26
|
+
name = first_word_basename(node)
|
|
27
|
+
if name in ("echo", "printf"):
|
|
28
|
+
return
|
|
29
|
+
for w in node.words[1:]:
|
|
30
|
+
approx = word_approx(w)
|
|
31
|
+
if approx in ("--", ":::", "::::"):
|
|
32
|
+
break
|
|
33
|
+
parts = word_parts(w)
|
|
34
|
+
if parts and parts[0].kind == "T_Glob" \
|
|
35
|
+
and parts[0].text in ("*", "?"):
|
|
36
|
+
ctx.info(parts[0], 2035, "Use ./*glob* or -- *glob* so names"
|
|
37
|
+
" with dashes won't become options.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# SC2103: cd there and back
|
|
41
|
+
|
|
42
|
+
def command_sequences(node):
|
|
43
|
+
"""Statement lists where consecutive-command checks apply."""
|
|
44
|
+
f = node.fields
|
|
45
|
+
if node.kind in ("T_Script", "T_BraceGroup", "T_Subshell"):
|
|
46
|
+
yield f.get("commands", [])
|
|
47
|
+
elif node.kind in ("T_WhileExpression", "T_UntilExpression",
|
|
48
|
+
"T_ForIn", "T_ForArithmetic", "T_SelectIn"):
|
|
49
|
+
yield f.get("body", [])
|
|
50
|
+
elif node.kind == "T_IfExpression":
|
|
51
|
+
for _cond, body in node.branches:
|
|
52
|
+
yield body
|
|
53
|
+
yield node.else_body
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SEQUENCE_KINDS = ("T_Script", "T_BraceGroup", "T_Subshell",
|
|
57
|
+
"T_WhileExpression", "T_UntilExpression", "T_ForIn",
|
|
58
|
+
"T_ForArithmetic", "T_SelectIn", "T_IfExpression")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@node_check(*SEQUENCE_KINDS)
|
|
62
|
+
def check_cd_and_back(ctx, node):
|
|
63
|
+
from .commands import has_set_e
|
|
64
|
+
if has_set_e(ctx):
|
|
65
|
+
return
|
|
66
|
+
for commands in command_sequences(node):
|
|
67
|
+
candidates = []
|
|
68
|
+
for cmd in commands:
|
|
69
|
+
if cmd.kind == "T_SimpleCommand" \
|
|
70
|
+
and first_word_basename(cmd) == "cd":
|
|
71
|
+
candidates.append(cmd)
|
|
72
|
+
for a, b in zip(candidates, candidates[1:]):
|
|
73
|
+
if _is_cd_revert(b) and not _is_cd_revert(a):
|
|
74
|
+
ctx.info(b, 2103, "Use a ( subshell ) to avoid having to"
|
|
75
|
+
" cd back.")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_cd_revert(cmd):
|
|
80
|
+
if len(cmd.words) != 2:
|
|
81
|
+
return False
|
|
82
|
+
return word_approx(cmd.words[1]) in ("..", "-")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# SC2093: exec that won't be the last command
|
|
86
|
+
|
|
87
|
+
CLEANUP_COMMANDS = frozenset({":", "echo", "exit", "printf", "return"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_execfail(ctx):
|
|
91
|
+
cached = ctx.cache.get("execfail")
|
|
92
|
+
if cached is None:
|
|
93
|
+
cached = False
|
|
94
|
+
if ctx.shell in ("sh", "dash", "ash"):
|
|
95
|
+
ctx.cache["execfail"] = False
|
|
96
|
+
return False
|
|
97
|
+
for n in walk(ctx.root):
|
|
98
|
+
if n.kind == "T_SimpleCommand" and \
|
|
99
|
+
first_word_basename(n) == "shopt":
|
|
100
|
+
if any(word_approx(w) == "execfail" for w in n.words[1:]):
|
|
101
|
+
cached = True
|
|
102
|
+
break
|
|
103
|
+
ctx.cache["execfail"] = cached
|
|
104
|
+
return cached
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@node_check(*SEQUENCE_KINDS)
|
|
108
|
+
def check_spurious_exec(ctx, node):
|
|
109
|
+
if _has_execfail(ctx):
|
|
110
|
+
return
|
|
111
|
+
in_loop = node.kind in ("T_WhileExpression", "T_UntilExpression",
|
|
112
|
+
"T_ForIn", "T_ForArithmetic", "T_SelectIn")
|
|
113
|
+
for commands in command_sequences(node):
|
|
114
|
+
cmds = list(commands)
|
|
115
|
+
while cmds and _is_cleanup(cmds[-1]):
|
|
116
|
+
cmds.pop()
|
|
117
|
+
check_until = len(cmds) if in_loop else len(cmds) - 1
|
|
118
|
+
for cmd in cmds[:max(check_until, 0)]:
|
|
119
|
+
if cmd.kind == "T_SimpleCommand" and len(cmd.words) >= 2 \
|
|
120
|
+
and literal_text(cmd.words[0]) == "exec":
|
|
121
|
+
ctx.warn(cmd, 2093, 'Remove "exec " if script should'
|
|
122
|
+
" continue after this command.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_cleanup(cmd):
|
|
126
|
+
if cmd.kind != "T_SimpleCommand":
|
|
127
|
+
return False
|
|
128
|
+
if not cmd.words:
|
|
129
|
+
return bool(cmd.assigns)
|
|
130
|
+
return first_word_basename(cmd) in CLEANUP_COMMANDS
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# SC2094: reading and writing the same file in a pipeline
|
|
134
|
+
|
|
135
|
+
@node_check("T_Pipeline", "T_SimpleCommand")
|
|
136
|
+
def check_redirect_to_same(ctx, node):
|
|
137
|
+
if node.kind == "T_SimpleCommand":
|
|
138
|
+
if node.parent is not None and node.parent.kind == "T_Pipeline":
|
|
139
|
+
return
|
|
140
|
+
commands = [node]
|
|
141
|
+
else:
|
|
142
|
+
commands = node.commands
|
|
143
|
+
reads = {}
|
|
144
|
+
writes = {}
|
|
145
|
+
for cmd in commands:
|
|
146
|
+
for r, target, is_write in _redirect_files(ctx, cmd):
|
|
147
|
+
d = writes if is_write else reads
|
|
148
|
+
d.setdefault(target, r)
|
|
149
|
+
if cmd.kind == "T_SimpleCommand" \
|
|
150
|
+
and first_word_basename(cmd) not in ("echo", "printf"):
|
|
151
|
+
for w in cmd.words[1:]:
|
|
152
|
+
text = _file_word_text(w)
|
|
153
|
+
if text:
|
|
154
|
+
reads.setdefault(text, w)
|
|
155
|
+
for target, r in writes.items():
|
|
156
|
+
if target in reads and target not in ("/dev/null", "/dev/stdin",
|
|
157
|
+
"/dev/stdout", "/dev/tty"):
|
|
158
|
+
ctx.info(r, 2094, "Make sure not to read and write the same"
|
|
159
|
+
" file in the same pipeline.")
|
|
160
|
+
other = reads[target]
|
|
161
|
+
ctx.info(other, 2094, "Make sure not to read and write the"
|
|
162
|
+
" same file in the same pipeline.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _redirect_files(ctx, cmd):
|
|
167
|
+
for r in cmd.get("redirects", ()) or ():
|
|
168
|
+
op = r.op
|
|
169
|
+
if isinstance(op, str) or op.kind != "T_IoFile":
|
|
170
|
+
continue
|
|
171
|
+
text = _file_word_text(op.file)
|
|
172
|
+
if not text:
|
|
173
|
+
continue
|
|
174
|
+
yield r, text, op.op in (">", ">>", "&>", "&>>", ">|")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _file_word_text(word):
|
|
178
|
+
text = word_approx(word)
|
|
179
|
+
if not text or text.startswith("-"):
|
|
180
|
+
return None
|
|
181
|
+
if "\0" in text:
|
|
182
|
+
return None
|
|
183
|
+
from ..astlib import has_expansions
|
|
184
|
+
if has_expansions(word):
|
|
185
|
+
# render expansions textually so $file == $file matches
|
|
186
|
+
out = []
|
|
187
|
+
for p in word_parts(word):
|
|
188
|
+
if p.kind == "T_DollarBraced":
|
|
189
|
+
out.append("${%s}" % p.content)
|
|
190
|
+
elif p.kind == "T_Literal":
|
|
191
|
+
out.append(p.text)
|
|
192
|
+
elif p.kind in ("T_SingleQuoted", "T_DollarSingleQuoted"):
|
|
193
|
+
out.append(p.text)
|
|
194
|
+
elif p.kind in ("T_DoubleQuoted",):
|
|
195
|
+
for q in p.parts:
|
|
196
|
+
if q.kind == "T_Literal":
|
|
197
|
+
out.append(q.text)
|
|
198
|
+
elif q.kind == "T_DollarBraced":
|
|
199
|
+
out.append("${%s}" % q.content)
|
|
200
|
+
else:
|
|
201
|
+
return None
|
|
202
|
+
else:
|
|
203
|
+
return None
|
|
204
|
+
return "".join(out)
|
|
205
|
+
return text
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# SC2028: echo with escape sequences
|
|
209
|
+
|
|
210
|
+
ECHO_ESCAPE_RE = re.compile(r"\\[ntrabceEfv]|\\x[0-9a-fA-F]|\\[0-7]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@node_check("T_SimpleCommand")
|
|
214
|
+
def check_unused_echo_escapes(ctx, node):
|
|
215
|
+
if first_word_basename(node) != "echo":
|
|
216
|
+
return
|
|
217
|
+
args = node.words[1:]
|
|
218
|
+
if args:
|
|
219
|
+
lit = literal_text(args[0])
|
|
220
|
+
if lit and lit.startswith("-") and "e" in lit:
|
|
221
|
+
return
|
|
222
|
+
for w in args:
|
|
223
|
+
for p in word_parts(w):
|
|
224
|
+
text = None
|
|
225
|
+
if p.kind == "T_SingleQuoted":
|
|
226
|
+
text = p.text
|
|
227
|
+
elif p.kind == "T_DoubleQuoted":
|
|
228
|
+
text = "".join(q.text for q in p.parts
|
|
229
|
+
if q.kind == "T_Literal")
|
|
230
|
+
if text and ECHO_ESCAPE_RE.search(text):
|
|
231
|
+
ctx.info(p, 2028, "echo may not expand escape sequences."
|
|
232
|
+
" Use printf.")
|
|
233
|
+
return
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Quoting and word-splitting checks (SC2086 family and friends)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..analyzer import node_check, tree_check
|
|
6
|
+
from ..astlib import (
|
|
7
|
+
CLEAN, SPECIAL_VARIABLES_WITHOUT_SPACES, UNBRACED_VARIABLES,
|
|
8
|
+
braced_modifier, braced_reference, closest_command, expanded_parts,
|
|
9
|
+
is_array_expansion, is_counting_reference, is_quote_free,
|
|
10
|
+
is_quoted_alternative_reference, word_parts,
|
|
11
|
+
)
|
|
12
|
+
from ..parser import literal_text, quoted_literal_text
|
|
13
|
+
from ..shast import ancestors
|
|
14
|
+
from ..varflow import VarFlow
|
|
15
|
+
|
|
16
|
+
WILL_SPLIT_KINDS = frozenset({
|
|
17
|
+
"T_DollarBraced", "T_DollarExpansion", "T_Backticked",
|
|
18
|
+
"T_BraceExpansion", "T_Glob", "T_Extglob",
|
|
19
|
+
"T_DollarBraceCommandExpansion",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def will_split(part):
|
|
24
|
+
if part.kind == "T_NormalWord":
|
|
25
|
+
return any(will_split(p) for p in part.parts)
|
|
26
|
+
return part.kind in WILL_SPLIT_KINDS
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def may_become_multiple_args(word):
|
|
30
|
+
for p in expanded_parts(word):
|
|
31
|
+
if is_array_expansion(p):
|
|
32
|
+
return True
|
|
33
|
+
if p.kind == "T_DollarBraced" and p.content.startswith("!"):
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def command_word_of(ctx, node):
|
|
39
|
+
"""True if node is (part of) the effective command name word."""
|
|
40
|
+
word = ctx.parent_word(node)
|
|
41
|
+
if word is None:
|
|
42
|
+
return False
|
|
43
|
+
cmd = closest_command(word)
|
|
44
|
+
if cmd is None or not cmd.words:
|
|
45
|
+
return False
|
|
46
|
+
if word is cmd.words[0]:
|
|
47
|
+
return True
|
|
48
|
+
return word is ctx.command_name_word(cmd)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ----------------------------------------------------------------------
|
|
52
|
+
# SC2086 / SC2223 / SC2248 spacefulness, and SC2089/SC2090 quotes-in-vars
|
|
53
|
+
|
|
54
|
+
QUOTE_CHARS_RE = re.compile(r'"|([/= ]|^)\'|\'( |$)|\\ ')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@tree_check
|
|
58
|
+
def check_spacefulness(ctx, root):
|
|
59
|
+
reported = set()
|
|
60
|
+
quote_holders = {} # var name -> assignment node that embedded quotes
|
|
61
|
+
|
|
62
|
+
def word_has_quotes(word):
|
|
63
|
+
"""Does this value contain shell quotes? Returns witness node."""
|
|
64
|
+
if word is None:
|
|
65
|
+
return None
|
|
66
|
+
for p in word_parts(word):
|
|
67
|
+
k = p.kind
|
|
68
|
+
if k in ("T_DoubleQuoted", "T_DollarDoubleQuoted"):
|
|
69
|
+
for q in p.parts:
|
|
70
|
+
w = word_has_quotes(q)
|
|
71
|
+
if w is not None:
|
|
72
|
+
return w
|
|
73
|
+
elif k == "T_DollarBraced":
|
|
74
|
+
if p.content in quote_holders:
|
|
75
|
+
return quote_holders[p.content]
|
|
76
|
+
elif k in ("T_Literal", "T_SingleQuoted",
|
|
77
|
+
"T_DollarSingleQuoted"):
|
|
78
|
+
text = p.text
|
|
79
|
+
if k == "T_Literal" and p.get("escaped"):
|
|
80
|
+
text = "\\" + text
|
|
81
|
+
if QUOTE_CHARS_RE.search(text):
|
|
82
|
+
return p
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def on_assign(name, value, node):
|
|
86
|
+
if value is not None and value.kind == "T_Array":
|
|
87
|
+
return
|
|
88
|
+
witness = word_has_quotes(value)
|
|
89
|
+
if witness is None:
|
|
90
|
+
quote_holders.pop(name, None)
|
|
91
|
+
else:
|
|
92
|
+
quote_holders[name] = witness if witness.kind == "T_Literal" \
|
|
93
|
+
or "Quoted" in witness.kind else witness
|
|
94
|
+
|
|
95
|
+
def on_reference(node, name, status, integer):
|
|
96
|
+
if node.pos in reported:
|
|
97
|
+
return
|
|
98
|
+
reported.add(node.pos)
|
|
99
|
+
if is_array_expansion(node):
|
|
100
|
+
return
|
|
101
|
+
if is_counting_reference(node):
|
|
102
|
+
return
|
|
103
|
+
if is_quoted_alternative_reference(node):
|
|
104
|
+
return
|
|
105
|
+
if is_quote_free(node, ctx.shell):
|
|
106
|
+
return
|
|
107
|
+
check_quotes_in_literals(node, name)
|
|
108
|
+
if command_word_of(ctx, node):
|
|
109
|
+
return
|
|
110
|
+
if name in SPECIAL_VARIABLES_WITHOUT_SPACES:
|
|
111
|
+
return
|
|
112
|
+
if status == CLEAN or integer:
|
|
113
|
+
ctx.style(node, 2248, "Prefer double quoting even when variables"
|
|
114
|
+
" don't contain special characters.")
|
|
115
|
+
return
|
|
116
|
+
if is_default_assignment(node):
|
|
117
|
+
ctx.info(node, 2223, "This default assignment may cause DoS due"
|
|
118
|
+
" to globbing. Quote it.")
|
|
119
|
+
else:
|
|
120
|
+
ctx.info(node, 2086, "Double quote to prevent globbing and word"
|
|
121
|
+
" splitting.")
|
|
122
|
+
|
|
123
|
+
def check_quotes_in_literals(node, name):
|
|
124
|
+
witness = quote_holders.get(node.content)
|
|
125
|
+
if witness is None:
|
|
126
|
+
return
|
|
127
|
+
cmd = closest_command(node)
|
|
128
|
+
if cmd is not None and cmd.words:
|
|
129
|
+
first = literal_text(cmd.words[0])
|
|
130
|
+
if first and first.rsplit("/", 1)[-1] == "eval":
|
|
131
|
+
return
|
|
132
|
+
ctx.warn(witness, 2089, "Quotes/backslashes will be treated "
|
|
133
|
+
"literally. Use an array.")
|
|
134
|
+
ctx.warn(node, 2090, "Quotes/backslashes in this variable will "
|
|
135
|
+
"not be respected.")
|
|
136
|
+
|
|
137
|
+
def is_default_assignment(node):
|
|
138
|
+
mod = braced_modifier(node.content)
|
|
139
|
+
if not (mod.startswith("=") or mod.startswith(":=")):
|
|
140
|
+
return False
|
|
141
|
+
cmd = closest_command(node)
|
|
142
|
+
return cmd is not None and bool(cmd.words) \
|
|
143
|
+
and literal_text(cmd.words[0]) == ":"
|
|
144
|
+
|
|
145
|
+
VarFlow(on_reference, ctx.shell, on_assign=on_assign).run(root)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ----------------------------------------------------------------------
|
|
149
|
+
# SC2046: unquoted command substitution
|
|
150
|
+
|
|
151
|
+
@node_check("T_DollarExpansion", "T_Backticked",
|
|
152
|
+
"T_DollarBraceCommandExpansion")
|
|
153
|
+
def check_unquoted_expansions(ctx, node):
|
|
154
|
+
if not node.commands:
|
|
155
|
+
return
|
|
156
|
+
if expansion_command_name(node) in ("seq", "pgrep"):
|
|
157
|
+
return
|
|
158
|
+
if is_quote_free(node, ctx.shell):
|
|
159
|
+
return
|
|
160
|
+
if command_word_of(ctx, node):
|
|
161
|
+
return
|
|
162
|
+
ctx.warn(node, 2046, "Quote this to prevent word splitting.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def expansion_command_name(node):
|
|
166
|
+
cmds = node.commands
|
|
167
|
+
if len(cmds) != 1 or cmds[0].kind != "T_SimpleCommand":
|
|
168
|
+
return None
|
|
169
|
+
if not cmds[0].words:
|
|
170
|
+
return None
|
|
171
|
+
name = literal_text(cmds[0].words[0])
|
|
172
|
+
return name.rsplit("/", 1)[-1] if name else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ----------------------------------------------------------------------
|
|
176
|
+
# SC2068: unquoted array expansions; SC2145: string/array concatenation
|
|
177
|
+
|
|
178
|
+
@node_check("T_NormalWord")
|
|
179
|
+
def check_unquoted_dollar_at(ctx, word):
|
|
180
|
+
if is_quote_free(word, ctx.shell, strict=True):
|
|
181
|
+
return
|
|
182
|
+
for p in word.parts:
|
|
183
|
+
if is_array_expansion(p):
|
|
184
|
+
if not is_quoted_alternative_reference(p):
|
|
185
|
+
ctx.err(p, 2068, "Double quote array expansions to avoid"
|
|
186
|
+
" re-splitting elements.")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@node_check("T_NormalWord")
|
|
191
|
+
def check_concatenated_dollar_at(ctx, word):
|
|
192
|
+
parts = expanded_parts(word)
|
|
193
|
+
if len(parts) <= 1:
|
|
194
|
+
return
|
|
195
|
+
if is_quote_free(word, ctx.shell):
|
|
196
|
+
return
|
|
197
|
+
for p in parts:
|
|
198
|
+
if is_array_expansion(p):
|
|
199
|
+
ctx.err(p, 2145, "Argument mixes string and array. Use * or"
|
|
200
|
+
" separate argument.")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ----------------------------------------------------------------------
|
|
205
|
+
# SC2048: $* and ${a[*]}
|
|
206
|
+
|
|
207
|
+
@node_check("T_DollarBraced")
|
|
208
|
+
def check_dollar_star(ctx, node):
|
|
209
|
+
content = node.content
|
|
210
|
+
if content.startswith("#"):
|
|
211
|
+
return
|
|
212
|
+
name = braced_reference(content)
|
|
213
|
+
is_star = name == "*"
|
|
214
|
+
if not is_star:
|
|
215
|
+
m = re.match(r"[A-Za-z_][A-Za-z0-9_]*\[\*\]", content)
|
|
216
|
+
is_star = bool(m)
|
|
217
|
+
if not is_star:
|
|
218
|
+
return
|
|
219
|
+
if is_quote_free(node, ctx.shell, strict=True):
|
|
220
|
+
return
|
|
221
|
+
ctx.warn(node, 2048, 'Use "$@" (with quotes) to prevent whitespace'
|
|
222
|
+
' problems.')
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ----------------------------------------------------------------------
|
|
226
|
+
# SC2006: legacy backticks
|
|
227
|
+
|
|
228
|
+
@node_check("T_Backticked")
|
|
229
|
+
def check_backticks(ctx, node):
|
|
230
|
+
if not node.commands:
|
|
231
|
+
return
|
|
232
|
+
ctx.style(node, 2006, "Use $(...) notation instead of legacy"
|
|
233
|
+
" backticks `...`.")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ----------------------------------------------------------------------
|
|
237
|
+
# SC2016: expressions in single quotes
|
|
238
|
+
|
|
239
|
+
SQ_DOLLAR_RE = re.compile(r"\$[{(0-9a-zA-Z_]|`[^`]+`")
|
|
240
|
+
SED_CONTRA_RE = re.compile(r"\$[{dpsaic]($|[^a-zA-Z])")
|
|
241
|
+
|
|
242
|
+
SQ_OK_COMMANDS = frozenset({
|
|
243
|
+
"trap", "sh", "bash", "ksh", "zsh", "ssh", "eval", "xprop", "alias",
|
|
244
|
+
"sudo", "doas", "run0", "docker", "podman", "oc", "dpkg-query", "jq",
|
|
245
|
+
"rename", "rg", "unset", "crontab", "watch", "git filter-branch",
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
PROMPT_VARS = frozenset({"PS1", "PS2", "PS3", "PS4", "PROMPT_COMMAND"})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@node_check("T_SingleQuoted")
|
|
252
|
+
def check_single_quoted_variables(ctx, node):
|
|
253
|
+
if not SQ_DOLLAR_RE.search(node.text):
|
|
254
|
+
return
|
|
255
|
+
names = _sq_command_chain(ctx, node)
|
|
256
|
+
if "sed" in names:
|
|
257
|
+
if not SED_CONTRA_RE.search(node.text):
|
|
258
|
+
ctx.info(node, 2016, "Expressions don't expand in single quotes,"
|
|
259
|
+
" use double quotes for that.")
|
|
260
|
+
return
|
|
261
|
+
for name in names:
|
|
262
|
+
if name in SQ_OK_COMMANDS or name.endswith("awk") \
|
|
263
|
+
or name.startswith("perl") or name.startswith("mumps"):
|
|
264
|
+
return
|
|
265
|
+
chain = [node] + list(ancestors(node))[:3]
|
|
266
|
+
for a in chain[1:4]:
|
|
267
|
+
if a.kind == "T_Assignment" and a.name in PROMPT_VARS:
|
|
268
|
+
return
|
|
269
|
+
if a.kind == "TC_Unary" and a.op == "-v":
|
|
270
|
+
return
|
|
271
|
+
ctx.info(node, 2016, "Expressions don't expand in single quotes, use"
|
|
272
|
+
" double quotes for that.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _sq_command_chain(ctx, node):
|
|
276
|
+
"""All command names involved: wrappers plus the effective command."""
|
|
277
|
+
cmd = closest_command(node)
|
|
278
|
+
if cmd is None:
|
|
279
|
+
return []
|
|
280
|
+
raw = literal_text(cmd.words[0]) if cmd.words else None
|
|
281
|
+
names = [raw.rsplit("/", 1)[-1]] if raw else []
|
|
282
|
+
word, idx, wrappers = ctx.command_resolution(cmd)
|
|
283
|
+
names.extend(wrappers)
|
|
284
|
+
name = literal_text(word) if word is not None else None
|
|
285
|
+
if name:
|
|
286
|
+
name = name.rsplit("/", 1)[-1]
|
|
287
|
+
names.append(name)
|
|
288
|
+
args = [literal_text(w) for w in cmd.words]
|
|
289
|
+
if name == "find":
|
|
290
|
+
for i, a in enumerate(args):
|
|
291
|
+
if a in ("-exec", "-execdir", "-ok", "-okdir") \
|
|
292
|
+
and i + 1 < len(args) and args[i + 1]:
|
|
293
|
+
names.append(args[i + 1].rsplit("/", 1)[-1])
|
|
294
|
+
elif name == "git" and len(args) > idx + 1 \
|
|
295
|
+
and args[idx + 1] == "filter-branch":
|
|
296
|
+
names.append("git filter-branch")
|
|
297
|
+
return names
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ----------------------------------------------------------------------
|
|
301
|
+
# SC2066 / SC2041 / SC2042 / SC2043 / SC2258: for-in words
|
|
302
|
+
|
|
303
|
+
@node_check("T_ForIn")
|
|
304
|
+
def check_for_in_quoted(ctx, node):
|
|
305
|
+
words = node.words
|
|
306
|
+
if not node.has_in:
|
|
307
|
+
return
|
|
308
|
+
if len(words) == 1:
|
|
309
|
+
word = words[0]
|
|
310
|
+
parts = word_parts(word)
|
|
311
|
+
if len(parts) == 1 and parts[0].kind == "T_DoubleQuoted":
|
|
312
|
+
dq = parts[0]
|
|
313
|
+
lit = quoted_literal_text(word)
|
|
314
|
+
if (any(will_split(p) for p in dq.parts)
|
|
315
|
+
and not may_become_multiple_args(word)) \
|
|
316
|
+
or (lit is not None
|
|
317
|
+
and any(c in lit for c in "*?[")):
|
|
318
|
+
ctx.err(dq, 2066, "Since you double quoted this, it will"
|
|
319
|
+
" not word split, and the loop will only run once.")
|
|
320
|
+
return
|
|
321
|
+
if len(parts) == 1 and parts[0].kind == "T_SingleQuoted":
|
|
322
|
+
ctx.warn(parts[0], 2041, "This is a literal string. To run as"
|
|
323
|
+
" a command, use $(..) instead of '..'.")
|
|
324
|
+
return
|
|
325
|
+
unquoted = _unquoted_literal(word)
|
|
326
|
+
if unquoted is not None and "," in unquoted:
|
|
327
|
+
ctx.warn(word, 2042, "Use spaces, not commas, to separate loop"
|
|
328
|
+
" elements.")
|
|
329
|
+
return
|
|
330
|
+
if not will_split(word) and not may_become_multiple_args(word):
|
|
331
|
+
ctx.warn(word, 2043, "This loop will only ever run once. Bad"
|
|
332
|
+
" quoting or missing glob/expansion?")
|
|
333
|
+
return
|
|
334
|
+
for word in words:
|
|
335
|
+
suffix = _trailing_unquoted_literal(word)
|
|
336
|
+
if suffix is not None and suffix.text.endswith(","):
|
|
337
|
+
ctx.warn(word, 2258, "The trailing comma is part of the value,"
|
|
338
|
+
" not a separator. Delete or quote it.")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _unquoted_literal(word):
|
|
342
|
+
out = []
|
|
343
|
+
for p in word_parts(word):
|
|
344
|
+
if p.kind != "T_Literal":
|
|
345
|
+
return None
|
|
346
|
+
out.append(p.text)
|
|
347
|
+
return "".join(out)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _trailing_unquoted_literal(word):
|
|
351
|
+
parts = word_parts(word)
|
|
352
|
+
if parts and parts[-1].kind == "T_Literal" \
|
|
353
|
+
and not parts[-1].get("escaped"):
|
|
354
|
+
return parts[-1]
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ----------------------------------------------------------------------
|
|
359
|
+
# SC2206 / SC2207: word splitting in array assignments
|
|
360
|
+
|
|
361
|
+
@node_check("T_Array")
|
|
362
|
+
def check_splitting_in_arrays(ctx, node):
|
|
363
|
+
for element in node.elements:
|
|
364
|
+
value = element.value if element.kind == "T_IndexedElement" \
|
|
365
|
+
else element
|
|
366
|
+
for p in word_parts(value):
|
|
367
|
+
k = p.kind
|
|
368
|
+
if k in ("T_DollarExpansion", "T_Backticked",
|
|
369
|
+
"T_DollarBraceCommandExpansion"):
|
|
370
|
+
ctx.warn(p, 2207, "Prefer mapfile or read -a to split"
|
|
371
|
+
" command output (or quote to avoid splitting).")
|
|
372
|
+
elif k == "T_DollarBraced":
|
|
373
|
+
name = braced_reference(p.content)
|
|
374
|
+
if name in SPECIAL_VARIABLES_WITHOUT_SPACES:
|
|
375
|
+
continue
|
|
376
|
+
if is_counting_reference(p):
|
|
377
|
+
continue
|
|
378
|
+
ctx.warn(p, 2206, "Quote to prevent word splitting/globbing,"
|
|
379
|
+
" or split robustly with mapfile or read -a.")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ----------------------------------------------------------------------
|
|
383
|
+
# SC2026 / SC2027 / SC2140: inexplicably unquoted words
|
|
384
|
+
|
|
385
|
+
@node_check("T_NormalWord")
|
|
386
|
+
def check_inexplicably_unquoted(ctx, word):
|
|
387
|
+
parts = word.parts
|
|
388
|
+
for i in range(len(parts) - 1):
|
|
389
|
+
a, b = parts[i], parts[i + 1]
|
|
390
|
+
if a.kind == "T_SingleQuoted" and b.kind == "T_Literal" \
|
|
391
|
+
and b.text and b.text.isalnum():
|
|
392
|
+
ctx.info(b, 2026, "This word is outside of quotes. Did you"
|
|
393
|
+
" intend to 'nest '\"'single quotes'\"' instead'?")
|
|
394
|
+
if i + 2 < len(parts) and a.kind == "T_DoubleQuoted" \
|
|
395
|
+
and parts[i + 2].kind == "T_DoubleQuoted":
|
|
396
|
+
trapped = b
|
|
397
|
+
if trapped.kind in ("T_DollarExpansion", "T_DollarBraced"):
|
|
398
|
+
ctx.warn(trapped, 2027, "The surrounding quotes actually"
|
|
399
|
+
" unquote this. Remove or escape them.")
|
|
400
|
+
elif trapped.kind == "T_Literal":
|
|
401
|
+
if trapped.text in ("=", ":", "/"):
|
|
402
|
+
continue
|
|
403
|
+
if _quotes_single_thing(a) and \
|
|
404
|
+
_quotes_single_thing(parts[i + 2]):
|
|
405
|
+
continue
|
|
406
|
+
if _is_regex_context(word):
|
|
407
|
+
continue
|
|
408
|
+
ctx.warn(trapped, 2140, 'Word is of the form "A"B"C"'
|
|
409
|
+
' (B indicated). Did you mean "ABC" or'
|
|
410
|
+
' "A\\"B\\"C"?')
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _quotes_single_thing(dq):
|
|
414
|
+
return len(dq.parts) == 1 and dq.parts[0].kind in (
|
|
415
|
+
"T_DollarExpansion", "T_DollarBraced", "T_Backticked")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _is_regex_context(word):
|
|
419
|
+
for a in ancestors(word):
|
|
420
|
+
if a.kind == "TC_Binary" and a.op == "=~" and a.rhs is word:
|
|
421
|
+
return True
|
|
422
|
+
if a.kind in ("T_SimpleCommand", "T_Script"):
|
|
423
|
+
return False
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ----------------------------------------------------------------------
|
|
428
|
+
# SC2250 (optional): prefer braces around variable references
|
|
429
|
+
|
|
430
|
+
@node_check("T_DollarBraced")
|
|
431
|
+
def check_variable_braces(ctx, node):
|
|
432
|
+
if node.get("braced"):
|
|
433
|
+
return
|
|
434
|
+
name = braced_reference(node.content)
|
|
435
|
+
if name in UNBRACED_VARIABLES:
|
|
436
|
+
return
|
|
437
|
+
if command_word_of(ctx, node):
|
|
438
|
+
return
|
|
439
|
+
ctx.style(node, 2250, "Prefer putting braces around variable references"
|
|
440
|
+
" even when not strictly required.")
|