diff --git a/docs/adr/0001-static-preindexed-source-built-on-linux.md b/docs/adr/0001-static-preindexed-source-built-on-linux.md index cc7e7ce..c7036a5 100644 --- a/docs/adr/0001-static-preindexed-source-built-on-linux.md +++ b/docs/adr/0001-static-preindexed-source-built-on-linux.md @@ -23,4 +23,7 @@ The MSIX container is built by the same tool rather than by `makemsix`, which nixpkgs does not carry. An unsigned MSIX is a ZIP holding the payload, an `AppxManifest.xml`, a `[Content_Types].xml` and an `AppxBlockMap.xml` of SHA-256 block hashes; that is a little Python, where packaging `makemsix` -would mean building Microsoft's msix-packaging SDK. +would mean building Microsoft's msix-packaging SDK. The ZIP has to be laid out +exactly as Microsoft's tools write it, ZIP64 records and data descriptors +included: Windows refuses a plain ZIP as a bad format, even though the +msix-packaging reader accepts one. diff --git a/tests/test_msix.py b/tests/test_msix.py index 0e8a8bd..f282eb9 100644 --- a/tests/test_msix.py +++ b/tests/test_msix.py @@ -59,6 +59,21 @@ class Build(unittest.TestCase): expected = [hashlib.sha256(data[i : i + msix.BLOCK_SIZE]).digest() for i in range(0, len(data), msix.BLOCK_SIZE)] self.assertEqual(expected, hashes) + def test_zip64_layout(self): + # Windows refuses to open a package laid out any other way. + end = self.package[-22:] + self.assertEqual(b"PK\x05\x06", end[:4]) + self.assertEqual((0xFFFF, 0xFFFF, 0xFFFFFFFF, 0xFFFFFFFF), struct.unpack(" bytes: ).encode() -def _zip_info(name: str) -> zipfile.ZipInfo: - info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_STORED - info.create_system = 0 - info.external_attr = 0 - return info +class _ZipWriter: + """The ZIP layout Microsoft's packaging tools write, which is the only one + Windows will open: it refuses a plain ZIP as a bad format (0x8007000B) + even where Microsoft's open-source reader accepts it. + + That layout is ZIP64 throughout. Each payload file is written with the + data-descriptor flag, a local header with no sizes, and a ZIP64 data + descriptor after the data; the footprint files that signing rewrites are + plain entries; and the end of the central directory is a ZIP64 record + and locator, with the classic record left holding only placeholders. + """ + + # Version 4.5, the first with ZIP64; the high byte, 0, is MS-DOS. + _ZIP64 = 45 + _DEFAULT = 20 + _DATA_DESCRIPTOR = 0x0008 + # 1980-01-01 00:00, the DOS epoch, so that the package is reproducible. + _TIME, _DATE = 0, (0 << 9) | (1 << 5) | 1 + + def __init__(self): + self.out = io.BytesIO() + self.central = [] + + def add(self, name: str, data: bytes, *, deflate: bool = False, descriptor: bool = True) -> int: + """Write one entry and return the length of its local header.""" + encoded = name.encode() + crc = zlib.crc32(data) + if deflate: + compressor = zlib.compressobj(9, zlib.DEFLATED, -15) + stored = compressor.compress(data) + compressor.flush() + else: + stored = data + method = 8 if deflate else 0 + version = self._ZIP64 if descriptor else self._DEFAULT + flags = self._DATA_DESCRIPTOR if descriptor else 0 + offset = self.out.tell() + + header = struct.pack( + "<4sHHHHHIIIHH", + b"PK\x03\x04", version, flags, method, self._TIME, self._DATE, + *((0, 0, 0) if descriptor else (crc, len(stored), len(data))), + len(encoded), 0, + ) + self.out.write(header + encoded + stored) + if descriptor: + self.out.write(struct.pack("<4sIQQ", b"PK\x07\x08", crc, len(stored), len(data))) + + self.central.append( + struct.pack( + "<4sHHHHHHIIIHHHHHII", + b"PK\x01\x02", self._ZIP64, version, flags, method, self._TIME, self._DATE, + crc, len(stored), len(data), len(encoded), 0, 0, 0, 0, 0, offset, + ) + + encoded + ) + return len(header) + len(encoded) + + def finish(self) -> bytes: + start = self.out.tell() + directory = b"".join(self.central) + self.out.write(directory) + record = self.out.tell() + count = len(self.central) + self.out.write( + struct.pack( + "<4sQHHIIQQQQ", + b"PK\x06\x06", 44, self._ZIP64, self._ZIP64, 0, 0, count, count, len(directory), start, + ) + ) + self.out.write(struct.pack("<4sIQI", b"PK\x06\x07", 0, record, 1)) + self.out.write(struct.pack("<4sHHHHIIH", b"PK\x05\x06", 0, 0, 0xFFFF, 0xFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0)) + return self.out.getvalue() def build(index_db: bytes, *, name: str, publisher: str, version: str, display_name: str, publisher_display_name: str) -> bytes: @@ -166,21 +231,15 @@ def build(index_db: bytes, *, name: str, publisher: str, version: str, display_n ("AppxManifest.xml", manifest), ] - out = io.BytesIO() - with zipfile.ZipFile(out, "w") as z: - mapped = [] - for path, data in payload: - info = _zip_info(path) - z.writestr(info, data) - # A local file header is 30 bytes, then the name; no extra field, - # since nothing here is large enough to need ZIP64. - mapped.append((path, data, 30 + len(info.filename.encode()))) - z.writestr(_zip_info("AppxBlockMap.xml"), _block_map(mapped)) - # The content types are outside the block map, so they can be - # compressed, and must be: osslsigncode 2.14 rewrites this file when it - # signs, deflating what it writes but keeping the original method, so - # a stored original comes out marked stored with deflated contents. - content_types = _zip_info("[Content_Types].xml") - content_types.compress_type = zipfile.ZIP_DEFLATED - z.writestr(content_types, _content_types([p for p, _ in payload])) - return out.getvalue() + z = _ZipWriter() + mapped = [] + for path, data in payload: + mapped.append((path, data, z.add(path, data))) + z.add("AppxBlockMap.xml", _block_map(mapped)) + # The content types are outside the block map, so they can be compressed, + # and must be: osslsigncode 2.14 rewrites this file when it signs, + # deflating what it writes but keeping the original method, so a stored + # original comes out marked stored with deflated contents. It is also a + # plain entry, as in Microsoft's packages, since signing rewrites it. + z.add("[Content_Types].xml", _content_types([p for p, _ in payload]), deflate=True, descriptor=False) + return z.finish()