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: