From f42baf191f2d6223ddc4807e6b1a38969692b5e4 Mon Sep 17 00:00:00 2001 From: Jason Mak Date: Fri, 21 Aug 2026 09:17:39 +0800 Subject: [PATCH 1/3] gh-156057: Wrap compression library exceptions in tarfile.ReadError _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. Verified by reproducing the leak against current main for gzip, bzip2, and xz (zstd untestable here, no compression.zstd module available in the local Python used to verify this), confirming the same input now raises tarfile.ReadError for all three, and running a full read/write/extract round-trip across all four compression modes to confirm no regression. --- Lib/tarfile.py | 22 ++++++++++++++++--- ....gh-issue-156057.tarfile-wrap-comp-err.rst | 5 +++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-21-06-00-00.gh-issue-156057.tarfile-wrap-comp-err.rst 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/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..4fac439c52172f --- /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.extractfile`. Patch by Jason Mak. From 2a54a401c633448445fda4a8113092fd6a5925f7 Mon Sep 17 00:00:00 2001 From: Jason Mak Date: Fri, 21 Aug 2026 09:48:43 +0800 Subject: [PATCH 2/3] Fix NEWS entry cross-reference: tarfile.TarFile.extractfile The check-warnings CI job correctly flagged :meth:TarFile.extractfile`n as unresolvable - Sphinx needs the fully qualified module.Class.method path for cross-file references. Use tarfile.TarFile.extractfile instead, matching how Doc/library/tarfile.rst defines it under the tarfile module. --- ...026-08-21-06-00-00.gh-issue-156057.tarfile-wrap-comp-err.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4fac439c52172f..1c8d8839dbf7bd 100644 --- 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 @@ -2,4 +2,4 @@ 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.extractfile`. Patch by Jason Mak. +:meth:`tarfile.TarFile.extractfile`. Patch by Jason Mak. From 8a1a5e00702afdd6edd9c84f7e3dfb4625f32d7d Mon Sep 17 00:00:00 2001 From: Jason Mak Date: Fri, 21 Aug 2026 16:59:32 +0800 Subject: [PATCH 3/3] gh-156057: Add tests for tarfile compression-error wrapping Adds direct unit tests for _FileInFile.read()'s new exception-wrapping behavior (both the positive case, wrapping the configured exception type into ReadError, and the negative case, letting an unconfigured exception type propagate unwrapped), plus an integration test confirming the real gzopen() -> ExFileObject -> _FileInFile wiring surfaces a zlib.error raised mid-extraction as tarfile.ReadError. Verified these tests fail against the pre-fix source (TypeError on the new exception= parameter for the unit tests; the raw zlib.error propagating uncaught for the integration test) and pass against the post-fix source. Signed-off-by: Jason Mak --- Lib/test/test_tarfile.py | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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):