From 6144182d10e8f7cd6c1eb680cfbcb8a3aa83fe0d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 19:59:33 -0700 Subject: [PATCH 01/10] test(bootstrap_impls): add reproduction test for stdlib symlink sys.path bug Because Python follows symlinks when initializing standard library paths, sys.path entries currently resolve to underlying repository cache or execroot locations rather than remaining within the runfiles tree. Add a pytest test target (stdlib_symlink_syspath_bootstrap_script_test) that collects all stdlib entries in sys.path and asserts that they are located within the runfiles root. --- tests/bootstrap_impls/BUILD.bazel | 13 +++++ .../stdlib_symlink_syspath_test.py | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/bootstrap_impls/stdlib_symlink_syspath_test.py diff --git a/tests/bootstrap_impls/BUILD.bazel b/tests/bootstrap_impls/BUILD.bazel index 89cd682a6a..2c4eae8d21 100644 --- a/tests/bootstrap_impls/BUILD.bazel +++ b/tests/bootstrap_impls/BUILD.bazel @@ -17,6 +17,7 @@ load("//python:py_test.bzl", "py_test") load("//tests/support:py_reconfig.bzl", "py_reconfig_binary", "py_reconfig_test") load("//tests/support:sh_py_run_test.bzl", "sh_py_run_test") load("//tests/support:support.bzl", "SUPPORTS_BOOTSTRAP_SCRIPT") +load("//tests/support/pytest_test:pytest_test.bzl", "pytest_test") load(":venv_relative_path_tests.bzl", "relative_path_test_suite") py_reconfig_binary( @@ -127,6 +128,18 @@ py_reconfig_test( target_compatible_with = SUPPORTS_BOOTSTRAP_SCRIPT, ) +pytest_test( + name = "stdlib_symlink_syspath_bootstrap_script_test", + srcs = ["stdlib_symlink_syspath_test.py"], + config_settings = { + "//python/config_settings:bootstrap_impl": "script", + }, + target_compatible_with = SUPPORTS_BOOTSTRAP_SCRIPT, + deps = [ + "//python/runfiles", + ], +) + py_reconfig_test( name = "sys_path_order_bootstrap_system_python_test", srcs = ["sys_path_order_test.py"], diff --git a/tests/bootstrap_impls/stdlib_symlink_syspath_test.py b/tests/bootstrap_impls/stdlib_symlink_syspath_test.py new file mode 100644 index 0000000000..a8b1dfadd7 --- /dev/null +++ b/tests/bootstrap_impls/stdlib_symlink_syspath_test.py @@ -0,0 +1,53 @@ +"""Tests that stdlib entries in sys.path point to runfiles locations. + +Verifies stdlib is not added from the underlying repository location. +""" + +from __future__ import annotations + +import os +import pathlib +import re +import sys + +from python.runfiles import runfiles + + +def _is_stdlib_path(path_str: str) -> bool: + norm = path_str.replace("\\", "/").rstrip("/") + base = norm.split("/")[-1].lower() + if base.endswith("-packages"): + return False + if re.match(r"^python\d*\.zip$", base): + return True + if base in ("lib-dynload", "dlls", "lib"): + return True + if re.match(r"^python3\.\d+$", base): + return True + return False + + +def test_stdlib_sys_path_in_runfiles() -> None: + rf = runfiles.CreateOrRaise() + runfiles_root = rf.root() + + stdlib_paths = [p for p in sys.path if _is_stdlib_path(p)] + assert stdlib_paths, ( + "Expected to find at least one stdlib path in sys.path:\n" + "\n".join(sys.path) + ) + + norm_root = pathlib.Path(os.path.normcase(runfiles_root)) + violations = [] + for p in stdlib_paths: + norm_p = pathlib.Path(os.path.normcase(p)) + if not norm_p.is_relative_to(norm_root): + violations.append(p) + + assert not violations, ( + "Expected stdlib sys.path entries to be located within " + f"runfiles tree ({runfiles_root}), but got underlying " + "repository locations:\n" + + "\n".join(f" {v}" for v in violations) + + "\nFull sys.path:\n" + + "\n".join(f" {p}" for p in sys.path) + ) From 193752a61a6616dc023d8dcaf7a441e8c29b2e22 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 22:02:21 -0700 Subject: [PATCH 02/10] fix(bootstrap): remap stdlib sys.path entries to runfiles during site init When CPython resolves interpreter symlinks during startup, it leaks underlying repository cache or external repository paths into sys.path and sys prefixes instead of their runfiles locations. This breaks runfiles isolation and prevents using a mixture of generated and non-generated files as part of the python runtime, as standard library modules and runtime site-packages are loaded from locations outside the runfiles tree. Fix this by passing interpreter_actual_path into site_init_template.py during virtual environment creation. During site initialization, collect any non-runfiles sys prefixes into a set and remap matching sys.path entries, sys prefixes, and site.PREFIXES to the runfiles runtime root. --- python/private/py_executable.bzl | 1 + python/private/site_init_template.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index 0e9c315a73..757fb2c20e 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl @@ -594,6 +594,7 @@ def _create_venv(ctx, output_prefix, imports, runtime_details, add_runfiles_root "%add_runfiles_root_to_sys_path%": add_runfiles_root_to_sys_path, "%coverage_tool%": _get_coverage_tool_runfiles_path(ctx, runtime), "%import_all%": "True" if read_possibly_native_flag(ctx, "python_import_all_repositories") else "False", + "%interpreter_actual_path%": interpreter_actual_path, "%site_init_runfiles_path%": runfiles_root_path(ctx, site_init.short_path), "%workspace_name%": ctx.workspace_name, }, diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index 12be98eb57..034ff3e438 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -29,6 +29,7 @@ _COVERAGE_TOOL = "%coverage_tool%" # True if the runfiles root should be added to sys.path _ADD_RUNFILES_ROOT_TO_SYS_PATH = "%add_runfiles_root_to_sys_path%" == "1" +_INTERPRETER_ACTUAL_PATH = "%interpreter_actual_path%" def _is_verbose(): @@ -52,6 +53,7 @@ def _print_verbose(*args, mapping=None, values=None): _print_verbose("workspace_name:", _WORKSPACE_NAME) _print_verbose("self_runfiles_path:", _SELF_RUNFILES_RELATIVE_PATH) _print_verbose("coverage_tool:", _COVERAGE_TOOL) +_print_verbose("interpreter_actual_path:", _INTERPRETER_ACTUAL_PATH) def _find_runfiles_root(): @@ -238,7 +240,75 @@ def _fixup_sys_base_executable(): sys._base_executable = exe +def _fixup_stdlib_paths(): + """Remap non-runfiles runtime paths to their runfiles locations. + + Replaces non-runfiles sys prefix roots (e.g. sys.base_prefix) with the + runtime root inside runfiles across sys.path, sys prefixes, and + site.PREFIXES. + """ + if not _INTERPRETER_ACTUAL_PATH or os.path.isabs(_INTERPRETER_ACTUAL_PATH): + return + if not _RUNFILES_ROOT: + return + + abs_interpreter = os.path.join(_RUNFILES_ROOT, _INTERPRETER_ACTUAL_PATH) + parent = os.path.dirname(abs_interpreter) + if os.path.basename(parent).lower() in ("bin", "scripts"): + runtime_root = os.path.dirname(parent) + else: + runtime_root = parent + + runfiles_norm = _RUNFILES_ROOT.replace("\\", "/").rstrip("/") + runfiles_prefix = runfiles_norm + "/" + + def _in_runfiles(path_str): + norm = path_str.replace("\\", "/").rstrip("/") + return norm == runfiles_norm or norm.startswith(runfiles_prefix) + + target_root = _get_windows_path_with_unc_prefix(runtime_root) + if _is_windows(): + target_root = target_root.replace("/", os.sep) + + old_prefixes = set() + for attr in ("base_prefix", "base_exec_prefix", "prefix", "exec_prefix"): + old_prefix = getattr(sys, attr) + if _in_runfiles(old_prefix): + continue + + old_prefixes.add(old_prefix) + + _print_verbose(f"remap sys.{attr}:", old_prefix, "->", target_root) + setattr(sys, attr, target_root) + + # Fast path: if no runtime prefixes were replaced, no paths leaked outside + # the tree and no further remapping is needed. + if not old_prefixes: + return + + for i, p in enumerate(sys.path): + for old_prefix in old_prefixes: + # Check both separators to match subdirectories regardless of + # Windows slash style, while preventing false-positive matches + # against sibling directories (e.g. /foo/prefix vs /foo/prefix2). + prefix_seps = (old_prefix + "/", old_prefix + "\\") + if p == old_prefix or p.startswith(prefix_seps): + new_path = target_root + p[len(old_prefix) :] + _print_verbose("remap stdlib sys.path:", p, "->", new_path) + sys.path[i] = new_path + break + + import site + + if hasattr(site, "PREFIXES"): + for i, prefix in enumerate(site.PREFIXES): + if not _in_runfiles(prefix): + _print_verbose("remap site.PREFIXES:", prefix, "->", target_root) + site.PREFIXES[i] = target_root + + _fixup_sys_base_executable() +_fixup_stdlib_paths() COVERAGE_SETUP = _setup_sys_path() _print_verbose("DONE") From 300c206efdaf8f70dbf0742bd524676a3a3e98cf Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 23:50:04 -0700 Subject: [PATCH 03/10] fix(bootstrap): only remap stdlib paths if runtime root contains a stdlib In WORKSPACE mode or when using platform/system Python runtimes (such as in runtime_env_toolchain), the runtime root in runfiles does not contain a Python standard library. Unconditionally remapping sys.base_prefix caused the valid system stdlib path to be replaced with a non-existent runfiles directory. Fix this by checking that the target runfiles runtime root actually contains a standard library directory (lib, lib64, Lib, or DLLs) before performing any sys.path or sys prefix remapping. --- python/private/site_init_template.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index 034ff3e438..a50a90c3b5 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -266,6 +266,16 @@ def _in_runfiles(path_str): norm = path_str.replace("\\", "/").rstrip("/") return norm == runfiles_norm or norm.startswith(runfiles_prefix) + # Fast path: only remap if runtime_root in runfiles actually contains a + # standard library (avoiding remapping system/platform Python runtimes). + has_stdlib = False + for entry in ("lib", "lib64", "Lib", "DLLs"): + if os.path.exists(os.path.join(runtime_root, entry)): + has_stdlib = True + break + if not has_stdlib: + return + target_root = _get_windows_path_with_unc_prefix(runtime_root) if _is_windows(): target_root = target_root.replace("/", os.sep) From f740b9e0d71388221c4e51e466734b230f6c9851 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 22 Aug 2026 23:59:14 -0700 Subject: [PATCH 04/10] fix(bootstrap): normalize path casing for Windows runfiles matching On Windows, drive letter casing (e.g. C:\ vs c:\) between runfiles root and sys.prefix caused virtual environment directories to fail runfiles containment checks. This resulted in venv sys.prefix being overwritten by the base Python runtime root. Use os.path.normcase when normalizing paths in _fixup_stdlib_paths so runfiles containment and sys.path matching are case-insensitive on Windows. --- python/private/site_init_template.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index a50a90c3b5..2db24d8732 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -259,11 +259,14 @@ def _fixup_stdlib_paths(): else: runtime_root = parent - runfiles_norm = _RUNFILES_ROOT.replace("\\", "/").rstrip("/") + def _norm_path(path_str): + return os.path.normcase(path_str).replace("\\", "/").rstrip("/") + + runfiles_norm = _norm_path(_RUNFILES_ROOT) runfiles_prefix = runfiles_norm + "/" def _in_runfiles(path_str): - norm = path_str.replace("\\", "/").rstrip("/") + norm = _norm_path(path_str) return norm == runfiles_norm or norm.startswith(runfiles_prefix) # Fast path: only remap if runtime_root in runfiles actually contains a @@ -297,12 +300,10 @@ def _in_runfiles(path_str): return for i, p in enumerate(sys.path): + norm_p = _norm_path(p) for old_prefix in old_prefixes: - # Check both separators to match subdirectories regardless of - # Windows slash style, while preventing false-positive matches - # against sibling directories (e.g. /foo/prefix vs /foo/prefix2). - prefix_seps = (old_prefix + "/", old_prefix + "\\") - if p == old_prefix or p.startswith(prefix_seps): + norm_old = _norm_path(old_prefix) + if norm_p == norm_old or norm_p.startswith(norm_old + "/"): new_path = target_root + p[len(old_prefix) :] _print_verbose("remap stdlib sys.path:", p, "->", new_path) sys.path[i] = new_path From 1ad8e1e5b9633690fac9ee5ba0c94904bb586ee2 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 00:08:57 -0700 Subject: [PATCH 05/10] fix(bootstrap): do not remap sys.prefix in virtual environments In virtual environments (where sys.prefix != sys.base_prefix), sys.prefix points to the .venv directory (which on Windows is created outside the runfiles tree in the output bin directory). Unconditionally remapping sys.prefix and sys.exec_prefix overwrote the venv prefix with the base Python runtime root. Fix this by only remapping sys.base_prefix and sys.base_exec_prefix when executing inside a virtual environment. --- python/private/site_init_template.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index 2db24d8732..7965cfaea7 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -283,8 +283,18 @@ def _in_runfiles(path_str): if _is_windows(): target_root = target_root.replace("/", os.sep) + # When running in a virtual environment (sys.prefix != sys.base_prefix), + # sys.prefix points to the .venv directory (which on Windows may reside + # outside the runfiles tree). Never overwrite sys.prefix / sys.exec_prefix + # with the base Python stdlib root in a venv. + in_venv = sys.prefix != sys.base_prefix + attrs = ( + ("base_prefix", "base_exec_prefix") + if in_venv + else ("base_prefix", "base_exec_prefix", "prefix", "exec_prefix") + ) old_prefixes = set() - for attr in ("base_prefix", "base_exec_prefix", "prefix", "exec_prefix"): + for attr in attrs: old_prefix = getattr(sys, attr) if _in_runfiles(old_prefix): continue From 6c7756988fcbcf3092223c9032f205b189f9f2e3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 00:55:07 -0700 Subject: [PATCH 06/10] docs: add news fragment for stdlib sys.path runfiles remapping fix Add news entry 4104.fixed.md to document the bootstrap fix for CPython interpreter symlink resolution leaking non-runfiles repository cache locations into sys.path and sys prefixes. --- news/4104.fixed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 news/4104.fixed.md diff --git a/news/4104.fixed.md b/news/4104.fixed.md new file mode 100644 index 0000000000..3c0db79d7a --- /dev/null +++ b/news/4104.fixed.md @@ -0,0 +1,5 @@ +(bootstrap) Fixed CPython interpreter symlink resolution during site +initialization leaking repository cache or execution root paths into +{obj}`sys.path` and {obj}`sys.base_prefix`, ensuring standard library and +runtime files resolve to their runfiles locations +([#4104](https://github.com/bazel-contrib/rules_python/pull/4104)). From a39b5bbcec8e1cda3666d68c3de5118b3a656769 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 01:46:44 -0700 Subject: [PATCH 07/10] docs: recategorize news entry for sys.path runfiles behavior as changed Recategorize the news fragment for PR 4104 from fixed to changed and rephrase to note that sys.path adds the runtime in runfiles instead of the underlying Bazel repository cache directory. --- news/4104.changed.md | 3 +++ news/4104.fixed.md | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) create mode 100644 news/4104.changed.md delete mode 100644 news/4104.fixed.md diff --git a/news/4104.changed.md b/news/4104.changed.md new file mode 100644 index 0000000000..09cd67d8ba --- /dev/null +++ b/news/4104.changed.md @@ -0,0 +1,3 @@ +(bootstrap) {obj}`sys.path` adds the runtime in runfiles instead of the +underlying Bazel repository cache directory +([#4104](https://github.com/bazel-contrib/rules_python/pull/4104)). diff --git a/news/4104.fixed.md b/news/4104.fixed.md deleted file mode 100644 index 3c0db79d7a..0000000000 --- a/news/4104.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -(bootstrap) Fixed CPython interpreter symlink resolution during site -initialization leaking repository cache or execution root paths into -{obj}`sys.path` and {obj}`sys.base_prefix`, ensuring standard library and -runtime files resolve to their runfiles locations -([#4104](https://github.com/bazel-contrib/rules_python/pull/4104)). From f4ab83d1c9348880eb4068ba9e7e53232fadff75 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 11:33:55 -0700 Subject: [PATCH 08/10] docs: clarify news entry to specify Python runtime in runfiles Update news/4104.changed.md wording to explicitly mention that sys.path adds the Python runtime in runfiles. --- news/4104.changed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/news/4104.changed.md b/news/4104.changed.md index 09cd67d8ba..e30e6baeb5 100644 --- a/news/4104.changed.md +++ b/news/4104.changed.md @@ -1,3 +1,3 @@ -(bootstrap) {obj}`sys.path` adds the runtime in runfiles instead of the -underlying Bazel repository cache directory +(bootstrap) {obj}`sys.path` adds the Python runtime in runfiles instead +of the underlying Bazel repository cache directory ([#4104](https://github.com/bazel-contrib/rules_python/pull/4104)). From 9d32dc29c50e40d5fde6eb6b85a388973a8b31bc Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 15:31:35 -0700 Subject: [PATCH 09/10] refactor(bootstrap): unconditionally remap stdlib paths and simplify attrs flow Remove the has_stdlib fast path check so sys prefix remapping runs unconditionally for consistency, and reflow the venv attribute selection from a ternary to a standard if-else statement. --- python/private/site_init_template.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index 7965cfaea7..28112fccf3 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -269,16 +269,6 @@ def _in_runfiles(path_str): norm = _norm_path(path_str) return norm == runfiles_norm or norm.startswith(runfiles_prefix) - # Fast path: only remap if runtime_root in runfiles actually contains a - # standard library (avoiding remapping system/platform Python runtimes). - has_stdlib = False - for entry in ("lib", "lib64", "Lib", "DLLs"): - if os.path.exists(os.path.join(runtime_root, entry)): - has_stdlib = True - break - if not has_stdlib: - return - target_root = _get_windows_path_with_unc_prefix(runtime_root) if _is_windows(): target_root = target_root.replace("/", os.sep) @@ -288,11 +278,10 @@ def _in_runfiles(path_str): # outside the runfiles tree). Never overwrite sys.prefix / sys.exec_prefix # with the base Python stdlib root in a venv. in_venv = sys.prefix != sys.base_prefix - attrs = ( - ("base_prefix", "base_exec_prefix") - if in_venv - else ("base_prefix", "base_exec_prefix", "prefix", "exec_prefix") - ) + if in_venv: + attrs = ("base_prefix", "base_exec_prefix") + else: + attrs = ("base_prefix", "base_exec_prefix", "prefix", "exec_prefix") old_prefixes = set() for attr in attrs: old_prefix = getattr(sys, attr) From 8e11863e4c3a138cab21f1169e0dafaf85537617 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 23 Aug 2026 15:42:30 -0700 Subject: [PATCH 10/10] fix(bootstrap): filter non-runfiles sys prefixes for Bazel markers before remapping When remapping non-runfiles sys prefixes without a has_stdlib check, filter old_prefix to ensure it contains Bazel output markers (/external/, /cache/, or /execroot/). This ensures hermetic CPython runtimes always remap consistently while system/platform runtimes in runtime_env_toolchain are not remapped to non-existent runfiles. --- python/private/site_init_template.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python/private/site_init_template.py b/python/private/site_init_template.py index 28112fccf3..d7565026c7 100644 --- a/python/private/site_init_template.py +++ b/python/private/site_init_template.py @@ -288,6 +288,15 @@ def _in_runfiles(path_str): if _in_runfiles(old_prefix): continue + # Only remap prefixes leaked from Bazel (external repositories, repo + # cache, or execution root). This avoids remapping system or platform + # Python runtimes (e.g. /usr) when using runtime_env_toolchain. + norm_prefix = _norm_path(old_prefix) + if not any( + marker in norm_prefix for marker in ("/external/", "/cache/", "/execroot/") + ): + continue + old_prefixes.add(old_prefix) _print_verbose(f"remap sys.{attr}:", old_prefix, "->", target_root)