Skip to content

Commit

Permalink
Improve command line argument quoting (conda#11189)
Browse files Browse the repository at this point in the history
Backport `shlex.join` for Python<3.8. Implement `shlex.join` for Windows cmd.
  • Loading branch information
kenodegard authored Feb 21, 2022
1 parent 2967d90 commit af1f52c
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 25 deletions.
72 changes: 47 additions & 25 deletions conda/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,35 +260,57 @@ def sys_prefix_unfollowed():
return unfollowed


def quote_for_shell(*arguments, shell=None):
def quote_for_shell(*arguments):
"""Properly quote arguments for command line passing.
For POSIX uses `shlex.join`, for Windows uses a custom implementation to properly escape
metacharacters.
:param arguments: Arguments to quote.
:type arguments: list of str
:return: Quoted arguments.
:rtype: str
"""
# [backport] Support passing in a list of strings or args of string.
if len(arguments) == 1 and isiterable(arguments[0]):
arguments = arguments[0]

if (not shell and on_win) or shell == "cmd.exe":
# [note] `subprocess.list2cmdline` is not a public function.
from subprocess import list2cmdline

return list2cmdline(arguments)

# If any multiline argument gets mixed with any other argument (which is true if we've
# arrived in this function) then we just quote it. This assumes something like:
# ['python', '-c', 'a\nmultiline\nprogram\n']
# There is no way of knowing why newlines are included, must ensure they are escaped/quoted.
quoted = []
# This could all be replaced with some regex wizardry but that is less readable and
# for code like this, readability is very important.
for arg in arguments:
if '"' in arg:
quote = "'"
elif "'" in arg:
quote = '"'
elif not any(c in arg for c in (" ", "\n")):
quote = ""
else:
quote = '"'
quoted.append(f"{quote}{arg}{quote}")
return " ".join(quoted)
return _args_join(arguments)


if on_win:
# https://ss64.com/nt/syntax-esc.html
# https://docs.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way

_RE_UNSAFE = re.compile(r'["%\s^<>&|]')
_RE_DBL = re.compile(r'(["%])')

def _args_join(args):
"""Return a shell-escaped string from *args*."""

def quote(s):
# derived from shlex.quote
if not s:
return '""'
# if any unsafe chars are present we must quote
if not _RE_UNSAFE.search(s):
return s
# double escape (" -> "")
s = _RE_DBL.sub(r"\1\1", s)
# quote entire string
return f'"{s}"'

return " ".join(quote(arg) for arg in args)
else:
try:
from shlex import join as _args_join
except ImportError:
# [backport] Python <3.8
def _args_join(args):
"""Return a shell-escaped string from *args*."""
from shlex import quote

return " ".join(quote(arg) for arg in args)


# Ensures arguments are a tuple or a list. Strings are converted
Expand Down
72 changes: 72 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import sys
from conda.common.compat import on_win

import pytest


SOME_PREFIX = "/some/prefix"
SOME_FILES = ["a", "b", "c"]
Expand Down Expand Up @@ -126,6 +128,76 @@ def is_prefix_activated_PATHwise(prefix=sys.prefix, test_programs=()):
return False


mark_posix_only = pytest.mark.skipif(on_win, reason="POSIX only")
mark_win_only = pytest.mark.skipif(not on_win, reason="Windows only")

_posix_quotes = "'{}'".format
_win_quotes = '"{}"'.format
_quotes = _win_quotes if on_win else _posix_quotes

@pytest.mark.parametrize(
["args", "expected"],
[
pytest.param("arg1", "arg1"),
pytest.param("arg1 and 2", _quotes("arg1 and 2")),
pytest.param("arg1\nand\n2", _quotes("arg1\nand\n2")),
pytest.param("numpy<1.22", _quotes("numpy<1.22")),
pytest.param("numpy>=1.0", _quotes("numpy>=1.0")),
pytest.param("one|two", _quotes("one|two")),
pytest.param(">/dev/null", _quotes(">/dev/null")),
pytest.param(">NUL", _quotes(">NUL")),
pytest.param("1>/dev/null", _quotes("1>/dev/null")),
pytest.param("1>NUL", _quotes("1>NUL")),
pytest.param("2>/dev/null", _quotes("2>/dev/null")),
pytest.param("2>NUL", _quotes("2>NUL")),
pytest.param("2>&1", _quotes("2>&1")),
pytest.param(None, _quotes("")),
pytest.param(
'malicious argument\\"&whoami',
'"malicious argument\\""&whoami"',
marks=mark_win_only,
),
pytest.param(
"C:\\temp\\some ^%file^% > nul",
'"C:\\temp\\some ^%%file^%% > nul"',
marks=mark_win_only,
),
pytest.param("!", "!" if on_win else "'!'"),
pytest.param("#", "#" if on_win else "'#'"),
pytest.param("$", "$" if on_win else "'$'"),
pytest.param("%", '"%%"' if on_win else "%"),
pytest.param("&", _quotes("&")),
pytest.param("'", "'" if on_win else "''\"'\"''"),
pytest.param("(", "(" if on_win else "'('"),
pytest.param(")", ")" if on_win else "')'"),
pytest.param("*", "*" if on_win else "'*'"),
pytest.param("+", "+"),
pytest.param(",", ","),
pytest.param("-", "-"),
pytest.param(".", "."),
pytest.param("/", "/"),
pytest.param(":", ":"),
pytest.param(";", ";" if on_win else "';'"),
pytest.param("<", _quotes("<")),
pytest.param("=", "="),
pytest.param(">", _quotes(">")),
pytest.param("?", "?" if on_win else "'?'"),
pytest.param("@", "@"),
pytest.param("[", "[" if on_win else "'['"),
pytest.param("\\", "\\" if on_win else "'\\'"),
pytest.param("]", "]" if on_win else "']'"),
pytest.param("^", _quotes("^")),
pytest.param("{", "{" if on_win else "'{'"),
pytest.param("|", _quotes("|")),
pytest.param("}", "}" if on_win else "'}'"),
pytest.param("~", "~" if on_win else "'~'"),
pytest.param('"', '""""' if on_win else "'\"'"),
],
)
def test_quote_for_shell(args, expected):
assert utils.quote_for_shell(args) == expected


# Some stuff I was playing with, env_unmodified(conda_tests_ctxt_mgmt_def_pol)
# from contextlib import contextmanager
# from conda.base.constants import DEFAULT_CHANNELS
Expand Down

0 comments on commit af1f52c

Please sign in to comment.