https://github.com/python/cpython/commit/a4f80477b02d53a592e6d3891686921c111ae771
commit: a4f80477b02d53a592e6d3891686921c111ae771
branch: main
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-10-31T20:13:35Z
summary:

gh-160314: Raise for a damaged later stream in lzma and bz2 (GH-160315)

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

files:
A Misc/NEWS.d/next/Library/2026-10-22-18-30-00.gh-issue-160314.q7RkZd.rst
M Doc/library/bz2.rst
M Doc/library/lzma.rst
M Lib/bz2.py
M Lib/compression/_common/_streams.py
M Lib/lzma.py
M Lib/test/test_bz2.py
M Lib/test/test_lzma.py

diff --git a/Doc/library/bz2.rst b/Doc/library/bz2.rst
index e52565dd4bb0a77..bb7028526acf972 100644
--- a/Doc/library/bz2.rst
+++ b/Doc/library/bz2.rst
@@ -191,6 +191,10 @@ The :mod:`!bz2` module contains:
       readers or writers, just like its equivalent classes in :mod:`gzip` and
       :mod:`lzma` have always been.
 
+   .. versionchanged:: next
+      A damaged bzip2 stream that follows another stream now raises
+      :exc:`OSError` instead of being ignored as trailing data.
+
 
 Incremental (de)compression
 ---------------------------
@@ -307,6 +311,10 @@ One-shot (de)compression
    .. versionchanged:: 3.3
       Support for multi-stream inputs was added.
 
+   .. versionchanged:: next
+      A damaged bzip2 stream that follows another stream now raises
+      :exc:`OSError` instead of being ignored as trailing data.
+
 
 Miscellaneous
 -------------
diff --git a/Doc/library/lzma.rst b/Doc/library/lzma.rst
index ae3dcd811fbec0f..ef6e931fb11f4fc 100644
--- a/Doc/library/lzma.rst
+++ b/Doc/library/lzma.rst
@@ -140,6 +140,10 @@ Reading and writing compressed files
    .. versionchanged:: 3.6
       Accepts a :term:`path-like object`.
 
+   .. versionchanged:: next
+      A damaged ``.xz`` stream that follows another stream now raises
+      :exc:`LZMAError` instead of being ignored as trailing data.
+
 
 Compressing and decompressing data in memory
 --------------------------------------------
@@ -300,6 +304,10 @@ Compressing and decompressing data in memory
    See :class:`LZMADecompressor` above for a description of the *format*,
    *memlimit* and *filters* arguments.
 
+   .. versionchanged:: next
+      A damaged ``.xz`` stream that follows another stream now raises
+      :exc:`LZMAError` instead of being ignored as trailing data.
+
 
 Miscellaneous
 -------------
diff --git a/Lib/bz2.py b/Lib/bz2.py
index bcfc5ceb6dc3458..3f575088571fa90 100644
--- a/Lib/bz2.py
+++ b/Lib/bz2.py
@@ -25,6 +25,9 @@
 # Value 2 no longer used
 _MODE_WRITE    = 3
 
+# Every bzip2 stream starts with one of these, "BZh" and the block size.
+_STREAM_MAGIC = (b'BZh1', b'BZh2', b'BZh3', b'BZh4', b'BZh5', b'BZh6', 
b'BZh7', b'BZh8', b'BZh9')
+
 
 class BZ2File(_streams.BaseStream):
 
@@ -92,7 +95,8 @@ def __init__(self, filename, mode="r", *, compresslevel=9):
 
         if self._mode == _MODE_READ:
             raw = _streams.DecompressReader(self._fp,
-                BZ2Decompressor, trailing_error=OSError)
+                BZ2Decompressor, trailing_error=OSError,
+                stream_magic=_STREAM_MAGIC)
             self._buffer = io.BufferedReader(raw)
         else:
             self._pos = 0
@@ -343,10 +347,11 @@ def decompress(data):
         try:
             res = decomp.decompress(data)
         except OSError:
-            if results:
-                break  # Leftover data is not a valid bzip2 stream; ignore it.
-            else:
+            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 "
diff --git a/Lib/compression/_common/_streams.py 
b/Lib/compression/_common/_streams.py
index 9f367d4e30440f7..09c65507297886b 100644
--- a/Lib/compression/_common/_streams.py
+++ b/Lib/compression/_common/_streams.py
@@ -36,7 +36,8 @@ class DecompressReader(io.RawIOBase):
     def readable(self):
         return True
 
-    def __init__(self, fp, decomp_factory, trailing_error=(), **decomp_args):
+    def __init__(self, fp, decomp_factory, trailing_error=(), *,
+                 stream_magic=(), **decomp_args):
         self._fp = fp
         self._eof = False
         self._pos = 0  # Current offset in decompressed stream
@@ -56,6 +57,11 @@ def __init__(self, fp, decomp_factory, trailing_error=(), 
**decomp_args):
         # trailing data to ignore
         self._trailing_error = trailing_error
 
+        # Prefix (or tuple of prefixes) that every compressed stream starts
+        # with, if the format has one.  Data starting with it is a damaged
+        # stream rather than trailing data, so its error is not ignored.
+        self._stream_magic = stream_magic
+
     def close(self):
         self._decompressor = None
         return super().close()
@@ -90,6 +96,9 @@ def read(self, size=-1):
                 try:
                     data = self._decompressor.decompress(rawblock, size)
                 except self._trailing_error:
+                    if (self._stream_magic
+                            and rawblock.startswith(self._stream_magic)):
+                        raise  # Damaged stream, not trailing data.
                     # Trailing data isn't a valid compressed stream; ignore it.
                     break
             else:
diff --git a/Lib/lzma.py b/Lib/lzma.py
index 73a7c702e037112..ac2c2f7a76a8c44 100644
--- a/Lib/lzma.py
+++ b/Lib/lzma.py
@@ -37,6 +37,13 @@
 _MODE_WRITE    = 3
 
 
+def _stream_magic(format):
+    # Every stream of an .xz file starts with these bytes.  The other
+    # formats, including an .lzma stream read with FORMAT_AUTO, have
+    # nothing that tells a damaged stream from trailing data.
+    return b"\xfd7zXZ\x00" if format in (FORMAT_AUTO, FORMAT_XZ) else b""
+
+
 class LZMAFile(_streams.BaseStream):
 
     """A file object providing transparent LZMA (de)compression.
@@ -130,7 +137,8 @@ def __init__(self, filename=None, mode="r", *,
 
         if self._mode == _MODE_READ:
             raw = _streams.DecompressReader(self._fp, LZMADecompressor,
-                trailing_error=LZMAError, format=format, filters=filters)
+                trailing_error=LZMAError, stream_magic=_stream_magic(format),
+                format=format, filters=filters)
             self._buffer = io.BufferedReader(raw)
 
     def close(self):
@@ -347,15 +355,17 @@ 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 results:
-                break  # Leftover data is not a valid LZMA/XZ stream; ignore 
it.
-            else:
+            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 "
diff --git a/Lib/test/test_bz2.py b/Lib/test/test_bz2.py
index 2f59f337abbe320..7c6c3d4d6372c0a 100644
--- a/Lib/test/test_bz2.py
+++ b/Lib/test/test_bz2.py
@@ -156,6 +156,22 @@ def testReadMultiStreamTrailingJunk(self):
         with BZ2File(self.filename) as bz2f:
             self.assertEqual(bz2f.read(), self.TEXT * 5)
 
+    def testReadMultiStreamDamagedStream(self):
+        # A damaged bzip2 stream following a valid one is not trailing junk,
+        # whatever the size of the reads.
+        for pos in 4, 100, -3:
+            with self.subTest(pos=pos):
+                damaged = bytearray(self.DATA)
+                damaged[pos] ^= 1
+                self.createTempFile(suffix=damaged)
+                with BZ2File(self.filename) as bz2f:
+                    self.assertRaises(OSError, bz2f.read)
+                with BZ2File(self.filename) as bz2f:
+                    self.assertEqual(bz2f.read(len(self.TEXT)), self.TEXT)
+                    with self.assertRaises(OSError):
+                        while bz2f.read(10):
+                            pass
+
     def testRead0(self):
         self.createTempFile()
         with BZ2File(self.filename) as bz2f:
@@ -1100,6 +1116,18 @@ def testDecompressMultiStreamTrailingJunk(self):
         text = bz2.decompress(self.DATA * 5 + self.BAD_DATA)
         self.assertEqual(text, self.TEXT * 5)
 
+    def testDecompressMultiStreamDamagedStream(self):
+        # A damaged bzip2 stream following a valid one is not trailing junk.
+        for pos in 4, 100, -3:
+            with self.subTest(pos=pos):
+                damaged = bytearray(self.DATA)
+                damaged[pos] ^= 1
+                self.assertRaises(OSError, bz2.decompress,
+                                  self.DATA + damaged)
+        # Not a bzip2 header: the block size digit must be 1 to 9.
+        text = bz2.decompress(self.DATA + b"BZh0" + self.BAD_DATA)
+        self.assertEqual(text, self.TEXT)
+
 
 class OpenTest(BaseTest):
     "Test the open function."
diff --git a/Lib/test/test_lzma.py b/Lib/test/test_lzma.py
index df0b66039d77a1a..d5431ed6df49d52 100644
--- a/Lib/test/test_lzma.py
+++ b/Lib/test/test_lzma.py
@@ -524,6 +524,16 @@ def test_decompress_multistream_trailing_junk(self):
         ddata = lzma.decompress(COMPRESSED_XZ * 3 + COMPRESSED_BOGUS)
         self.assertEqual(ddata, INPUT * 3)
 
+    def test_decompress_multistream_damaged_stream(self):
+        # A damaged .xz stream following a valid one is not trailing junk.
+        for pos in 6, 100, -20, -1:
+            damaged = bytearray(COMPRESSED_XZ)
+            damaged[pos] ^= 1
+            for format in lzma.FORMAT_AUTO, lzma.FORMAT_XZ:
+                with self.subTest(pos=pos, format=format):
+                    self.assertRaises(LZMAError, lzma.decompress,
+                                      COMPRESSED_XZ + damaged, format)
+
 
 class TempFile:
     """Context manager - creates a file, and deletes it on __exit__."""
@@ -885,6 +895,25 @@ def test_read_multistream_trailing_junk(self):
         with LZMAFile(BytesIO(COMPRESSED_XZ * 5 + COMPRESSED_BOGUS)) as f:
             self.assertEqual(f.read(), INPUT * 5)
 
+    def test_read_multistream_damaged_stream(self):
+        # A damaged .xz stream following a valid one is not trailing junk,
+        # whatever the size of the reads.
+        for pos in 6, 100, -20, -1:
+            damaged = bytearray(COMPRESSED_XZ)
+            damaged[pos] ^= 1
+            cdata = COMPRESSED_XZ + damaged
+            for format in lzma.FORMAT_AUTO, lzma.FORMAT_XZ:
+                with self.subTest(pos=pos, format=format):
+                    with LZMAFile(BytesIO(cdata), format=format) as f:
+                        self.assertRaises(LZMAError, f.read)
+                    with LZMAFile(BytesIO(cdata), format=format) as f:
+                        self.assertEqual(f.read(len(INPUT)), INPUT)
+                        with self.assertRaises(LZMAError):
+                            while f.read(10):
+                                pass
+                    with LZMAFile(BytesIO(cdata), format=format) as f:
+                        self.assertRaises(LZMAError, f.seek, 0, 2)
+
     def test_read_from_file(self):
         with TempFile(TESTFN, COMPRESSED_XZ):
             with LZMAFile(TESTFN) as f:
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-22-18-30-00.gh-issue-160314.q7RkZd.rst 
b/Misc/NEWS.d/next/Library/2026-10-22-18-30-00.gh-issue-160314.q7RkZd.rst
new file mode 100644
index 000000000000000..3694e255546f68a
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-18-30-00.gh-issue-160314.q7RkZd.rst
@@ -0,0 +1,4 @@
+Raise an error in :class:`lzma.LZMAFile`, :func:`lzma.decompress`,
+:class:`bz2.BZ2File` and :func:`bz2.decompress` for a damaged ``.xz`` or bzip2
+stream that follows another stream, instead of silently ignoring it and
+everything after it as trailing data.

_______________________________________________
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