Skip to content

Commit 509eb96

Browse files
authored
[3.14] gh-160314: Raise for a damaged later stream in lzma and bz2 (GH-160315) (#161035)
A damaged .xz or bzip2 stream that followed another stream was taken for trailing data and silently ignored. Leftover data that starts with the stream magic is now treated as a stream, as gzip does. ANT-2026-3TVAVL5A (cherry picked from commit a4f8047)
1 parent 9cd3c61 commit 509eb96

6 files changed

Lines changed: 94 additions & 9 deletions

File tree

‎Lib/bz2.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
# Value 2 no longer used
2323
_MODE_WRITE = 3
2424

25+
# Every bzip2 stream starts with one of these, "BZh" and the block size.
26+
_STREAM_MAGIC = (b'BZh1', b'BZh2', b'BZh3', b'BZh4', b'BZh5', b'BZh6', b'BZh7', b'BZh8', b'BZh9')
27+
2528

2629
class BZ2File(_streams.BaseStream):
2730

@@ -89,7 +92,8 @@ def __init__(self, filename, mode="r", *, compresslevel=9):
8992

9093
if self._mode == _MODE_READ:
9194
raw = _streams.DecompressReader(self._fp,
92-
BZ2Decompressor, trailing_error=OSError)
95+
BZ2Decompressor, trailing_error=OSError,
96+
stream_magic=_STREAM_MAGIC)
9397
self._buffer = io.BufferedReader(raw)
9498
else:
9599
self._pos = 0
@@ -340,10 +344,11 @@ def decompress(data):
340344
try:
341345
res = decomp.decompress(data)
342346
except OSError:
343-
if results:
344-
break # Leftover data is not a valid bzip2 stream; ignore it.
345-
else:
347+
if not results:
346348
raise # Error on the first iteration; bail out.
349+
if data.startswith(_STREAM_MAGIC):
350+
raise # Leftover data is a damaged bzip2 stream; bail out.
351+
break # Leftover data is not a valid bzip2 stream; ignore it.
347352
results.append(res)
348353
if not decomp.eof:
349354
raise ValueError("Compressed data ended before the "

‎Lib/compression/_common/_streams.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ class DecompressReader(io.RawIOBase):
3636
def readable(self):
3737
return True
3838

39-
def __init__(self, fp, decomp_factory, trailing_error=(), **decomp_args):
39+
def __init__(self, fp, decomp_factory, trailing_error=(), *,
40+
stream_magic=(), **decomp_args):
4041
self._fp = fp
4142
self._eof = False
4243
self._pos = 0 # Current offset in decompressed stream
@@ -56,6 +57,11 @@ def __init__(self, fp, decomp_factory, trailing_error=(), **decomp_args):
5657
# trailing data to ignore
5758
self._trailing_error = trailing_error
5859

60+
# Prefix (or tuple of prefixes) that every compressed stream starts
61+
# with, if the format has one. Data starting with it is a damaged
62+
# stream rather than trailing data, so its error is not ignored.
63+
self._stream_magic = stream_magic
64+
5965
def close(self):
6066
self._decompressor = None
6167
return super().close()
@@ -90,6 +96,9 @@ def read(self, size=-1):
9096
try:
9197
data = self._decompressor.decompress(rawblock, size)
9298
except self._trailing_error:
99+
if (self._stream_magic
100+
and rawblock.startswith(self._stream_magic)):
101+
raise # Damaged stream, not trailing data.
93102
# Trailing data isn't a valid compressed stream; ignore it.
94103
break
95104
else:

‎Lib/lzma.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@
3535
_MODE_WRITE = 3
3636

3737

38+
def _stream_magic(format):
39+
# Every stream of an .xz file starts with these bytes. The other
40+
# formats, including an .lzma stream read with FORMAT_AUTO, have
41+
# nothing that tells a damaged stream from trailing data.
42+
return b"\xfd7zXZ\x00" if format in (FORMAT_AUTO, FORMAT_XZ) else b""
43+
44+
3845
class LZMAFile(_streams.BaseStream):
3946

4047
"""A file object providing transparent LZMA (de)compression.
@@ -128,7 +135,8 @@ def __init__(self, filename=None, mode="r", *,
128135

129136
if self._mode == _MODE_READ:
130137
raw = _streams.DecompressReader(self._fp, LZMADecompressor,
131-
trailing_error=LZMAError, format=format, filters=filters)
138+
trailing_error=LZMAError, stream_magic=_stream_magic(format),
139+
format=format, filters=filters)
132140
self._buffer = io.BufferedReader(raw)
133141

134142
def close(self):
@@ -345,15 +353,17 @@ def decompress(data, format=FORMAT_AUTO, memlimit=None, filters=None):
345353
For incremental decompression, use an LZMADecompressor instead.
346354
"""
347355
results = []
356+
magic = _stream_magic(format)
348357
while True:
349358
decomp = LZMADecompressor(format, memlimit, filters)
350359
try:
351360
res = decomp.decompress(data)
352361
except LZMAError:
353-
if results:
354-
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
355-
else:
362+
if not results:
356363
raise # Error on the first iteration; bail out.
364+
if magic and data.startswith(magic):
365+
raise # Leftover data is a damaged XZ stream; bail out.
366+
break # Leftover data is not a valid LZMA/XZ stream; ignore it.
357367
results.append(res)
358368
if not decomp.eof:
359369
raise LZMAError("Compressed data ended before the "

‎Lib/test/test_bz2.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@ def testReadMultiStreamTrailingJunk(self):
156156
with BZ2File(self.filename) as bz2f:
157157
self.assertEqual(bz2f.read(), self.TEXT * 5)
158158

159+
def testReadMultiStreamDamagedStream(self):
160+
# A damaged bzip2 stream following a valid one is not trailing junk,
161+
# whatever the size of the reads.
162+
for pos in 4, 100, -3:
163+
with self.subTest(pos=pos):
164+
damaged = bytearray(self.DATA)
165+
damaged[pos] ^= 1
166+
self.createTempFile(suffix=damaged)
167+
with BZ2File(self.filename) as bz2f:
168+
self.assertRaises(OSError, bz2f.read)
169+
with BZ2File(self.filename) as bz2f:
170+
self.assertEqual(bz2f.read(len(self.TEXT)), self.TEXT)
171+
with self.assertRaises(OSError):
172+
while bz2f.read(10):
173+
pass
174+
159175
def testRead0(self):
160176
self.createTempFile()
161177
with BZ2File(self.filename) as bz2f:
@@ -1100,6 +1116,18 @@ def testDecompressMultiStreamTrailingJunk(self):
11001116
text = bz2.decompress(self.DATA * 5 + self.BAD_DATA)
11011117
self.assertEqual(text, self.TEXT * 5)
11021118

1119+
def testDecompressMultiStreamDamagedStream(self):
1120+
# A damaged bzip2 stream following a valid one is not trailing junk.
1121+
for pos in 4, 100, -3:
1122+
with self.subTest(pos=pos):
1123+
damaged = bytearray(self.DATA)
1124+
damaged[pos] ^= 1
1125+
self.assertRaises(OSError, bz2.decompress,
1126+
self.DATA + damaged)
1127+
# Not a bzip2 header: the block size digit must be 1 to 9.
1128+
text = bz2.decompress(self.DATA + b"BZh0" + self.BAD_DATA)
1129+
self.assertEqual(text, self.TEXT)
1130+
11031131

11041132
class OpenTest(BaseTest):
11051133
"Test the open function."

‎Lib/test/test_lzma.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,16 @@ def test_decompress_multistream_trailing_junk(self):
518518
ddata = lzma.decompress(COMPRESSED_XZ * 3 + COMPRESSED_BOGUS)
519519
self.assertEqual(ddata, INPUT * 3)
520520

521+
def test_decompress_multistream_damaged_stream(self):
522+
# A damaged .xz stream following a valid one is not trailing junk.
523+
for pos in 6, 100, -20, -1:
524+
damaged = bytearray(COMPRESSED_XZ)
525+
damaged[pos] ^= 1
526+
for format in lzma.FORMAT_AUTO, lzma.FORMAT_XZ:
527+
with self.subTest(pos=pos, format=format):
528+
self.assertRaises(LZMAError, lzma.decompress,
529+
COMPRESSED_XZ + damaged, format)
530+
521531

522532
class TempFile:
523533
"""Context manager - creates a file, and deletes it on __exit__."""
@@ -879,6 +889,25 @@ def test_read_multistream_trailing_junk(self):
879889
with LZMAFile(BytesIO(COMPRESSED_XZ * 5 + COMPRESSED_BOGUS)) as f:
880890
self.assertEqual(f.read(), INPUT * 5)
881891

892+
def test_read_multistream_damaged_stream(self):
893+
# A damaged .xz stream following a valid one is not trailing junk,
894+
# whatever the size of the reads.
895+
for pos in 6, 100, -20, -1:
896+
damaged = bytearray(COMPRESSED_XZ)
897+
damaged[pos] ^= 1
898+
cdata = COMPRESSED_XZ + damaged
899+
for format in lzma.FORMAT_AUTO, lzma.FORMAT_XZ:
900+
with self.subTest(pos=pos, format=format):
901+
with LZMAFile(BytesIO(cdata), format=format) as f:
902+
self.assertRaises(LZMAError, f.read)
903+
with LZMAFile(BytesIO(cdata), format=format) as f:
904+
self.assertEqual(f.read(len(INPUT)), INPUT)
905+
with self.assertRaises(LZMAError):
906+
while f.read(10):
907+
pass
908+
with LZMAFile(BytesIO(cdata), format=format) as f:
909+
self.assertRaises(LZMAError, f.seek, 0, 2)
910+
882911
def test_read_from_file(self):
883912
with TempFile(TESTFN, COMPRESSED_XZ):
884913
with LZMAFile(TESTFN) as f:
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Raise an error in :class:`lzma.LZMAFile`, :func:`lzma.decompress`,
2+
:class:`bz2.BZ2File` and :func:`bz2.decompress` for a damaged ``.xz`` or bzip2
3+
stream that follows another stream, instead of silently ignoring it and
4+
everything after it as trailing data.

0 commit comments

Comments
 (0)