Skip to content

Non-UTF-8 interpreter paths break DiskCache lookups #198

Closed

Description

@gaborbernat

An interpreter path containing non-UTF-8 bytes makes a DiskCache lookup raise UnicodeEncodeError. The same candidate works without DiskCache.

Affected code

src/python_discovery/_cache.py:135 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.

Mechanism

The cache key hashes the path after a strict UTF-8 encode, before the interpreter can be probed or skipped.

Reproduction

Check out the reported commit or current main. Save the files below as named, then build and run:

docker build -t python-discovery-audit -f - . <<'DOCKERFILE'
FROM python:3.15-bookworm
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY . /src
WORKDIR /src
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.9.1
RUN pip install -e . --group test
DOCKERFILE
docker run --rm --network=none --memory=2g -u 0 -w /src \
  -v "$PWD/repro:/tmp/repro:ro" -v "$PWD/repro.d:/tmp/repro.d:ro" \
  python-discovery-audit /tmp/repro

repro

#!/usr/bin/env python3
import os
import shutil
import subprocess
import sys
import tempfile

# set up: the ~/.local/bin of a home directory named in Latin-1, holding a working python3.11
assert os.path.exists("/usr/bin/python3.11"), "the candidate interpreter /usr/bin/python3.11 is missing"
work = tempfile.mkdtemp()
local_bin = os.fsencode(work) + b"/Andr\xe9/.local/bin"  # a lone 0xe9 byte is not valid UTF-8
os.makedirs(local_bin)
os.symlink("/usr/bin/python3.11", local_bin + b"/python3.11")
candidate = os.fsdecode(local_bin) + "/python3.11"  # as os.environ and os.listdir spell it: 0xe9 -> '\udce9'
search_path = os.fsdecode(local_bin) + ":/usr/bin"
cache_dir = os.path.join(work, "cache")
print("PATH:", ascii(search_path))

# run: the same search with a DiskCache and without one
find_python311 = [sys.executable, "/tmp/repro.d/find_python311.py", search_path]
with_cache = subprocess.run([*find_python311, cache_dir], capture_output=True, text=True)
without_cache = subprocess.run(find_python311, capture_output=True, text=True)
shutil.rmtree(work)
print(f"--- with DiskCache (exit {with_cache.returncode}):\n{with_cache.stdout}{with_cache.stderr}")
print(f"--- without cache (exit {without_cache.returncode}):\n{without_cache.stdout}{without_cache.stderr}")

# check: without a cache the candidate is probed and selected; with one, discovery must not crash on its path
selected_without_cache = f"selected: {ascii(candidate)}" in without_cache.stdout
crashed_hashing_cache_key = (
    "UnicodeEncodeError: 'utf-8' codec can't encode character '\\udce9'" in with_cache.stderr
    and ", in py_info\n" in with_cache.stderr
)
print("BUG" if selected_without_cache and crashed_hashing_cache_key else "OK")

repro.d/find_python311.py

import sys
from pathlib import Path

from python_discovery import DiskCache, get_interpreter


def print_spawned_argv(event, args):
    if event == "subprocess.Popen":
        print("spawned:", ascii(args[1]))


search_path = sys.argv[1]
cache = DiskCache(root=Path(sys.argv[2])) if len(sys.argv) > 2 else None

sys.addaudithook(print_spawned_argv)
result = get_interpreter("3.11", env={"PATH": search_path}, cache=cache)
print("selected:", ascii(result.executable), "version", ".".join(map(str, result.version_info[:3])))

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

PATH: '/tmp/tmpt7uelg3r/Andr\udce9/.local/bin:/usr/bin'
--- with DiskCache (exit 1):
spawned: ['/usr/local/bin/python3.15', '/src/src/python_discovery/_py_info_collect.py', '9bf28a0b57fd532deac863f8534a99be', '14f7f960d6b4deef0239cc99f9cc7d99']
Traceback (most recent call last):
  File "/tmp/repro.d/find_python311.py", line 16, in <module>
    result = get_interpreter("3.11", env={"PATH": search_path}, cache=cache)
  File "/src/src/python_discovery/_discovery.py", line 52, in get_interpreter
    if result := _find_interpreter(spec_str, preferred_paths, cache, env, predicate):
                 ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/src/src/python_discovery/_discovery.py", line 135, in _find_interpreter
    for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
                                        ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/src/src/python_discovery/_discovery.py", line 203, in propose_interpreters
    yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
  File "/src/src/python_discovery/_discovery.py", line 261, in _propose_from_path
    interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
  File "/src/src/python_discovery/_py_info.py", line 385, in from_exe
    return cls._from_exe_with_args(
           ~~~~~~~~~~~~~~~~~~~~~~~^
        exe,
        ^^^^
    ...<5 lines>...
        env=env,
        ^^^^^^^^
    )
    ^
  File "/src/src/python_discovery/_py_info.py", line 411, in _from_exe_with_args
    proposed = from_exe(
        cls,
    ...<5 lines>...
        executable_args=executable_args,
    )
  File "/src/src/python_discovery/_cached_py_info.py", line 65, in from_exe
    result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache, executable_args=executable_args)
  File "/src/src/python_discovery/_cached_py_info.py", line 87, in _get_from_cache
    result = _CACHE[key] = _get_via_file_cache(cls, cache, key[0], exe, env, executable_args=executable_args)
                           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/src/src/python_discovery/_cached_py_info.py", line 114, in _get_via_file_cache
    py_info_store = resolved_cache.py_info(path)
  File "/src/src/python_discovery/_cache.py", line 135, in py_info
    key = sha256(str(path).encode("utf-8")).hexdigest()
                 ~~~~~~~~~~~~~~~~^^^^^^^^^
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' in position 21: surrogates not allowed

--- without cache (exit 0):
spawned: ['/usr/local/bin/python3.15', '/src/src/python_discovery/_py_info_collect.py', '0c6ce1ec1fb66174f1f0cd53ccf893ac', '4c9244727f9c6f18abb6c37d157f7f73']
spawned: ['/tmp/tmpt7uelg3r/Andr\udce9/.local/bin/python3.11', '/src/src/python_discovery/_py_info_collect.py', '5c792c8b15a9abdf6daff8d484c3cb04', '991e8c0bbb0cde6a9b8e82a54856d8ae']
spawned: ['/usr/bin/python3.11', '/src/src/python_discovery/_py_info_collect.py', 'c86dbdb2c788ccdf05d348f72f4d55e1', 'be106ca36f9f1f81e65706bc5b22f6eb']
selected: '/tmp/tmpt7uelg3r/Andr\udce9/.local/bin/python3.11' version 3.11.2

BUG

Expected: A path's filesystem bytes should be handled without aborting discovery.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is unlikely: the filesystem must contain a path with bytes that are invalid UTF-8. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-VPI28HMC.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions