Skip to content

Commit e80962a

Browse files
authored
gh-160310: Fix quadratic one-shot decompress() with many members (GH-160311)
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
1 parent c3fe3c3 commit e80962a

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
@@ -341,23 +341,11 @@ def decompress(data):
341341
342342
For incremental decompression, use a BZ2Decompressor object instead.
343343
"""
344-
results = []
345-
while data:
346-
decomp = BZ2Decompressor()
347-
try:
348-
res = decomp.decompress(data)
349-
except OSError:
350-
if not results:
351-
raise # Error on the first iteration; bail out.
352-
if data.startswith(_STREAM_MAGIC):
353-
raise # Leftover data is a damaged bzip2 stream; bail out.
354-
break # Leftover data is not a valid bzip2 stream; ignore it.
355-
results.append(res)
356-
if not decomp.eof:
357-
raise ValueError("Compressed data ended before the "
358-
"end-of-stream marker was reached")
359-
data = decomp.unused_data
360-
return b"".join(results)
344+
if not data:
345+
return b""
346+
return _streams._decompress_all(data, BZ2Decompressor, OSError,
347+
stream_magic=_STREAM_MAGIC,
348+
truncated_error=ValueError)
361349

362350

363351
def __getattr__(name):

‎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
@@ -33,6 +33,7 @@
3333
from _zstd import (ZstdCompressor, ZstdDecompressor, ZstdDict, ZstdError,
3434
get_frame_size, zstd_version, ZSTD_VERSION,
3535
zstd_version_info, ZSTD_VERSION_INFO)
36+
from compression._common import _streams
3637
from compression.zstd._zstdfile import ZstdFile, open, _nbytes
3738

3839

@@ -160,17 +161,9 @@ def decompress(data, zstd_dict=None, options=None):
160161
161162
For incremental decompression, use a ZstdDecompressor instead.
162163
"""
163-
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
173-
return b''.join(results)
164+
return _streams._decompress_all(data, ZstdDecompressor,
165+
truncated_error=ZstdError,
166+
options=options, zstd_dict=zstd_dict)
174167

175168

176169
class CompressionParameter(enum.IntEnum):

‎Lib/gzip.py‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -682,24 +682,47 @@ 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 = len(data)
685689
while True:
686-
fp = io.BytesIO(data)
687690
if _read_gzip_header(fp) is None:
688691
return b"".join(decompressed_members)
689692
# Use a zlib raw deflate compressor
690693
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:
694+
crc = zlib.crc32(b"")
695+
length = 0
696+
# Feed the member in chunks of increasing size, so that what is read
697+
# past its end (and copied into unused_data) is bounded by the size
698+
# of the member, not by the size of all the members that follow it.
699+
while not do.eof and (chunk := fp.read(chunk_size)):
700+
decompressed = do.decompress(chunk)
701+
crc = zlib.crc32(decompressed, crc)
702+
length += len(decompressed)
703+
decompressed_members.append(decompressed)
704+
chunk_size *= 2
705+
# Later members start small: a larger first chunk reads further past
706+
# the end of a short member, a smaller one needs more calls.
707+
chunk_size = 1024
708+
# Rewind to the end of the deflate stream and read the trailer
709+
fp.seek(-len(do.unused_data), io.SEEK_CUR)
710+
trailer = fp.read(8)
711+
if not do.eof or len(trailer) < 8:
694712
raise EOFError("Compressed file ended before the end-of-stream "
695713
"marker was reached")
696-
crc, length = struct.unpack("<II", do.unused_data[:8])
697-
if crc != zlib.crc32(decompressed):
714+
crc32, isize = struct.unpack("<II", trailer)
715+
if crc32 != crc:
698716
raise BadGzipFile("CRC check failed")
699-
if length != (len(decompressed) & 0xffffffff):
717+
if isize != (length & 0xffffffff):
700718
raise BadGzipFile("Incorrect length of data produced")
701-
decompressed_members.append(decompressed)
702-
data = do.unused_data[8:].lstrip(b"\x00")
719+
# Gzip files can be padded with zeroes and still have archives.
720+
# Consume all zero bytes and set the file position to the first
721+
# non-zero byte.
722+
while chunk := fp.read(chunk_size):
723+
if chunk := chunk.lstrip(b"\x00"):
724+
fp.seek(-len(chunk), io.SEEK_CUR)
725+
break
703726

704727

705728
def main():

‎Lib/lzma.py‎

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -354,23 +354,8 @@ def decompress(data, format=FORMAT_AUTO, memlimit=None, filters=None):
354354
355355
For incremental decompression, use an LZMADecompressor instead.
356356
"""
357-
results = []
358-
magic = _stream_magic(format)
359-
while True:
360-
decomp = LZMADecompressor(format, memlimit, filters)
361-
try:
362-
res = decomp.decompress(data)
363-
except LZMAError:
364-
if not results:
365-
raise # Error on the first iteration; bail out.
366-
if magic and data.startswith(magic):
367-
raise # Leftover data is a damaged XZ stream; bail out.
368-
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
369-
results.append(res)
370-
if not decomp.eof:
371-
raise LZMAError("Compressed data ended before the "
372-
"end-of-stream marker was reached")
373-
data = decomp.unused_data
374-
if not data:
375-
break
376-
return b"".join(results)
357+
return _streams._decompress_all(data, LZMADecompressor, LZMAError,
358+
stream_magic=_stream_magic(format),
359+
truncated_error=LZMAError,
360+
format=format, memlimit=memlimit,
361+
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
@@ -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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,11 +514,62 @@ 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+
# The damage is past the first chunk fed for the second stream.
528+
damaged = bytearray(lzma.compress(big))
529+
damaged[-1] ^= 1
530+
self.assertRaises(LZMAError, lzma.decompress, COMPRESSED_XZ + damaged)
531+
532+
def test_decompress_error_releases_buffer(self):
533+
# The argument can be resized while the exception is handled.
534+
for cdata in COMPRESSED_XZ[:-10], COMPRESSED_BOGUS:
535+
with self.subTest(cdata=cdata[:10]):
536+
cdata = bytearray(cdata)
537+
try:
538+
lzma.decompress(cdata)
539+
except LZMAError:
540+
cdata += b"more"
541+
else:
542+
self.fail("no exception")
543+
544+
def test_decompress_many_streams(self):
545+
# The amount of data passed to the decompressors should grow
546+
# linearly with the number of streams.
547+
class Decompressor:
548+
def __init__(self, *args, **kwargs):
549+
self.decomp = LZMADecompressor(*args, **kwargs)
550+
def __getattr__(self, name):
551+
return getattr(self.decomp, name)
552+
def decompress(self, data):
553+
nonlocal fed
554+
fed += len(data)
555+
return self.decomp.decompress(data)
556+
cdata = lzma.compress(b"")
557+
with support.swap_attr(lzma, "LZMADecompressor", Decompressor):
558+
fed = 0
559+
self.assertEqual(lzma.decompress(cdata * 1000), b"")
560+
fed1 = fed
561+
fed = 0
562+
self.assertEqual(lzma.decompress(cdata * 2000), b"")
563+
self.assertLess(fed, fed1 * 3)
564+
517565
# Test robust handling of non-LZMA data following the compressed stream(s).
518566

519567
def test_decompress_trailing_junk(self):
520568
ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_BOGUS)
521569
self.assertEqual(ddata, INPUT)
570+
ddata = lzma.decompress(COMPRESSED_ALONE + COMPRESSED_BOGUS,
571+
lzma.FORMAT_ALONE)
572+
self.assertEqual(ddata, INPUT)
522573

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

0 commit comments

Comments
 (0)