diff --git a/docs/changelog/210.security.rst b/docs/changelog/210.security.rst new file mode 100644 index 0000000..bce145b --- /dev/null +++ b/docs/changelog/210.security.rst @@ -0,0 +1,2 @@ +Discovery uses the profile default for empty or relative ``APPDATA`` and skips relative profile paths when finding +uv-managed Pythons. diff --git a/docs/explanation.rst b/docs/explanation.rst index 14ab839..56eaa78 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -91,7 +91,8 @@ overrides it; otherwise the store is the ``python`` bucket of uv's state directo uv follows the XDG convention on macOS too, and falls back to the legacy directory only when that directory already exists. python-discovery searches both, in the same order, so a leftover legacy directory cannot hide the store uv installs into. A relative ``XDG_DATA_HOME`` does not count, as the XDG specification requires, and Windows ignores it -outright. Every one of these variables comes from the ``env`` mapping passed to +outright. An empty or relative ``APPDATA`` uses the profile default, and discovery skips any relative store root. +Every one of these variables comes from the ``env`` mapping passed to :func:`~python_discovery.get_interpreter`, rather than from the process environment. Each install lives in its own subdirectory, but the actual binary location varies by OS and implementation: diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 99fbb59..61abd70 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -288,7 +288,8 @@ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]: yield Path(install_dir).expanduser() return for state_dir in _uv_state_dirs(env): - yield state_dir / "python" + if state_dir.is_absolute(): # a relative profile path would search the caller's working directory + yield state_dir / "python" def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]: @@ -296,7 +297,10 @@ def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]: windows = sys.platform == "win32" home = Path(env.get("USERPROFILE" if windows else "HOME") or "~").expanduser() if windows: - app_data = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming" + default_app_data = home / "AppData" / "Roaming" + app_data = Path(env.get("APPDATA") or default_app_data) + if not app_data.is_absolute(): + app_data = default_app_data yield app_data / "uv" / "data" yield app_data / "uv" return diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 73d6f84..cfcb51b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -638,6 +638,57 @@ def test_discovery_uv_store_roots_per_platform( assert uv_probed() == executables +@pytest.mark.parametrize( + ("appdata", "store"), + [ + pytest.param("", "uv/data", id="empty"), + pytest.param("relative", "relative/uv/data", id="relative"), + ], +) +def test_discovery_uv_store_invalid_appdata_uses_profile_default( + uv_home: Path, + monkeypatch: pytest.MonkeyPatch, + uv_plant: Callable[[Path], str], + uv_probed: Callable[..., list[str]], + appdata: str, + store: str, +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", appdata) + (checkout := uv_home / "checkout").mkdir() + monkeypatch.chdir(checkout) + uv_plant(checkout / store / "python") + profile_executable = uv_plant(uv_home / "AppData" / "Roaming" / "uv" / "data" / "python") + + assert uv_probed() == [profile_executable] + + +@pytest.mark.parametrize( + ("platform", "profile_var", "store"), + [ + pytest.param("win32", "USERPROFILE", "relative/AppData/Roaming/uv/data", id="windows"), + pytest.param("linux", "HOME", "relative/.local/share/uv", id="linux"), + pytest.param("darwin", "HOME", "relative/Library/Application Support/uv", id="macos"), + ], +) +def test_discovery_uv_store_skips_relative_profile( + uv_home: Path, + monkeypatch: pytest.MonkeyPatch, + uv_plant: Callable[[Path], str], + uv_probed: Callable[..., list[str]], + platform: str, + profile_var: str, + store: str, +) -> None: + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setenv(profile_var, "relative") + (checkout := uv_home / "checkout").mkdir() + monkeypatch.chdir(checkout) + uv_plant(checkout / store / "python") + + assert uv_probed() == [] + + def test_discovery_uv_store_windows_ignores_xdg_data_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch,