Skip to content

Commit df57e42

Browse files
HipHoffclaude
andcommitted
gh-160842: Read every frame of a multi-frame Zstandard zipfile member
ZipExtFile._read1() treated the end of the first zstd frame as the end of the member. When the decompressor reaches the end of a frame and compressed input remains, continue with a new ZstdDecompressor. Bytes that are not a valid frame raise ZstdError, and a truncated last frame still fails the CRC check. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uX6RoHqnajMDyVi3LrJjs
1 parent 19270d3 commit df57e42

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

‎Lib/test/test_zipfile/test_core.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4935,6 +4935,70 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
49354935
compression = zipfile.ZIP_ZSTANDARD
49364936

49374937

4938+
@requires_zstd()
4939+
class ZstdMultipleFramesTests(unittest.TestCase):
4940+
# A zstd stream may consist of several concatenated frames (RFC 8878,
4941+
# section 3.1), as written by multi-threaded or streaming compressors.
4942+
part1 = b'first frame ' * 1000
4943+
part2 = b'second frame ' * 1000
4944+
4945+
def make_zip(self, member_data, content):
4946+
# Store member_data, then relabel the member as Zstandard-compressed
4947+
# with the CRC and size of the decompressed content.
4948+
buf = io.BytesIO()
4949+
with zipfile.ZipFile(buf, 'w') as zf:
4950+
zf.writestr(zipfile.ZipInfo('a'), member_data)
4951+
data = bytearray(buf.getvalue())
4952+
crc = zipfile.crc32(content)
4953+
cd = data.rindex(zipfile.stringCentralDir)
4954+
# Offsets of the method, CRC-32 and uncompressed size fields in the
4955+
# local file header and in the central directory record.
4956+
for method, crc_pos, size_pos in ((8, 14, 22),
4957+
(cd + 10, cd + 16, cd + 24)):
4958+
struct.pack_into('<H', data, method, zipfile.ZIP_ZSTANDARD)
4959+
struct.pack_into('<L', data, crc_pos, crc)
4960+
struct.pack_into('<L', data, size_pos, len(content))
4961+
return io.BytesIO(data)
4962+
4963+
def test_read_multiple_frames(self):
4964+
from compression import zstd
4965+
content = self.part1 + self.part2
4966+
member_data = zstd.compress(self.part1) + zstd.compress(self.part2)
4967+
with zipfile.ZipFile(self.make_zip(member_data, content)) as zf:
4968+
self.assertIsNone(zf.testzip())
4969+
self.assertEqual(zf.read('a'), content)
4970+
for size in (1, 7, 1000, len(content) + 1):
4971+
with self.subTest(size=size), zf.open('a') as f:
4972+
chunks = iter(lambda: f.read(size), b'')
4973+
self.assertEqual(b''.join(chunks), content)
4974+
4975+
def test_read_many_small_frames(self):
4976+
from compression import zstd
4977+
parts = [bytes([i]) * i for i in range(1, 50)]
4978+
content = b''.join(parts)
4979+
member_data = b''.join(zstd.compress(p) for p in parts)
4980+
with zipfile.ZipFile(self.make_zip(member_data, content)) as zf:
4981+
self.assertEqual(zf.read('a'), content)
4982+
4983+
def test_garbage_between_frames(self):
4984+
from compression import zstd
4985+
content = self.part1 + self.part2
4986+
member_data = (zstd.compress(self.part1) + b'garbage!' +
4987+
zstd.compress(self.part2))
4988+
with zipfile.ZipFile(self.make_zip(member_data, content)) as zf:
4989+
with self.assertRaises(zstd.ZstdError):
4990+
zf.read('a')
4991+
4992+
def test_truncated_last_frame(self):
4993+
from compression import zstd
4994+
content = self.part1 + self.part2
4995+
member_data = (zstd.compress(self.part1) +
4996+
zstd.compress(self.part2)[:-4])
4997+
with zipfile.ZipFile(self.make_zip(member_data, content)) as zf:
4998+
with self.assertRaises(zipfile.BadZipFile):
4999+
zf.read('a')
5000+
5001+
49385002
class MonkeypatchedDecompressorTests(unittest.TestCase):
49395003
# Some third-party projects monkey-patch _get_decompressor() to add
49405004
# additional compression schemes. This can break at any time as the

‎Lib/zipfile/__init__.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,15 @@ def _read1(self, n):
11971197
data += self._read2(n - len(data))
11981198
elif self._compress_type == ZIP_STORED:
11991199
data = self._read2(n)
1200+
elif (self._compress_type == ZIP_ZSTANDARD and
1201+
self._decompressor.eof):
1202+
# A zstd stream may consist of several frames (RFC 8878,
1203+
# section 3.1). Decode the next one with a new decompressor;
1204+
# bytes that are not a valid frame raise ZstdError.
1205+
data = self._decompressor.unused_data
1206+
self._decompressor = _get_decompressor(ZIP_ZSTANDARD)
1207+
if not data:
1208+
data = self._read2(n)
12001209
else:
12011210
# bzip2/lzma/zstd: a bounded decompress() call may leave input
12021211
# buffered inside the decompressor; drain that before reading more.
@@ -1233,9 +1242,16 @@ def _read1(self, n):
12331242
remove=(3, 21),
12341243
stacklevel=4)
12351244
data = self._decompressor.decompress(data)
1236-
self._eof = (self._decompressor.eof or
1237-
self._compress_left <= 0 and
1238-
getattr(self._decompressor, "needs_input", True))
1245+
if self._compress_type == ZIP_ZSTANDARD:
1246+
# The end of a zstd frame is not the end of the member.
1247+
self._eof = (self._compress_left <= 0 and
1248+
(self._decompressor.needs_input or
1249+
self._decompressor.eof and
1250+
not self._decompressor.unused_data))
1251+
else:
1252+
self._eof = (self._decompressor.eof or
1253+
self._compress_left <= 0 and
1254+
getattr(self._decompressor, "needs_input", True))
12391255

12401256
data = data[:self._left]
12411257
self._left -= len(data)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:mod:`zipfile` now reads Zstandard-compressed members that consist of
2+
several frames, instead of returning only the first frame and raising
3+
:exc:`~zipfile.BadZipFile` on the CRC check.

0 commit comments

Comments
 (0)