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..95a8e8a 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. To select an interpreter in the working directory, use +``./python`` as the spec on POSIX or ``./python.exe`` on Windows. + 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(