Skip to content

Commit 7440c35

Browse files
authored
[3.15] gh-160310: Fix quadratic one-shot decompress() with many members (GH-160311) (#161037)
gzip.decompress(), bz2.decompress(), lzma.decompress() and compression.zstd.decompress() handed each new decompressor all of the remaining input and copied the rest back through unused_data, so input made of many concatenated members took quadratic time. Each member is now fed in chunks that start at 1 KiB and double, and the loop steps back over unused_data by offset. The first chunk is the whole input, so single-member data still takes one decompressor call. bz2, lzma and zstd share the loop through a private helper in compression._common._streams; gzip keeps its own because it parses the member header, trailer and padding itself. ANT-2026-9R8SO5S2 (cherry picked from commit e80962a)
1 parent 0a312db commit 7440c35

10 files changed

Lines changed: 295 additions & 57 deletions

File tree

‎Lib/bz2.py‎

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -338,20 +338,8 @@ def decompress(data):
338338
339339
For incremental decompression, use a BZ2Decompressor object instead.
340340
"""
341-
results = []
342-
while data:
343-
decomp = BZ2Decompressor()
344-
try:
345-
res = decomp.decompress(data)
346-
except OSError:
347-
if not results:
348-
raise # Error on the first iteration; bail out.
349-
if data.startswith(_STREAM_MAGIC):
350-
raise # Leftover data is a damaged bzip2 stream; bail out.
351-
break # Leftover data is not a valid bzip2 stream; ignore it.
352-
results.append(res)
353-
if not decomp.eof:
354-
raise ValueError("Compressed data ended before the "
355-
"end-of-stream marker was reached")
356-
data = decomp.unused_data
357-
return b"".join(results)
341+
if not data:
342+
return b""
343+
return _streams._decompress_all(data, BZ2Decompressor, OSError,
344+
stream_magic=_STREAM_MAGIC,
345+
truncated_error=ValueError)

‎Lib/compression/_common/_streams.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,60 @@ def seek(self, offset, whence=io.SEEK_SET):
169169
def tell(self):
170170
"""Return the current file position."""
171171
return self._pos
172+
173+
174+
def _decompress_all(data, decomp_factory, trailing_error=(), *,
175+
stream_magic=(), truncated_error, **decomp_args):
176+
"""Decompress all of the concatenated streams in *data*.
177+
178+
Each stream is decompressed by a new decompressor, made by calling
179+
*decomp_factory* with *decomp_args*. As in DecompressReader, an error
180+
of class *trailing_error* in a stream after the first one ends the
181+
data, unless that stream starts with *stream_magic*. A stream without
182+
an end-of-stream marker raises *truncated_error*.
183+
"""
184+
results = []
185+
# The views are released on exit, so that the caller can resize a
186+
# bytearray argument again, also while handling an exception.
187+
with memoryview(data) as view, view.cast("B") as data:
188+
pos = 0
189+
# There is usually a single stream: the first chunk is all of the
190+
# data, to decompress it in one go.
191+
chunk_size = len(data)
192+
while True:
193+
decomp = decomp_factory(**decomp_args)
194+
start = pos
195+
res = []
196+
try:
197+
# Feed the stream in chunks of increasing size, so that what
198+
# is read past its end (and copied into unused_data) is
199+
# bounded by the size of the stream, not by the size of all
200+
# the streams that follow it.
201+
while not decomp.eof and pos < len(data):
202+
with data[pos:pos + chunk_size] as chunk:
203+
res.append(decomp.decompress(chunk))
204+
pos += len(chunk)
205+
chunk_size *= 2
206+
except trailing_error:
207+
if not results:
208+
raise # Error in the first stream; bail out.
209+
if stream_magic:
210+
if isinstance(stream_magic, bytes):
211+
stream_magic = (stream_magic,)
212+
size = max(map(len, stream_magic))
213+
with data[start:start + size] as head:
214+
if head.tobytes().startswith(stream_magic):
215+
raise # Damaged stream, not trailing data.
216+
# Trailing data isn't a valid compressed stream; ignore it.
217+
break
218+
results.extend(res)
219+
if not decomp.eof:
220+
raise truncated_error("Compressed data ended before the "
221+
"end-of-stream marker was reached")
222+
pos -= len(decomp.unused_data)
223+
if pos == len(data):
224+
break
225+
# Later streams start small: a larger first chunk reads further
226+
# past the end of a short stream, a smaller one needs more calls.
227+
chunk_size = 1024
228+
return b"".join(results)

‎Lib/compression/zstd/__init__.py‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import enum
3131
from _zstd import (ZstdCompressor, ZstdDecompressor, ZstdDict, ZstdError,
3232
get_frame_size, zstd_version)
33+
from compression._common import _streams
3334
from compression.zstd._zstdfile import ZstdFile, open, _nbytes
3435

3536
# zstd_version_number is (MAJOR * 100 * 100 + MINOR * 100 + RELEASE)
@@ -161,17 +162,9 @@ def decompress(data, zstd_dict=None, options=None):
161162
162163
For incremental decompression, use a ZstdDecompressor instead.
163164
"""
164-
results = []
165-
while True:
166-
decomp = ZstdDecompressor(options=options, zstd_dict=zstd_dict)
167-
results.append(decomp.decompress(data))
168-
if not decomp.eof:
169-
raise ZstdError('Compressed data ended before the '
170-
'end-of-stream marker was reached')
171-
data = decomp.unused_data
172-
if not data:
173-
break
174-
return b''.join(results)
165+
return _streams._decompress_all(data, ZstdDecompressor,
166+
truncated_error=ZstdError,
167+
options=options, zstd_dict=zstd_dict)
175168

176169

177170
class CompressionParameter(enum.IntEnum):

‎Lib/gzip.py‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -681,24 +681,47 @@ def decompress(data):
681681
Return the decompressed string.
682682
"""
683683
decompressed_members = []
684+
fp = io.BytesIO(data)
685+
# There is usually a single member: the first chunk is all of the data,
686+
# to decompress it in one go.
687+
chunk_size = len(data)
684688
while True:
685-
fp = io.BytesIO(data)
686689
if _read_gzip_header(fp) is None:
687690
return b"".join(decompressed_members)
688691
# Use a zlib raw deflate compressor
689692
do = zlib.decompressobj(wbits=-zlib.MAX_WBITS)
690-
# Read all the data except the header
691-
decompressed = do.decompress(data[fp.tell():])
692-
if not do.eof or len(do.unused_data) < 8:
693+
crc = zlib.crc32(b"")
694+
length = 0
695+
# Feed the member in chunks of increasing size, so that what is read
696+
# past its end (and copied into unused_data) is bounded by the size
697+
# of the member, not by the size of all the members that follow it.
698+
while not do.eof and (chunk := fp.read(chunk_size)):
699+
decompressed = do.decompress(chunk)
700+
crc = zlib.crc32(decompressed, crc)
701+
length += len(decompressed)
702+
decompressed_members.append(decompressed)
703+
chunk_size *= 2
704+
# Later members start small: a larger first chunk reads further past
705+
# the end of a short member, a smaller one needs more calls.
706+
chunk_size = 1024
707+
# Rewind to the end of the deflate stream and read the trailer
708+
fp.seek(-len(do.unused_data), io.SEEK_CUR)
709+
trailer = fp.read(8)
710+
if not do.eof or len(trailer) < 8:
693711
raise EOFError("Compressed file ended before the end-of-stream "
694712
"marker was reached")
695-
crc, length = struct.unpack("<II", do.unused_data[:8])
696-
if crc != zlib.crc32(decompressed):
713+
crc32, isize = struct.unpack("<II", trailer)
714+
if crc32 != crc:
697715
raise BadGzipFile("CRC check failed")
698-
if length != (len(decompressed) & 0xffffffff):
716+
if isize != (length & 0xffffffff):
699717
raise BadGzipFile("Incorrect length of data produced")
700-
decompressed_members.append(decompressed)
701-
data = do.unused_data[8:].lstrip(b"\x00")
718+
# Gzip files can be padded with zeroes and still have archives.
719+
# Consume all zero bytes and set the file position to the first
720+
# non-zero byte.
721+
while chunk := fp.read(chunk_size):
722+
if chunk := chunk.lstrip(b"\x00"):
723+
fp.seek(-len(chunk), io.SEEK_CUR)
724+
break
702725

703726

704727
def main():

‎Lib/lzma.py‎

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -352,23 +352,8 @@ def decompress(data, format=FORMAT_AUTO, memlimit=None, filters=None):
352352
353353
For incremental decompression, use an LZMADecompressor instead.
354354
"""
355-
results = []
356-
magic = _stream_magic(format)
357-
while True:
358-
decomp = LZMADecompressor(format, memlimit, filters)
359-
try:
360-
res = decomp.decompress(data)
361-
except LZMAError:
362-
if not results:
363-
raise # Error on the first iteration; bail out.
364-
if magic and data.startswith(magic):
365-
raise # Leftover data is a damaged XZ stream; bail out.
366-
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
367-
results.append(res)
368-
if not decomp.eof:
369-
raise LZMAError("Compressed data ended before the "
370-
"end-of-stream marker was reached")
371-
data = decomp.unused_data
372-
if not data:
373-
break
374-
return b"".join(results)
355+
return _streams._decompress_all(data, LZMADecompressor, LZMAError,
356+
stream_magic=_stream_magic(format),
357+
truncated_error=LZMAError,
358+
format=format, memlimit=memlimit,
359+
filters=filters)

‎Lib/test/test_bz2.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,53 @@ def testDecompressMultiStreamTrailingJunk(self):
11161116
text = bz2.decompress(self.DATA * 5 + self.BAD_DATA)
11171117
self.assertEqual(text, self.TEXT * 5)
11181118

1119+
def testDecompressMultiStreamBig(self):
1120+
big = random.randbytes(100_000)
1121+
streams = [self.TEXT, b'', big, self.TEXT, big]
1122+
expected = b''.join(streams)
1123+
data = b''.join(map(bz2.compress, streams))
1124+
self.assertEqual(bz2.decompress(data), expected)
1125+
self.assertEqual(bz2.decompress(bytearray(data)), expected)
1126+
self.assertEqual(bz2.decompress(memoryview(data)), expected)
1127+
self.assertEqual(bz2.decompress(data + self.BAD_DATA), expected)
1128+
self.assertRaises(ValueError, bz2.decompress, data[:-50_000])
1129+
# The damage is past the first chunk fed for the second stream.
1130+
damaged = bytearray(bz2.compress(big))
1131+
damaged[-3] ^= 1
1132+
self.assertRaises(OSError, bz2.decompress, self.DATA + damaged)
1133+
1134+
def testDecompressErrorReleasesBuffer(self):
1135+
# The argument can be resized while the exception is handled.
1136+
for data in self.DATA[:-10], self.BAD_DATA:
1137+
with self.subTest(data=data[:10]):
1138+
data = bytearray(data)
1139+
try:
1140+
bz2.decompress(data)
1141+
except (ValueError, OSError):
1142+
data += b'more'
1143+
else:
1144+
self.fail('no exception')
1145+
1146+
def testDecompressManyStreams(self):
1147+
# The amount of data passed to the decompressors should grow
1148+
# linearly with the number of streams.
1149+
class Decompressor:
1150+
def __init__(self, *args, **kwargs):
1151+
self.decomp = BZ2Decompressor(*args, **kwargs)
1152+
def __getattr__(self, name):
1153+
return getattr(self.decomp, name)
1154+
def decompress(self, data):
1155+
nonlocal fed
1156+
fed += len(data)
1157+
return self.decomp.decompress(data)
1158+
with support.swap_attr(bz2, 'BZ2Decompressor', Decompressor):
1159+
fed = 0
1160+
self.assertEqual(bz2.decompress(self.EMPTY_DATA * 1000), b'')
1161+
fed1 = fed
1162+
fed = 0
1163+
self.assertEqual(bz2.decompress(self.EMPTY_DATA * 2000), b'')
1164+
self.assertLess(fed, fed1 * 3)
1165+
11191166
def testDecompressMultiStreamDamagedStream(self):
11201167
# A damaged bzip2 stream following a valid one is not trailing junk.
11211168
for pos in 4, 100, -3:

‎Lib/test/test_gzip.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,50 @@ def test_decompress_missing_trailer(self):
816816
compressed_data = gzip.compress(data1)
817817
self.assertRaises(EOFError, gzip.decompress, compressed_data[:-8])
818818

819+
def test_decompress_multi_member(self):
820+
big = os.urandom(100_000)
821+
members = [data1, b'', big, data2, big]
822+
expected = b''.join(members)
823+
for padding in b'', b'\0', b'\0' * 5000:
824+
with self.subTest(padding=len(padding)):
825+
datac = b''.join(gzip.compress(data) + padding
826+
for data in members)
827+
self.assertEqual(gzip.decompress(datac), expected)
828+
datac = b''.join(map(gzip.compress, members))
829+
self.assertEqual(gzip.decompress(bytearray(datac)), expected)
830+
self.assertEqual(gzip.decompress(memoryview(datac)), expected)
831+
self.assertRaises(EOFError, gzip.decompress, datac[:-4])
832+
self.assertRaises(EOFError, gzip.decompress, datac[:-50_000])
833+
self.assertRaises(gzip.BadGzipFile, gzip.decompress, datac + b'junk')
834+
# Damage the CRC in the trailer of the third member.
835+
parts = [gzip.compress(data) for data in members]
836+
corrupted = bytearray(b''.join(parts))
837+
corrupted[len(b''.join(parts[:3])) - 8] ^= 1
838+
with self.assertRaisesRegex(gzip.BadGzipFile, 'CRC check failed'):
839+
gzip.decompress(corrupted)
840+
841+
def test_decompress_many_members(self):
842+
# The amount of data passed to the decompressors should grow
843+
# linearly with the number of members.
844+
decompressobj = zlib.decompressobj
845+
class Decompressor:
846+
def __init__(self, *args, **kwargs):
847+
self.decomp = decompressobj(*args, **kwargs)
848+
def __getattr__(self, name):
849+
return getattr(self.decomp, name)
850+
def decompress(self, data):
851+
nonlocal fed
852+
fed += len(data)
853+
return self.decomp.decompress(data)
854+
datac = gzip.compress(b'')
855+
with mock.patch.object(zlib, 'decompressobj', Decompressor):
856+
fed = 0
857+
self.assertEqual(gzip.decompress(datac * 1000), b'')
858+
fed1 = fed
859+
fed = 0
860+
self.assertEqual(gzip.decompress(datac * 2000), b'')
861+
self.assertLess(fed, fed1 * 3)
862+
819863
def test_truncated_header(self):
820864
truncated_headers = [
821865
b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00", # Missing OS byte

‎Lib/test/test_lzma.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,11 +508,62 @@ def test_decompress_multistream(self):
508508
ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_ALONE)
509509
self.assertEqual(ddata, INPUT * 2)
510510

511+
def test_decompress_multistream_big(self):
512+
big = random.randbytes(100_000)
513+
streams = [INPUT, b"", big, INPUT, big]
514+
expected = b"".join(streams)
515+
cdata = b"".join(map(lzma.compress, streams))
516+
self.assertEqual(lzma.decompress(cdata), expected)
517+
self.assertEqual(lzma.decompress(bytearray(cdata)), expected)
518+
self.assertEqual(lzma.decompress(memoryview(cdata)), expected)
519+
self.assertEqual(lzma.decompress(cdata + COMPRESSED_BOGUS), expected)
520+
self.assertRaises(LZMAError, lzma.decompress, cdata[:-50_000])
521+
# The damage is past the first chunk fed for the second stream.
522+
damaged = bytearray(lzma.compress(big))
523+
damaged[-1] ^= 1
524+
self.assertRaises(LZMAError, lzma.decompress, COMPRESSED_XZ + damaged)
525+
526+
def test_decompress_error_releases_buffer(self):
527+
# The argument can be resized while the exception is handled.
528+
for cdata in COMPRESSED_XZ[:-10], COMPRESSED_BOGUS:
529+
with self.subTest(cdata=cdata[:10]):
530+
cdata = bytearray(cdata)
531+
try:
532+
lzma.decompress(cdata)
533+
except LZMAError:
534+
cdata += b"more"
535+
else:
536+
self.fail("no exception")
537+
538+
def test_decompress_many_streams(self):
539+
# The amount of data passed to the decompressors should grow
540+
# linearly with the number of streams.
541+
class Decompressor:
542+
def __init__(self, *args, **kwargs):
543+
self.decomp = LZMADecompressor(*args, **kwargs)
544+
def __getattr__(self, name):
545+
return getattr(self.decomp, name)
546+
def decompress(self, data):
547+
nonlocal fed
548+
fed += len(data)
549+
return self.decomp.decompress(data)
550+
cdata = lzma.compress(b"")
551+
with support.swap_attr(lzma, "LZMADecompressor", Decompressor):
552+
fed = 0
553+
self.assertEqual(lzma.decompress(cdata * 1000), b"")
554+
fed1 = fed
555+
fed = 0
556+
self.assertEqual(lzma.decompress(cdata * 2000), b"")
557+
self.assertLess(fed, fed1 * 3)
558+
511559
# Test robust handling of non-LZMA data following the compressed stream(s).
512560

513561
def test_decompress_trailing_junk(self):
514562
ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_BOGUS)
515563
self.assertEqual(ddata, INPUT)
564+
ddata = lzma.decompress(COMPRESSED_ALONE + COMPRESSED_BOGUS,
565+
lzma.FORMAT_ALONE)
566+
self.assertEqual(ddata, INPUT)
516567

517568
def test_decompress_multistream_trailing_junk(self):
518569
ddata = lzma.decompress(COMPRESSED_XZ * 3 + COMPRESSED_BOGUS)

0 commit comments

Comments
 (0)