diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 451302715329fe..d79504e62e089a 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -661,13 +661,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)] @@ -744,7 +746,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 @@ -767,7 +772,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 @@ -1789,6 +1795,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, @@ -2034,6 +2046,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 @@ -2056,6 +2069,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 @@ -2112,6 +2126,7 @@ def xzopen(cls, name, mode="r", fileobj=None, preset=None, **kwargs): fileobj.close() raise t._extfileobj = False + t.exception = LZMAError return t @classmethod @@ -2147,6 +2162,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 3899eac6be3b3a..f9334b94d37526 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -1191,6 +1191,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 00000000000000..1c8d8839dbf7bd --- /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.