Preserve file permissions when extracting runfiles zip In Python rules, the Python stub script is responsible for extracting the runfiles from the zip file when --build_python_zip is used. However, there's a bug in the standard Python library (https://bugs.python.org/issue15795) that causes file permissions to be lost when extracting zips. This causes runfiles to not be marked executable, which is a problem when the py_runtime is an in-build runtime (issue #5104). This change updates the stub script to workaround the bug by directly chmodding extracted files with their intended mode. It also adds a regression test combining a custom py_runtime with --build_python_zip. Fixes #5104, unblocks #7375. RELNOTES: Fixed an issue where some `py_runtime`s were incompatible with using `--build_python_zip` (#5104). PiperOrigin-RevId: 246195931
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt index dac21c9..a6c5110 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt +++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/python/python_stub_template.txt
@@ -99,13 +99,38 @@ raise AssertionError('Cannot find .runfiles directory for %s' % sys.argv[0]) +def ExtractZip(zip_path, dest_dir): + """Extracts the contents of a zip file, preserving the unix file mode bits. + + These include the permission bits, and in particular, the executable bit. + + Ideally the zipfile module should set these bits, but it doesn't. See: + https://bugs.python.org/issue15795. + + Args: + zip_path: The path to the zip file to extract + dest_dir: The path to the destination directory + """ + zip_path = GetWindowsPathWithUNCPrefix(zip_path) + dest_dir = GetWindowsPathWithUNCPrefix(dest_dir) + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + zf.extract(info, dest_dir) + # UNC-prefixed paths must be absolute/normalized. See + # https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation + file_path = os.path.abspath(os.path.join(dest_dir, info.filename)) + # The Unix st_mode bits (see "man 7 inode") are stored in the upper 16 + # bits of external_attr. Of those, we set the lower 12 bits, which are the + # file mode bits (since the file type bits can't be set by chmod anyway). + attrs = info.external_attr >> 16 + if attrs != 0: # Rumor has it these can be 0 for zips created on Windows. + os.chmod(file_path, attrs & 0o7777) + # Create the runfiles tree by extracting the zip file def CreateModuleSpace(): - ZIP_RUNFILES_DIRECTORY_NAME = "runfiles" temp_dir = tempfile.mkdtemp("", "Bazel.runfiles_") - zf = zipfile.ZipFile(GetWindowsPathWithUNCPrefix(os.path.dirname(__file__))) - zf.extractall(GetWindowsPathWithUNCPrefix(temp_dir)) - return os.path.join(temp_dir, ZIP_RUNFILES_DIRECTORY_NAME) + ExtractZip(os.path.dirname(__file__), temp_dir) + return os.path.join(temp_dir, "runfiles") # Returns repository roots to add to the import path. def GetRepositoriesImports(module_space, import_all):
diff --git a/src/test/shell/bazel/python_version_test.sh b/src/test/shell/bazel/python_version_test.sh index 22c2e22..e463f4d 100755 --- a/src/test/shell/bazel/python_version_test.sh +++ b/src/test/shell/bazel/python_version_test.sh
@@ -188,6 +188,47 @@ expect_log "File contents: abcdefg" } +# Regression test for #5104. This test ensures that it's possible to use +# --build_python_zip in combination with a py_runtime (as opposed to not using +# a py_runtime, i.e., the legacy --python_path mechanism). +# +# Note that with --incompatible_use_python_toolchains flipped, we're always +# using a py_runtime, so in that case this amounts to a test that +# --build_python_zip works at all. +# +# The specific issue #5104 was caused by file permissions being lost when +# unzipping runfiles, which led to an unexecutable runtime. +function test_build_python_zip_works_with_py_runtime() { + mkdir -p test + + cat > test/BUILD << EOF +py_binary( + name = "pybin", + srcs = ["pybin.py"], +) + +py_runtime( + name = "mock_runtime", + interpreter = ":mockpy.sh", + python_version = "PY3", +) +EOF + cat > test/pybin.py << EOF +# This doesn't actually run because we use a mock Python runtime that never +# executes the Python code. +print("I am pybin!") +EOF + cat > test/mockpy.sh <<EOF +#!/bin/bash +echo "I am mockpy!" +EOF + chmod u+x test/mockpy.sh + + bazel run //test:pybin --python_top=//test:mock_runtime --build_python_zip \ + &> $TEST_log || fail "bazel run failed" + expect_log "I am mockpy!" +} + function test_pybin_can_have_different_version_pybin_as_data_dep() { mkdir -p test