diff --git a/docs/changelog/129.bugfix.rst b/docs/changelog/129.bugfix.rst new file mode 100644 index 0000000..ad7d484 --- /dev/null +++ b/docs/changelog/129.bugfix.rst @@ -0,0 +1 @@ +Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index f6eda20..5bde25e 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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 diff --git a/tests/test_discovery.py b/tests/test_discovery.py index c683099..ff278a8 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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