diff --git a/changelogs/fragments/11347-incus-regex.yml b/changelogs/fragments/11347-incus-regex.yml new file mode 100644 index 0000000000..070c312e8c --- /dev/null +++ b/changelogs/fragments/11347-incus-regex.yml @@ -0,0 +1,6 @@ +bugfixes: + - > + incus connection plugin - fix parsing of commands for Windows, enforcing a ``\`` after the drive letter and colon symbol + (https://github.com/ansible-collections/community.general/pull/11347). +minor_changes: + - incus connection plugin - simplify regular expression matching commands (https://github.com/ansible-collections/community.general/pull/11347). diff --git a/plugins/connection/incus.py b/plugins/connection/incus.py index 51376514b3..ea3356ffd6 100644 --- a/plugins/connection/incus.py +++ b/plugins/connection/incus.py @@ -104,11 +104,11 @@ class Connection(ConnectionBase): if getattr(self._shell, "_IS_WINDOWS", False): # Initializing regular expression patterns to match on a PowerShell or cmd command line. self.powershell_regex_pattern = re.compile( - r"^(?P(\"?([a-z]:)?[a-z0-9 ()\\.]+)?powershell(\.exe)?\"?|(([a-z]:)?[a-z0-9()\\.]+)?powershell(\.exe)?)\s+.*(?P-c(ommand)?)\s+", # noqa: E501 + r'^"?(?P(?:[a-z]:\\)?[a-z0-9 ()\\.]*powershell(?:\.exe)?)"?\s+(?P.*)(?P-c(?:ommand)?)\s+(?P.*(\n.*)*)', re.IGNORECASE, ) self.cmd_regex_pattern = re.compile( - r"^(?P(\"?([a-z]:)?[a-z0-9 ()\\.]+)?cmd(\.exe)?\"?|(([a-z]:)?[a-z0-9()\\.]+)?cmd(\.exe)?)\s+.*(?P/c)\s+", + r'^"?(?P(?:[a-z]:\\)?[a-z0-9 ()\\.]*cmd(?:\.exe)?)"?\s+(?P.*)(?P/c)\s+(?P.*)', re.IGNORECASE, ) @@ -153,21 +153,14 @@ class Connection(ConnectionBase): host=self._instance(), ) - # Split the command on the argument -c(ommand) for PowerShell or /c for cmd. - before_command_argument, after_command_argument = cmd.split(regex_match.group("command"), 1) - - exec_cmd.extend( - [ - # To avoid splitting on a space contained in the path, set the executable as the first argument. - regex_match.group("executable").strip('"'), - # Remove the executable path and split the rest by space. - *(before_command_argument[len(regex_match.group("executable")) :].lstrip().split(" ")), - # Set the command argument depending on cmd or powershell. - regex_match.group("command"), - # Add the rest of the command at the end. - after_command_argument, - ] - ) + # To avoid splitting on a space contained in the path, set the executable as the first argument. + exec_cmd.append(regex_match.group("executable")) + if args := regex_match.group("args"): + exec_cmd.extend(args.strip().split(" ")) + # Set the command argument depending on cmd or powershell and the rest of it + exec_cmd.append(regex_match.group("command")) + if post_args := regex_match.group("post_args"): + exec_cmd.append(post_args.strip()) else: # For anything else using -EncodedCommand or else, just split on space. exec_cmd.extend(cmd.split(" ")) diff --git a/tests/unit/plugins/connection/test_incus.py b/tests/unit/plugins/connection/test_incus.py new file mode 100644 index 0000000000..f77256c96f --- /dev/null +++ b/tests/unit/plugins/connection/test_incus.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import pytest +import typing as t +from io import StringIO + +from ansible.playbook.play_context import PlayContext +from ansible.plugins.loader import connection_loader + +BUILD_CMD_TEST_CASES: list[dict[str, t.Any]] = [ + dict( + id="sh simple", + input=dict( + cmd="""echo 123""", + shell="sh", + ), + output=["/bin/sh", "-c", "echo 123"], + ), + dict( + id="powershell simple", + input=dict( + cmd="""powershell -c My-Command1 -Param 'param' """, + shell="powershell", + ), + output=["powershell", "-c", "My-Command1 -Param 'param'"], + ), + dict( + id=r"D:\my path\powershell.exe simple", + input=dict( + cmd=r"""D:\my path\powershell.exe -c My-Command1 -Param 'param' """, + shell="powershell", + ), + output=[r"D:\my path\powershell.exe", "-c", "My-Command1 -Param 'param'"], + ), + dict( + id=r"\\127.0.0.1\share\powershell simple", + input=dict( + cmd=r"""\\127.0.0.1\share\powershell -c My-Command1 -Param 'param' """, + shell="powershell", + ), + output=[r"\\127.0.0.1\share\powershell", "-c", "My-Command1 -Param 'param'"], + ), + dict( + id=r"C:\powershell simple", + input=dict( + cmd=r"""C:\powershell -c My-Command1 -Param 'param' """, + shell="powershell", + ), + output=[r"C:\powershell", "-c", "My-Command1 -Param 'param'"], + ), + dict( + id=r'"C:\powershell" simple', + input=dict( + cmd=r""""C:\powershell" -c My-Command1 -Param 'param' """, + shell="cmd", # the plugins does not care the shell type, as long as it is windows + ), + output=[r"C:\powershell", "-c", "My-Command1 -Param 'param'"], + ), + dict( + id="powershell multiline", + input=dict( + cmd=( + "powershell -ExecutionPolicy bypass -Command\n" + "My-Command1 \\\n" + " -Param 'param';\n" + "My-Command2 \\\n" + " -Param 'param'" + ), + shell="powershell", + ), + output=[ + "powershell", + "-ExecutionPolicy", + "bypass", + "-Command", + "My-Command1 \\\n -Param 'param';\nMy-Command2 \\\n -Param 'param'", + ], + ), + dict( + id="cmd simple", + input=dict( + cmd="""CMD.EXE /C some-command /flag1 /flag2""", + shell="powershell", + ), + output=["CMD.EXE", "/C", "some-command /flag1 /flag2"], + ), + dict( + id=r"C:\cmd simple", + input=dict( + cmd=r"""C:\CMD.EXE /C some-command /flag1 /flag2""", + shell="powershell", + ), + output=[r"C:\CMD.EXE", "/C", "some-command /flag1 /flag2"], + ), + dict( + id=r'"C:\cmd" simple', + input=dict( + cmd=r""""C:\CMD" /c some-command /flag1 /flag2""", + shell="powershell", + ), + output=[r"C:\CMD", "/c", "some-command /flag1 /flag2"], + ), +] + + +BUILD_CMD_TEST_CASE_IDS: list[str] = [tc["id"] for tc in BUILD_CMD_TEST_CASES] + + +@pytest.mark.parametrize("testcase", BUILD_CMD_TEST_CASES, ids=BUILD_CMD_TEST_CASE_IDS) +def test_build_command(mocker, testcase): + mocker.patch("ansible.module_utils.common.process.get_bin_path").return_value = "/test/bin/incus" + + play_context = PlayContext() + play_context.shell = testcase["input"].get("shell", "sh") + in_stream = StringIO() + conn = connection_loader.get("community.general.incus", play_context, in_stream) + conn.set_option("remote_addr", "server1") + conn.set_option("remote_user", "root") + + cli_preamble = [ + "/test/bin/incus", + "--project", + "default", + "exec", + *(["-T"] if play_context.shell in ["cmd", "powershell"] else []), + "local:server1", + "--", + ] + + built = conn._build_command(testcase["input"]["cmd"]) + tc_cmd = cli_preamble + testcase["output"] + assert built == tc_cmd, f"\n built = {built}\ntestcase = {tc_cmd}"