Skip to content

Commit d701ba3

Browse files
committed
gh-160310: Fix quadratic one-shot decompress() with many members
gzip.decompress(), bz2.decompress(), lzma.decompress() and compression.zstd.decompress() passed all of the remaining input to a new decompressor for every member and copied the rest back via unused_data. Feed each member in chunks of increasing size instead.
1 parent 3b3c692 commit d701ba3

9 files changed

Lines changed: 315 additions & 48 deletions

File tree

‎Lib/bz2.py‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -337,18 +337,42 @@ def decompress(data):
337337
For incremental decompression, use a BZ2Decompressor object instead.
338338
"""
339339
results = []
340-
while data:
341-
decomp = BZ2Decompressor()
342-
try:
343-
res = decomp.decompress(data)
344-
except OSError:
345-
if results:
346-
break # Leftover data is not a valid bzip2 stream; ignore it.
347-
else:
348-
raise # Error on the first iteration; bail out.
349-
results.append(res)
350-
if not decomp.eof:
351-
raise ValueError("Compressed data ended before the "
352-
"end-of-stream marker was reached")
353-
data = decomp.unused_data
340+
if not data:
341+
return b""
342+
# The views are released on exit, so that the caller can resize a
343+
# bytearray argument again, also while handling an exception.
344+
with memoryview(data) as view, view.cast("B") as data:
345+
pos = 0
346+
# There is usually a single stream: the first chunk is all of the
347+
# data, to decompress it in one go.
348+
chunk_size = len(data)
349+
while True:
350+
decomp = BZ2Decompressor()
351+
res = []
352+
try:
353+
# Feed the stream in chunks of increasing size, so that what
354+
# is read past its end (and copied into unused_data) is
355+
# bounded by the size of the stream, not by the size of all
356+
# the streams that follow it.
357+
while not decomp.eof and pos < len(data):
358+
with data[pos:pos + chunk_size] as chunk:
359+
res.append(decomp.decompress(chunk))
360+
pos += len(chunk)
361+
chunk_size *= 2
362+
except OSError:
363+
if results:
364+
# Leftover data is not a valid bzip2 stream; ignore it.
365+
break
366+
else:
367+
raise # Error on the first iteration; bail out.
368+
results.extend(res)
369+
if not decomp.eof:
370+
raise ValueError("Compressed data ended before the "
371+
"end-of-stream marker was reached")
372+
pos -= len(decomp.unused_data)
373+
if pos == len(data):
374+
break
375+
# Later streams start small: a larger first chunk reads further
376+
# past the end of a short stream, a smaller one needs more calls.
377+
chunk_size = 1024
354378
return b"".join(results)

‎Lib/compression/zstd/__init__.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,33 @@ def decompress(data, zstd_dict=None, options=None):
161161
For incremental decompression, use a ZstdDecompressor instead.
162162
"""
163163
results = []
164-
while True:
165-
decomp = ZstdDecompressor(options=options, zstd_dict=zstd_dict)
166-
results.append(decomp.decompress(data))
167-
if not decomp.eof:
168-
raise ZstdError('Compressed data ended before the '
169-
'end-of-stream marker was reached')
170-
data = decomp.unused_data
171-
if not data:
172-
break
164+
# The views are released on exit, so that the caller can resize a
165+
# bytearray argument again, also while handling an exception.
166+
with memoryview(data) as view, view.cast('B') as data:
167+
pos = 0
168+
# There is usually a single frame: the first chunk is all of the
169+
# data, to decompress it in one go.
170+
chunk_size = len(data)
171+
while True:
172+
decomp = ZstdDecompressor(options=options, zstd_dict=zstd_dict)
173+
# Feed the frame in chunks of increasing size, so that what is
174+
# read past its end (and copied into unused_data) is bounded by
175+
# the size of the frame, not by the size of all the frames that
176+
# follow it.
177+
while not decomp.eof and pos < len(data):
178+
with data[pos:pos + chunk_size] as chunk:
179+
results.append(decomp.decompress(chunk))
180+
pos += len(chunk)
181+
chunk_size *= 2
182+
if not decomp.eof:
183+
raise ZstdError('Compressed data ended before the '
184+
'end-of-stream marker was reached')
185+
pos -= len(decomp.unused_data)
186+
if pos == len(data):
187+
break
188+
# Later frames start small: a larger first chunk reads further
189+
# past the end of a short frame, a smaller one needs more calls.
190+
chunk_size = 1024
173191
return b''.join(results)
174192

175193

‎Lib/gzip.py‎

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

704728

705729
def main():

‎Lib/lzma.py‎

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -347,20 +347,40 @@ def decompress(data, format=FORMAT_AUTO, memlimit=None, filters=None):
347347
For incremental decompression, use an LZMADecompressor instead.
348348
"""
349349
results = []
350-
while True:
351-
decomp = LZMADecompressor(format, memlimit, filters)
352-
try:
353-
res = decomp.decompress(data)
354-
except LZMAError:
355-
if results:
356-
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
357-
else:
358-
raise # Error on the first iteration; bail out.
359-
results.append(res)
360-
if not decomp.eof:
361-
raise LZMAError("Compressed data ended before the "
362-
"end-of-stream marker was reached")
363-
data = decomp.unused_data
364-
if not data:
365-
break
350+
# The views are released on exit, so that the caller can resize a
351+
# bytearray argument again, also while handling an exception.
352+
with memoryview(data) as view, view.cast("B") as data:
353+
pos = 0
354+
# There is usually a single stream: the first chunk is all of the
355+
# data, to decompress it in one go.
356+
chunk_size = len(data)
357+
while True:
358+
decomp = LZMADecompressor(format, memlimit, filters)
359+
res = []
360+
try:
361+
# Feed the stream in chunks of increasing size, so that what
362+
# is read past its end (and copied into unused_data) is
363+
# bounded by the size of the stream, not by the size of all
364+
# the streams that follow it.
365+
while not decomp.eof and pos < len(data):
366+
with data[pos:pos + chunk_size] as chunk:
367+
res.append(decomp.decompress(chunk))
368+
pos += len(chunk)
369+
chunk_size *= 2
370+
except LZMAError:
371+
if results:
372+
# Leftover data is not a valid LZMA/XZ stream; ignore it.
373+
break
374+
else:
375+
raise # Error on the first iteration; bail out.
376+
results.extend(res)
377+
if not decomp.eof:
378+
raise LZMAError("Compressed data ended before the "
379+
"end-of-stream marker was reached")
380+
pos -= len(decomp.unused_data)
381+
if pos == len(data):
382+
break
383+
# Later streams start small: a larger first chunk reads further
384+
# past the end of a short stream, a smaller one needs more calls.
385+
chunk_size = 1024
366386
return b"".join(results)

‎Lib/test/test_bz2.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,49 @@ def testDecompressMultiStreamTrailingJunk(self):
11001100
text = bz2.decompress(self.DATA * 5 + self.BAD_DATA)
11011101
self.assertEqual(text, self.TEXT * 5)
11021102

1103+
def testDecompressMultiStreamBig(self):
1104+
big = random.randbytes(100_000)
1105+
streams = [self.TEXT, b'', big, self.TEXT, big]
1106+
expected = b''.join(streams)
1107+
data = b''.join(map(bz2.compress, streams))
1108+
self.assertEqual(bz2.decompress(data), expected)
1109+
self.assertEqual(bz2.decompress(bytearray(data)), expected)
1110+
self.assertEqual(bz2.decompress(memoryview(data)), expected)
1111+
self.assertEqual(bz2.decompress(data + self.BAD_DATA), expected)
1112+
self.assertRaises(ValueError, bz2.decompress, data[:-50_000])
1113+
1114+
def testDecompressErrorReleasesBuffer(self):
1115+
# The argument can be resized while the exception is handled.
1116+
for data in self.DATA[:-10], self.BAD_DATA:
1117+
with self.subTest(data=data[:10]):
1118+
data = bytearray(data)
1119+
try:
1120+
bz2.decompress(data)
1121+
except (ValueError, OSError):
1122+
data += b'more'
1123+
else:
1124+
self.fail('no exception')
1125+
1126+
def testDecompressManyStreams(self):
1127+
# The amount of data passed to the decompressors should grow
1128+
# linearly with the number of streams.
1129+
class Decompressor:
1130+
def __init__(self, *args, **kwargs):
1131+
self.decomp = BZ2Decompressor(*args, **kwargs)
1132+
def __getattr__(self, name):
1133+
return getattr(self.decomp, name)
1134+
def decompress(self, data):
1135+
nonlocal fed
1136+
fed += len(data)
1137+
return self.decomp.decompress(data)
1138+
with support.swap_attr(bz2, 'BZ2Decompressor', Decompressor):
1139+
fed = 0
1140+
self.assertEqual(bz2.decompress(self.EMPTY_DATA * 1000), b'')
1141+
fed1 = fed
1142+
fed = 0
1143+
self.assertEqual(bz2.decompress(self.EMPTY_DATA * 2000), b'')
1144+
self.assertLess(fed, fed1 * 3)
1145+
11031146

11041147
class OpenTest(BaseTest):
11051148
"Test the open function."

‎Lib/test/test_gzip.py‎

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

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

‎Lib/test/test_lzma.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,50 @@ def test_decompress_multistream(self):
514514
ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_ALONE)
515515
self.assertEqual(ddata, INPUT * 2)
516516

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

519563
def test_decompress_trailing_junk(self):

0 commit comments

Comments
 (0)