fix: avoid stage1 bootstrap stdlib shadowing (#3854) The stage 1 system-python bootstrap imports standard-library modules before it re-execs into the configured runtime. If the target output directory contains files named like standard-library modules, such as `shutil.py` or `types.py`, the bootstrap can import those files instead and fail before user code starts. This change removes Python's unsafe script-directory prepend before those early imports, while preserving `sys.path[0]` when Python has already suppressed that prepend through `-P`/`PYTHONSAFEPATH` or isolated mode. Stage 2 now uses the same isolated-mode guard when removing or recreating the main-directory path. It also pins the Windows venv entry point template to LF line endings so the compile-pip CI dirty-worktree check is stable across checkout settings. Before this change, a generated target output could shadow bootstrap stdlib imports, and isolated-mode runs on older Python versions could lose a required stdlib path. After this change, bootstrap stdlib imports resolve from the interpreter's standard library, isolated mode preserves the interpreter-provided path, and the line-ending-sensitive CI check stays clean. Tests: ```shell bazel test --config=fast-tests \ //tests/bootstrap_impls:stdlib_shadowing_system_python_test bazel test --config=fast-tests \ //tests/bootstrap_impls:stdlib_shadowing_system_python_test \ //tests/bootstrap_impls:interpreter_args_test \ //tests/bootstrap_impls:sys_path_order_bootstrap_script_test ``` (cherry picked from commit fbb2ccf4ca4ac5d43f2e4181469b04dc14cfb092)
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 79d2dfb..b3e308c 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml
@@ -84,6 +84,9 @@ coverage_targets: ["..."] .coverage_targets_example_bzlmod_build_file_generation: &coverage_targets_example_bzlmod_build_file_generation coverage_targets: ["//:bzlmod_build_file_generation_test"] +.coverage_targets_bootstrap: &coverage_targets_bootstrap + coverage_targets: + - //tests/bootstrap_impls:stdlib_shadowing_system_python_test .coverage_targets_example_multi_python: &coverage_targets_example_multi_python coverage_targets: - //tests:my_lib_3_10_test @@ -216,6 +219,7 @@ bazel: 7.x ubuntu: <<: *reusable_config + <<: *coverage_targets_bootstrap name: "Default: Ubuntu, Bazel {bazel}" platform: ubuntu2204 bazel: ${{ bazel }}
diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb6c6d..6506c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md
@@ -46,6 +46,8 @@ * Fixed a flaky error on Windows 2022 when looking up the win32 version during site initialization by retrying the lookup ([#3721](https://github.com/bazel-contrib/rules_python/issues/3721)). +* (bootstrap) Fixed stage 1 bootstrap imports when target outputs shadow standard +library modules. * (coverage) Skip lcov report when no data was collected. * (pypi) Fixed `experimental_index_url` checking truthiness before envsubst expansion. @@ -68,6 +70,7 @@ + {#v2-1-0} ## [2.1.0] - 2026-06-17
diff --git a/python/private/python_bootstrap_template.txt b/python/private/python_bootstrap_template.txt index 38c93ec..482918c 100644 --- a/python/private/python_bootstrap_template.txt +++ b/python/private/python_bootstrap_template.txt
@@ -5,13 +5,31 @@ from __future__ import division from __future__ import print_function +import sys + +# By default, Python prepends the directory containing this script to +# sys.path. The stage 1 bootstrap only needs stdlib modules before it +# re-execs into the configured runtime, so avoid resolving imports from the +# target's output or runfiles package directory. This matters when that +# directory contains files with stdlib names, such as shutil.py or types.py. +# +# Python 3.11 introduced PYTHONSAFEPATH (-P), which disables the unsafe prepend. +# Isolated mode (-I) also disables it, including on older interpreters without +# safe_path. In either case, sys.path[0] is not the script directory and should +# be preserved. +if ( + not getattr(sys.flags, "safe_path", False) and + not getattr(sys.flags, "isolated", False) and + sys.path +): + del sys.path[0] + # Generated file from @rules_python//python/private:python_bootstrap_template.txt from os.path import abspath, dirname, join, basename, normpath import os import shutil import subprocess -import sys # NOTE: The sentinel strings are split (e.g., "%stage2" + "_bootstrap%") so that # the substitution logic won't replace them. This allows runtime detection of
diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py index b11fc76..f445ad2 100644 --- a/python/private/stage2_bootstrap_template.py +++ b/python/private/stage2_bootstrap_template.py
@@ -9,11 +9,16 @@ # and is a special case of #7091. # # Python 3.11 introduced an PYTHONSAFEPATH (-P) option that disables this -# behaviour, which we set in the stage 1 bootstrap. +# behaviour, which we set in the stage 1 bootstrap. Isolated mode (-I) also +# disables it, including on older interpreters without safe_path. # So the prepended entry needs to be removed only if the above option is either # unset or not supported by the interpreter. # NOTE: This can be removed when Python 3.10 and below is no longer supported -if not getattr(sys.flags, "safe_path", False): +if ( + not getattr(sys.flags, "safe_path", False) + and not getattr(sys.flags, "isolated", False) + and sys.path +): del sys.path[0] import contextlib @@ -539,8 +544,10 @@ # means only other generated files are importable (not source files). # # To replicate this behavior, we add main's directory within the runfiles - # when safe path isn't enabled. - if not getattr(sys.flags, "safe_path", False): + # when safe path or isolated mode isn't enabled. + if not getattr(sys.flags, "safe_path", False) and not getattr( + sys.flags, "isolated", False + ): prepend_path_entries = [ os.path.join(runfiles_root, os.path.dirname(main_rel_path)) ]
diff --git a/tests/bootstrap_impls/BUILD.bazel b/tests/bootstrap_impls/BUILD.bazel index ab3148d..89cd682 100644 --- a/tests/bootstrap_impls/BUILD.bazel +++ b/tests/bootstrap_impls/BUILD.bazel
@@ -136,6 +136,32 @@ main = "sys_path_order_test.py", ) +genrule( + name = "stdlib_shadowing_outputs", + outs = [ + "shutil.py", + "types.py", + ], + cmd = """ +cat > $(@D)/shutil.py <<'PY' +raise RuntimeError("target output shutil.py shadowed the stdlib shutil module") +PY +cat > $(@D)/types.py <<'PY' +raise RuntimeError("target output types.py shadowed the stdlib types module") +PY +""", +) + +py_reconfig_test( + name = "stdlib_shadowing_system_python_test", + srcs = [ + "stdlib_shadowing_test.py", + ":stdlib_shadowing_outputs", + ], + bootstrap_impl = "system_python", + main = "stdlib_shadowing_test.py", +) + py_reconfig_test( name = "main_module_test", srcs = ["main_module.py"],
diff --git a/tests/bootstrap_impls/stdlib_shadowing_test.py b/tests/bootstrap_impls/stdlib_shadowing_test.py new file mode 100644 index 0000000..fc91385 --- /dev/null +++ b/tests/bootstrap_impls/stdlib_shadowing_test.py
@@ -0,0 +1,20 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Verifies stage 1 bootstrap stdlib imports cannot be shadowed.""" + + +def test_bootstrap_reached_main(): + # If stage 1 imports target outputs such as shutil.py instead of stdlib + # modules, the process fails before this test module is executed. + pass