gh-156057: Wrap compression library exceptions in tarfile.ReadError (GH-156143) _FileInFile.read() (used by TarFile.extractfile() for seekable-mode archives) called the underlying compressed fileobj's read() directly. If a member's compressed payload was corrupted after the header had already been read successfully, the codec's own exception (zlib.error, OSError from bz2, lzma.LZMAError, or zstd.ZstdError) would leak straight through instead of being wrapped in tarfile.ReadError, unlike the existing streaming-mode (r|gz etc.) code path in _Stream, which already handles this correctly via a self.exception attribute set per codec. Give TarFile (and _FileInFile) the same per-codec exception attribute, set by gzopen/xzopen/zstopen to the specific error type (bz2 already matches the OSError class default), and wrap the read() call in _FileInFile with it. --------- Signed-off-by: Jason Mak <squrrielbro@gmail.com> Co-authored-by: Ethan Furman <ethan@stoneleaf.us>
diff --git a/Lib/tarfile.py b/Lib/tarfile.py index c0b9f3f..dc2d35f 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py
@@ -665,13 +665,15 @@ class _FileInFile(object): object. """ - def __init__(self, fileobj, offset, size, name, blockinfo=None): + def __init__(self, fileobj, offset, size, name, blockinfo=None, + exception=OSError): self.fileobj = fileobj self.offset = offset self.size = size self.position = 0 self.name = name self.closed = False + self.exception = exception if blockinfo is None: blockinfo = [(0, size)] @@ -748,7 +750,10 @@ def read(self, size=None): length = min(size, stop - self.position) if data: self.fileobj.seek(offset + (self.position - start)) - b = self.fileobj.read(length) + try: + b = self.fileobj.read(length) + except self.exception as e: + raise ReadError(f"invalid compressed data: {e}") from e if len(b) != length: raise ReadError("unexpected end of data") buf += b @@ -771,7 +776,8 @@ class ExFileObject(io.BufferedReader): def __init__(self, tarfile, tarinfo): fileobj = _FileInFile(tarfile.fileobj, tarinfo.offset_data, - tarinfo.size, tarinfo.name, tarinfo.sparse) + tarinfo.size, tarinfo.name, tarinfo.sparse, + exception=tarfile.exception) super().__init__(fileobj) #class ExFileObject @@ -1801,6 +1807,12 @@ class TarFile(object): fileobject = ExFileObject # The file-object for extractfile(). + exception = OSError # The exception raised by the underlying + # fileobj.read() on corrupt compressed + # data past the header (see gzopen() etc. + # for the more specific exception types + # used by each compression format). + extraction_filter = None # The default filter for extraction. def __init__(self, name=None, mode="r", fileobj=None, format=None, @@ -2046,6 +2058,7 @@ def gzopen(cls, name, mode="r", fileobj=None, compresslevel=6, **kwargs): try: from gzip import GzipFile + import zlib except ImportError: raise CompressionError("gzip module is not available") from None @@ -2068,6 +2081,7 @@ def gzopen(cls, name, mode="r", fileobj=None, compresslevel=6, **kwargs): fileobj.close() raise t._extfileobj = False + t.exception = zlib.error return t @classmethod @@ -2124,6 +2138,7 @@ def xzopen(cls, name, mode="r", fileobj=None, preset=None, **kwargs): fileobj.close() raise t._extfileobj = False + t.exception = LZMAError return t @classmethod @@ -2159,6 +2174,7 @@ def zstopen(cls, name, mode="r", fileobj=None, level=None, options=None, fileobj.close() raise t._extfileobj = False + t.exception = ZstdError return t # All *open() methods are registered here.
diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 2b4cf74..605e66b 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py
@@ -1204,6 +1204,72 @@ def runTest(self): tarfile.open(fileobj=f, mode='r|gz') +class FileInFileExceptionWrappingTest(unittest.TestCase): + """ + See: https://github.com/python/cpython/issues/156057 + + _FileInFile.read() must wrap whatever exception type its + underlying fileobj.read() raises for corrupt compressed data, + using the exception class attribute each TarFile.*open() + classmethod configures, in tarfile.ReadError -- and must not + swallow an exception of an unconfigured type. + """ + + class _FakeFileobj: + def __init__(self, exc): + self.exc = exc + + def seek(self, position): + pass + + def read(self, size): + raise self.exc + + def test_wraps_configured_exception_type(self): + class Boom(Exception): + pass + + fif = tarfile._FileInFile( + self._FakeFileobj(Boom("simulated corrupt compressed data")), + offset=0, size=10, name="member", exception=Boom) + with self.assertRaises(tarfile.ReadError): + fif.read() + + def test_does_not_wrap_unconfigured_exception_type(self): + class Boom(Exception): + pass + + class Unrelated(Exception): + pass + + fif = tarfile._FileInFile( + self._FakeFileobj(Unrelated("not the configured exception type")), + offset=0, size=10, name="member", exception=Boom) + with self.assertRaises(Unrelated): + fif.read() + + +@support.requires_gzip() +class GzipExtractfileWrapsCompressionErrorTest(unittest.TestCase): + """ + See: https://github.com/python/cpython/issues/156057 + + Confirms the real gzopen() -> ExFileObject -> _FileInFile wiring: + a zlib.error raised while reading a member's compressed data past + the header surfaces as tarfile.ReadError, not the raw zlib.error. + """ + + def test_extractfile_read_wraps_zlib_error(self): + with tarfile.open(gzipname, mode='r:gz') as tar: + tarinfo = tar.getmember("ustar/regtype") + with tar.extractfile(tarinfo) as fobj: + with unittest.mock.patch.object( + tar.fileobj, "read", + side_effect=zlib.error("simulated corrupt data")): + with self.assertRaises(tarfile.ReadError): + fobj.read() + + class MemberReadTest(ReadTest, unittest.TestCase): def _test_member(self, tarinfo, chksum=None, **kwargs):
diff --git a/Misc/NEWS.d/next/Library/2026-08-21-06-00-00.gh-issue-156057.tarfile-wrap-comp-err.rst b/Misc/NEWS.d/next/Library/2026-08-21-06-00-00.gh-issue-156057.tarfile-wrap-comp-err.rst new file mode 100644 index 0000000..1c8d883 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-21-06-00-00.gh-issue-156057.tarfile-wrap-comp-err.rst
@@ -0,0 +1,5 @@ +Wrap the underlying compression library's exception in :exc:`tarfile.ReadError` +when a member's compressed data is corrupt past its header, for all of +:mod:`tarfile`'s supported compression formats (gzip, bzip2, lzma, zstd). +Previously the raw exception (e.g. :exc:`zlib.error`) could leak through +:meth:`tarfile.TarFile.extractfile`. Patch by Jason Mak.