Skip to content

Literal executable names miss symlinks on PATH #188

Closed

Description

@gaborbernat

A literal executable-name request can miss a symlink on PATH and fall through to another interpreter. A copy of the same executable under the same name is found.

Affected code

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

Mechanism

The literal-name match checks the resolved executable name, losing the symlink name that matched the request.

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 tempfile

# set up: the same CPython 3.11 binary named python3.11m in two PATH directories
scene = tempfile.mkdtemp()
link_dir = os.path.join(scene, "link")
copy_dir = os.path.join(scene, "copy")
os.mkdir(link_dir)
os.mkdir(copy_dir)
os.symlink("/usr/bin/python3.11", os.path.join(link_dir, "python3.11m"))
shutil.copy("/usr/bin/python3.11", os.path.join(copy_dir, "python3.11m"))

# run: the same lookup once per directory, each in a fresh process (discovery caches results in-process);
# cwd is neither PATH dir, because _satisfies_path accepts os.path.abspath("python3.11m") before any name check
caller = ["/opt/venv/bin/python", "/tmp/repro.d/find_python3_11m.py"]
via_link = subprocess.run([*caller, link_dir], cwd=scene, capture_output=True, text=True, check=True)
via_copy = subprocess.run([*caller, copy_dir], cwd=scene, capture_output=True, text=True, check=True)
shutil.rmtree(scene)

print(f"PATH={link_dir}   python3.11m -> /usr/bin/python3.11")
print(via_link.stderr, end="")
print("  get_interpreter returned:", via_link.stdout.strip())
print()
print(f"PATH={copy_dir}   python3.11m is a copy of /usr/bin/python3.11")
print(via_copy.stderr, end="")
print("  get_interpreter returned:", via_copy.stdout.strip())

# check: an interpreter found under the requested name must be returned whether it is a symlink or a copy
link_rejected = via_link.stdout.strip() == "None"
copy_accepted = via_copy.stdout.strip() == os.path.join(copy_dir, "python3.11m")
print("BUG" if link_rejected and copy_accepted else "OK")

repro.d/find_python3_11m.py

import logging
import sys

from python_discovery import get_interpreter

logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format="    %(message)s")

found = get_interpreter("python3.11m", env={"PATH": sys.argv[1]})

print(found.executable if found else None)

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

PATH=/tmp/tmpoobpc4fq/link   python3.11m -> /usr/bin/python3.11
    find interpreter for spec PythonSpec(path=python3.11m)
    discover PATH[0]=/tmp/tmpoobpc4fq/link
    filesystem is case-sensitive
    get interpreter info via cmd: /tmp/tmpoobpc4fq/link/python3.11m /src/src/python_discovery/_py_info_collect.py 30c8a2ed1504045e939f80d75f36c3a0 3600dd3f687acb75adc72fd93da7818b
    get interpreter info via cmd: /usr/bin/python3.11 /src/src/python_discovery/_py_info_collect.py 263aec6c6ea950dd03a388e3f902e179 4cc2819ed863b696544d3a3276172495
    proposed PathPythonInfo(spec=CPython3.11.2.final.0-64-x86_64, system=/usr/bin/python3.11, exe=/tmp/tmpoobpc4fq/link/python3.11m, platform=linux, version='3.11.2 (main, Oct  7 2026, 12:35:07) [GCC 12.2.0]', encoding_fs_io=utf-8-utf-8)
  get_interpreter returned: None

PATH=/tmp/tmpoobpc4fq/copy   python3.11m is a copy of /usr/bin/python3.11
    find interpreter for spec PythonSpec(path=python3.11m)
    discover PATH[0]=/tmp/tmpoobpc4fq/copy
    filesystem is case-sensitive
    get interpreter info via cmd: /tmp/tmpoobpc4fq/copy/python3.11m /src/src/python_discovery/_py_info_collect.py 1f7da2019cf95a2a464571fc6015d707 a7fdb7af5a810bd468fe94cdaa7adab6
    proposed PathPythonInfo(spec=CPython3.11.2.final.0-64-x86_64, exe=/tmp/tmpoobpc4fq/copy/python3.11m, platform=linux, version='3.11.2 (main, Oct  7 2026, 12:35:07) [GCC 12.2.0]', encoding_fs_io=utf-8-utf-8)
    accepted PathPythonInfo(spec=CPython3.11.2.final.0-64-x86_64, exe=/tmp/tmpoobpc4fq/copy/python3.11m, platform=linux, version='3.11.2 (main, Oct  7 2026, 12:35:07) [GCC 12.2.0]', encoding_fs_io=utf-8-utf-8)
  get_interpreter returned: /tmp/tmpoobpc4fq/copy/python3.11m
BUG

Expected: The symlink's name should satisfy a literal executable-name request.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: the request must use a name outside the version-spec grammar and find it through a symlink. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

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

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