Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/209.security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Discovery skips ``.python-version`` files over 4096 characters during shim resolution, limiting memory use and allowing
a parent or global version to apply.
2 changes: 2 additions & 0 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ detects these shims and resolves them to the actual binary.

`mise <https://mise.jdx.dev/>`_ and `asdf <https://asdf-vm.com/>`_ work similarly, using the
``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations.
python-discovery skips version files over 4096 characters. It checks parent directories and, for pyenv, the global
version when needed.

How uv-managed Pythons are discovered
---------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,13 @@ def _versions_in_file(path: str | Path) -> list[str]:
try:
# pyenv reads raw bytes, so a byte that is not UTF-8 (a Latin-1 comment, say) must not hide the versions
with Path(path).open(encoding="utf-8", errors="replace") as fh:
return [v for line in fh if (v := line.strip()) and not v.startswith("#")]
# Version files can sit in shared parent directories; bound the in-process read.
max_chars = 4096
content = fh.read(max_chars + 1)
if len(content) > max_chars:
_LOGGER.debug("ignored oversized version file %s", path)
return []
return [v for line in content.split("\n") if (v := line.strip()) and not v.startswith("#")]
except OSError: # skip a file we cannot read, the same as one that names no version
_LOGGER.debug("failed to read %s", path, exc_info=True)
return []
Expand Down
29 changes: 29 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,35 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)


@pytest.mark.parametrize(
("length", "version"),
[pytest.param(4096, "2.7.18", id="at-limit"), pytest.param(4097, "2.7.15", id="over-limit")],
)
def test_shim_oversized_python_version_file_is_skipped(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
pyenv_probed: Callable[[], tuple[str, ...]],
length: int,
version: str,
) -> None:
root = Path(os.environ["PYENV_ROOT"])
_create_versioned_binary(root, ("versions",), "2.7.15", "python2.7")
(root / "version").write_text("2.7.15\n", encoding="utf-8")
(tmp_path / ".python-version").write_text("2.7.18\n#" + "x" * (length - 8), encoding="utf-8")
monkeypatch.chdir(tmp_path)

assert pyenv_probed() == ("versions", version)


def test_shim_python_version_file_keeps_unicode_separator_in_version(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]]
) -> None:
(tmp_path / ".python-version").write_text("2.7.18\u2028ignored\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)

assert pyenv_probed() == ("shims", "python2.7.exe" if IS_WIN else "python2.7")


def test_shim_python_version_file_not_utf8(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]]
) -> None:
Expand Down