Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/4060.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
40 changes: 33 additions & 7 deletions src/tox/config/loader/str_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/config/loader/test_str_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
("\\\\\\", ["\\\\\\"]),
(" \\'\\'\\ '", [" \\'\\'\\ '"]),
Expand Down Expand Up @@ -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"),
[
Expand Down