diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index 6fdd53d5e789..a255820da147 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -55,6 +55,7 @@ if salt.utils.platform.is_windows(): import salt.platform.win from salt.utils.win_functions import escape_argument as _cmd_quote + from salt.utils.win_functions import shlex_split from salt.utils.win_runas import runas as win_runas HAS_WIN_RUNAS = True @@ -100,25 +101,20 @@ def _check_cb(cb_): return lambda x: x -def _python_shell_default(python_shell, shell=False): +def _python_shell_default(python_shell, __pub_jid): """ Set python_shell default based on the shell parameter and __opts__['cmd_safe'] """ - if shell: - if salt.utils.platform.is_windows(): - # On Windows python_shell / subprocess 'shell' parameter must always be - # False as we prepend the shell manually - return False - else: - # Non-Windows requires python_shell to be enabled - return True if python_shell is None else python_shell - else: - try: - if __opts__.get("cmd_safe", True) is False and python_shell is None: - # Override-switch for python_shell - return True - except NameError: - pass + try: + # Default to python_shell=True when run directly from remote execution + # system. Cross-module calls won't have a jid. + if __pub_jid and python_shell is None: + return True + elif __opts__.get("cmd_safe", True) is False and python_shell is None: + # Override-switch for python_shell + return True + except NameError: + pass return python_shell @@ -305,7 +301,7 @@ def _run( log_callback=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=False, env=None, clean_env=False, @@ -973,7 +969,7 @@ def _run_quiet( stdin=None, output_encoding=None, runas=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=False, env=None, template=None, @@ -1022,7 +1018,7 @@ def _run_all_quiet( cwd=None, stdin=None, runas=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=False, env=None, template=None, @@ -1078,7 +1074,7 @@ def run( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -1346,7 +1342,7 @@ def run( salt '*' cmd.run cmd='sed -e s/=/:/g' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) stderr = subprocess.STDOUT if redirect_stderr else subprocess.PIPE ret = _run( cmd, @@ -1671,7 +1667,7 @@ def run_stdout( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -1866,7 +1862,7 @@ def run_stdout( salt '*' cmd.run_stdout "grep f" stdin='one\\ntwo\\nthree\\nfour\\nfive\\n' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) ret = _run( cmd, runas=runas, @@ -1905,7 +1901,7 @@ def run_stderr( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -2100,7 +2096,7 @@ def run_stderr( salt '*' cmd.run_stderr "grep f" stdin='one\\ntwo\\nthree\\nfour\\nfive\\n' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) ret = _run( cmd, runas=runas, @@ -2139,7 +2135,7 @@ def run_all( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -2377,7 +2373,7 @@ def run_all( salt '*' cmd.run_all "grep f" stdin='one\\ntwo\\nthree\\nfour\\nfive\\n' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) stderr = subprocess.STDOUT if redirect_stderr else subprocess.PIPE ret = _run( cmd, @@ -2421,7 +2417,7 @@ def retcode( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -2602,7 +2598,7 @@ def retcode( salt '*' cmd.retcode "grep f" stdin='one\\ntwo\\nthree\\nfour\\nfive\\n' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) ret = _run( cmd, runas=runas, @@ -2639,7 +2635,7 @@ def _retcode_quiet( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=False, env=None, clean_env=False, @@ -2697,7 +2693,7 @@ def script( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, template=None, @@ -2903,7 +2899,8 @@ def script( saltenv = __opts__.get("saltenv", "base") except NameError: saltenv = "base" - python_shell = _python_shell_default(python_shell, shell) + + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) def _cleanup_tempfile(path): try: @@ -2995,10 +2992,13 @@ def _cleanup_tempfile(path): os.chmod(path, 320) os.chown(path, __salt__["file.user_to_uid"](runas), -1) - if isinstance(args, (list, tuple)): - new_cmd = [path, *args] if args else [path] - else: - new_cmd = [path, str(args)] if args else [path] + if isinstance(args, str): + if salt.utils.platform.is_windows(): + args = shlex_split(args) + else: + args = shlex.split(args) + + new_cmd = [path, *args] if args else [path] ret = {} try: @@ -3050,7 +3050,7 @@ def script_retcode( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, template="jinja", @@ -3395,7 +3395,7 @@ def run_chroot( stdin=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=True, binds=None, env=None, @@ -4627,7 +4627,7 @@ def run_bg( cwd=None, runas=None, group=None, - shell=None, + shell=DEFAULT_SHELL, python_shell=None, env=None, clean_env=False, @@ -4832,7 +4832,7 @@ def run_bg( salt '*' cmd.run_bg cmd='ls -lR / | sed -e s/=/:/g > /tmp/dontwait' """ - python_shell = _python_shell_default(python_shell, shell) + python_shell = _python_shell_default(python_shell, kwargs.get("__pub_jid", "")) res = _run( cmd, stdin=None, diff --git a/salt/utils/win_functions.py b/salt/utils/win_functions.py index 24c319749f82..eb13f357b27d 100644 --- a/salt/utils/win_functions.py +++ b/salt/utils/win_functions.py @@ -421,3 +421,41 @@ def squid_to_guid(squid): guid += squid_match.group(index)[::-1] guid += "}" return guid + + +def shlex_split(string): + """ + Windows version of shlex.split() + + Based on winshlex: https://github.com/jdjebi/winshlex/blob/master/winshlex/lex.py + """ + re_cmd_lex = r""""((?:""|\\["\\]|[^"])*)"?()|(\\\\(?=\\*")|\\")|(&&?|\|\|?|\d?>|[<])|([^\s"&|<>]+)|(\s+)|(.)""" + args = [] + accu = None # collects pieces of one arg + for qs, qss, esc, pipe, word, white, fail in re.findall(re_cmd_lex, string): + if word: + pass # most frequent + elif esc: + word = esc[1] + elif white or pipe: + if accu is not None: + args.append(accu) + if pipe: + args.append(pipe) + accu = None + continue + elif fail: + raise ValueError("invalid or incomplete shell string") + elif qs: + word = qs.replace('\\"', '"').replace("\\\\", "\\") + if platform == 0: + word = word.replace('""', '"') + else: + word = qss # may be even empty; must be last + + accu = (accu or "") + word + + if accu is not None: + args.append(accu) + + return args diff --git a/tests/integration/modules/test_cmdmod.py b/tests/integration/modules/test_cmdmod.py index 7a965e9fe4e8..5ee6be9075d1 100644 --- a/tests/integration/modules/test_cmdmod.py +++ b/tests/integration/modules/test_cmdmod.py @@ -293,6 +293,7 @@ def test_script_cwd_with_space(self): ) self.assertEqual(ret["stdout"], " ".join(args)) + @pytest.mark.skip_if_not_root @pytest.mark.destructive_test def test_tty(self): """ diff --git a/tests/integration/shell/test_enabled.py b/tests/integration/shell/test_enabled.py index 5adcc89a247d..146e74f9746b 100644 --- a/tests/integration/shell/test_enabled.py +++ b/tests/integration/shell/test_enabled.py @@ -23,26 +23,26 @@ class EnabledTest(ModuleCase): ) @pytest.mark.skip_on_windows(reason="Skip on Windows OS") - def test_shell_default_disabled(self): + @pytest.mark.skip_on_freebsd + def test_shell_default_enabled(self): """ - ensure that python_shell defaults to False for cmd.run + ensure that python_shell defaults to True for cmd.run """ - disabled_ret = ( - "first\nsecond\nthird\n|\nwc\n-l\n;\nexport\nSALTY_VARIABLE=saltines" - "\n&&\necho\n$SALTY_VARIABLE\n;\necho\nduh\n&>\n/dev/null" - ) + disabled_ret = "3\nsaltines" # the result of running self.cmd in a shell ret = self.run_function("cmd.run", [self.cmd]) self.assertEqual(ret, disabled_ret) @pytest.mark.skip_on_windows(reason="Skip on Windows OS") - @pytest.mark.skip_on_freebsd - def test_shell_enabled(self): + def test_shell_disabled(self): """ - test shell enabled output for cmd.run + test shell disabled output for cmd.run """ - enabled_ret = "3\nsaltines" # the result of running self.cmd in a shell - ret = self.run_function("cmd.run", [self.cmd], python_shell=True) - self.assertEqual(ret.strip(), enabled_ret) + disabled_ret = ( + "first\nsecond\nthird\n|\nwc\n-l\n;\nexport\nSALTY_VARIABLE=saltines" + "\n&&\necho\n$SALTY_VARIABLE\n;\necho\nduh\n&>\n/dev/null" + ) + ret = self.run_function("cmd.run", [self.cmd], python_shell=False) + self.assertEqual(ret.strip(), disabled_ret) @pytest.mark.skip_on_windows(reason="Skip on Windows OS") @pytest.mark.skip_on_freebsd diff --git a/tests/pytests/functional/modules/cmd/test_script_batch.py b/tests/pytests/functional/modules/cmd/test_script_batch.py index 0184aace200c..721505f78670 100644 --- a/tests/pytests/functional/modules/cmd/test_script_batch.py +++ b/tests/pytests/functional/modules/cmd/test_script_batch.py @@ -27,36 +27,40 @@ def echo_script(state_tree): @pytest.mark.parametrize( - "command, expected", + "args, expected", [ + ("foo bar", "a: foo, b: bar"), + ('foo "bar bar"', "a: foo, b: bar bar"), (["foo", "bar"], "a: foo, b: bar"), (["foo foo", "bar bar"], "a: foo foo, b: bar bar"), ], ) -def test_echo(modules, echo_script, command, expected): +def test_echo(modules, echo_script, args, expected): """ Test argument processing with a batch script """ script = "salt://echo-script/test.bat" - result = modules.cmd.script(script, args=command, shell="cmd") + result = modules.cmd.script(script, args=args, shell="cmd") assert result["stdout"] == expected @pytest.mark.parametrize( - "command, expected", + "args, expected", [ + ("foo bar", "a: foo, b: bar"), + ('foo "bar bar"', "a: foo, b: bar bar"), (["foo", "bar"], "a: foo, b: bar"), (["foo foo", "bar bar"], "a: foo foo, b: bar bar"), ], ) -def test_echo_runas(modules, account, echo_script, command, expected): +def test_echo_runas(modules, account, echo_script, args, expected): """ Test argument processing with a batch script and runas """ script = "salt://echo-script/test.bat" result = modules.cmd.script( script, - args=command, + args=args, shell="cmd", runas=account.username, password=account.password, diff --git a/tests/pytests/functional/modules/cmd/test_script_powershell.py b/tests/pytests/functional/modules/cmd/test_script_powershell.py index c1f076dca8a7..7618e28c5900 100644 --- a/tests/pytests/functional/modules/cmd/test_script_powershell.py +++ b/tests/pytests/functional/modules/cmd/test_script_powershell.py @@ -63,17 +63,19 @@ def test_exitcode(cmd, shell, exitcode_script): @pytest.mark.parametrize( - "command, expected", + "args, expected", [ + ("foo bar", "a: foo, b: bar"), + ('foo "bar bar"', "a: foo, b: bar bar"), (["foo", "bar"], "a: foo, b: bar"), (["foo foo", "bar bar"], "a: foo foo, b: bar bar"), ], ) -def test_echo(cmd, shell, echo_script, command, expected): +def test_echo(cmd, shell, echo_script, args, expected): """ Test argument processing with a powershell script """ - ret = cmd.script("salt://echo.ps1", args=command, shell=shell, saltenv="base") + ret = cmd.script("salt://echo.ps1", args=args, shell=shell, saltenv="base") assert isinstance(ret["pid"], int) assert ret["retcode"] == 0 assert ret["stderr"] == "" @@ -81,19 +83,21 @@ def test_echo(cmd, shell, echo_script, command, expected): @pytest.mark.parametrize( - "command, expected", + "args, expected", [ + ("foo bar", "a: foo, b: bar"), + ('foo "bar bar"', "a: foo, b: bar bar"), (["foo", "bar"], "a: foo, b: bar"), (["foo foo", "bar bar"], "a: foo foo, b: bar bar"), ], ) -def test_echo_runas(cmd, shell, account, echo_script, command, expected): +def test_echo_runas(cmd, shell, account, echo_script, args, expected): """ Test argument processing with a powershell script and runas """ ret = cmd.script( "salt://echo.ps1", - args=command, + args=args, shell=shell, runas=account.username, password=account.password, diff --git a/tests/pytests/unit/modules/test_cmdmod.py b/tests/pytests/unit/modules/test_cmdmod.py index dccb23f0de67..92ea89965b2f 100644 --- a/tests/pytests/unit/modules/test_cmdmod.py +++ b/tests/pytests/unit/modules/test_cmdmod.py @@ -17,6 +17,7 @@ import salt.grains.extra import salt.modules.cmdmod as cmdmod import salt.utils.files +import salt.utils.path import salt.utils.platform import salt.utils.stringutils from salt._logging import LOG_LEVELS @@ -670,12 +671,17 @@ def test_run_all_output_loglevel_debug(caplog): stdout = b"test" proc = MagicMock(return_value=MockTimedProc(stdout=stdout)) - expected = "Executing command 'some' in directory" + if salt.utils.platform.is_windows(): + run_cmd = salt.utils.path.which("cmd") + expected = f"Executing command '{run_cmd}' in directory" + else: + expected = "Executing command 'some' in directory" + with patch("salt.utils.timed_subprocess.TimedProc", proc): with caplog.at_level(logging.DEBUG, logger="salt.modules.cmdmod"): ret = cmdmod.run_all("some command", output_loglevel="debug") result = caplog.text - assert expected in result + assert expected.lower() in result.lower() assert ret["stdout"] == salt.utils.stringutils.to_unicode(stdout) diff --git a/tests/pytests/unit/utils/test_win_functions.py b/tests/pytests/unit/utils/test_win_functions.py index 6fcb1081d38d..d79d52f0921f 100644 --- a/tests/pytests/unit/utils/test_win_functions.py +++ b/tests/pytests/unit/utils/test_win_functions.py @@ -190,3 +190,19 @@ def test_get_sam_name(test_user): expected = "\\".join([platform.node()[:15], test_user]) result = win_functions.get_sam_name(test_user) assert result.lower() == expected.lower() + + +@pytest.mark.parametrize( + "string, expected", + [ + ("foo", ["foo"]), + ("foo bar", ["foo", "bar"]), + ("foo bar=baz", ["foo", "bar=baz"]), + ('foo "bar baz"', ["foo", "bar baz"]), + ("C:\\Temp\\test.txt", ["C:\\Temp\\test.txt"]), + ('"C:\\Temp\\Space Dir\\test.txt"', ["C:\\Temp\\Space Dir\\test.txt"]), + ], +) +def test_shlex_split(string, expected): + result = win_functions.shlex_split(string=string) + assert result == expected