diff --git a/docs/changelog/4060.bugfix.rst b/docs/changelog/4060.bugfix.rst new file mode 100644 index 000000000..48815e2b6 --- /dev/null +++ b/docs/changelog/4060.bugfix.rst @@ -0,0 +1,2 @@ +A UNC or extended-length path in ``commands`` no longer loses one of its two leading backslashes, so +``\\server\share\file.txt`` survives tokenization on Windows - by :user:`MohammedAlkindi`. diff --git a/src/tox/config/loader/str_convert.py b/src/tox/config/loader/str_convert.py index ff04ee51e..f8295de8f 100644 --- a/src/tox/config/loader/str_convert.py +++ b/src/tox/config/loader/str_convert.py @@ -55,22 +55,48 @@ def to_dict(value: str, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[ @staticmethod def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -> str: - """Escape backslash in value that is not followed by a special character. + r"""Escape backslash in value that is not followed by a special character. This allows windows paths to be written without double backslash, while retaining the POSIX backslash escape semantics for quotes and escapes. + A backslash pair at the very start of a word, immediately followed by more path text, is the exception: a UNC + path (``\\server\share``) or an extended-length path prefix requires exactly two literal leading backslashes, so + that leading pair must survive as-is rather than being collapsed the way an interior ``\\`` (the POSIX-escaped + form of a single literal backslash) is elsewhere in a path. A bare ``\\`` with nothing (or only whitespace) + after it is not a path prefix, and keeps the ordinary collapsing behavior. + """ result = [] - for ix, char in enumerate(value): + ix = 0 + at_word_start = True + n = len(value) + while ix < n: + char = value[ix] + if char.isspace(): + result.append(char) + at_word_start = True + ix += 1 + continue + starts_backslash_pair = at_word_start and char == escape and value[ix + 1 : ix + 2] == escape + if starts_backslash_pair: + after_run = value[ix + 2 : ix + 3] + if after_run and after_run != escape and not after_run.isspace(): + # exactly two leading backslashes starting a word, followed by more text: a UNC/extended-path + # prefix - keep both backslashes literal instead of collapsing them + result.extend((escape * 2, escape * 2)) + ix += 2 + at_word_start = False + continue result.append(char) + at_word_start = False if char == escape: last_char = value[ix - 1 : ix] - if last_char == escape: - continue - next_char = value[ix + 1 : ix + 2] - if next_char not in {escape, *special_chars}: - result.append(escape) # escape escapes that are not themselves escaping a special character + if last_char != escape: + next_char = value[ix + 1 : ix + 2] + if next_char not in {escape, *special_chars}: + result.append(escape) # escape escapes that are not themselves escaping a special character + ix += 1 return "".join(result) @staticmethod diff --git a/tests/config/loader/test_str_convert.py b/tests/config/loader/test_str_convert.py index 67aa9e9bb..7abbdf72d 100644 --- a/tests/config/loader/test_str_convert.py +++ b/tests/config/loader/test_str_convert.py @@ -159,6 +159,11 @@ def test_invalid_shell_expression(value: str, expected: list[str]) -> None: WINDOWS_TRAILING_SEP_ARGS = [ (r"command path\to\trailing\sep\ -b foo", ["command", "path\\to\\trailing\\sep\\", "-b", "foo"]), ] +WINDOWS_UNC_PATH_ARGS = [ + (r"xcopy \\server\share\file.txt .", ["xcopy", r"\\server\share\file.txt", "."]), + (r"\\server\share", [r"\\server\share"]), + (r"copy a \\srv\share\a b", ["copy", "a", r"\\srv\share\a", "b"]), +] WACKY_SLASH_ARGS = [ ("\\\\\\", ["\\\\\\"]), (" \\'\\'\\ '", [" \\'\\'\\ '"]), @@ -229,6 +234,15 @@ def test_shlex_win32_trailing_sep(sys_platform: str, value: str, expected: list[ assert result.args == expected +@pytest.mark.parametrize(("value", "expected"), WINDOWS_UNC_PATH_ARGS) +def test_shlex_win32_unc_path(sys_platform: str, value: str, expected: list[str]) -> None: + if sys_platform != "win32": + pytest.skip("UNC path prefix only relevant on Windows") + result = StrConvert().to_command(value) + assert result is not None + assert result.args == expected + + @pytest.mark.parametrize( ("value", "expected"), [