Skip to content

Commit 769d44c

Browse files
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>
1 parent 197d332 commit 769d44c

3 files changed

Lines changed: 90 additions & 3 deletions

File tree

‎Lib/tarfile.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -665,13 +665,15 @@ class _FileInFile(object):
665665
object.
666666
"""
667667

668-
def __init__(self, fileobj, offset, size, name, blockinfo=None):
668+
def __init__(self, fileobj, offset, size, name, blockinfo=None,
669+
exception=OSError):
669670
self.fileobj = fileobj
670671
self.offset = offset
671672
self.size = size
672673
self.position = 0
673674
self.name = name
674675
self.closed = False
676+
self.exception = exception
675677

676678
if blockinfo is None:
677679
blockinfo = [(0, size)]
@@ -748,7 +750,10 @@ def read(self, size=None):
748750
length = min(size, stop - self.position)
749751
if data:
750752
self.fileobj.seek(offset + (self.position - start))
751-
b = self.fileobj.read(length)
753+
try:
754+
b = self.fileobj.read(length)
755+
except self.exception as e:
756+
raise ReadError(f"invalid compressed data: {e}") from e
752757
if len(b) != length:
753758
raise ReadError("unexpected end of data")
754759
buf += b
@@ -771,7 +776,8 @@ class ExFileObject(io.BufferedReader):
771776

772777
def __init__(self, tarfile, tarinfo):
773778
fileobj = _FileInFile(tarfile.fileobj, tarinfo.offset_data,
774-
tarinfo.size, tarinfo.name, tarinfo.sparse)
779+
tarinfo.size, tarinfo.name, tarinfo.sparse,
780+
exception=tarfile.exception)
775781
super().__init__(fileobj)
776782
#class ExFileObject
777783

@@ -1801,6 +1807,12 @@ class TarFile(object):
18011807

18021808
fileobject = ExFileObject # The file-object for extractfile().
18031809

1810+
exception = OSError # The exception raised by the underlying
1811+
# fileobj.read() on corrupt compressed
1812+
# data past the header (see gzopen() etc.
1813+
# for the more specific exception types
1814+
# used by each compression format).
1815+
18041816
extraction_filter = None # The default filter for extraction.
18051817

18061818
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):
20462058

20472059
try:
20482060
from gzip import GzipFile
2061+
import zlib
20492062
except ImportError:
20502063
raise CompressionError("gzip module is not available") from None
20512064

@@ -2068,6 +2081,7 @@ def gzopen(cls, name, mode="r", fileobj=None, compresslevel=6, **kwargs):
20682081
fileobj.close()
20692082
raise
20702083
t._extfileobj = False
2084+
t.exception = zlib.error
20712085
return t
20722086

20732087
@classmethod
@@ -2124,6 +2138,7 @@ def xzopen(cls, name, mode="r", fileobj=None, preset=None, **kwargs):
21242138
fileobj.close()
21252139
raise
21262140
t._extfileobj = False
2141+
t.exception = LZMAError
21272142
return t
21282143

21292144
@classmethod
@@ -2159,6 +2174,7 @@ def zstopen(cls, name, mode="r", fileobj=None, level=None, options=None,
21592174
fileobj.close()
21602175
raise
21612176
t._extfileobj = False
2177+
t.exception = ZstdError
21622178
return t
21632179

21642180
# All *open() methods are registered here.

‎Lib/test/test_tarfile.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,72 @@ def runTest(self):
12041204
tarfile.open(fileobj=f, mode='r|gz')
12051205

12061206

1207+
class FileInFileExceptionWrappingTest(unittest.TestCase):
1208+
"""
1209+
See: https://github.com/python/cpython/issues/156057
1210+
1211+
_FileInFile.read() must wrap whatever exception type its
1212+
underlying fileobj.read() raises for corrupt compressed data,
1213+
using the exception class attribute each TarFile.*open()
1214+
classmethod configures, in tarfile.ReadError -- and must not
1215+
swallow an exception of an unconfigured type.
1216+
"""
1217+
1218+
class _FakeFileobj:
1219+
def __init__(self, exc):
1220+
self.exc = exc
1221+
1222+
def seek(self, position):
1223+
pass
1224+
1225+
def read(self, size):
1226+
raise self.exc
1227+
1228+
def test_wraps_configured_exception_type(self):
1229+
class Boom(Exception):
1230+
pass
1231+
1232+
fif = tarfile._FileInFile(
1233+
self._FakeFileobj(Boom("simulated corrupt compressed data")),
1234+
offset=0, size=10, name="member", exception=Boom)
1235+
with self.assertRaises(tarfile.ReadError):
1236+
fif.read()
1237+
1238+
def test_does_not_wrap_unconfigured_exception_type(self):
1239+
class Boom(Exception):
1240+
pass
1241+
1242+
class Unrelated(Exception):
1243+
pass
1244+
1245+
fif = tarfile._FileInFile(
1246+
self._FakeFileobj(Unrelated("not the configured exception type")),
1247+
offset=0, size=10, name="member", exception=Boom)
1248+
with self.assertRaises(Unrelated):
1249+
fif.read()
1250+
1251+
1252+
@support.requires_gzip()
1253+
class GzipExtractfileWrapsCompressionErrorTest(unittest.TestCase):
1254+
"""
1255+
See: https://github.com/python/cpython/issues/156057
1256+
1257+
Confirms the real gzopen() -> ExFileObject -> _FileInFile wiring:
1258+
a zlib.error raised while reading a member's compressed data past
1259+
the header surfaces as tarfile.ReadError, not the raw zlib.error.
1260+
"""
1261+
1262+
def test_extractfile_read_wraps_zlib_error(self):
1263+
with tarfile.open(gzipname, mode='r:gz') as tar:
1264+
tarinfo = tar.getmember("ustar/regtype")
1265+
with tar.extractfile(tarinfo) as fobj:
1266+
with unittest.mock.patch.object(
1267+
tar.fileobj, "read",
1268+
side_effect=zlib.error("simulated corrupt data")):
1269+
with self.assertRaises(tarfile.ReadError):
1270+
fobj.read()
1271+
1272+
12071273
class MemberReadTest(ReadTest, unittest.TestCase):
12081274

12091275
def _test_member(self, tarinfo, chksum=None, **kwargs):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Wrap the underlying compression library's exception in :exc:`tarfile.ReadError`
2+
when a member's compressed data is corrupt past its header, for all of
3+
:mod:`tarfile`'s supported compression formats (gzip, bzip2, lzma, zstd).
4+
Previously the raw exception (e.g. :exc:`zlib.error`) could leak through
5+
:meth:`tarfile.TarFile.extractfile`. Patch by Jason Mak.

0 commit comments

Comments
 (0)