From 637f8d5fb0caf8d35b43e79eb1acc7f33ae90087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:33:27 -0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(shims):=20bound=20versio?= =?UTF-8?q?n=20file=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version file in a shared parent directory could exhaust the caller's memory during shim resolution. Skip files over 4096 characters so discovery can continue with another version source. --- docs/explanation.rst | 2 ++ src/python_discovery/_discovery.py | 8 +++++++- tests/test_discovery.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) 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..6b8840e 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.splitlines() 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..fcedc95 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -401,6 +401,26 @@ 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_not_utf8( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: From cf94e090f46b293142232d362086e10b164a4930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:34:25 -0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs(news):=20note=20version?= =?UTF-8?q?=20file=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/209.security.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/209.security.rst 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. From eb8d06a0841cd7d168bf8850472bd02a2d91d6d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:37:20 -0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(shims):=20preserve=20ver?= =?UTF-8?q?sion=20file=20newlines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/python_discovery/_discovery.py | 2 +- tests/test_discovery.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 6b8840e..17b9a99 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -555,7 +555,7 @@ def _versions_in_file(path: str | Path) -> list[str]: if len(content) > max_chars: _LOGGER.debug("ignored oversized version file %s", path) return [] - return [v for line in content.splitlines() if (v := line.strip()) and not v.startswith("#")] + 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 fcedc95..2df959e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -421,6 +421,15 @@ def test_shim_oversized_python_version_file_is_skipped( 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: