Skip to content

Commit d8913a9

Browse files
committed
gh-160310: Share the one-shot decompress loop in compression._common
bz2.decompress(), lzma.decompress() and compression.zstd.decompress() had three copies of the chunked multi-stream loop. Move it into compression._common._streams._decompress_all(), which takes the decompressor factory, the trailing-data error, the stream magic and the truncation error. gzip keeps its own loop.
1 parent 6b488c7 commit d8913a9

6 files changed

Lines changed: 80 additions & 112 deletions

File tree

‎Lib/bz2.py‎

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -341,49 +341,11 @@ def decompress(data):
341341
342342
For incremental decompression, use a BZ2Decompressor object instead.
343343
"""
344-
results = []
345344
if not data:
346345
return b""
347-
# The views are released on exit, so that the caller can resize a
348-
# bytearray argument again, also while handling an exception.
349-
with memoryview(data) as view, view.cast("B") as data:
350-
pos = 0
351-
# There is usually a single stream: the first chunk is all of the
352-
# data, to decompress it in one go.
353-
chunk_size = len(data)
354-
while True:
355-
decomp = BZ2Decompressor()
356-
start = pos
357-
res = []
358-
try:
359-
# Feed the stream in chunks of increasing size, so that what
360-
# is read past its end (and copied into unused_data) is
361-
# bounded by the size of the stream, not by the size of all
362-
# the streams that follow it.
363-
while not decomp.eof and pos < len(data):
364-
with data[pos:pos + chunk_size] as chunk:
365-
res.append(decomp.decompress(chunk))
366-
pos += len(chunk)
367-
chunk_size *= 2
368-
except OSError:
369-
if not results:
370-
raise # Error on the first iteration; bail out.
371-
with data[start:start + 4] as head:
372-
if head.tobytes().startswith(_STREAM_MAGIC):
373-
# Leftover data is a damaged bzip2 stream; bail out.
374-
raise
375-
break # Leftover data is not a valid bzip2 stream; ignore it.
376-
results.extend(res)
377-
if not decomp.eof:
378-
raise ValueError("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
386-
return b"".join(results)
346+
return _streams._decompress_all(data, BZ2Decompressor, OSError,
347+
stream_magic=_STREAM_MAGIC,
348+
truncated_error=ValueError)
387349

388350

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

193168

194169
class CompressionParameter(enum.IntEnum):

‎Lib/lzma.py‎

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -354,45 +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-
# The views are released on exit, so that the caller can resize a
360-
# bytearray argument again, also while handling an exception.
361-
with memoryview(data) as view, view.cast("B") as data:
362-
pos = 0
363-
# There is usually a single stream: the first chunk is all of the
364-
# data, to decompress it in one go.
365-
chunk_size = len(data)
366-
while True:
367-
decomp = LZMADecompressor(format, memlimit, filters)
368-
start = pos
369-
res = []
370-
try:
371-
# Feed the stream in chunks of increasing size, so that what
372-
# is read past its end (and copied into unused_data) is
373-
# bounded by the size of the stream, not by the size of all
374-
# the streams that follow it.
375-
while not decomp.eof and pos < len(data):
376-
with data[pos:pos + chunk_size] as chunk:
377-
res.append(decomp.decompress(chunk))
378-
pos += len(chunk)
379-
chunk_size *= 2
380-
except LZMAError:
381-
if not results:
382-
raise # Error on the first iteration; bail out.
383-
with data[start:start + len(magic)] as head:
384-
if magic and head.tobytes().startswith(magic):
385-
# Leftover data is a damaged XZ stream; bail out.
386-
raise
387-
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
388-
results.extend(res)
389-
if not decomp.eof:
390-
raise LZMAError("Compressed data ended before the "
391-
"end-of-stream marker was reached")
392-
pos -= len(decomp.unused_data)
393-
if pos == len(data):
394-
break
395-
# Later streams start small: a larger first chunk reads further
396-
# past the end of a short stream, a smaller one needs more calls.
397-
chunk_size = 1024
398-
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,10 @@ def testDecompressMultiStreamBig(self):
11261126
self.assertEqual(bz2.decompress(memoryview(data)), expected)
11271127
self.assertEqual(bz2.decompress(data + self.BAD_DATA), expected)
11281128
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)
11291133

11301134
def testDecompressErrorReleasesBuffer(self):
11311135
# The argument can be resized while the exception is handled.

‎Lib/test/test_lzma.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,10 @@ def test_decompress_multistream_big(self):
524524
self.assertEqual(lzma.decompress(memoryview(cdata)), expected)
525525
self.assertEqual(lzma.decompress(cdata + COMPRESSED_BOGUS), expected)
526526
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)
527531

528532
def test_decompress_error_releases_buffer(self):
529533
# The argument can be resized while the exception is handled.
@@ -563,6 +567,9 @@ def decompress(self, data):
563567
def test_decompress_trailing_junk(self):
564568
ddata = lzma.decompress(COMPRESSED_XZ + COMPRESSED_BOGUS)
565569
self.assertEqual(ddata, INPUT)
570+
ddata = lzma.decompress(COMPRESSED_ALONE + COMPRESSED_BOGUS,
571+
lzma.FORMAT_ALONE)
572+
self.assertEqual(ddata, INPUT)
566573

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

0 commit comments

Comments
 (0)