Skip to content

Commit e2311cf

Browse files
rasmusfaberencukou
andauthored
gh-156002: Keep reading through monkey-patched zipfile decompressors (GH-157180)
GH-156003 made ZipExtFile._read1() call decompress(data, max_length) on non-deflate decompressors and consult needs_input before reading more. A decompressor installed by monkey-patching _get_decompressor() (as projects like zipfile-zstd, zipfile-deflate64, ... do) may support neither, and every read through it then failed with AttributeError. - Make LZMADecompressor.needs_input public to simplify implementation. - Make the needs_input attribute optional. - If `decompress()` fails with TypeError, try again with one argument. - Since the fallback to one-argument call is a maintenance burden, raise DeprecationWarning. - Add tests for future changes, so we can make informed decisions about breaking monkey-patchers. Co-authored-by: Petr Viktorin <encukou@gmail.com>
1 parent 4616116 commit e2311cf

4 files changed

Lines changed: 98 additions & 14 deletions

File tree

Lib/_py_warnings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,8 @@ def wrapper(*args, **kwargs):
873873
_DEPRECATED_MSG = "{name!r} is deprecated and slated for removal in Python {remove}"
874874

875875

876-
def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info):
876+
def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info,
877+
stacklevel=3):
877878
"""Warn that *name* is deprecated or should be removed.
878879
879880
RuntimeError is raised if *remove* specifies a major/minor tuple older than
@@ -889,7 +890,7 @@ def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_i
889890
raise RuntimeError(msg)
890891
else:
891892
msg = message.format(name=name, remove=remove_formatted)
892-
_wm.warn(msg, DeprecationWarning, stacklevel=3)
893+
_wm.warn(msg, DeprecationWarning, stacklevel=stacklevel)
893894

894895

895896
# Private utility function called by _PyErr_WarnUnawaitedCoroutine

Lib/test/test_zipfile/test_core.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
with_source_date_epoch, without_source_date_epoch,
3434
)
3535
from test.support.import_helper import ensure_lazy_imports
36-
from test.support.warnings_helper import check_no_resource_warning
36+
from test.support.warnings_helper import (
37+
check_no_resource_warning, ignore_warnings,
38+
)
3739

3840

3941
TESTFN2 = TESTFN + "2"
@@ -4916,6 +4918,75 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
49164918
compression = zipfile.ZIP_ZSTANDARD
49174919

49184920

4921+
class MonkeypatchedDecompressorTests(unittest.TestCase):
4922+
# Some third-party projects monkey-patch _get_decompressor() to add
4923+
# additional compression schemes. This can break at any time as the
4924+
# internal compressor objects change.
4925+
# To protect users, we try to keep this case working.
4926+
# See also: GH-156002 and GH-113767.
4927+
COMPRESSION = 99
4928+
4929+
class Compressor:
4930+
"""Compressor with only the original BZ2Compressor API"""
4931+
def compress(self, data):
4932+
return data.swapcase()
4933+
4934+
def flush(self):
4935+
return b''
4936+
4937+
class Decompressor:
4938+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
4939+
eof = False
4940+
4941+
def decompress(self, data):
4942+
return data.swapcase()
4943+
4944+
def setUp(self):
4945+
orig_check_compression = zipfile._check_compression
4946+
orig_get_compressor = zipfile._get_compressor
4947+
orig_get_decompressor = zipfile._get_decompressor
4948+
4949+
def check_compression(compression):
4950+
if compression != self.COMPRESSION:
4951+
orig_check_compression(compression)
4952+
4953+
def get_compressor(compress_type, compresslevel=None):
4954+
if compress_type == self.COMPRESSION:
4955+
return self.Compressor()
4956+
return orig_get_compressor(compress_type, compresslevel)
4957+
4958+
def get_decompressor(compress_type):
4959+
if compress_type == self.COMPRESSION:
4960+
return self.Decompressor()
4961+
return orig_get_decompressor(compress_type)
4962+
4963+
self.enterContext(mock.patch.object(
4964+
zipfile, '_check_compression', check_compression))
4965+
self.enterContext(mock.patch.object(
4966+
zipfile, '_get_compressor', get_compressor))
4967+
self.enterContext(mock.patch.object(
4968+
zipfile, '_get_decompressor', get_decompressor))
4969+
4970+
def test_roundtrip_monkeypatched_decompressor(self):
4971+
data = bytes(range(256)) * 8
4972+
buf = io.BytesIO()
4973+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
4974+
zf.writestr("member", data)
4975+
self.assertIn(data.swapcase(), buf.getvalue())
4976+
with (ignore_warnings(category=DeprecationWarning,
4977+
message='.*two arguments.*'),
4978+
zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf):
4979+
self.assertEqual(zf.read("member"), data)
4980+
with zf.open("member") as f:
4981+
self.assertEqual(f.read(100), data[:100])
4982+
self.assertEqual(f.read1(100), data[100:200])
4983+
f.seek(-100, os.SEEK_END)
4984+
self.assertEqual(f.read(), data[-100:])
4985+
# Rewinding past the read buffer re-creates the decompressor.
4986+
f.seek(0)
4987+
self.assertEqual(f.read(), data)
4988+
4989+
49194990
class AbstractBadCrcTests:
49204991
def test_testzip_with_bad_crc(self):
49214992
"""Tests that files with bad CRCs return their name from testzip."""

Lib/zipfile/__init__.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ def unused_data(self):
802802
return b''
803803

804804
@property
805-
def _needs_input(self):
805+
def needs_input(self):
806806
# While the LZMA properties header is still being buffered, more input
807807
# is required; afterwards defer to the wrapped decompressor so a bounded
808808
# decompress() call can be drained across reads.
@@ -893,13 +893,6 @@ def _get_compressor(compress_type, compresslevel=None):
893893
return None
894894

895895

896-
def _decompressor_needs_input(decompressor):
897-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
898-
# wrapper keeps it private (_needs_input) to avoid adding public API.
899-
needs_input = getattr(decompressor, "needs_input", None)
900-
return decompressor._needs_input if needs_input is None else needs_input
901-
902-
903896
def _get_decompressor(compress_type):
904897
_check_compression(compress_type)
905898
if compress_type == ZIP_STORED:
@@ -1207,7 +1200,7 @@ def _read1(self, n):
12071200
else:
12081201
# bzip2/lzma/zstd: a bounded decompress() call may leave input
12091202
# buffered inside the decompressor; drain that before reading more.
1210-
if _decompressor_needs_input(self._decompressor):
1203+
if getattr(self._decompressor, "needs_input", True):
12111204
data = self._read2(n)
12121205
else:
12131206
data = b''
@@ -1226,10 +1219,23 @@ def _read1(self, n):
12261219
# Bound the output of a single decompress() call (mirroring the
12271220
# DEFLATE path above) so that a small compressed member cannot
12281221
# expand into one unbounded read.
1229-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1222+
try:
1223+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1224+
except TypeError:
1225+
# See MonkeypatchedDecompressorTests in test_core.py
1226+
warnings._deprecated(
1227+
'one-argument decompress()',
1228+
'The decompress() method of '
1229+
+ type(self._decompressor).__name__
1230+
+ ' should take two arguments, data and max_length.'
1231+
+ ' One-argument calls will stop working before'
1232+
+ ' Python 3.21.',
1233+
remove=(3, 21),
1234+
stacklevel=4)
1235+
data = self._decompressor.decompress(data)
12301236
self._eof = (self._decompressor.eof or
12311237
self._compress_left <= 0 and
1232-
_decompressor_needs_input(self._decompressor))
1238+
getattr(self._decompressor, "needs_input", True))
12331239

12341240
data = data[:self._left]
12351241
self._left -= len(data)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:mod:`zipfile` again reads members through a third-party decompressor
2+
installed by monkey-patching the private ``_get_decompressor()`` to return an
3+
object that only implements old BZ2Decompressor API from Python 3.3.
4+
Calling decompress() with one argument is deprecated.
5+
Note that decompressors without ``needs_input`` and two-argument
6+
``decompress()`` are vulnerable to :cve:`2026-15310`.

0 commit comments

Comments
 (0)