diff --git a/docs/changelog/207.security.rst b/docs/changelog/207.security.rst new file mode 100644 index 0000000..eeba0c0 --- /dev/null +++ b/docs/changelog/207.security.rst @@ -0,0 +1,2 @@ +Bare interpreter names now search ``PATH`` without probing a matching file in the working directory. Use ``./name`` +when the working directory contains the intended interpreter. diff --git a/docs/explanation.rst b/docs/explanation.rst index 14ab839..2d455d8 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -13,9 +13,11 @@ order. It stops as soon as it finds an interpreter that matches your spec. Start["get_interpreter()"] --> AbsPath{"Is spec an
absolute path?"} AbsPath -->|Yes| TryAbs["Use path directly"] AbsPath -->|No| TryFirst["try_first_with paths"] - TryFirst --> RelPath{"Is spec a
relative path?"} + TryFirst --> RelPath{"Does spec name a
relative directory?"} RelPath -->|Yes| TryRel["Resolve relative to cwd"] - RelPath -->|No| Current["Current interpreter"] + RelPath -->|No| Bare{"Bare executable name?"} + Bare -->|Yes| PATH + Bare -->|No| Current["Current interpreter"] Current --> Win{"Windows?"} Win -->|Yes| PEP514["PEP 514 registry"] Win -->|No| PATH @@ -329,7 +331,8 @@ A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Ev Order matters: the parts appear in the sequence shown above. Combine the ABI flags as ``python3.13td`` (free-threaded then debug, the order CPython uses in ``sys.abiflags``), and keep ``-arch`` before ``-machine`` as in ``python3.13d-64-arm64``. Mixing ``d`` with ``-dbg`` in one spec, or swapping the arch -and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match. +and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match on ``PATH``. +Only a spec with a directory component, such as ``./python3`` or ``venv/bin/python``, searches the working directory. **Full examples:** diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 99fbb59..9c37bb7 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -216,7 +216,8 @@ def _propose_explicit( yield PythonInfo.from_exe(exe_raw, cache, env=env), True if spec.path is not None: - if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch + # Unparsed bare names reach spec.path; only an explicit directory should search the working directory. + if os.path.dirname(spec.path) and (exe_raw := _check_exe(spec.path, tested_exes)): yield PythonInfo.from_exe(exe_raw, cache, env=env), True else: yield from _propose_current_and_windows(spec, cache, env, tested_exes) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 73d6f84..65fe44e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -94,6 +94,25 @@ def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch assert result is not None +@pytest.mark.parametrize( + "name", + [ + pytest.param("3.14.0rc1", id="prerelease"), + pytest.param("python3.13-arm64-64", id="swapped-platform"), + pytest.param("python_d", id="executable-name"), + ], +) +def test_bare_name_spec_does_not_probe_cwd( + name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + (tmp_path / name).touch(mode=0o755) + monkeypatch.chdir(tmp_path) + from_exe = mocker.patch("python_discovery._discovery.PythonInfo.from_exe", return_value=None) + + assert get_interpreter(name, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "uv")}) is None + from_exe.assert_not_called() + + @pytest.mark.parametrize( "sep", [