fix(bootstrap): handle when runfiles env vars don't point to current binary's runfiles (#3192)

The stage1 bootstrap script had a bug in the find_runfiles_root
function where it would unconditionally use the RUNFILES_DIR et al
environment variables if they were set.

This failed in a particular nested context: an outer binary
calling an inner binary when the inner binary isn't a data
dependency of the outer binary (i.e. the outer doesn't contain
the inner in runfiles). This would cause the inner binary to
incorrectly resolve its runfiles, leading to failures. Such
a case can occur if a genrule calls the outer binary, which has
the inner binary passed as an arg.

This change adds a check to validate that the script's entry point
exists within the inherited RUNFILES_DIR before using it. If the
entry point is not found, it proceeds with other runfiles discovery
methods. This matches the system_python runfiles discovery logic.

Fixes https://github.com/bazel-contrib/rules_python/issues/3187
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc3d7bb..03eccf8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,29 @@
 END_UNRELEASED_TEMPLATE
 -->
 
+{#v0-0-0}
+## Unreleased
+
+[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
+
+{#v0-0-0-changed}
+### Changed
+* Nothing changed.
+
+{#v0-0-0-fixed}
+### Fixed
+* (bootstrap) The stage1 bootstrap script now correctly handles nested `RUNFILES_DIR`
+  environments, fixing issues where a `py_binary` calls another `py_binary`
+  ([#3187](https://github.com/bazel-contrib/rules_python/issues/3187)).
+
+{#v0-0-0-added}
+### Added
+* Nothing added.
+
+{#v0-0-0-removed}
+### Removed
+* Nothing removed.
+
 {#1-6-0}
 ## [1.6.0] - 2025-08-23
 
@@ -102,7 +125,7 @@
   name.
 * (pypi) The selection of the whls has been changed and should no longer result
   in ambiguous select matches ({gh-issue}`2759`) and should be much more efficient
-  when running `bazel query` due to fewer repositories being included 
+  when running `bazel query` due to fewer repositories being included
   ({gh-issue}`2849`).
 * Multi-line python imports (e.g. with escaped newlines) are now correctly processed by Gazelle.
 * (toolchains) `local_runtime_repo` works with multiarch Debian with Python 3.8
diff --git a/python/private/python_bootstrap_template.txt b/python/private/python_bootstrap_template.txt
index 62ded87..495a52c 100644
--- a/python/private/python_bootstrap_template.txt
+++ b/python/private/python_bootstrap_template.txt
@@ -516,6 +516,9 @@
     module_space = FindModuleSpace(main_rel_path)
     delete_module_space = False
 
+  if os.environ.get("RULES_PYTHON_TESTING_TELL_MODULE_SPACE"):
+    new_env["RULES_PYTHON_TESTING_MODULE_SPACE"] = module_space
+
   python_imports = '%imports%'
   python_path_entries = CreatePythonPathEntries(python_imports, module_space)
   python_path_entries += GetRepositoriesImports(module_space, %import_all%)
diff --git a/python/private/stage1_bootstrap_template.sh b/python/private/stage1_bootstrap_template.sh
index 9927d4f..a984344 100644
--- a/python/private/stage1_bootstrap_template.sh
+++ b/python/private/stage1_bootstrap_template.sh
@@ -61,14 +61,20 @@
 
 else
   function find_runfiles_root() {
+    local maybe_root=""
     if [[ -n "${RUNFILES_DIR:-}" ]]; then
-      echo "$RUNFILES_DIR"
-      return 0
+      maybe_root="$RUNFILES_DIR"
     elif [[ "${RUNFILES_MANIFEST_FILE:-}" = *".runfiles_manifest" ]]; then
-      echo "${RUNFILES_MANIFEST_FILE%%.runfiles_manifest}.runfiles"
-      return 0
+      maybe_root="${RUNFILES_MANIFEST_FILE%%.runfiles_manifest}.runfiles"
     elif [[ "${RUNFILES_MANIFEST_FILE:-}" = *".runfiles/MANIFEST" ]]; then
-      echo "${RUNFILES_MANIFEST_FILE%%.runfiles/MANIFEST}.runfiles"
+      maybe_root="${RUNFILES_MANIFEST_FILE%%.runfiles/MANIFEST}.runfiles"
+    fi
+
+    # The RUNFILES_DIR et al variables may misreport the runfiles directory
+    # if an outer binary invokes this binary when it isn't a data dependency.
+    # e.g. a genrule calls `bazel-bin/outer --inner=bazel-bin/inner`
+    if [[ -n "$maybe_root" && -e "$maybe_root/$STAGE2_BOOTSTRAP" ]]; then
+      echo "$maybe_root"
       return 0
     fi
 
@@ -99,6 +105,9 @@
   RUNFILES_DIR=$(find_runfiles_root $0)
 fi
 
+if [[ -n "$RULES_PYTHON_TESTING_TELL_MODULE_SPACE" ]]; then
+  export RULES_PYTHON_TESTING_MODULE_SPACE="$RUNFILES_DIR"
+fi
 
 function find_python_interpreter() {
   runfiles_root="$1"
diff --git a/tests/bootstrap_impls/bin_calls_bin/BUILD.bazel b/tests/bootstrap_impls/bin_calls_bin/BUILD.bazel
new file mode 100644
index 0000000..02835fb
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/BUILD.bazel
@@ -0,0 +1,86 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+load("//tests/support:py_reconfig.bzl", "py_reconfig_binary")
+load("//tests/support:support.bzl", "NOT_WINDOWS", "SUPPORTS_BOOTSTRAP_SCRIPT")
+
+# =====
+# bootstrap_impl=system_python testing
+# =====
+py_reconfig_binary(
+    name = "outer_bootstrap_system_python",
+    srcs = ["outer.py"],
+    bootstrap_impl = "system_python",
+    main = "outer.py",
+    tags = ["manual"],
+)
+
+py_reconfig_binary(
+    name = "inner_bootstrap_system_python",
+    srcs = ["inner.py"],
+    bootstrap_impl = "system_python",
+    main = "inner.py",
+    tags = ["manual"],
+)
+
+genrule(
+    name = "outer_calls_inner_system_python",
+    outs = ["outer_calls_inner_system_python.out"],
+    cmd = "RULES_PYTHON_TESTING_TELL_MODULE_SPACE=1 $(location :outer_bootstrap_system_python) $(location :inner_bootstrap_system_python) > $@",
+    tags = ["manual"],
+    tools = [
+        ":inner_bootstrap_system_python",
+        ":outer_bootstrap_system_python",
+    ],
+)
+
+sh_test(
+    name = "bootstrap_system_python_test",
+    srcs = ["verify_system_python.sh"],
+    data = [
+        "verify.sh",
+        ":outer_calls_inner_system_python",
+    ],
+    # The way verify_system_python.sh loads verify.sh doesn't work
+    # with Windows for some annoying reason. Just skip windows for now;
+    # the logic being test isn't OS-specific, so this should be fine.
+    target_compatible_with = NOT_WINDOWS,
+)
+
+# =====
+# bootstrap_impl=script testing
+# =====
+py_reconfig_binary(
+    name = "inner_bootstrap_script",
+    srcs = ["inner.py"],
+    bootstrap_impl = "script",
+    main = "inner.py",
+    tags = ["manual"],
+)
+
+py_reconfig_binary(
+    name = "outer_bootstrap_script",
+    srcs = ["outer.py"],
+    bootstrap_impl = "script",
+    main = "outer.py",
+    tags = ["manual"],
+)
+
+genrule(
+    name = "outer_calls_inner_script_python",
+    outs = ["outer_calls_inner_script_python.out"],
+    cmd = "RULES_PYTHON_TESTING_TELL_MODULE_SPACE=1 $(location :outer_bootstrap_script) $(location :inner_bootstrap_script) > $@",
+    tags = ["manual"],
+    tools = [
+        ":inner_bootstrap_script",
+        ":outer_bootstrap_script",
+    ],
+)
+
+sh_test(
+    name = "bootstrap_script_python_test",
+    srcs = ["verify_script_python.sh"],
+    data = [
+        "verify.sh",
+        ":outer_calls_inner_script_python",
+    ],
+    target_compatible_with = SUPPORTS_BOOTSTRAP_SCRIPT,
+)
diff --git a/tests/bootstrap_impls/bin_calls_bin/inner.py b/tests/bootstrap_impls/bin_calls_bin/inner.py
new file mode 100644
index 0000000..e67b31d
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/inner.py
@@ -0,0 +1,4 @@
+import os
+
+module_space = os.environ.get("RULES_PYTHON_TESTING_MODULE_SPACE")
+print(f"inner: RULES_PYTHON_TESTING_MODULE_SPACE='{module_space}'")
diff --git a/tests/bootstrap_impls/bin_calls_bin/outer.py b/tests/bootstrap_impls/bin_calls_bin/outer.py
new file mode 100644
index 0000000..19dac06
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/outer.py
@@ -0,0 +1,18 @@
+import os
+import subprocess
+import sys
+
+if __name__ == "__main__":
+    module_space = os.environ.get("RULES_PYTHON_TESTING_MODULE_SPACE")
+    print(f"outer: RULES_PYTHON_TESTING_MODULE_SPACE='{module_space}'")
+
+    inner_binary_path = sys.argv[1]
+    result = subprocess.run(
+        [inner_binary_path],
+        capture_output=True,
+        text=True,
+        check=True,
+    )
+    print(result.stdout, end="")
+    if result.stderr:
+        print(result.stderr, end="", file=sys.stderr)
diff --git a/tests/bootstrap_impls/bin_calls_bin/verify.sh b/tests/bootstrap_impls/bin_calls_bin/verify.sh
new file mode 100755
index 0000000..433704e
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/verify.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+set -euo pipefail
+
+verify_output() {
+  local OUTPUT_FILE=$1
+
+  # Extract the RULES_PYTHON_TESTING_MODULE_SPACE values
+  local OUTER_MODULE_SPACE=$(grep "outer: RULES_PYTHON_TESTING_MODULE_SPACE" "$OUTPUT_FILE" | sed "s/outer: RULES_PYTHON_TESTING_MODULE_SPACE='\(.*\)'/\1/")
+  local INNER_MODULE_SPACE=$(grep "inner: RULES_PYTHON_TESTING_MODULE_SPACE" "$OUTPUT_FILE" | sed "s/inner: RULES_PYTHON_TESTING_MODULE_SPACE='\(.*\)'/\1/")
+
+  echo "Outer module space: $OUTER_MODULE_SPACE"
+  echo "Inner module space: $INNER_MODULE_SPACE"
+
+  # Check 1: The two values are different
+  if [ "$OUTER_MODULE_SPACE" == "$INNER_MODULE_SPACE" ]; then
+    echo "Error: Outer and Inner module spaces are the same."
+    exit 1
+  fi
+
+  # Check 2: Inner is not a subdirectory of Outer
+  case "$INNER_MODULE_SPACE" in
+    "$OUTER_MODULE_SPACE"/*)
+      echo "Error: Inner module space is a subdirectory of Outer's."
+      exit 1
+      ;;
+    *)
+      # This is the success case
+      ;;
+  esac
+
+  echo "Verification successful."
+}
diff --git a/tests/bootstrap_impls/bin_calls_bin/verify_script_python.sh b/tests/bootstrap_impls/bin_calls_bin/verify_script_python.sh
new file mode 100755
index 0000000..012daee
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/verify_script_python.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+set -euo pipefail
+
+source "$(dirname "$0")/verify.sh"
+verify_output "$(dirname "$0")/outer_calls_inner_script_python.out"
diff --git a/tests/bootstrap_impls/bin_calls_bin/verify_system_python.sh b/tests/bootstrap_impls/bin_calls_bin/verify_system_python.sh
new file mode 100755
index 0000000..460769f
--- /dev/null
+++ b/tests/bootstrap_impls/bin_calls_bin/verify_system_python.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+set -euo pipefail
+
+source "$(dirname "$0")/verify.sh"
+verify_output "$(dirname "$0")/outer_calls_inner_system_python.out"
diff --git a/tests/support/support.bzl b/tests/support/support.bzl
index adb8e75..f869462 100644
--- a/tests/support/support.bzl
+++ b/tests/support/support.bzl
@@ -54,3 +54,8 @@
     "@platforms//os:windows": ["@platforms//:incompatible"],
     "//conditions:default": [],
 }) if BZLMOD_ENABLED else ["@platforms//:incompatible"]
+
+NOT_WINDOWS = select({
+    "@platforms//os:windows": ["@platforms//:incompatible"],
+    "//conditions:default": [],
+})