diff --git a/docs/changelog/209.security.rst b/docs/changelog/209.security.rst new file mode 100644 index 0000000..5dbbe86 --- /dev/null +++ b/docs/changelog/209.security.rst @@ -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. diff --git a/docs/explanation.rst b/docs/explanation.rst index 14ab839..2527ba2 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -64,6 +64,8 @@ detects these shims and resolves them to the actual binary. `mise `_ and `asdf `_ 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 --------------------------------------- diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 99fbb59..17b9a99 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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 [] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 73d6f84..2df959e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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: