Split merge: accept 64-bit data descriptors on small streamed ZIP entries

Streaming writers (Python's unseekable ZipFile, force_zip64) write ZIP64
data descriptors even for small files, so the descriptor width can't be
taken from the final sizes. The reader now accepts the signed or unsigned,
32- or 64-bit layout that matches the central directory and fits before
the next entry, preferring one that ends exactly there, then the width the
local header's ZIP64 metadata implies. Format tests cover streamed ZIP64
stored and deflated entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-10-13 14:54:55 +11:00
1 parent ca53b26701
commit 77c6c286cd
2 files changed
+47 -8

No files matched your search

+9
View File
@@ -887,10 +887,19 @@ class Round3(unittest.TestCase):
sink = Sink()
with zipfile.ZipFile(sink, 'w', method) as z:
z.writestr('assets/streamed', content)
# Streamed with ZIP64 forced: a 64-bit data descriptor for a small file.
with z.open('assets/streamed64', 'w', force_zip64=True) as f:
f.write(content)
z.writestr('assets/after', b'next entry')
path = self.tmpfile(sink.buf.getvalue())
with zipfile.ZipFile(path) as z:
self.assertTrue(z.getinfo('assets/streamed').flag_bits & 0x08)
self.assertTrue(z.getinfo('assets/streamed64').flag_bits & 0x08)
self.assertLess(z.getinfo('assets/streamed64').file_size, 0xffffffff)
self.assertEqual(z.read('assets/streamed64'), content) # zipfile accepts it
self.assertEqual(android.read_member(path, 'assets/streamed'), content)
self.assertEqual(android.read_member(path, 'assets/streamed64'), content)
self.assertEqual(android.read_member(path, 'assets/after'), b'next entry')
def test_central_sizes_and_checksum_forged(self):
# The reviewer's case: stored 'abcdef', central sizes 3 and the prefix's checksum, local untouched.
+38 -8
View File
@@ -331,6 +331,16 @@ def _zip64_sizes(extra, usize, csize):
return usize, csize
def _has_zip64_extra(extra):
at = 0
while at + 4 <= len(extra):
kind, length = struct.unpack_from('<HH', extra, at)
if kind == 0x0001:
return True
at += 4 + length
return False
def read_member(path, member):
"""A split member's bytes, read from its raw data and checked against both headers.
@@ -365,15 +375,35 @@ def read_member(path, member):
if len(compressed) != info.compress_size:
raise FrameError(f'{name} is truncated')
if flags & 0x08:
# Sizes and checksum follow the data: (signature,) crc, sizes (8 bytes each for ZIP64).
want = 20 if info.file_size >= 0xffffffff or info.compress_size >= 0xffffffff else 12
tail = raw.read(4 + want)
signed = tail[:4] == b'PK\7\x08'
body = tail[4:4 + want] if signed else tail[:want]
if len(body) < want:
# Sizes and checksum follow the data: (signature,) crc, then sizes of 4 bytes each,
# or 8 for ZIP64. Streaming writers use the ZIP64 form even for small files when
# sizes weren't known up front, so the width isn't implied by the final sizes: take
# the layout that matches the central directory and fits before the next entry,
# preferring one that ends exactly there, then the width the local header implies.
zip64 = 0xffffffff in (csize, usize) or _has_zip64_extra(extra)
tail = raw.read(24)
if len(tail) < 12:
raise FrameError(f'{name}: truncated data descriptor')
crc, csize, usize = struct.unpack_from('<IQQ' if want == 20 else '<III', body)
descriptor = want + (4 if signed else 0)
end = start + info.compress_size
fits = []
for signed in (True, False):
if signed and tail[:4] != b'PK\7\x08':
continue
for width in (20, 12):
body = tail[4:4 + width] if signed else tail[:width]
if len(body) < width:
continue
values = struct.unpack_from('<IQQ' if width == 20 else '<III', body)
length = width + (4 if signed else 0)
if values == (info.CRC, info.compress_size, size) and \
(following is None or end + length <= following):
exact = following is not None and end + length == following
fits.append((not exact, (width == 20) != zip64, length))
if not fits:
raise FrameError(f'{name} does not match its header (local and central entries differ, '
'or its data descriptor is truncated)')
crc, csize, usize = info.CRC, info.compress_size, size
descriptor = min(fits)[2]
else:
usize, csize = _zip64_sizes(extra, usize, csize)
descriptor = 0