Skip to content

Commit 921af06

Browse files
henryiiiclaude
andcommitted
fix: address batch quoting review findings
- reject arguments containing %VAR% references that cmd.exe would expand (verified: expansion happens even inside double quotes and can inject commands from environment values); references to undefined variables are left alone since cmd.exe passes them through literally - quote ( and ) as metacharacters: an unquoted ")" inside a conda.bat style "if (...)" block ends the block early, silently corrupting the argument or aborting the script - accept pre-quoted arguments such as '"urllib3<1.25"' again (the historical workaround from #312) instead of raising ValueError Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bwhe91T5gnFxoeS2WLKfM4
1 parent 7da2b87 commit 921af06

2 files changed

Lines changed: 155 additions & 13 deletions

File tree

nox/popen.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
__lazy_modules__ = {"contextlib", "locale"}
1818

1919
import contextlib
20+
import itertools
2021
import locale
22+
import os
2123
import subprocess
2224
import sys
2325
from typing import IO, TYPE_CHECKING
@@ -40,20 +42,79 @@ def __dir__() -> list[str]:
4042
DEFAULT_INTERRUPT_TIMEOUT = 0.3
4143
DEFAULT_TERMINATE_TIMEOUT = 0.2
4244

43-
_CMD_META = frozenset("&<>^|")
44-
45-
46-
def _windows_batch_command(args: Sequence[str]) -> str:
47-
"""Build a command line that protects the cmd.exe metacharacters ``&<>^|``.
48-
49-
Note that ``%`` cannot be protected at this layer: cmd.exe expands
50-
``%VAR%`` references even inside double quotes.
45+
_CMD_META = frozenset("&<>^|()")
46+
47+
# Variables cmd.exe resolves dynamically or defines itself, so a reference to
48+
# one expands even when it is absent from the child process environment.
49+
_CMD_AUTO_VARS = frozenset(
50+
{
51+
"__APPDIR__",
52+
"__CD__",
53+
"CD",
54+
"CMDCMDLINE",
55+
"CMDEXTVERSION",
56+
"COMSPEC",
57+
"DATE",
58+
"ERRORLEVEL",
59+
"HIGHESTNUMANODENUMBER",
60+
"PATHEXT",
61+
"PROMPT",
62+
"RANDOM",
63+
"TIME",
64+
}
65+
)
66+
67+
68+
def _expandable_reference(arg: str, env: Mapping[str, str]) -> str | None:
69+
"""Find a ``%VAR%`` reference that cmd.exe would expand, if there is one.
70+
71+
cmd.exe scans the text between each pair of ``%`` characters; when it
72+
names a defined (or dynamic) variable - optionally followed by ``:``
73+
modifiers - the value is substituted, even inside double quotes. An
74+
undefined name is left alone and the scan resumes at the trailing ``%``.
5175
"""
76+
defined = {name.upper() for name in env}
77+
percents = [index for index, char in enumerate(arg) if char == "%"]
78+
for start, end in itertools.pairwise(percents):
79+
name = arg[start + 1 : end].partition(":")[0].upper()
80+
if name and (
81+
name in defined
82+
or name in _CMD_AUTO_VARS
83+
# hidden variables such as the per-drive working directory "=C:"
84+
or name.startswith("=")
85+
):
86+
return arg[start : end + 1]
87+
return None
88+
89+
90+
def _windows_batch_command(
91+
args: Sequence[str], env: Mapping[str, str] | None = None
92+
) -> str:
93+
"""Build a command line that protects the cmd.exe metacharacters ``&<>^|()``.
94+
95+
Arguments cmd.exe would corrupt anyway are rejected: ``%VAR%`` references
96+
are expanded even inside double quotes, and quote characters cannot be
97+
escaped - cmd.exe knows no backslash escapes, it only counts quotes.
98+
"""
99+
if env is None:
100+
env = os.environ
52101

53102
quoted_args = []
54103
for arg in args:
104+
reference = _expandable_reference(arg, env)
105+
if reference is not None:
106+
msg = (
107+
"Cannot escape argument for batch script, cmd.exe would"
108+
f" expand {reference}: {arg}"
109+
)
110+
raise ValueError(msg)
55111
if _CMD_META.isdisjoint(arg):
56112
quoted_args.append(subprocess.list2cmdline([arg]))
113+
elif len(arg) >= 2 and arg[0] == arg[-1] == '"' and '"' not in arg[1:-1]:
114+
# A pre-quoted argument such as '"urllib3<1.25"' (the historical
115+
# workaround): its outer quotes already protect the
116+
# metacharacters, so pass it through unchanged.
117+
quoted_args.append(arg)
57118
elif '"' in arg:
58119
# list2cmdline escapes embedded quotes with backslashes, but
59120
# cmd.exe knows no backslash escapes - it only counts quote
@@ -127,7 +188,7 @@ def popen(
127188

128189
popen_args: Sequence[str] | str = args
129190
if sys.platform.startswith("win") and args[0].casefold().endswith((".bat", ".cmd")):
130-
popen_args = _windows_batch_command(args)
191+
popen_args = _windows_batch_command(args, env)
131192

132193
proc = subprocess.Popen(popen_args, env=env, stdout=stdout, stderr=stderr)
133194

tests/test_command.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ def _write_echo_batch(tmp_path: Path, suffix: str = ".bat") -> Path:
5757

5858
def test_windows_batch_command() -> None:
5959
command = nox.popen._windows_batch_command(
60-
[r"C:\Program Files\tool.cmd", "plain", "has space", "requests<99"]
60+
[r"C:\Program Files\tool.cmd", "plain", "has space", "requests<99", "py(3)"]
6161
)
6262

63-
assert command == r'"C:\Program Files\tool.cmd" plain "has space" "requests<99"'
63+
expected = r'"C:\Program Files\tool.cmd" plain "has space" "requests<99" "py(3)"'
64+
assert command == expected
6465

6566

66-
@pytest.mark.parametrize("argument", ['"urllib3<1.25"', 'a"<b', 'a">b'])
67+
@pytest.mark.parametrize("argument", ['a"<b', 'a">b', '"urllib3<1.25', 'urllib3<1.25"'])
6768
def test_windows_batch_command_rejects_quoted_metacharacters(argument: str) -> None:
6869
# cmd.exe only counts quote characters, so an argument mixing quotes and
6970
# metacharacters cannot be escaped reliably; 'a">b' would even run
@@ -72,10 +73,62 @@ def test_windows_batch_command_rejects_quoted_metacharacters(argument: str) -> N
7273
nox.popen._windows_batch_command([r"C:\tool.bat", argument])
7374

7475

76+
def test_windows_batch_command_accepts_pre_quoted_argument() -> None:
77+
# the historical workaround from #312: the caller quotes the constraint
78+
# themselves, and the outer quotes already protect the metacharacters.
79+
command = nox.popen._windows_batch_command([r"C:\tool.bat", '"urllib3<1.25"'])
80+
81+
assert command == r'C:\tool.bat "urllib3<1.25"'
82+
83+
84+
@pytest.mark.parametrize(
85+
"argument",
86+
["%NOX_TEST_VAR%", "pre%NOX_TEST_VAR%post", "%%NOX_TEST_VAR%%", '"%NOX_TEST_VAR%"'],
87+
)
88+
def test_windows_batch_command_rejects_percent_expansion(argument: str) -> None:
89+
# cmd.exe expands %VAR% references even inside double quotes, so an
90+
# argument referencing a defined variable cannot be passed through
91+
# faithfully; a variable value could even inject extra commands.
92+
with pytest.raises(ValueError, match="would expand %NOX_TEST_VAR%"):
93+
nox.popen._windows_batch_command(
94+
[r"C:\tool.bat", argument], {"nox_test_var": "value"}
95+
)
96+
97+
98+
@pytest.mark.parametrize("argument", ["%RANDOM%", "%__CD__%", "%=C:%", "%PATH:;=,%"])
99+
def test_windows_batch_command_rejects_dynamic_percent_expansion(
100+
argument: str,
101+
) -> None:
102+
# these expand even when the variable is missing from the child
103+
# environment: cmd.exe resolves them dynamically or defines them itself.
104+
with pytest.raises(ValueError, match="would expand"):
105+
nox.popen._windows_batch_command([r"C:\tool.bat", argument], {"PATH": "C:;D:"})
106+
107+
108+
@pytest.mark.parametrize(
109+
"argument", ["100%", "%%", "%undefined_variable%", "https://x.test/%20a%20b.whl"]
110+
)
111+
def test_windows_batch_command_allows_literal_percents(argument: str) -> None:
112+
# cmd.exe leaves references to undefined variables alone, so these
113+
# arguments survive verbatim and are safe to pass through.
114+
command = nox.popen._windows_batch_command([r"C:\tool.bat", argument], {})
115+
116+
assert command == f"C:\\tool.bat {argument}"
117+
118+
75119
@only_on_windows
76120
@pytest.mark.parametrize("suffix", [".bat", ".cmd"])
77121
@pytest.mark.parametrize(
78-
"argument", ["name&value", "name<value", "name>value", "name^value", "name|value"]
122+
"argument",
123+
[
124+
"name&value",
125+
"name<value",
126+
"name>value",
127+
"name^value",
128+
"name|value",
129+
"name(value",
130+
"name)value",
131+
],
79132
)
80133
def test_run_windows_batch_metacharacter_arg(
81134
tmp_path: Path, suffix: str, argument: str
@@ -87,6 +140,34 @@ def test_run_windows_batch_metacharacter_arg(
87140
assert result.strip() == argument
88141

89142

143+
@only_on_windows
144+
def test_run_windows_batch_paren_arg_in_block(tmp_path: Path) -> None:
145+
# conda.bat-style scripts wrap commands in if (...) blocks, where an
146+
# unquoted ")" in an argument ends the block early and silently corrupts
147+
# the argument (or aborts with "... was unexpected at this time").
148+
batch = tmp_path / "block.bat"
149+
batch.write_text(
150+
f'@echo off\nif "1"=="1" (\n'
151+
f' "{PYTHON}" -c "import sys; print(sys.argv[1])" %*\n)\n',
152+
encoding="utf-8",
153+
)
154+
155+
result = nox.command.run([batch, "(pkg)"], silent=True)
156+
157+
assert result.strip() == "(pkg)"
158+
159+
160+
@only_on_windows
161+
def test_run_windows_batch_pre_quoted_arg(tmp_path: Path) -> None:
162+
# the pre-quoted idiom from #312 keeps working; the child process sees
163+
# the argument without the caller's quotes, which is what conda expects.
164+
batch = _write_echo_batch(tmp_path)
165+
166+
result = nox.command.run([batch, '"urllib3<1.25"'], silent=True)
167+
168+
assert result.strip() == "urllib3<1.25"
169+
170+
90171
@only_on_windows
91172
def test_run_windows_batch_quoted_metacharacter_arg(
92173
tmp_path: Path, monkeypatch: pytest.MonkeyPatch

0 commit comments

Comments
 (0)