From da7eaa0360d76c340940fa1563756c8057e1452b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 18:41:39 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(discovery):=20skip=20rel?= =?UTF-8?q?ative=20PATH=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Windows caller omits PATH, the default search path starts with the working directory. A planted interpreter can run during discovery. Search absolute directories only, including when PATH is supplied explicitly. --- docs/changelog/202.security.rst | 2 ++ docs/how-to/standalone-usage.rst | 3 +++ src/python_discovery/_discovery.py | 8 ++------ tests/test_discovery.py | 30 ++++++++++++++++++++++++++++-- 4 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 docs/changelog/202.security.rst diff --git a/docs/changelog/202.security.rst b/docs/changelog/202.security.rst new file mode 100644 index 0000000..abce897 --- /dev/null +++ b/docs/changelog/202.security.rst @@ -0,0 +1,2 @@ +Skip relative ``PATH`` entries, including the working-directory entry in the Windows default search path, when +discovering interpreters. diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst index aeaf37c..875ad4e 100644 --- a/docs/how-to/standalone-usage.rst +++ b/docs/how-to/standalone-usage.rst @@ -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. Use ``./python`` as the spec when you intend to select an +interpreter from the working directory. + Customize interpreter query timeout ------------------------------------- diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 5f82233..583b8c3 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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 diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 0b69e59..fde450c 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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( From 1ceb719bd6df85532f1a855395256e0ab1faec88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 18:45:22 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(discovery):=20show=20Wi?= =?UTF-8?q?ndows=20relative=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/how-to/standalone-usage.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst index 875ad4e..95a8e8a 100644 --- a/docs/how-to/standalone-usage.rst +++ b/docs/how-to/standalone-usage.rst @@ -61,8 +61,8 @@ 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. Use ``./python`` as the spec when you intend to select an -interpreter from the working directory. +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 -------------------------------------