Ensure regular file bit set in wheel archives (#1731)

#1453 added logic to manually set zipinfo for each file in the wheel
archive. When wheel archives generated by `_Whlfile` are installed by
the `installer` module (or `pip`), the file mode is inspected as such:

* `installer`:
https://github.com/pypa/installer/blob/fcc0d6f14f99974316c2e490cead07a9a0b7a6ac/src/installer/sources.py#L318-L321
* `pip`:
https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L96-L100

as you can tell, if the regular file bit is not set, the installer
thinks that the file being installed is not an executable and therefore
the executable bit will not be preserved when the file from wheel is
extracted onto the host filesystem.

Since all files being archived into Whlfile are regular files anyway, we
set `S_IFREG` on the file mode for all files in the zip archive.

Fixes #1711

---------

Signed-off-by: Thomas Lam <thomaslam@canva.com>
Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a137e9d..dfcdf36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,8 @@
 ### Fixed
 
 * (bzlmod) pip.parse now does not fail with an empty `requirements.txt`.
+* (py_wheel) Wheels generated by `py_wheel` now preserve executable bits when
+  being extracted by `installer` and/or `pip`.
 
 ### Added
 
diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py
index b683da0..0c3e87b 100644
--- a/examples/wheel/wheel_test.py
+++ b/examples/wheel/wheel_test.py
@@ -15,6 +15,7 @@
 import hashlib
 import os
 import platform
+import stat
 import subprocess
 import unittest
 import zipfile
@@ -58,7 +59,11 @@
         for zinfo in zf.infolist():
             self.assertEqual(zinfo.date_time, (1980, 1, 1, 0, 0, 0), msg=zinfo.filename)
             self.assertEqual(zinfo.create_system, 3, msg=zinfo.filename)
-            self.assertEqual(zinfo.external_attr, 0o777 << 16, msg=zinfo.filename)
+            self.assertEqual(
+                zinfo.external_attr,
+                (stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO | stat.S_IFREG) << 16,
+                msg=zinfo.filename
+            )
             self.assertEqual(
                 zinfo.compress_type, zipfile.ZIP_DEFLATED, msg=zinfo.filename
             )
@@ -78,7 +83,7 @@
                 ],
             )
         self.assertFileSha256Equal(
-            filename, "2818e70fdebd148934f41820f8c54d5d7676d783c0d66c7c8af2ee9141e7ddc7"
+            filename, "79a4e9c1838c0631d5d8fa49a26efd6e9a364f6b38d9597c0f6df112271a0e28"
         )
 
     def test_py_package_wheel(self):
@@ -100,7 +105,7 @@
                 ],
             )
         self.assertFileSha256Equal(
-            filename, "273e27adf9bf90287a42ac911dcece8aa95f2905c37d786725477b26de23627c"
+            filename, "b4815a1d3a17cc6a5ce717ed42b940fa7788cb5168f5c1de02f5f50abed7083e"
         )
 
     def test_customized_wheel(self):
@@ -189,7 +194,7 @@
 second = second.main:s""",
             )
         self.assertFileSha256Equal(
-            filename, "48eed93258bba0bb366c879b77917d947267d89e7e60005d1766d844fb909118"
+            filename, "27f3038be6e768d28735441a1bc567eca2213bd3568d18b22a414e6399a2d48e"
         )
 
     def test_filename_escaping(self):
@@ -255,7 +260,7 @@
             for line in record_contents.splitlines():
                 self.assertFalse(line.startswith("/"))
         self.assertFileSha256Equal(
-            filename, "16e0345c102c6866fed34999d8de5aed7f351adbf372b27adef3bc15161db65e"
+            filename, "f034b3278781f4df32a33df70d794bb94170b450e477c8bd9cd42d2d922476ae"
         )
 
     def test_custom_package_root_multi_prefix_wheel(self):
@@ -286,7 +291,7 @@
             for line in record_contents.splitlines():
                 self.assertFalse(line.startswith("/"))
         self.assertFileSha256Equal(
-            filename, "d2031eb21c69e290db5eac76b0dc026858e9dbdb3da2dc0314e4e9f69eab2e1a"
+            filename, "ff19f5e4540948247742716338bb4194d619cb56df409045d1a99f265ce8e36c"
         )
 
     def test_custom_package_root_multi_prefix_reverse_order_wheel(self):
@@ -317,7 +322,7 @@
             for line in record_contents.splitlines():
                 self.assertFalse(line.startswith("/"))
         self.assertFileSha256Equal(
-            filename, "a37b90685600ccfa56cc5405d1e9a3729ed21dfb31c76fd356e491e2af989566"
+            filename, "4331e378ea8b8148409ae7c02177e4eb24d151a85ef937bb44b79ff5258d634b"
         )
 
     def test_python_requires_wheel(self):
@@ -342,7 +347,7 @@
 """,
             )
         self.assertFileSha256Equal(
-            filename, "529afa454113572e6cd91f069cc9cfe5c28369f29cd495fff19d0ecce389d8e4"
+            filename, "b34676828f93da8cd898d50dcd4f36e02fe273150e213aacb999310a05f5f38c"
         )
 
     def test_python_abi3_binary_wheel(self):
@@ -407,7 +412,7 @@
                 ],
             )
         self.assertFileSha256Equal(
-            filename, "cc9484d527075f07651ca0e7dff4a185c1314020726bcad55fe28d1bba0fec2e"
+            filename, "ac9216bd54dcae1a6270c35fccf8a73b0be87c1b026c28e963b7c76b2f9b722b"
         )
 
     def test_rule_expands_workspace_status_keys_in_wheel_metadata(self):
diff --git a/tools/wheelmaker.py b/tools/wheelmaker.py
index 2898755..26153f6 100644
--- a/tools/wheelmaker.py
+++ b/tools/wheelmaker.py
@@ -19,6 +19,7 @@
 import hashlib
 import os
 import re
+import stat
 import sys
 import zipfile
 from pathlib import Path
@@ -189,7 +190,13 @@
 
         zinfo = zipfile.ZipInfo(filename=arcname, date_time=_ZIP_EPOCH)
         zinfo.create_system = 3  # ZipInfo entry created on a unix-y system
-        zinfo.external_attr = 0o777 << 16  # permissions: rwxrwxrwx
+        # Both pip and installer expect the regular file bit to be set in order for the
+        # executable bit to be preserved after extraction
+        # https://github.com/pypa/pip/blob/23.3.2/src/pip/_internal/utils/unpacking.py#L96-L100
+        # https://github.com/pypa/installer/blob/0.7.0/src/installer/sources.py#L310-L313
+        zinfo.external_attr = (
+            stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO | stat.S_IFREG
+        ) << 16  # permissions: -rwxrwxrwx
         zinfo.compress_type = self.compression
         return zinfo