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
14 changes: 14 additions & 0 deletions src/poetry/utils/env/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import contextlib
import os
import re
import shutil
import subprocess
import sys
import sysconfig
Expand Down Expand Up @@ -461,6 +462,19 @@ def execute(self, bin: str, *args: str, **kwargs: Any) -> int:
if not self._is_windows:
return os.execvpe(command[0], command, env=env)

# On Windows, resolve the executable against the env's ``PATH``
# ourselves before handing the command to ``subprocess`` with
# ``shell=True``. ``cmd.exe`` can fail to resolve a bare name when
# ``PATH`` exceeds its ~8KiB parsing limit (see
# https://github.com/python/cpython/issues/137254), but
# :func:`shutil.which` works for paths of any length. This mainly
# affects entries from ``[project.scripts]``, which are installed
# as ``.cmd`` shims rather than ``.exe`` and therefore aren't
# picked up by the venv-relative lookup in :meth:`_bin`.
resolved = shutil.which(command[0], path=env.get("PATH"))
if resolved is not None:
command[0] = resolved

kwargs["shell"] = True
exe = subprocess.Popen(command, env=env, **kwargs)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (python.lang.security.audit.dangerous-subprocess-use-tainted-env-args): Detected subprocess function 'Popen' with user controlled data. A malicious actor could leverage this to perform command injection. You may consider using 'shlex.quote()'.

Source: opengrep

exe.communicate()
Expand Down
81 changes: 81 additions & 0 deletions tests/utils/env/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,87 @@ def test_env_scheme_dict_returns_modified_when_read_only(
)


class _FakeWindowsSelf:
"""Lightweight stand-in used to drive ``Env.execute`` on a non-Windows
host without touching the venv-creation machinery in :class:`Env`.

``Env.execute`` only reads ``self._is_windows`` and calls
``self.get_command_from_bin``, so a duck-typed object is enough.
"""

_is_windows = True

def __init__(self, command: list[str]) -> None:
self._command = command

def get_command_from_bin(self, bin: str) -> list[str]:
return self._command


def test_execute_resolves_full_path_on_windows(mocker: MockerFixture) -> None:
"""On Windows, ``Env.execute`` must resolve the executable's full path
via ``shutil.which`` before invoking ``subprocess.Popen``, passing the
env-aware ``PATH`` so the lookup honors the caller-supplied environment
(and bypasses ``cmd.exe``'s ~8 KiB ``PATH`` parse limit).

``cmd.exe`` fails to resolve bare names when ``PATH`` exceeds its
~8 KiB parse limit (https://github.com/python/cpython/issues/137254),
affecting ``[project.scripts]`` entries (installed as ``.cmd`` shims)
that aren't found by ``Env._bin``'s venv-relative ``.exe`` lookup.

Regression test for https://github.com/python-poetry/poetry/issues/10482.
Comment on lines +579 to +590
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Also assert how shutil.which is called to fully specify the contract of the new behavior.

The test currently only checks that the shutil.which result is used in the Popen call. To better lock in the regression fix, also assert how shutil.which is called — e.g. patch it, then verify it’s called once with the expected executable name and a path derived from env["PATH"] (or whatever Env.execute passes). This will catch regressions where the env-aware path or the executable name is changed or dropped.

Suggested implementation:

def test_execute_resolves_full_path_on_windows(mocker: MockerFixture) -> None:
    """On Windows, ``Env.execute`` must resolve the executable's full path
    via ``shutil.which`` before invoking ``subprocess.Popen``.

    ``cmd.exe`` fails to resolve bare names when ``PATH`` exceeds its
    ~8 KiB parse limit (https://github.com/python/cpython/issues/137254),
    affecting ``[project.scripts]`` entries (installed as ``.cmd`` shims)
    that aren't found by ``Env._bin``'s venv-relative ``.exe`` lookup.

    Regression test for https://github.com/python-poetry/poetry/issues/10482.
    """
    # Arrange
    env_vars = {"PATH": r"C:\foo;C:\bar"}
    executable = "script"

    which_mock = mocker.patch(
        "poetry.utils.env.shutil.which",
        return_value=rf"C:\foo\{executable}.cmd",
    )
    popen_mock = mocker.patch("poetry.utils.env.subprocess.Popen")

    # NOTE: adjust the fake-env class/constructor to match the rest of this test module.
    fake_env = FakeEnv(command=[executable])  # type: ignore[name-defined]

    # Act
    fake_env.execute(executable, env=env_vars)  # type: ignore[attr-defined]

    # Assert: `shutil.which` must be called with the bare executable and an
    # env-aware PATH so regressions in name/`path` handling are caught.
    which_mock.assert_called_once_with(executable, path=env_vars["PATH"])

    # Existing assertions about `Popen` using the full path should remain;
    # this ensures the `shutil.which` result is still honored.
    popen_mock.assert_called_once()
  1. Replace FakeEnv and its usage with the actual fake/test env class used in this file (e.g. DummyEnv, WindowsEnv, etc.), and adjust the constructor arguments accordingly.
  2. If execute is a method on a different object (e.g. env = MyEnv(...)), update fake_env.execute(...) to match the real API.
  3. If the production code calls shutil.which via a different import path (for example, poetry.utils.env.base_env.shutil.which or poetry.utils.env.Env.shutil.which), update the string passed to mocker.patch to match the module where shutil.which is actually looked up.
  4. If the test suite already has more specific assertions on subprocess.Popen (e.g. checking arguments or kwargs), keep them in addition to the new which_mock.assert_called_once_with(...) assertion so the full contract stays covered.

"""
from poetry.utils.env.base_env import Env

resolved = r"C:\Users\u\proj\.venv\Scripts\myscript.cmd"
env_vars = {"PATH": r"C:\foo;C:\bar"}
which_mock = mocker.patch("shutil.which", return_value=resolved)
popen_mock = mocker.MagicMock()
popen_mock.communicate.return_value = (b"", b"")
popen_mock.returncode = 0
popen_cls = mocker.patch(
"poetry.utils.env.base_env.subprocess.Popen", return_value=popen_mock
)

fake = _FakeWindowsSelf(command=["myscript"])
rc = Env.execute(fake, "myscript", "arg1", env=env_vars) # type: ignore[arg-type]

assert rc == 0
# Pin down the ``shutil.which`` contract: bare executable + env-aware
# PATH. Regressions that drop the ``path=`` kwarg or pass the wrong
# name would silently re-introduce the cmd.exe PATH-length failure
# this PR is fixing.
which_mock.assert_called_once_with("myscript", path=env_vars["PATH"])
call_command = popen_cls.call_args.args[0]
assert call_command[0] == resolved
assert call_command[1] == "arg1"


def test_execute_falls_back_to_bare_name_when_unresolved(
mocker: MockerFixture,
) -> None:
"""If ``shutil.which`` cannot resolve the executable, ``Env.execute``
must still call ``subprocess.Popen`` so the shell's own resolution
path can take over (preserving existing behavior for cases where the
binary lives outside the venv but is reachable via ``PATH``)."""
from poetry.utils.env.base_env import Env

mocker.patch("shutil.which", return_value=None)
popen_mock = mocker.MagicMock()
popen_mock.communicate.return_value = (b"", b"")
popen_mock.returncode = 0
popen_cls = mocker.patch(
"poetry.utils.env.base_env.subprocess.Popen", return_value=popen_mock
)

fake = _FakeWindowsSelf(command=["unknown"])
rc = Env.execute(fake, "unknown") # type: ignore[arg-type]

assert rc == 0
call_command = popen_cls.call_args.args[0]
assert call_command[0] == "unknown"


def test_marker_env_is_equal_for_all_envs(tmp_path: Path, manager: EnvManager) -> None:
venv_path = tmp_path / "Virtual Env"
manager.build_venv(venv_path)
Expand Down
Loading