From aaf331e84d6144393d99b81dd60ed21cedd4232a Mon Sep 17 00:00:00 2001 From: Emma Harper Smith Date: Fri, 23 Oct 2026 10:45:34 -0700 Subject: [PATCH] Correct repeated calls to decompress() with max_length=0 --- Lib/test/test_bz2.py | 23 ++++++++++++ Lib/test/test_zlib.py | 23 ++++++++++++ Lib/test/test_zstd.py | 35 +++++++++++++++++++ ...-10-22-23-01-17.gh-issue-160000.mL0zRo.rst | 5 +++ Modules/_bz2module.c | 7 ++++ Modules/_zstd/decompressor.c | 7 ++++ Modules/zlibmodule.c | 6 ++++ 7 files changed, 106 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-22-23-01-17.gh-issue-160000.mL0zRo.rst diff --git a/Lib/test/test_bz2.py b/Lib/test/test_bz2.py index 2c95a8eec510b0..8db1ec9468568a 100644 --- a/Lib/test/test_bz2.py +++ b/Lib/test/test_bz2.py @@ -967,6 +967,29 @@ def testDecompressorChunksMaxsize(self): self.assertEqual(out, self.BIG_TEXT) self.assertEqual(bzd.unused_data, b"") + def test_decompressor_max_length_zero(self): + # max_length=0 buffers the input and never produces output, + # however many times it is repeated. + bzd = BZ2Decompressor() + for _ in range(20): + self.assertEqual(bzd.decompress(b'', max_length=0), b'') + self.assertEqual(bzd.decompress(self.DATA, max_length=0), b'') + for _ in range(20): + self.assertEqual(bzd.decompress(b'', max_length=0), b'') + self.assertFalse(bzd.needs_input) + self.assertFalse(bzd.eof) + self.assertEqual(bzd.decompress(b''), self.TEXT) + self.assertTrue(bzd.eof) + + # Output pending from an earlier call is kept for later. + bzd = BZ2Decompressor() + out = bzd.decompress(self.DATA, max_length=1) + for _ in range(20): + self.assertEqual(bzd.decompress(b'', max_length=0), b'') + self.assertFalse(bzd.needs_input) + self.assertEqual(out + bzd.decompress(b''), self.TEXT) + self.assertTrue(bzd.eof) + def test_decompressor_inputbuf_1(self): # Test reusing input buffer after moving existing # contents to beginning diff --git a/Lib/test/test_zlib.py b/Lib/test/test_zlib.py index dcb4eac1b77952..0b77ef8ae44ae8 100644 --- a/Lib/test/test_zlib.py +++ b/Lib/test/test_zlib.py @@ -1231,6 +1231,29 @@ def testDecompressorChunksMaxsize(self): self.assertEqual(out, self.BIG_TEXT) self.assertEqual(zlibd.unused_data, b"") + def test_decompressor_max_length_zero(self): + # max_length=0 buffers the input and never produces output, + # however many times it is repeated. + zlibd = zlib._ZlibDecompressor() + for _ in range(20): + self.assertEqual(zlibd.decompress(b'', max_length=0), b'') + self.assertEqual(zlibd.decompress(self.DATA, max_length=0), b'') + for _ in range(20): + self.assertEqual(zlibd.decompress(b'', max_length=0), b'') + self.assertFalse(zlibd.needs_input) + self.assertFalse(zlibd.eof) + self.assertEqual(zlibd.decompress(b''), self.TEXT) + self.assertTrue(zlibd.eof) + + # Output pending from an earlier call is kept for later. + zlibd = zlib._ZlibDecompressor() + out = zlibd.decompress(self.DATA, max_length=1) + for _ in range(20): + self.assertEqual(zlibd.decompress(b'', max_length=0), b'') + self.assertFalse(zlibd.needs_input) + self.assertEqual(out + zlibd.decompress(b''), self.TEXT) + self.assertTrue(zlibd.eof) + def test_decompressor_inputbuf_1(self): # Test reusing input buffer after moving existing # contents to beginning diff --git a/Lib/test/test_zstd.py b/Lib/test/test_zstd.py index 372326a26c71ea..829ec98f94ba6d 100644 --- a/Lib/test/test_zstd.py +++ b/Lib/test/test_zstd.py @@ -853,6 +853,29 @@ def test_decompress_empty_content_frame(self): self.assertEqual(d.unused_data, b'') self.assertEqual(d.unused_data, b'') # twice + def test_decompressor_max_length_zero(self): + # max_length=0 buffers the input and never produces output, + # however many times it is repeated. + d = ZstdDecompressor() + for _ in range(20): + self.assertEqual(d.decompress(b'', max_length=0), b'') + self.assertEqual(d.decompress(COMPRESSED_100_PLUS_32KB, max_length=0), b'') + for _ in range(20): + self.assertEqual(d.decompress(b'', max_length=0), b'') + self.assertFalse(d.needs_input) + self.assertFalse(d.eof) + self.assertEqual(d.decompress(b''), DECOMPRESSED_100_PLUS_32KB) + self.assertTrue(d.eof) + + # Output pending from an earlier call is kept for later. + d = ZstdDecompressor() + out = d.decompress(COMPRESSED_100_PLUS_32KB, max_length=1) + for _ in range(20): + self.assertEqual(d.decompress(b'', max_length=0), b'') + self.assertFalse(d.needs_input) + self.assertEqual(out + d.decompress(b''), DECOMPRESSED_100_PLUS_32KB) + self.assertTrue(d.eof) + class DecompressorFlagsTestCase(unittest.TestCase): @classmethod @@ -1167,6 +1190,18 @@ def test_decompressor_skippable(self): d = ZstdDecompressor() dat = d.decompress(SKIPPABLE_FRAME, 0) + self.assertEqual(dat, b'') + self.assertFalse(d.eof) + self.assertFalse(d.needs_input) + + dat = d.decompress(b'', 0) + + self.assertEqual(dat, b'') + self.assertFalse(d.eof) + self.assertFalse(d.needs_input) + + dat = d.decompress(b'') + self.assertEqual(dat, b'') self.assertTrue(d.eof) self.assertFalse(d.needs_input) diff --git a/Misc/NEWS.d/next/Library/2026-10-22-23-01-17.gh-issue-160000.mL0zRo.rst b/Misc/NEWS.d/next/Library/2026-10-22-23-01-17.gh-issue-160000.mL0zRo.rst new file mode 100644 index 00000000000000..9514df2b756ece --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-23-01-17.gh-issue-160000.mL0zRo.rst @@ -0,0 +1,5 @@ +Fix :meth:`compression.zstd.ZstdDecompressor.decompress` raising an error when +called repeatedly with ``max_length=0``. Calling ``decompress()`` with +``max_length=0`` on the Zstandard decompressor, :class:`bz2.BZ2Decompressor` +and :class:`!zlib._ZlibDecompressor` now always buffers the input and returns +``b''`` without decompressing anything. diff --git a/Modules/_bz2module.c b/Modules/_bz2module.c index e0d85b78741a91..71a069757847c8 100644 --- a/Modules/_bz2module.c +++ b/Modules/_bz2module.c @@ -421,6 +421,12 @@ decompress_buf(BZ2Decompressor *d, Py_ssize_t max_length) goto error; } + /* With max_length == 0 there is no room for output. Don't call into + libbz2: any input is kept in the input buffer for the next call. */ + if (max_length == 0) { + goto finish; + } + for (;;) { int bzret; /* On a 64-bit system, buffer length might not fit in avail_out, so we @@ -456,6 +462,7 @@ decompress_buf(BZ2Decompressor *d, Py_ssize_t max_length) } } +finish: result = OutputBuffer_Finish(&buffer, bzs->avail_out); if (result != NULL) { return result; diff --git a/Modules/_zstd/decompressor.c b/Modules/_zstd/decompressor.c index ca9ea7e3b8f99b..fa73041d73bd34 100644 --- a/Modules/_zstd/decompressor.c +++ b/Modules/_zstd/decompressor.c @@ -226,6 +226,12 @@ decompress_lock_held(ZstdDecompressor *self, ZSTD_inBuffer *in, } assert(out.pos == 0); + /* With max_length == 0 there is no room for output. Don't call into + libzstd: any input is kept in the input buffer for the next call. */ + if (max_length == 0) { + goto finish; + } + while (1) { /* Decompress */ Py_BEGIN_ALLOW_THREADS @@ -269,6 +275,7 @@ decompress_lock_held(ZstdDecompressor *self, ZSTD_inBuffer *in, } } +finish: /* Return a bytes object */ ret = _OutputBuffer_Finish(&buffer, &out); if (ret != NULL) { diff --git a/Modules/zlibmodule.c b/Modules/zlibmodule.c index 31533fa047af0b..6109162237ae97 100644 --- a/Modules/zlibmodule.c +++ b/Modules/zlibmodule.c @@ -1488,6 +1488,12 @@ decompress_buf(ZlibDecompressor *self, Py_ssize_t max_length) int err = Z_OK; + /* With max_length == 0 there is no room for output. Don't call into + zlib: any input is kept in the input buffer for the next call. */ + if (max_length == 0) { + return Py_GetConstant(Py_CONSTANT_EMPTY_BYTES); + } + /* When sys.maxsize is passed as default use DEF_BUF_SIZE as start buffer. In this particular case the data may not necessarily be very big, so it is better to grow dynamically.*/