Skip to content

Commit 34be38a

Browse files
authored
[v2] Double-quote Windows alias command args if metacharacters present (aws#10223)
1 parent 6b97442 commit 34be38a

File tree

3 files changed

+113
-16
lines changed

3 files changed

+113
-16
lines changed

awscli/alias.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,9 @@ def __init__(self, alias_name, alias_value, invoker=subprocess.call):
415415

416416
def __call__(self, args, parsed_globals):
417417
command_components = [self._alias_value[1:]]
418-
command_components.extend(compat_shell_quote(a) for a in args)
418+
command_components.extend(
419+
compat_shell_quote(a, shell=True) for a in args
420+
)
419421
command = ' '.join(command_components)
420422
LOG.debug(
421423
'Using external alias %r with value: %r to run: %r',

awscli/compat.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@
7575
OUTPUT_ENCODING_ENV_VAR = 'AWS_CLI_OUTPUT_ENCODING'
7676
PYTHONUTF8_ENV_VAR = 'PYTHONUTF8'
7777

78+
# cmd.exe characters that require double-quoting to be treated as literals.
79+
# https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd
80+
_WIN_CMD_UNSAFE_CHARS = set('&<>[]|{}^=;!\'()+,`~ \t')
81+
7882
LOG = logging.getLogger(__name__)
7983

8084

@@ -261,13 +265,14 @@ def compat_input(prompt):
261265
# This is unused directly when the user pastes the cross-device
262266
# verification code, which may be longer than some terminal's buffers
263267
import readline # noqa: F401
268+
264269
return raw_input()
265270
except ImportError:
266271
LOG.debug('readline module not available')
267272
return raw_input()
268273

269274

270-
def compat_shell_quote(s, platform=None):
275+
def compat_shell_quote(s, platform=None, shell=False):
271276
"""Return a shell-escaped version of the string *s*
272277
273278
Unfortunately `shlex.quote` doesn't support Windows, so this method
@@ -277,13 +282,15 @@ def compat_shell_quote(s, platform=None):
277282
platform = sys.platform
278283

279284
if platform == "win32":
280-
return _windows_shell_quote(s)
285+
if shell:
286+
return _windows_cmd_shell_quote(s)
287+
return _windows_argv_quote(s)
281288
else:
282289
return shlex.quote(s)
283290

284291

285-
def _windows_shell_quote(s):
286-
"""Return a Windows shell-escaped version of the string *s*
292+
def _windows_argv_quote(s):
293+
"""Return a Windows argv-escaped version of the string *s*
287294
288295
Windows has potentially bizarre rules depending on where you look. When
289296
spawning a process via the Windows C runtime the rules are as follows:
@@ -305,37 +312,37 @@ def _windows_shell_quote(s):
305312
return '""'
306313

307314
buff = []
308-
num_backspaces = 0
315+
num_backslashes = 0
309316
for character in s:
310317
if character == '\\':
311318
# We can't simply append backslashes because we don't know if
312319
# they will need to be escaped. Instead we separately keep track
313320
# of how many we've seen.
314-
num_backspaces += 1
321+
num_backslashes += 1
315322
elif character == '"':
316-
if num_backspaces > 0:
323+
if num_backslashes > 0:
317324
# The backslashes are part of a chain that lead up to a
318325
# double quote, so they need to be escaped.
319-
buff.append('\\' * (num_backspaces * 2))
320-
num_backspaces = 0
326+
buff.append('\\' * (num_backslashes * 2))
327+
num_backslashes = 0
321328

322329
# The double quote also needs to be escaped. The fact that we're
323330
# seeing it at all means that it must have been escaped in the
324331
# original source.
325332
buff.append('\\"')
326333
else:
327-
if num_backspaces > 0:
334+
if num_backslashes > 0:
328335
# The backslashes aren't part of a chain leading up to a
329336
# double quote, so they can be inserted directly without
330337
# being escaped.
331-
buff.append('\\' * num_backspaces)
332-
num_backspaces = 0
338+
buff.append('\\' * num_backslashes)
339+
num_backslashes = 0
333340
buff.append(character)
334341

335-
# There may be some leftover backspaces if they were on the trailing
342+
# There may be some leftover backslashes if they were on the trailing
336343
# end, so they're added back in here.
337-
if num_backspaces > 0:
338-
buff.append('\\' * num_backspaces)
344+
if num_backslashes > 0:
345+
buff.append('\\' * num_backslashes)
339346

340347
new_s = ''.join(buff)
341348
if ' ' in new_s or '\t' in new_s:
@@ -345,6 +352,60 @@ def _windows_shell_quote(s):
345352
return new_s
346353

347354

355+
def _windows_cmd_shell_quote(s):
356+
"""Return a Windows shell-escaped version of the string *s* that is
357+
safe to pass through cmd.exe
358+
359+
Handles two interpretation layers:
360+
1. cmd.exe metacharacters - neutralized by double-quoting when
361+
the string contains any cmd.exe special characters.
362+
2. MSVC C runtime argv parsing - backslash/double-quote escaping
363+
so the target process receives the correct argument.
364+
365+
Note: cmd.exe %VAR% expansion and !VAR! delayed expansion
366+
cannot be reliably escaped inside double quotes on the
367+
command line and are not handled here.
368+
369+
:param s: A string to escape
370+
:return: An escaped string
371+
"""
372+
if not s:
373+
return '""'
374+
375+
buff = []
376+
num_backslashes = 0
377+
needs_quoting = False
378+
for character in s:
379+
if character == '\\':
380+
num_backslashes += 1
381+
elif character == '"':
382+
if num_backslashes > 0:
383+
buff.append('\\' * (num_backslashes * 2))
384+
num_backslashes = 0
385+
buff.append('\\"')
386+
needs_quoting = True
387+
else:
388+
if num_backslashes > 0:
389+
buff.append('\\' * num_backslashes)
390+
num_backslashes = 0
391+
if character in _WIN_CMD_UNSAFE_CHARS:
392+
needs_quoting = True
393+
buff.append(character)
394+
395+
if needs_quoting:
396+
# Trailing backslashes must be doubled when we append a closing
397+
# double quote — without doubling, a trailing backslash would
398+
# escape the closing quote.
399+
if num_backslashes > 0:
400+
buff.append('\\' * (num_backslashes * 2))
401+
inner = ''.join(buff)
402+
return f'"{inner}"'
403+
404+
if num_backslashes > 0:
405+
buff.append('\\' * num_backslashes)
406+
return ''.join(buff)
407+
408+
348409
def get_popen_kwargs_for_pager_cmd(pager_cmd=None):
349410
"""Returns the default pager to use dependent on platform
350411

tests/unit/test_compat.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,40 @@ def test_compat_shell_quote_windows(input_string, expected_output):
7979
assert compat_shell_quote(input_string, "win32") == expected_output
8080

8181

82+
@pytest.mark.parametrize(
83+
"input_string, expected_output",
84+
(
85+
('', '""'),
86+
('foo', 'foo'),
87+
('foo bar', '"foo bar"'),
88+
('foo\tbar', '"foo\tbar"'),
89+
('"', '"\\""'),
90+
('\\', '\\'),
91+
('\\a', '\\a'),
92+
('\\\\', '\\\\'),
93+
('\\"', '"\\\\\\""'),
94+
('foo&bar', '"foo&bar"'),
95+
('foo|bar', '"foo|bar"'),
96+
('foo>bar', '"foo>bar"'),
97+
('foo<bar', '"foo<bar"'),
98+
('foo^bar', '"foo^bar"'),
99+
('foo(bar)', '"foo(bar)"'),
100+
('foo,bar', '"foo,bar"'),
101+
('foo;bar', '"foo;bar"'),
102+
('foo=bar', '"foo=bar"'),
103+
('foo!bar', '"foo!bar"'),
104+
('foo%PATH%bar', 'foo%PATH%bar'),
105+
('foo\\', 'foo\\'),
106+
('foo bar\\', '"foo bar\\\\"'),
107+
),
108+
)
109+
def test_compat_shell_quote_windows_for_cmd_exe(input_string, expected_output):
110+
assert (
111+
compat_shell_quote(input_string, "win32", shell=True)
112+
== expected_output
113+
)
114+
115+
82116
@pytest.mark.parametrize(
83117
"input_string, expected_output",
84118
(

0 commit comments

Comments
 (0)