Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/changelog/129.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`.
7 changes: 6 additions & 1 deletion src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,12 @@ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
path = os.defpath
if path:
for entry in map(Path, path.split(os.pathsep)):
# An empty component (a leading/trailing/doubled separator) means "the current directory", the
# same footgun a shell has when PATH is misconfigured - Path("") resolves to it. Since every
# yielded path here is later searched for interpreters to execute and interrogate, silently
# including the caller's CWD would let whatever directory virtualenv happens to run from smuggle
# in a candidate binary.
for entry in map(Path, filter(None, path.split(os.pathsep))):
with suppress(OSError):
if entry.is_dir() and next(entry.iterdir(), None):
yield entry
Expand Down
32 changes: 32 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,38 @@ def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
assert paths


@pytest.mark.parametrize(
("raw_path", "expected"),
[
pytest.param("{first}{sep}", ["first"], id="trailing"),
pytest.param("{sep}{first}", ["first"], id="leading"),
pytest.param("{first}{sep}{sep}{second}", ["first", "second"], id="doubled"),
],
)
def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None:
"""An empty PATH entry means "current directory" - it must never be searched for interpreters."""
dirs = {}
for name in ("first", "second"):
dirs[name] = tmp_path / name
dirs[name].mkdir()
(dirs[name] / "dummy").touch()

paths = list(get_paths({"PATH": raw_path.format(first=dirs["first"], second=dirs["second"], sep=os.pathsep)}))

assert paths == [dirs[name] for name in expected]


def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD."""
cwd_marker = tmp_path / "python3.11"
cwd_marker.touch(mode=0o755)
monkeypatch.chdir(tmp_path)

paths = list(get_paths({"PATH": f"{os.pathsep}"}))

assert not any(path.resolve() == tmp_path.resolve() for path in paths)


def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("_VIRTUALENV_DEBUG", "1")
a_dir = tmp_path
Expand Down
Loading