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
2 changes: 2 additions & 0 deletions docs/changelog/202.security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Skip relative ``PATH`` entries, including the working-directory entry in the Windows default search path, when
discovering interpreters.
3 changes: 3 additions & 0 deletions docs/how-to/standalone-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ shell. You can override these to control exactly where the library looks.
env = {**os.environ, "PATH": "/usr/local/bin:/usr/bin"}
result = get_interpreter("python3.12", env=env)

Only absolute directories in ``PATH`` are searched. To select an interpreter in the working directory, use
``./python`` as the spec on POSIX or ``./python.exe`` on Windows.

Customize interpreter query timeout
-------------------------------------

Expand Down
8 changes: 2 additions & 6 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,14 +432,10 @@ 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:
# 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.
# Relative entries can execute an interpreter supplied by the working directory.
for entry in map(Path, filter(None, path.split(os.pathsep))):
with suppress(OSError):
if entry.is_dir() and next(entry.iterdir(), None):
if entry.is_absolute() and entry.is_dir() and next(entry.iterdir(), None):
yield entry


Expand Down
30 changes: 28 additions & 2 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,34 @@ def test_discovery_via_path_with_file(tmp_path: Path, monkeypatch: pytest.Monkey

def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PATH", raising=False)
paths = list(get_paths({}))
assert paths
assert all(path.is_absolute() for path in get_paths({}))


@pytest.mark.parametrize("relative", [".", "bin", "~/.local/bin"])
def test_get_paths_skips_relative_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, relative: str) -> None:
monkeypatch.chdir(tmp_path)
relative_dir = tmp_path / relative
relative_dir.mkdir(parents=True, exist_ok=True)
(relative_dir / "python3").touch()
absolute_dir = tmp_path / "absolute"
absolute_dir.mkdir()
(absolute_dir / "python3").touch()

assert list(get_paths({"PATH": f"{relative}{os.pathsep}{absolute_dir}"})) == [absolute_dir]


def test_get_paths_skips_relative_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "python3").touch()
absolute_dir = tmp_path / "absolute"
absolute_dir.mkdir()
(absolute_dir / "python3").touch()

with (
patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True),
patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}"),
):
assert list(get_paths({})) == [absolute_dir]


@pytest.mark.parametrize(
Expand Down