Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Lib/_py_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,8 @@ def wrapper(*args, **kwargs):
_DEPRECATED_MSG = "{name!r} is deprecated and slated for removal in Python {remove}"


def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info):
def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_info,
stacklevel=3):
"""Warn that *name* is deprecated or should be removed.

RuntimeError is raised if *remove* specifies a major/minor tuple older than
Expand All @@ -889,7 +890,7 @@ def _deprecated(name, message=_DEPRECATED_MSG, *, remove, _version=sys.version_i
raise RuntimeError(msg)
else:
msg = message.format(name=name, remove=remove_formatted)
_wm.warn(msg, DeprecationWarning, stacklevel=3)
_wm.warn(msg, DeprecationWarning, stacklevel=stacklevel)


# Private utility function called by _PyErr_WarnUnawaitedCoroutine
Expand Down
73 changes: 72 additions & 1 deletion Lib/test/test_zipfile/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
with_source_date_epoch, without_source_date_epoch,
)
from test.support.import_helper import ensure_lazy_imports
from test.support.warnings_helper import check_no_resource_warning
from test.support.warnings_helper import (
check_no_resource_warning, ignore_warnings,
)


TESTFN2 = TESTFN + "2"
Expand Down Expand Up @@ -4916,6 +4918,75 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
compression = zipfile.ZIP_ZSTANDARD


class MonkeypatchedDecompressorTests(unittest.TestCase):
# Some third-party projects monkey-patch _get_decompressor() to add
# additional compression schemes. This can break at any time as the
# internal compressor objects change.
# To protect users, we try to keep this case working.
# See also: GH-156002 and GH-113767.
COMPRESSION = 99

class Compressor:
"""Compressor with only the original BZ2Compressor API"""
def compress(self, data):
return data.swapcase()

def flush(self):
return b''

class Decompressor:
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
eof = False

def decompress(self, data):
return data.swapcase()

def setUp(self):
orig_check_compression = zipfile._check_compression
orig_get_compressor = zipfile._get_compressor
orig_get_decompressor = zipfile._get_decompressor

def check_compression(compression):
if compression != self.COMPRESSION:
orig_check_compression(compression)

def get_compressor(compress_type, compresslevel=None):
if compress_type == self.COMPRESSION:
return self.Compressor()
return orig_get_compressor(compress_type, compresslevel)

def get_decompressor(compress_type):
if compress_type == self.COMPRESSION:
return self.Decompressor()
return orig_get_decompressor(compress_type)

self.enterContext(mock.patch.object(
zipfile, '_check_compression', check_compression))
self.enterContext(mock.patch.object(
zipfile, '_get_compressor', get_compressor))
self.enterContext(mock.patch.object(
zipfile, '_get_decompressor', get_decompressor))

def test_roundtrip_monkeypatched_decompressor(self):
data = bytes(range(256)) * 8
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
zf.writestr("member", data)
self.assertIn(data.swapcase(), buf.getvalue())
with (ignore_warnings(category=DeprecationWarning,
message='.*two arguments.*'),
zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf):
self.assertEqual(zf.read("member"), data)
with zf.open("member") as f:
self.assertEqual(f.read(100), data[:100])
self.assertEqual(f.read1(100), data[100:200])
f.seek(-100, os.SEEK_END)
self.assertEqual(f.read(), data[-100:])
# Rewinding past the read buffer re-creates the decompressor.
f.seek(0)
self.assertEqual(f.read(), data)


class AbstractBadCrcTests:
def test_testzip_with_bad_crc(self):
"""Tests that files with bad CRCs return their name from testzip."""
Expand Down
28 changes: 17 additions & 11 deletions Lib/zipfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ def unused_data(self):
return b''

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


def _decompressor_needs_input(decompressor):
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
# wrapper keeps it private (_needs_input) to avoid adding public API.
needs_input = getattr(decompressor, "needs_input", None)
return decompressor._needs_input if needs_input is None else needs_input


def _get_decompressor(compress_type):
_check_compression(compress_type)
if compress_type == ZIP_STORED:
Expand Down Expand Up @@ -1207,7 +1200,7 @@ def _read1(self, n):
else:
# bzip2/lzma/zstd: a bounded decompress() call may leave input
# buffered inside the decompressor; drain that before reading more.
if _decompressor_needs_input(self._decompressor):
if getattr(self._decompressor, "needs_input", True):
data = self._read2(n)
else:
data = b''
Expand All @@ -1226,10 +1219,23 @@ def _read1(self, n):
# Bound the output of a single decompress() call (mirroring the
# DEFLATE path above) so that a small compressed member cannot
# expand into one unbounded read.
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
try:
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
except TypeError:
# See MonkeypatchedDecompressorTests in test_core.py
warnings._deprecated(
'one-argument decompress()',
'The decompress() method of '
+ type(self._decompressor).__name__
+ ' should take two arguments, data and max_length.'
+ ' One-argument calls will stop working before'
+ ' Python 3.21.',
remove=(3, 21),
stacklevel=4)
data = self._decompressor.decompress(data)
self._eof = (self._decompressor.eof or
self._compress_left <= 0 and
_decompressor_needs_input(self._decompressor))
getattr(self._decompressor, "needs_input", True))

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