fix: zip extract permission bits (#2102)

* Restore Unix executable bits when extracting zip archives

- SafeZipExtractor._extract_member now chmods each extracted file with the
  Unix mode stored in ZipInfo.external_attr (POSIX only, no-op when the
  archive carries no Unix attributes, e.g. Windows-authored zips).
- stdlib zipfile never restores permission bits itself; a fix is tracked
  upstream at https://github.com/python/cpython/pull/150061.
- Fixes archives with more than one executable losing their exec bit after
  extraction, e.g. the bundled JBR inside the Kotlin Language Server
  distribution (jbr/bin/java and native libs), which previously only had
  its single top-level launcher script chmod'd by the language-server code.

Fixes oraios/serena#2100

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben KeilandCopilot authored and GitHub committed 2026-09-23 17:47:53 +02:00
1 parent 9fa4237107
commit b83b655ced
3 files changed
+34

No files matched your search

+19
View File
@@ -1,3 +1,4 @@
import os
import sys
import zipfile
from pathlib import Path
@@ -90,6 +91,24 @@ def test_skip_on_error(monkeypatch, temp_zip_file: Path, tmp_path: Path) -> None
assert (dest_dir / "folder" / "file3.txt").exists()
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix permission bits are not applicable on Windows")
def test_restores_executable_permission(tmp_path: Path) -> None:
"""Executable bits stored in the archive's external_attr should be restored on extraction."""
zip_path = tmp_path / "exec.zip"
with zipfile.ZipFile(zip_path, "w") as zipf:
info = zipfile.ZipInfo("bin/tool")
info.external_attr = 0o755 << 16
zipf.writestr(info, "#!/bin/sh\necho hi\n")
dest_dir = tmp_path / "extracted"
extractor = SafeZipExtractor(zip_path, dest_dir, verbose=False)
extractor.extract_all()
extracted_file = dest_dir / "bin" / "tool"
assert extracted_file.exists()
assert os.stat(extracted_file).st_mode & 0o111
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only test")
def test_long_path_normalization(temp_zip_file: Path, tmp_path: Path) -> None:
r"""Ensure _normalize_path adds \\?\\ prefix on Windows."""