Skip to content

Commit c3d39f0

Browse files
committed
feat(pathfinder): add CTK-root canary fallback to find_nvidia_binary_utility
After the deterministic search over the explicit trusted directories (NVIDIA wheel bin/, CONDA_PREFIX, CUDA_HOME/CUDA_PATH) misses, fall back to a CTK-root canary probe: resolve cudart through the OS dynamic loader, which honors LD_LIBRARY_PATH on Linux and the native DLL search on Windows, derive the CUDA Toolkit root from its absolute path, and search that root's bin layout. This addresses the concern raised on #2196: users who follow the CUDA Linux installation guide set LD_LIBRARY_PATH for libraries and PATH for executables. The bounded finder alone would stop finding the utility for them because PATH is intentionally never consulted. The canary fallback recovers that case through LD_LIBRARY_PATH instead of PATH. LD_LIBRARY_PATH is still an attack vector, but a significantly harder one to exploit than PATH, and the ambient PATH and process CWD remain unused. The canary runs only after the explicit trusted dirs miss, so the common wheel/conda/CUDA_HOME cases never spawn the resolver subprocess. The canary -> CTK-root resolution is factored into a shared resolve_ctk_root_via_canary helper reused by the dynamic-library CTK-root canary flow. Adds tests for the fallback (found, ordering, Windows bin layout, not consulted when found earlier, cached) and for resolve_ctk_root_via_canary. Adds 1.6.0 release notes for the minor version bump.
1 parent 72366a8 commit c3d39f0

5 files changed

Lines changed: 206 additions & 12 deletions

File tree

cuda_pathfinder/cuda/pathfinder/_binaries/find_nvidia_binary_utility.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from cuda.pathfinder._utils.find_sub_dirs import find_sub_dirs_all_sitepackages
1010
from cuda.pathfinder._utils.platform_aware import IS_WINDOWS
1111

12+
# CUDA Toolkit canary library used to derive the toolkit root when it is only
13+
# visible through the dynamic loader. ``cudart`` always ships with the CTK and
14+
# matches the anchor used by the dynamic-library CTK-root canary flow.
15+
_CTK_ROOT_CANARY_ANCHOR_LIBNAME = "cudart"
16+
1217

1318
class UnsupportedBinaryError(Exception):
1419
def __init__(self, utility: str) -> None:
@@ -41,6 +46,35 @@ def _is_executable_file(path: str) -> bool:
4146
return os.access(path, os.X_OK)
4247

4348

49+
def _ctk_bin_subdirs(root: str) -> list[str]:
50+
"""Return the bin directories to search under a CUDA Toolkit ``root``.
51+
52+
On Windows the CTK ships binaries under ``bin/x64`` (CTK 13), ``bin/x86_64``,
53+
and ``bin`` (CTK 12); on Linux they live in ``bin``.
54+
"""
55+
if IS_WINDOWS:
56+
return [
57+
os.path.join(root, "bin", "x64"),
58+
os.path.join(root, "bin", "x86_64"),
59+
os.path.join(root, "bin"),
60+
]
61+
return [os.path.join(root, "bin")]
62+
63+
64+
def _resolve_ctk_root_via_canary() -> str | None:
65+
"""Derive the CUDA Toolkit root from the ``cudart`` canary library.
66+
67+
``cudart`` is resolved by the OS dynamic loader, which honors
68+
``LD_LIBRARY_PATH`` on Linux and the native DLL search on Windows, and the
69+
toolkit root is derived from its absolute path. The ambient ``PATH`` is
70+
never consulted. The loader module is imported lazily to avoid pulling the
71+
dynamic-library machinery in at import time.
72+
"""
73+
from cuda.pathfinder._dynamic_libs.load_nvidia_dynamic_lib import resolve_ctk_root_via_canary
74+
75+
return resolve_ctk_root_via_canary(_CTK_ROOT_CANARY_ANCHOR_LIBNAME)
76+
77+
4478
def _resolve_in_trusted_dirs(normalized_name: str, dirs: list[str]) -> str | None:
4579
"""Resolve ``normalized_name`` against ``dirs`` only, in order.
4680
@@ -99,6 +133,15 @@ def find_nvidia_binary_utility(utility_name: str) -> str | None:
99133
``bin/x64``, ``bin/x86_64``, and ``bin`` subdirectories on Windows,
100134
or just ``bin`` on Linux.
101135
136+
4. **CTK-root canary fallback**
137+
138+
- Only when steps 1-3 miss: resolve the ``cudart`` library through the
139+
OS dynamic loader (which honors ``LD_LIBRARY_PATH`` on Linux and the
140+
native DLL search on Windows), derive the CUDA Toolkit root from it,
141+
and search that root's bin layout. This finds the utility for users
142+
who follow the CUDA install guide and set ``LD_LIBRARY_PATH`` for
143+
libraries without also setting ``CUDA_HOME`` / ``CUDA_PATH``.
144+
102145
Note:
103146
Results are cached using ``@functools.cache`` for performance. The cache
104147
persists for the lifetime of the process.
@@ -107,8 +150,9 @@ def find_nvidia_binary_utility(utility_name: str) -> str | None:
107150
(``.exe``, ``.bat``, ``.cmd``). On Unix-like systems, executables
108151
are identified by the ``X_OK`` (execute) permission bit.
109152
110-
Lookup is restricted to the trusted directories listed above; the
111-
process working directory and the ambient ``PATH`` are never consulted.
153+
Lookup is restricted to the trusted directories and the canary-derived
154+
CTK root listed above; the process working directory and the ambient
155+
``PATH`` are never consulted.
112156
113157
Example:
114158
>>> from cuda.pathfinder import find_nvidia_binary_utility
@@ -135,10 +179,17 @@ def find_nvidia_binary_utility(utility_name: str) -> str | None:
135179

136180
# 3. Search in CUDA Toolkit (CUDA_HOME/CUDA_PATH)
137181
if (cuda_home := get_cuda_path_or_home()) is not None:
138-
if IS_WINDOWS:
139-
dirs.append(os.path.join(cuda_home, "bin", "x64"))
140-
dirs.append(os.path.join(cuda_home, "bin", "x86_64"))
141-
dirs.append(os.path.join(cuda_home, "bin"))
182+
dirs.extend(_ctk_bin_subdirs(cuda_home))
142183

143184
normalized_name = _normalize_utility_name(utility_name)
144-
return _resolve_in_trusted_dirs(normalized_name, dirs)
185+
found = _resolve_in_trusted_dirs(normalized_name, dirs)
186+
if found is not None:
187+
return found
188+
189+
# 4. CTK-root canary fallback: only when the explicit trusted dirs above
190+
# miss. Resolve cudart via the dynamic loader (honors LD_LIBRARY_PATH),
191+
# derive the toolkit root, and search its bin layout. PATH is never used.
192+
ctk_root = _resolve_ctk_root_via_canary()
193+
if ctk_root is not None:
194+
return _resolve_in_trusted_dirs(normalized_name, _ctk_bin_subdirs(ctk_root))
195+
return None

cuda_pathfinder/cuda/pathfinder/_dynamic_libs/load_nvidia_dynamic_lib.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,25 @@ def _loadable_via_canary_subprocess(libname: str, *, timeout: float = _CANARY_PR
136136
return _resolve_system_loaded_abs_path_in_subprocess(libname, timeout=timeout) is not None
137137

138138

139+
def resolve_ctk_root_via_canary(canary_libname: str) -> str | None:
140+
"""Resolve the CUDA Toolkit root from a system-loadable canary library.
141+
142+
The canary library's absolute path is resolved by the OS dynamic loader in
143+
an isolated subprocess, which honors ``LD_LIBRARY_PATH`` on Linux and the
144+
native DLL search on Windows. The toolkit root is then derived from that
145+
path. Returns ``None`` if the canary cannot be resolved or no root can be
146+
derived. The ambient ``PATH`` is never consulted.
147+
"""
148+
canary_abs_path = _resolve_system_loaded_abs_path_in_subprocess(canary_libname)
149+
if canary_abs_path is None:
150+
return None
151+
return derive_ctk_root(canary_abs_path)
152+
153+
139154
def _try_ctk_root_canary(ctx: SearchContext) -> str | None:
140155
"""Try CTK-root canary fallback for descriptor-configured libraries."""
141156
for canary_libname in ctx.desc.ctk_root_canary_anchor_libnames:
142-
canary_abs_path = _resolve_system_loaded_abs_path_in_subprocess(canary_libname)
143-
if canary_abs_path is None:
144-
continue
145-
ctk_root = derive_ctk_root(canary_abs_path)
157+
ctk_root = resolve_ctk_root_via_canary(canary_libname)
146158
if ctk_root is None:
147159
continue
148160
find = find_via_ctk_root(ctx, ctk_root)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
.. SPDX-License-Identifier: Apache-2.0
3+
4+
.. py:currentmodule:: cuda.pathfinder
5+
6+
``cuda-pathfinder`` 1.6.0 Release notes
7+
=======================================
8+
9+
Highlights
10+
----------
11+
12+
* :func:`find_nvidia_binary_utility` now resolves binaries through a bounded,
13+
deterministic search of trusted directories instead of ``shutil.which``. The
14+
process working directory and the ambient ``PATH`` are never consulted, which
15+
closes a lookup ambiguity on Windows where ``shutil.which`` prepends the CWD
16+
even when an explicit search path is supplied.
17+
(`PR #2196 <https://github.com/NVIDIA/cuda-python/pull/2196>`_)
18+
19+
* :func:`find_nvidia_binary_utility` gains a CTK-root canary fallback. When the
20+
NVIDIA wheel, ``CONDA_PREFIX``, and ``CUDA_HOME`` / ``CUDA_PATH`` directories
21+
all miss, ``cudart`` is resolved through the OS dynamic loader, which honors
22+
``LD_LIBRARY_PATH`` on Linux and the native DLL search on Windows. The CUDA
23+
Toolkit root is derived from that path and its ``bin`` layout is searched.
24+
This locates the utility for users who follow the CUDA installation guide and
25+
set ``LD_LIBRARY_PATH`` for libraries without also setting ``CUDA_HOME`` /
26+
``CUDA_PATH``, while still never falling back to ``PATH``.
27+
(`PR #2196 <https://github.com/NVIDIA/cuda-python/pull/2196>`_)

cuda_pathfinder/tests/test_ctk_root_discovery.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_load_lib_no_cache,
1818
_resolve_system_loaded_abs_path_in_subprocess,
1919
_try_ctk_root_canary,
20+
resolve_ctk_root_via_canary,
2021
)
2122
from cuda.pathfinder._dynamic_libs.search_steps import (
2223
SearchContext,
@@ -369,6 +370,35 @@ def test_canary_skips_when_abs_path_none(mocker):
369370
assert _try_ctk_root_canary(_ctx("nvvm")) is None
370371

371372

373+
# ---------------------------------------------------------------------------
374+
# resolve_ctk_root_via_canary (shared by lib and binary discovery)
375+
# ---------------------------------------------------------------------------
376+
377+
378+
def test_resolve_ctk_root_via_canary_returns_root(tmp_path, mocker):
379+
ctk_root = tmp_path / "cuda-13"
380+
_create_cudart_in_ctk(ctk_root)
381+
probe = mocker.patch(
382+
f"{_MODULE}._resolve_system_loaded_abs_path_in_subprocess",
383+
return_value=_fake_canary_path(ctk_root),
384+
)
385+
assert resolve_ctk_root_via_canary("cudart") == str(ctk_root)
386+
probe.assert_called_once_with("cudart")
387+
388+
389+
def test_resolve_ctk_root_via_canary_none_when_probe_fails(mocker):
390+
mocker.patch(f"{_MODULE}._resolve_system_loaded_abs_path_in_subprocess", return_value=None)
391+
assert resolve_ctk_root_via_canary("cudart") is None
392+
393+
394+
def test_resolve_ctk_root_via_canary_none_when_unrecognized(mocker):
395+
mocker.patch(
396+
f"{_MODULE}._resolve_system_loaded_abs_path_in_subprocess",
397+
return_value=os.path.join(os.sep, "weird", "path", "libcudart.so.13"),
398+
)
399+
assert resolve_ctk_root_via_canary("cudart") is None
400+
401+
372402
# ---------------------------------------------------------------------------
373403
# _load_lib_no_cache search-order
374404
# ---------------------------------------------------------------------------

cuda_pathfinder/tests/test_find_nvidia_binaries.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def test_find_binary_search_path_includes_site_packages_conda_cuda(monkeypatch,
7676
)
7777
monkeypatch.setenv("CONDA_PREFIX", conda_prefix)
7878
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=cuda_home)
79+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
7980
expected_dirs = [
8081
site_dir,
8182
os.path.join(conda_prefix, "bin"),
@@ -109,6 +110,7 @@ def test_find_binary_windows_extension_and_search_dirs(monkeypatch, mocker):
109110
)
110111
monkeypatch.setenv("CONDA_PREFIX", conda_prefix)
111112
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=cuda_home)
113+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
112114
expected_dirs = [
113115
site_dir,
114116
os.path.join(conda_prefix, "Library", "bin"),
@@ -142,6 +144,7 @@ def test_find_binary_first_matching_dir_wins(monkeypatch, mocker):
142144
mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[site_dir])
143145
monkeypatch.setenv("CONDA_PREFIX", conda_prefix)
144146
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=cuda_home)
147+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
145148
conda_nvcc = os.path.join(conda_prefix, "bin", "nvcc")
146149
cuda_nvcc = os.path.join(cuda_home, "bin", "nvcc")
147150
checked = _patch_exec_probe(mocker, existing=[conda_nvcc, cuda_nvcc])
@@ -153,6 +156,72 @@ def test_find_binary_first_matching_dir_wins(monkeypatch, mocker):
153156
assert checked == [os.path.join(site_dir, "nvcc"), conda_nvcc]
154157

155158

159+
@pytest.mark.usefixtures("clear_find_binary_cache")
160+
def test_find_binary_ctk_root_canary_fallback(monkeypatch, mocker):
161+
# When the explicit trusted dirs (wheels, conda, CUDA_HOME/PATH) all miss,
162+
# the cudart-canary-derived CTK root is searched last.
163+
ctk_root = os.path.join(os.sep, "opt", "cuda")
164+
165+
mocker.patch.object(binary_finder_module, "IS_WINDOWS", new=False)
166+
mocker.patch.object(binary_finder_module.supported_nvidia_binaries, "SITE_PACKAGES_BINDIRS", {})
167+
mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
168+
monkeypatch.delenv("CONDA_PREFIX", raising=False)
169+
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=None)
170+
canary_mock = mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=ctk_root)
171+
ctk_nvcc = os.path.join(ctk_root, "bin", "nvcc")
172+
checked = _patch_exec_probe(mocker, existing=[ctk_nvcc])
173+
174+
result = find_nvidia_binary_utility("nvcc")
175+
176+
assert result == ctk_nvcc
177+
canary_mock.assert_called_once_with()
178+
# No earlier trusted dirs existed, so the only probe is the canary bin dir.
179+
assert checked == [ctk_nvcc]
180+
181+
182+
@pytest.mark.usefixtures("clear_find_binary_cache")
183+
def test_find_binary_canary_windows_bin_layout(monkeypatch, mocker):
184+
ctk_root = os.path.join("C:", os.sep, "cuda")
185+
186+
mocker.patch.object(binary_finder_module, "IS_WINDOWS", new=True)
187+
mocker.patch.object(binary_finder_module.supported_nvidia_binaries, "SITE_PACKAGES_BINDIRS", {})
188+
mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
189+
monkeypatch.delenv("CONDA_PREFIX", raising=False)
190+
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=None)
191+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=ctk_root)
192+
expected_dirs = [
193+
os.path.join(ctk_root, "bin", "x64"),
194+
os.path.join(ctk_root, "bin", "x86_64"),
195+
os.path.join(ctk_root, "bin"),
196+
]
197+
checked = _patch_exec_probe(mocker)
198+
199+
result = find_nvidia_binary_utility("nvcc")
200+
201+
assert result is None
202+
assert checked == [os.path.join(d, "nvcc.exe") for d in expected_dirs]
203+
204+
205+
@pytest.mark.usefixtures("clear_find_binary_cache")
206+
def test_find_binary_canary_not_consulted_when_found_earlier(monkeypatch, mocker):
207+
# An earlier trusted dir hit must short-circuit before the canary subprocess.
208+
conda_prefix = os.path.join(os.sep, "conda")
209+
210+
mocker.patch.object(binary_finder_module, "IS_WINDOWS", new=False)
211+
mocker.patch.object(binary_finder_module.supported_nvidia_binaries, "SITE_PACKAGES_BINDIRS", {})
212+
mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
213+
monkeypatch.setenv("CONDA_PREFIX", conda_prefix)
214+
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=None)
215+
canary_mock = mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
216+
conda_nvcc = os.path.join(conda_prefix, "bin", "nvcc")
217+
_patch_exec_probe(mocker, existing=[conda_nvcc])
218+
219+
result = find_nvidia_binary_utility("nvcc")
220+
221+
assert result == conda_nvcc
222+
canary_mock.assert_not_called()
223+
224+
156225
@pytest.mark.usefixtures("clear_find_binary_cache")
157226
def test_find_binary_returns_none_with_no_candidates(monkeypatch, mocker):
158227
site_key = os.path.join("nvidia", "cuda_nvcc", "bin")
@@ -166,6 +235,7 @@ def test_find_binary_returns_none_with_no_candidates(monkeypatch, mocker):
166235
find_sub_dirs_mock = mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
167236
monkeypatch.delenv("CONDA_PREFIX", raising=False)
168237
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=None)
238+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
169239
checked = _patch_exec_probe(mocker)
170240

171241
result = find_nvidia_binary_utility("nvcc")
@@ -186,6 +256,7 @@ def test_find_binary_without_site_packages_entry(monkeypatch, mocker):
186256
find_sub_dirs_mock = mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
187257
monkeypatch.setenv("CONDA_PREFIX", conda_prefix)
188258
mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=cuda_home)
259+
mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
189260
expected_dirs = [
190261
os.path.join(conda_prefix, "bin"),
191262
os.path.join(cuda_home, "bin"),
@@ -206,15 +277,18 @@ def test_find_binary_cache_negative_result(monkeypatch, mocker):
206277
mocker.patch.object(binary_finder_module, "find_sub_dirs_all_sitepackages", return_value=[])
207278
monkeypatch.delenv("CONDA_PREFIX", raising=False)
208279
cuda_home_mock = mocker.patch.object(binary_finder_module, "get_cuda_path_or_home", return_value=None)
280+
canary_mock = mocker.patch.object(binary_finder_module, "_resolve_ctk_root_via_canary", return_value=None)
209281
_patch_exec_probe(mocker)
210282

211283
first = find_nvidia_binary_utility("nvcc")
212284
second = find_nvidia_binary_utility("nvcc")
213285

214286
assert first is None
215287
assert second is None
216-
# The second call is served from @functools.cache, so the body runs only once.
288+
# The second call is served from @functools.cache, so the body runs only
289+
# once, including the canary fallback.
217290
cuda_home_mock.assert_called_once_with()
291+
canary_mock.assert_called_once_with()
218292

219293

220294
class TestResolveInTrustedDirs:

0 commit comments

Comments
 (0)