From 9571ffabbf9438597d7f67ff6ecb63bccef82aef Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Sun, 28 Jun 2026 22:03:00 +0800 Subject: [PATCH 1/5] gh-152486: Fix `force_zip64` not honored when writing central directory --- Lib/test/test_zipfile/test_core.py | 21 +++++++++++++++++++++ Lib/zipfile/__init__.py | 9 +++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 4f20209927e7b3..12c886c51e6b5b 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -1243,6 +1243,22 @@ def test_force_zip64(self): self.assertEqual(len(zinfos), 1) self.assertGreaterEqual(zinfos[0].extract_version, zipfile.ZIP64_VERSION) # requires zip64 to extract + def test_force_zip64_central(self): + """Test that force_zip64 is honored when writing to central directory""" + fh = io.BytesIO() + with zipfile.ZipFile(fh, 'w') as zh: + with zh.open('strfile', 'w', force_zip64=True) as zi: + pass + + fh.seek(0) + with zipfile.ZipFile(fh, 'r') as zh: + zinfo = zh.getinfo('strfile') + self.assertEqual(zinfo.extra, ( + b'\x01\x00\x10\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00' + )) + def test_unseekable_zip_unknown_filesize(self): """Test that creating a zip with/without seeking will raise a RuntimeError if zip64 was required but not used""" @@ -1448,6 +1464,11 @@ def comparable_zinfo(zinfo): # pretending it's always set. attrs['flag_bits'] |= 0x800 + # Check decoded data (file size, compressed size) and ignore zip64 subfield + # in extra data since is may be inconsistent for a ZipInfo before writing + # and one read from a file. + attrs['extra'] = zipfile._Extra.strip(attrs['extra'], (1,)) + return attrs _struct_pack = struct.pack diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e8..8b34c705722ca2 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -453,6 +453,7 @@ class ZipInfo: 'file_size', '_raw_time', '_end_offset', + '_force_zip64', ) def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): @@ -488,6 +489,7 @@ def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): self.compress_size = 0 # Size of the compressed file self.file_size = 0 # Size of the uncompressed file self._end_offset = None # Start of the next local header or central directory + self._force_zip64 = None # Whether zip64 extension is forced # Other attributes are set by class ZipFile: # header_offset Byte offset to the file header # CRC CRC-32 of the uncompressed file @@ -2202,6 +2204,7 @@ def open(self, name, mode="r", pwd=None, *, force_zip64=False): zinfo = self.getinfo(name) if mode == 'w': + zinfo._force_zip64 = force_zip64 return self._open_to_write(zinfo, force_zip64=force_zip64) if self._writing: @@ -2646,8 +2649,10 @@ def _write_end_record(self): dosdate = (dt[0] - 1980) << 9 | dt[1] << 5 | dt[2] dostime = dt[3] << 11 | dt[4] << 5 | (dt[5] // 2) extra = [] - if zinfo.file_size > ZIP64_LIMIT \ - or zinfo.compress_size > ZIP64_LIMIT: + if (zinfo._force_zip64 + or zinfo.file_size > ZIP64_LIMIT + or zinfo.compress_size > ZIP64_LIMIT + ): extra.append(zinfo.file_size) extra.append(zinfo.compress_size) file_size = 0xffffffff From 4b984b95fa5323eb686ac29cfb1cc34da20bc6f9 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Mon, 29 Jun 2026 08:50:22 +0800 Subject: [PATCH 2/5] Adjust comment and merge test into `test_force_zip64` --- Lib/test/test_zipfile/test_core.py | 68 +++++++++++++++++++----------- Lib/zipfile/__init__.py | 2 +- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 12c886c51e6b5b..2af56410ad8382 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -1207,8 +1207,9 @@ def test_force_zip64(self): # bytes are checked to ensure that they line up with the zip spec. # The spec for this can be found at: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT # The relevant sections for this test are: - # - 4.3.7 for local file header - # - 4.5.3 for zip64 extra field + # - 4.3.7 for local file header + # - 4.3.12 for central directory + # - 4.5.3 for zip64 extra field data = io.BytesIO() with zipfile.ZipFile(data, mode="w", allowZip64=True) as zf: @@ -1217,11 +1218,11 @@ def test_force_zip64(self): zipdata = data.getvalue() - # pull out and check zip information + # check the local file entry ( header, vers, os, flags, comp, csize, usize, fn_len, - ex_total_len, filename, ex_id, ex_len, ex_usize, ex_csize, cd_sig - ) = struct.unpack("<4sBBHH8xIIHH8shhQQx4s", zipdata[:63]) + ex_total_len, filename, ex_id, ex_len, ex_usize, ex_csize + ) = struct.unpack("<4sBBHH8xIIHH8sHHQQx", zipdata[:59]) self.assertEqual(header, b"PK\x03\x04") # local file header self.assertGreaterEqual(vers, zipfile.ZIP64_VERSION) # requires zip64 to extract @@ -1236,28 +1237,45 @@ def test_force_zip64(self): self.assertEqual(ex_len, 16) # 16 bytes of data self.assertEqual(ex_usize, 1) # uncompressed size self.assertEqual(ex_csize, 1) # compressed size - self.assertEqual(cd_sig, b"PK\x01\x02") # ensure the central directory header is next - - z = zipfile.ZipFile(io.BytesIO(zipdata)) - zinfos = z.infolist() - self.assertEqual(len(zinfos), 1) - self.assertGreaterEqual(zinfos[0].extract_version, zipfile.ZIP64_VERSION) # requires zip64 to extract - def test_force_zip64_central(self): - """Test that force_zip64 is honored when writing to central directory""" - fh = io.BytesIO() - with zipfile.ZipFile(fh, 'w') as zh: - with zh.open('strfile', 'w', force_zip64=True) as zi: - pass + # check the entry in central directory, + # which should immedially follow the local file entry + ( + header, v_made, v_ext, os, flags, comp, csize, usize, fn_len, + ex_total_len, comment_len, in_attr, local_offset, filename, + ex_id, ex_len, ex_usize, ex_csize + ) = struct.unpack("<4sBxBBHH8xIIHHH2xH4xI8sHHQQ", zipdata[59:133]) + + self.assertEqual(header, b"PK\x01\x02") # central directory file header + self.assertGreaterEqual(v_made, zipfile.ZIP64_VERSION) + self.assertGreaterEqual(v_ext, zipfile.ZIP64_VERSION) + self.assertEqual(os, 0) + self.assertEqual(flags, 0) + self.assertEqual(comp, 0) + self.assertEqual(csize, 0xFFFFFFFF) + self.assertEqual(usize, 0xFFFFFFFF) + self.assertEqual(fn_len, 8) + self.assertEqual(ex_total_len, 20) + self.assertEqual(comment_len, 0) # no file comment + self.assertEqual(local_offset, 0) + self.assertEqual(ex_id, 1) + self.assertEqual(ex_len, 16) + self.assertEqual(ex_usize, 1) + self.assertEqual(ex_csize, 1) + + # check the ZipInfo object + with zipfile.ZipFile(data) as zf: + zinfos = zf.infolist() + zinfo = zf.getinfo('text.txt') - fh.seek(0) - with zipfile.ZipFile(fh, 'r') as zh: - zinfo = zh.getinfo('strfile') - self.assertEqual(zinfo.extra, ( - b'\x01\x00\x10\x00' - b'\x00\x00\x00\x00\x00\x00\x00\x00' - b'\x00\x00\x00\x00\x00\x00\x00\x00' - )) + self.assertEqual(len(zinfos), 1) + self.assertGreaterEqual(zinfo.extract_version, zipfile.ZIP64_VERSION) # requires zip64 to extract + self.assertEqual(zinfo.extra, ( + b'\x01\x00' + b'\x10\x00' + b'\x01\x00\x00\x00\x00\x00\x00\x00' + b'\x01\x00\x00\x00\x00\x00\x00\x00' + )) def test_unseekable_zip_unknown_filesize(self): """Test that creating a zip with/without seeking will raise a RuntimeError if zip64 was required but not used""" diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 8b34c705722ca2..7f68f0ec0b7df2 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -489,7 +489,7 @@ def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): self.compress_size = 0 # Size of the compressed file self.file_size = 0 # Size of the uncompressed file self._end_offset = None # Start of the next local header or central directory - self._force_zip64 = None # Whether zip64 extension is forced + self._force_zip64 = None # Whether zip64 extension is enforced # Other attributes are set by class ZipFile: # header_offset Byte offset to the file header # CRC CRC-32 of the uncompressed file From 069e1c9e0e7648a5dbb621333a237f44c6a0daa7 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:29:17 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-29-01-29-09.gh-issue-152486.Uphik6.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-29-01-29-09.gh-issue-152486.Uphik6.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-29-01-29-09.gh-issue-152486.Uphik6.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-29-01-29-09.gh-issue-152486.Uphik6.rst new file mode 100644 index 00000000000000..79ff46900512b4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-29-01-29-09.gh-issue-152486.Uphik6.rst @@ -0,0 +1 @@ +Fix an issue where :meth:`~zipfile.ZipFile.open` with ``force_zip64=True`` would write the Zip64 extra field to the local file header but fail to do so in the central directory for small files. From d694b404dac24af230f7ee51c47f0eb626131231 Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Mon, 29 Jun 2026 09:45:30 +0800 Subject: [PATCH 4/5] Fix typo --- Lib/test/test_zipfile/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 2af56410ad8382..3e211cf7b2d8f7 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -1239,7 +1239,7 @@ def test_force_zip64(self): self.assertEqual(ex_csize, 1) # compressed size # check the entry in central directory, - # which should immedially follow the local file entry + # which should immediately follow the local file entry ( header, v_made, v_ext, os, flags, comp, csize, usize, fn_len, ex_total_len, comment_len, in_attr, local_offset, filename, From 9e202a07f5ad4a9d8158b35235670f6a0578c70d Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Mon, 29 Jun 2026 09:48:59 +0800 Subject: [PATCH 5/5] Add context about GH-152486 --- Lib/test/test_zipfile/test_core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 3e211cf7b2d8f7..a2170fd42fb4ba 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -1197,6 +1197,9 @@ def test_force_zip64(self): # sizes to 0xFFFFFFFF to indicate to the extractor that the zip64 # record should be read. Additionally, it would not set the required # version to indicate that zip64 extensions are required to extract it. + # GH-152486 describes another issue where force_zip64 was honored only + # by the local file entry but not by the central directory for a small + # file. # This test replicates the situation and reads the raw data to specifically ensure: # - The required extract version is always >= ZIP64_VERSION # - The compressed and uncompressed size in the file headers are both