https://github.com/python/cpython/commit/7440c35307eaea48ee51b97a510d4ee519207a3a
commit: 7440c35307eaea48ee51b97a510d4ee519207a3a
branch: 3.15
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-10-31T22:49:54Z
summary:

[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 e80962a4bd68b162a54fc63cfb754237c2f81d8e)

files:
A Misc/NEWS.d/next/Library/2026-10-22-08-34-22.gh-issue-160310.q7Zm2K.rst
M Lib/bz2.py
M Lib/compression/_common/_streams.py
M Lib/compression/zstd/__init__.py
M Lib/gzip.py
M Lib/lzma.py
M Lib/test/test_bz2.py
M Lib/test/test_gzip.py
M Lib/test/test_lzma.py
M Lib/test/test_zstd.py

diff --git a/Lib/bz2.py b/Lib/bz2.py
index f4f3cda5a66cbd8..7e41866004f7518 100644
--- a/Lib/bz2.py
+++ b/Lib/bz2.py
@@ -338,20 +338,8 @@ def decompress(data):
 
     For incremental decompression, use a BZ2Decompressor object instead.
     """
-    results = []
-    while data:
-        decomp = BZ2Decompressor()
-        try:
-            res = decomp.decompress(data)
-        except OSError:
-            if not results:
-                raise  # Error on the first iteration; bail out.
-            if data.startswith(_STREAM_MAGIC):
-                raise  # Leftover data is a damaged bzip2 stream; bail out.
-            break  # Leftover data is not a valid bzip2 stream; ignore it.
-        results.append(res)
-        if not decomp.eof:
-            raise ValueError("Compressed data ended before the "
-                             "end-of-stream marker was reached")
-        data = decomp.unused_data
-    return b"".join(results)
+    if not data:
+        return b""
+    return _streams._decompress_all(data, BZ2Decompressor, OSError,
+                                    stream_magic=_STREAM_MAGIC,
+                                    truncated_error=ValueError)
diff --git a/Lib/compression/_common/_streams.py 
b/Lib/compression/_common/_streams.py
index 09c65507297886b..0e3308c0c6e5fdc 100644
--- a/Lib/compression/_common/_streams.py
+++ b/Lib/compression/_common/_streams.py
@@ -169,3 +169,60 @@ def seek(self, offset, whence=io.SEEK_SET):
     def tell(self):
         """Return the current file position."""
         return self._pos
+
+
+def _decompress_all(data, decomp_factory, trailing_error=(), *,
+                    stream_magic=(), truncated_error, **decomp_args):
+    """Decompress all of the concatenated streams in *data*.
+
+    Each stream is decompressed by a new decompressor, made by calling
+    *decomp_factory* with *decomp_args*.  As in DecompressReader, an error
+    of class *trailing_error* in a stream after the first one ends the
+    data, unless that stream starts with *stream_magic*.  A stream without
+    an end-of-stream marker raises *truncated_error*.
+    """
+    results = []
+    # The views are released on exit, so that the caller can resize a
+    # bytearray argument again, also while handling an exception.
+    with memoryview(data) as view, view.cast("B") as data:
+        pos = 0
+        # There is usually a single stream: the first chunk is all of the
+        # data, to decompress it in one go.
+        chunk_size = len(data)
+        while True:
+            decomp = decomp_factory(**decomp_args)
+            start = pos
+            res = []
+            try:
+                # Feed the stream in chunks of increasing size, so that what
+                # is read past its end (and copied into unused_data) is
+                # bounded by the size of the stream, not by the size of all
+                # the streams that follow it.
+                while not decomp.eof and pos < len(data):
+                    with data[pos:pos + chunk_size] as chunk:
+                        res.append(decomp.decompress(chunk))
+                        pos += len(chunk)
+                    chunk_size *= 2
+            except trailing_error:
+                if not results:
+                    raise  # Error in the first stream; bail out.
+                if stream_magic:
+                    if isinstance(stream_magic, bytes):
+                        stream_magic = (stream_magic,)
+                    size = max(map(len, stream_magic))
+                    with data[start:start + size] as head:
+                        if head.tobytes().startswith(stream_magic):
+                            raise  # Damaged stream, not trailing data.
+                # Trailing data isn't a valid compressed stream; ignore it.
+                break
+            results.extend(res)
+            if not decomp.eof:
+                raise truncated_error("Compressed data ended before the "
+                                      "end-of-stream marker was reached")
+            pos -= len(decomp.unused_data)
+            if pos == len(data):
+                break
+            # Later streams start small: a larger first chunk reads further
+            # past the end of a short stream, a smaller one needs more calls.
+            chunk_size = 1024
+    return b"".join(results)
diff --git a/Lib/compression/zstd/__init__.py b/Lib/compression/zstd/__init__.py
index 5326cf9b19cf883..b1b43cee15111f1 100644
--- a/Lib/compression/zstd/__init__.py
+++ b/Lib/compression/zstd/__init__.py
@@ -30,6 +30,7 @@
 import enum
 from _zstd import (ZstdCompressor, ZstdDecompressor, ZstdDict, ZstdError,
                    get_frame_size, zstd_version)
+from compression._common import _streams
 from compression.zstd._zstdfile import ZstdFile, open, _nbytes
 
 # zstd_version_number is (MAJOR * 100 * 100 + MINOR * 100 + RELEASE)
@@ -161,17 +162,9 @@ def decompress(data, zstd_dict=None, options=None):
 
     For incremental decompression, use a ZstdDecompressor instead.
     """
-    results = []
-    while True:
-        decomp = ZstdDecompressor(options=options, zstd_dict=zstd_dict)
-        results.append(decomp.decompress(data))
-        if not decomp.eof:
-            raise ZstdError('Compressed data ended before the '
-                            'end-of-stream marker was reached')
-        data = decomp.unused_data
-        if not data:
-            break
-    return b''.join(results)
+    return _streams._decompress_all(data, ZstdDecompressor,
+                                    truncated_error=ZstdError,
+                                    options=options, zstd_dict=zstd_dict)
 
 
 class CompressionParameter(enum.IntEnum):
diff --git a/Lib/gzip.py b/Lib/gzip.py
index b50089c31fadc86..a478329aef77f7a 100644
--- a/Lib/gzip.py
+++ b/Lib/gzip.py
@@ -681,24 +681,47 @@ def decompress(data):
     Return the decompressed string.
     """
     decompressed_members = []
+    fp = io.BytesIO(data)
+    # There is usually a single member: the first chunk is all of the data,
+    # to decompress it in one go.
+    chunk_size = len(data)
     while True:
-        fp = io.BytesIO(data)
         if _read_gzip_header(fp) is None:
             return b"".join(decompressed_members)
         # Use a zlib raw deflate compressor
         do = zlib.decompressobj(wbits=-zlib.MAX_WBITS)
-        # Read all the data except the header
-        decompressed = do.decompress(data[fp.tell():])
-        if not do.eof or len(do.unused_data) < 8:
+        crc = zlib.crc32(b"")
+        length = 0
+        # Feed the member in chunks of increasing size, so that what is read
+        # past its end (and copied into unused_data) is bounded by the size
+        # of the member, not by the size of all the members that follow it.
+        while not do.eof and (chunk := fp.read(chunk_size)):
+            decompressed = do.decompress(chunk)
+            crc = zlib.crc32(decompressed, crc)
+            length += len(decompressed)
+            decompressed_members.append(decompressed)
+            chunk_size *= 2
+        # Later members start small: a larger first chunk reads further past
+        # the end of a short member, a smaller one needs more calls.
+        chunk_size = 1024
+        # Rewind to the end of the deflate stream and read the trailer
+        fp.seek(-len(do.unused_data), io.SEEK_CUR)
+        trailer = fp.read(8)
+        if not do.eof or len(trailer) < 8:
             raise EOFError("Compressed file ended before the end-of-stream "
                            "marker was reached")
-        crc, length = struct.unpack("<II", do.unused_data[:8])
-        if crc != zlib.crc32(decompressed):
+        crc32, isize = struct.unpack("<II", trailer)
+        if crc32 != crc:
             raise BadGzipFile("CRC check failed")
-        if length != (len(decompressed) & 0xffffffff):
+        if isize != (length & 0xffffffff):
             raise BadGzipFile("Incorrect length of data produced")
-        decompressed_members.append(decompressed)
-        data = do.unused_data[8:].lstrip(b"\x00")
+        # Gzip files can be padded with zeroes and still have archives.
+        # Consume all zero bytes and set the file position to the first
+        # non-zero byte.
+        while chunk := fp.read(chunk_size):
+            if chunk := chunk.lstrip(b"\x00"):
+                fp.seek(-len(chunk), io.SEEK_CUR)
+                break
 
 
 def main():
diff --git a/Lib/lzma.py b/Lib/lzma.py
index b023c87494f8526..42775a0a3735f50 100644
--- a/Lib/lzma.py
+++ b/Lib/lzma.py
@@ -352,23 +352,8 @@ def decompress(data, format=FORMAT_AUTO, memlimit=None, 
filters=None):
 
     For incremental decompression, use an LZMADecompressor instead.
     """
-    results = []
-    magic = _stream_magic(format)
-    while True:
-        decomp = LZMADecompressor(format, memlimit, filters)
-        try:
-            res = decomp.decompress(data)
-        except LZMAError:
-            if not results:
-                raise  # Error on the first iteration; bail out.
-            if magic and data.startswith(magic):
-                raise  # Leftover data is a damaged XZ stream; bail out.
-            break  # Leftover data is not a valid LZMA/XZ stream; ignore it.
-        results.append(res)
-        if not decomp.eof:
-            raise LZMAError("Compressed data ended before the "
-                            "end-of-stream marker was reached")
-        data = decomp.unused_data
-        if not data:
-            break
-    return b"".join(results)
+    return _streams._decompress_all(data, LZMADecompressor, LZMAError,
+                                    stream_magic=_stream_magic(format),
+                                    truncated_error=LZMAError,
+                                    format=format, memlimit=memlimit,
+                                    filters=filters)
diff --git a/Lib/test/test_bz2.py b/Lib/test/test_bz2.py
index 5a6f7fe98e1462d..abc06c1a1e0ae8f 100644
--- a/Lib/test/test_bz2.py
+++ b/Lib/test/test_bz2.py
@@ -1116,6 +1116,53 @@ def testDecompressMultiStreamTrailingJunk(self):
         text = bz2.decompress(self.DATA * 5 + self.BAD_DATA)
         self.assertEqual(text, self.TEXT * 5)
 
+    def testDecompressMultiStreamBig(self):
+        big = random.randbytes(100_000)
+        streams = [self.TEXT, b'', big, self.TEXT, big]
+        expected = b''.join(streams)
+        data = b''.join(map(bz2.compress, streams))
+        self.assertEqual(bz2.decompress(data), expected)
+        self.assertEqual(bz2.decompress(bytearray(data)), expected)
+        self.assertEqual(bz2.decompress(memoryview(data)), expected)
+        self.assertEqual(bz2.decompress(data + self.BAD_DATA), expected)
+        self.assertRaises(ValueError, bz2.decompress, data[:-50_000])
+        # The damage is past the first chunk fed for the second stream.
+        damaged = bytearray(bz2.compress(big))
+        damaged[-3] ^= 1
+        self.assertRaises(OSError, bz2.decompress, self.DATA + damaged)
+
+    def testDecompressErrorReleasesBuffer(self):
+        # The argument can be resized while the exception is handled.
+        for data in self.DATA[:-10], self.BAD_DATA:
+            with self.subTest(data=data[:10]):
+                data = bytearray(data)
+                try:
+                    bz2.decompress(data)
+                except (ValueError, OSError):
+                    data += b'more'
+                else:
+                    self.fail('no exception')
+
+    def testDecompressManyStreams(self):
+        # The amount of data passed to the decompressors should grow
+        # linearly with the number of streams.
+        class Decompressor:
+            def __init__(self, *args, **kwargs):
+                self.decomp = BZ2Decompressor(*args, **kwargs)
+            def __getattr__(self, name):
+                return getattr(self.decomp, name)
+            def decompress(self, data):
+                nonlocal fed
+                fed += len(data)
+                return self.decomp.decompress(data)
+        with support.swap_attr(bz2, 'BZ2Decompressor', Decompressor):
+            fed = 0
+            self.assertEqual(bz2.decompress(self.EMPTY_DATA * 1000), b'')
+            fed1 = fed
+            fed = 0
+            self.assertEqual(bz2.decompress(self.EMPTY_DATA * 2000), b'')
+        self.assertLess(fed, fed1 * 3)
+
     def testDecompressMultiStreamDamagedStream(self):
         # A damaged bzip2 stream following a valid one is not trailing junk.
         for pos in 4, 100, -3:
diff --git a/Lib/test/test_gzip.py b/Lib/test/test_gzip.py
index cafac9d3c8be6e7..f1f5f6069f8ad9d 100644
--- a/Lib/test/test_gzip.py
+++ b/Lib/test/test_gzip.py
@@ -816,6 +816,50 @@ def test_decompress_missing_trailer(self):
         compressed_data = gzip.compress(data1)
         self.assertRaises(EOFError, gzip.decompress, compressed_data[:-8])
 
+    def test_decompress_multi_member(self):
+        big = os.urandom(100_000)
+        members = [data1, b'', big, data2, big]
+        expected = b''.join(members)
+        for padding in b'', b'\0', b'\0' * 5000:
+            with self.subTest(padding=len(padding)):
+                datac = b''.join(gzip.compress(data) + padding
+                                 for data in members)
+                self.assertEqual(gzip.decompress(datac), expected)
+        datac = b''.join(map(gzip.compress, members))
+        self.assertEqual(gzip.decompress(bytearray(datac)), expected)
+        self.assertEqual(gzip.decompress(memoryview(datac)), expected)
+        self.assertRaises(EOFError, gzip.decompress, datac[:-4])
+        self.assertRaises(EOFError, gzip.decompress, datac[:-50_000])
+        self.assertRaises(gzip.BadGzipFile, gzip.decompress, datac + b'junk')
+        # Damage the CRC in the trailer of the third member.
+        parts = [gzip.compress(data) for data in members]
+        corrupted = bytearray(b''.join(parts))
+        corrupted[len(b''.join(parts[:3])) - 8] ^= 1
+        with self.assertRaisesRegex(gzip.BadGzipFile, 'CRC check failed'):
+            gzip.decompress(corrupted)
+
+    def test_decompress_many_members(self):
+        # The amount of data passed to the decompressors should grow
+        # linearly with the number of members.
+        decompressobj = zlib.decompressobj
+        class Decompressor:
+            def __init__(self, *args, **kwargs):
+                self.decomp = decompressobj(*args, **kwargs)
+            def __getattr__(self, name):
+                return getattr(self.decomp, name)
+            def decompress(self, data):
+                nonlocal fed
+                fed += len(data)
+                return self.decomp.decompress(data)
+        datac = gzip.compress(b'')
+        with mock.patch.object(zlib, 'decompressobj', Decompressor):
+            fed = 0
+            self.assertEqual(gzip.decompress(datac * 1000), b'')
+            fed1 = fed
+            fed = 0
+            self.assertEqual(gzip.decompress(datac * 2000), b'')
+        self.assertLess(fed, fed1 * 3)
+
     def test_truncated_header(self):
         truncated_headers = [
             b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00",             # Missing OS 
byte
diff --git a/Lib/test/test_lzma.py b/Lib/test/test_lzma.py
index 8927842d3afc79d..6c42155bfdacb76 100644
--- a/Lib/test/test_lzma.py
+++ b/Lib/test/test_lzma.py
@@ -508,11 +508,62 @@ def test_decompress_multistream(self):
         ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_ALONE)
         self.assertEqual(ddata, INPUT * 2)
 
+    def test_decompress_multistream_big(self):
+        big = random.randbytes(100_000)
+        streams = [INPUT, b"", big, INPUT, big]
+        expected = b"".join(streams)
+        cdata = b"".join(map(lzma.compress, streams))
+        self.assertEqual(lzma.decompress(cdata), expected)
+        self.assertEqual(lzma.decompress(bytearray(cdata)), expected)
+        self.assertEqual(lzma.decompress(memoryview(cdata)), expected)
+        self.assertEqual(lzma.decompress(cdata + COMPRESSED_BOGUS), expected)
+        self.assertRaises(LZMAError, lzma.decompress, cdata[:-50_000])
+        # The damage is past the first chunk fed for the second stream.
+        damaged = bytearray(lzma.compress(big))
+        damaged[-1] ^= 1
+        self.assertRaises(LZMAError, lzma.decompress, COMPRESSED_XZ + damaged)
+
+    def test_decompress_error_releases_buffer(self):
+        # The argument can be resized while the exception is handled.
+        for cdata in COMPRESSED_XZ[:-10], COMPRESSED_BOGUS:
+            with self.subTest(cdata=cdata[:10]):
+                cdata = bytearray(cdata)
+                try:
+                    lzma.decompress(cdata)
+                except LZMAError:
+                    cdata += b"more"
+                else:
+                    self.fail("no exception")
+
+    def test_decompress_many_streams(self):
+        # The amount of data passed to the decompressors should grow
+        # linearly with the number of streams.
+        class Decompressor:
+            def __init__(self, *args, **kwargs):
+                self.decomp = LZMADecompressor(*args, **kwargs)
+            def __getattr__(self, name):
+                return getattr(self.decomp, name)
+            def decompress(self, data):
+                nonlocal fed
+                fed += len(data)
+                return self.decomp.decompress(data)
+        cdata = lzma.compress(b"")
+        with support.swap_attr(lzma, "LZMADecompressor", Decompressor):
+            fed = 0
+            self.assertEqual(lzma.decompress(cdata * 1000), b"")
+            fed1 = fed
+            fed = 0
+            self.assertEqual(lzma.decompress(cdata * 2000), b"")
+        self.assertLess(fed, fed1 * 3)
+
     # Test robust handling of non-LZMA data following the compressed stream(s).
 
     def test_decompress_trailing_junk(self):
         ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_BOGUS)
         self.assertEqual(ddata, INPUT)
+        ddata = lzma.decompress(COMPRESSED_ALONE + COMPRESSED_BOGUS,
+                                lzma.FORMAT_ALONE)
+        self.assertEqual(ddata, INPUT)
 
     def test_decompress_multistream_trailing_junk(self):
         ddata = lzma.decompress(COMPRESSED_XZ * 3 + COMPRESSED_BOGUS)
diff --git a/Lib/test/test_zstd.py b/Lib/test/test_zstd.py
index 1f72506d14793e3..c307cddfedd6ea2 100644
--- a/Lib/test/test_zstd.py
+++ b/Lib/test/test_zstd.py
@@ -191,6 +191,52 @@ def test_decompress_2x130_1K(self):
         dat = decompress(DAT_130K_C + DAT_130K_C)
         self.assertEqual(len(dat), 2 * _130_1K)
 
+    def test_decompress_multi_frames(self):
+        big = random.randbytes(100_000)
+        frames = [b"a" * 100, b"", big, b"b" * 100, big]
+        expected = b"".join(frames)
+        dat = b"".join(map(compress, frames))
+        self.assertEqual(decompress(dat), expected)
+        self.assertEqual(decompress(bytearray(dat)), expected)
+        self.assertEqual(decompress(memoryview(dat)), expected)
+        with self.assertRaisesRegex(ZstdError, "Compressed data ended before"):
+            decompress(dat[:-50_000])
+        with self.assertRaisesRegex(ZstdError, "Unknown frame descriptor"):
+            decompress(dat + b"junk")
+
+    def test_decompress_error_releases_buffer(self):
+        # The argument can be resized while the exception is handled.
+        for dat in COMPRESSED_100_PLUS_32KB[:-10], b"junk" * 10:
+            with self.subTest(dat=dat[:10]):
+                dat = bytearray(dat)
+                try:
+                    decompress(dat)
+                except ZstdError:
+                    dat += b"more"
+                else:
+                    self.fail("no exception")
+
+    def test_decompress_many_frames(self):
+        # The amount of data passed to the decompressors should grow
+        # linearly with the number of frames.
+        class Decompressor:
+            def __init__(self, *args, **kwargs):
+                self.decomp = ZstdDecompressor(*args, **kwargs)
+            def __getattr__(self, name):
+                return getattr(self.decomp, name)
+            def decompress(self, data):
+                nonlocal fed
+                fed += len(data)
+                return self.decomp.decompress(data)
+        dat = compress(b"")
+        with support.swap_attr(zstd, "ZstdDecompressor", Decompressor):
+            fed = 0
+            self.assertEqual(decompress(dat * 1000), b"")
+            fed1 = fed
+            fed = 0
+            self.assertEqual(decompress(dat * 2000), b"")
+        self.assertLess(fed, fed1 * 3)
+
 
 class CompressorTestCase(unittest.TestCase):
 
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-22-08-34-22.gh-issue-160310.q7Zm2K.rst 
b/Misc/NEWS.d/next/Library/2026-10-22-08-34-22.gh-issue-160310.q7Zm2K.rst
new file mode 100644
index 000000000000000..82e62d73bb32ec9
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-08-34-22.gh-issue-160310.q7Zm2K.rst
@@ -0,0 +1,4 @@
+Fix quadratic time complexity of :func:`gzip.decompress`,
+:func:`bz2.decompress`, :func:`lzma.decompress` and
+:func:`compression.zstd.decompress` on data consisting of many concatenated
+members.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to