Skip to content

py_binary no longer runs on Windows with rules_python 2.0.0+ using default Windows settings #3792

@BarrettStephen

Description

@BarrettStephen

🐞 bug report

Affected Rule

The issue is caused by the rule: py_binary

Is this a regression?

Yes, the previous version in which this bug was not present was: 1.9.0

Description

Trying to run a py_binary (either directly or as a dep of a rule) no longer works on Windows in rules_python 2.0.0 or later as it now requires symlink permissions.

🔬 Minimal Reproduction

simply bazel run <py_binary_target> on Windows.

From scratch that would be making a new repo with:

.bazelversion

8.7.0

MODULE.bazel

module(
    name = "bazel_demo",
    version = "1.0.0",
)

bazel_dep(name = "rules_python", version = "2.0.0")

hello.py

if __name__ == "__main__":
    print("hello!")

BUILD.bazel

load("@rules_python//python:defs.bzl", "py_binary")

py_binary(
    name = "hello",
    srcs = ["hello.py"],
)

and then running bazel run hello, which I did in powershell.

🔥 Exception or Error


PS C:\git\bazel_demo> bazel run hello
INFO: Analyzed target //:hello (79 packages loaded, 4467 targets configured).
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello
  bazel-bin/hello.exe
INFO: Elapsed time: 13.164s, Critical Path: 4.66s
INFO: 6 processes: 10 action cache hit, 5 internal, 1 local.
INFO: Build completed successfully, 6 total actions
INFO: Running command line: bazel-bin/hello.exe
Traceback (most recent call last):
  File "C:\bzl\ufith4hp\execroot\_main\bazel-out\x64_windows-fastbuild-ST-591ff087943d\bin\hello", line 643, in 
    main()
  File "C:\bzl\ufith4hp\execroot\_main\bazel-out\x64_windows-fastbuild-ST-591ff087943d\bin\hello", line 597, in main
    python_program = _create_venv(runfiles_root, delete_dirs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\bzl\ufith4hp\execroot\_main\bazel-out\x64_windows-fastbuild-ST-591ff087943d\bin\hello", line 366, in _create_venv
    _symlink_exist_ok(from_=venv_python_exe, to=python_exe_actual)
  File "C:\bzl\ufith4hp\execroot\_main\bazel-out\x64_windows-fastbuild-ST-591ff087943d\bin\hello", line 527, in _symlink_exist_ok
    os.symlink(to, from_)
OSError: [WinError 1314] A required privilege is not held by the client: 'c:\\bzl\\ufith4hp\\execroot\\_main\\bazel-out\\x64_windows-fastbuild-st-591ff087943d\\bin\\hello.exe.runfiles\\rules_python++python+python_3_11_x86_64-pc-windows-msvc\\python.exe' -> 'C:\\Users\\SBARRE~3\\AppData\\Local\\Temp\\bazel._hello.venv.llbvzwkl\\Scripts\\python.exe'

🌍 Your Environment

Operating System:

  
Windows 11 Enterprise
24H2
26100.8246
  

Output of bazel version:

  
bazel 8.7.0
  

Rules_python version:

  
2.0.0
  

Although same problem is in 2.0.1

Anything else relevant?

This was somewhat discussed here: #2586 (comment)
But I moved the discussion into this issue.

I work in a large enterprise and enabling symlinks for users is not an option due to security concerns. This is what grok had to say about symlinks on Windows:

Windows has a very different security model than Linux/macOS. Many system services, installers, updaters, and even parts of the OS itself run with high privileges (SYSTEM, LocalService, etc.). These processes often follow symbolic links when they resolve file paths.
If any user could freely create symlinks, they could abuse this in several ways

Attack Type How It Works Potential Impact
DLL Hijacking / Binary Planting Create a symlink from a user-writable folder (e.g. C:\Users\victim\AppData\...) pointing to a malicious DLL, then get a privileged service to load it Code execution as SYSTEM
Privilege Escalation via Symlink Point a symlink at a sensitive file (like a config file, registry hive, or executable) and trick an elevated process into writing to it Overwrite protected files
TOCTOU Attacks Create a symlink between the moment a privileged process checks permissions and when it actually accesses the file Bypass file access checks
Escaping Restricted Environments In sandboxes, containers, or low-privilege contexts, use symlinks to reach files outside the allowed scope Break out of the sandbox
Path Traversal / Redirection Redirect a privileged process to read/write files in protected locations (e.g. C:\Windows\System32) Data theft or system modification

I created this patch (with help from Claude) which resolved the issue. Note the commit message and comments give more context.

Subject: [PATCH] Re-enable zipapp mode for Windows

rules_python commit bd7696af broke Windows support when the user does not
have symlink permissions (the default) by forcing Windows to use venv mode
which requires symlinks.

This patch restores the pre-bd7696af behavior by:
1. Re-enabling zipapp mode (build_python_zip) for Windows
2. Handling symlink failures gracefully in zipapp bootstrap
3. Removing the transition that sets enable_runfiles=true for Windows
   (added in c805941d for venv mode, not needed for zipapp)
4. Setting PYTHONPATH in zipapp mode for subprocesses

PYTHONPATH handling:
After 0.40.0, commit 547521ed added granular site-packages imports for
Windows venv support. In projects with many dependencies, these import
paths can combine with the runfiles root prefix to exceed Windows' 32767
character environment variable limit.

The solution: attempt to set the full PYTHONPATH with all imports. If this
exceeds the limit (raising ValueError), fall back to setting PYTHONPATH to
just the runfiles root. This fallback works for most cases since sys.path
is already correctly configured by _bazel_site_init for the current process.

Non-Windows platforms are unaffected - they still use venv mode by default.
---
 python/private/py_executable.bzl            | 26 +--------
 python/private/stage2_bootstrap_template.py | 64 +++++++++++++++++++++
 python/private/zipapp/zip_main_template.py  | 11 +++-
 3 files changed, 74 insertions(+), 27 deletions(-)

diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index 6c65bf8f..c2a4a8f8 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -398,10 +398,8 @@ def _create_executable(
     extra_default_outputs = []

     # NOTE: --build_python_zip defaults to true on Windows
-    build_zip_enabled = read_possibly_native_flag(ctx, "build_python_zip") and not is_windows
-    if is_windows:
-        # The legacy build_python_zip codepath isn't compatible with full venvs on Windows.
-        build_zip_enabled = False
+    # Re-enable zipapp for Windows when symlinks aren't available (restores pre-bd7696af behavior)
+    build_zip_enabled = read_possibly_native_flag(ctx, "build_python_zip")

     # When --build_python_zip is enabled, then the zip file becomes
     # one of the default outputs.
@@ -1956,28 +1954,8 @@ def _create_run_environment_info(ctx, inherited_environment):
         inherited_environment = inherited_environment,
     )

-def _add_config_setting_defaults(kwargs):
-    config_settings = kwargs.get("config_settings", None)
-    if config_settings == None:
-        config_settings = {}
-
-    # NOTE: This code runs in loading phase within the context of the caller.
-    # Label() must be used to resolve repo names within rules_python's
-    # context to avoid unknown repo name errors.
-    default = select({
-        labels.PLATFORMS_OS_WINDOWS: {
-            labels.ENABLE_RUNFILES: "true",
-        },
-        "//conditions:default": {},
-    })
-
-    # Let user-provided settings have precedence
-    config_settings = default | config_settings
-    kwargs["config_settings"] = config_settings
-
 def common_executable_macro_kwargs_setup(kwargs):
     convert_legacy_create_init_to_int(kwargs)
-    _add_config_setting_defaults(kwargs)

 def _transition_executable_impl(settings, attr):
     settings = dict(settings)
diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py
index dec356d3..acc980d3 100644
--- a/python/private/stage2_bootstrap_template.py
+++ b/python/private/stage2_bootstrap_template.py
@@ -51,6 +51,10 @@ COVERAGE_INSTRUMENTED = "%coverage_instrumented%" == "1"
 # It uses forward slashes, so must be converted for proper usage on Windows.
 BUILD_DATA_FILE = "%build_data_file%"

+# Colon-separated list of runfiles-root-relative import paths
+# Used to construct PYTHONPATH for subprocesses in zipapp mode
+IMPORTS = "%imports%"
+
 # ===== Template substitutions end =====


@@ -522,6 +526,66 @@ def main():
     else:
         main_filename = None

+    # Set PYTHONPATH in zipapp mode so subprocesses spawned with sys.executable
+    # can find modules. In zipapp mode, sys.executable points to the raw Python
+    # interpreter, not a venv wrapper, so subprocesses need PYTHONPATH to locate
+    # modules. In venv mode, this isn't needed because the venv's Python already
+    # knows where its site-packages are.
+    #
+    # On Windows, the detailed site-packages imports (added after 0.40.0) can
+    # cause PYTHONPATH to exceed the 32767 character limit. We try to set the
+    # full path, but fall back to just the runfiles root if it's too long.
+    if sys._xoptions.get("RULES_PYTHON_ZIP_DIR"):
+        print_verbose("zipapp mode: IMPORTS has %d parts" % (len(IMPORTS.split(':')) if IMPORTS else 0))
+
+        pythonpath_entries = []
+
+        # Start with runfiles root
+        pythonpath_entries.append(runfiles_root)
+
+        # Add imports from the build-time configuration
+        if IMPORTS:
+            import_parts = IMPORTS.split(':')
+            for part in import_parts:
+                if part:  # Skip empty strings
+                    import_path = os.path.join(runfiles_root, part)
+                    if IS_WINDOWS:
+                        import_path = import_path.replace('/', os.sep)
+                    pythonpath_entries.append(import_path)
+
+        # Preserve any existing PYTHONPATH
+        old_pythonpath = os.environ.get("PYTHONPATH")
+        if old_pythonpath:
+            print_verbose("zipapp mode: inheriting PYTHONPATH of length %d" % len(old_pythonpath))
+            pythonpath_entries.extend(old_pythonpath.split(os.pathsep))
+
+        # Deduplicate while preserving order (like 0.40.0)
+        seen = set()
+        deduped = []
+        for p in pythonpath_entries:
+            if p and p not in seen:
+                seen.add(p)
+                deduped.append(p)
+
+        pythonpath = os.pathsep.join(deduped)
+        if IS_WINDOWS:
+            pythonpath = pythonpath.replace('/', os.sep)
+
+        print_verbose("zipapp mode: PYTHONPATH length=%d paths=%d" % (len(pythonpath), len(deduped)))
+
+        # On Windows, environment variables have a 32767 character limit.
+        # If we exceed it, Python's os.environ will raise ValueError.
+        # In that case, set PYTHONPATH to just the runfiles root and let
+        # sys.path handle the rest.
+        try:
+            os.environ["PYTHONPATH"] = pythonpath
+        except ValueError as e:
+            if IS_WINDOWS and len(pythonpath) > 32767:
+                print_verbose("zipapp mode: PYTHONPATH too long (%d chars), using runfiles root only" % len(pythonpath))
+                os.environ["PYTHONPATH"] = runfiles_root
+            else:
+                raise
+
     if os.environ.get("COVERAGE_DIR"):
         import _bazel_site_init

diff --git a/python/private/zipapp/zip_main_template.py b/python/private/zipapp/zip_main_template.py
index 6d12bc9c..a45b6a06 100644
--- a/python/private/zipapp/zip_main_template.py
+++ b/python/private/zipapp/zip_main_template.py
@@ -294,9 +294,14 @@ def finish_venv_setup(runfiles_root):
         try:
             os.symlink(symlink_to, python_program)
         except OSError as e:
-            raise Exception(
-                f"Unable to create venv python interpreter symlink: {python_program} -> {symlink_to}"
-            ) from e
+            # On Windows without --windows_enable_symlinks, just use the actual interpreter directly
+            if IS_WINDOWS:
+                print_verbose(f"Windows symlink failed, using interpreter directly: {e}")
+                python_program = symlink_to
+            else:
+                raise Exception(
+                    f"Unable to create venv python interpreter symlink: {python_program} -> {symlink_to}"
+                ) from e
     venv_root = dirname(dirname(python_program))
     pyvenv_cfg = join(venv_root, "pyvenv.cfg")
     if not os.path.exists(pyvenv_cfg):
--
2.52.0

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions