diff --git a/docs/changelog/212.security.rst b/docs/changelog/212.security.rst new file mode 100644 index 0000000..a556dac --- /dev/null +++ b/docs/changelog/212.security.rst @@ -0,0 +1 @@ +Skip PEP 514 registry entries with relative or non-string executable paths and continue interpreter discovery. diff --git a/src/python_discovery/_windows/_pep514.py b/src/python_discovery/_windows/_pep514.py index 7f20e1c..6f094dd 100644 --- a/src/python_discovery/_windows/_pep514.py +++ b/src/python_discovery/_windows/_pep514.py @@ -8,6 +8,7 @@ import sys import winreg from logging import basicConfig, getLogger +from pathlib import PureWindowsPath from typing import TYPE_CHECKING, Any, Final if TYPE_CHECKING: @@ -136,12 +137,16 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[ def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] - if (exe := get_value(ip_key, "ExecutablePath")) is not None: - return exe - if (ip := get_value(ip_key, None)) is None: - msg(key_path, "no ExecutablePath or default for it") + if (exe := get_value(ip_key, "ExecutablePath")) is None: + if (exe := get_value(ip_key, None)) is None: + msg(key_path, "no ExecutablePath or default for it") + return None + if isinstance(exe, str): + exe = os.path.join(exe, "python.exe") + if not isinstance(exe, str) or not PureWindowsPath(exe).is_absolute(): + msg(key_path, f"expected an absolute executable path, got {exe!r}") return None - return os.path.join(ip, "python.exe") + return exe def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index bb9250e..7b95781 100644 --- a/tests/windows/test_windows_pep514.py +++ b/tests/windows/test_windows_pep514.py @@ -135,7 +135,7 @@ def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptur pep514._run() out, err = capsys.readouterr() - py = r"C:\Users\user\AppData\Local\Programs\Python" + py = r"C:\\Users\\user\\AppData\\Local\\Programs\\Python" expected = textwrap.dedent( rf""" ('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None) @@ -168,6 +168,40 @@ def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptur assert caplog.messages == expected_logs +@pytest.mark.usefixtures("_mock_registry") +@pytest.mark.parametrize( + ("executable_path", "install_path"), + [ + pytest.param(r"%LOCALAPPDATA%\Programs\Python\Python311\python.exe", None, id="unexpanded-variable"), + pytest.param("python.exe", None, id="bare-name"), + pytest.param(r"bin\python.exe", None, id="relative"), + pytest.param("C:python.exe", None, id="drive-relative"), + pytest.param(r"\Python311\python.exe", None, id="current-drive"), + pytest.param(42, None, id="integer-executable"), + pytest.param(b"C:\\python.exe", None, id="bytes-executable"), + pytest.param(["C:\\python.exe"], None, id="list-executable"), + pytest.param(None, "", id="empty-install-path"), + pytest.param(None, "Python311", id="relative-install-path"), + pytest.param(None, 42, id="integer-install-path"), + ], +) +def test_pep514_rejects_non_absolute_executable( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + executable_path: str | int | bytes | list[str] | None, + install_path: str | int | None, +) -> None: + from python_discovery._windows import _pep514 as pep514 + + registered = {"ExecutablePath": executable_path, None: install_path} + mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) + mocker.patch.object(pep514, "get_value", side_effect=lambda _key, name: registered.get(name)) + mocker.patch.object(pep514.os.path, "exists", return_value=True) + + assert pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") is None + assert "expected an absolute executable path" in caplog.text + + @pytest.fixture def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, str | None] | None]: from python_discovery._windows import _pep514 as pep514