Skip to content

Commit 2146494

Browse files
committed
Make LZMADecompressor.needs_input public; simplify implementation
Calling decompress() with one argument is a maintenance burden, so mark it as deprecated in 3.16.
1 parent 26d3267 commit 2146494

4 files changed

Lines changed: 35 additions & 45 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: 7 additions & 4 deletions
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"
@@ -4920,8 +4922,7 @@ class MonkeypatchedDecompressorTests(unittest.TestCase):
49204922
# Some third-party projects monkey-patch _get_decompressor() to add
49214923
# additional compression schemes. This can break at any time as the
49224924
# internal compressor objects change.
4923-
# To protect users, we try to keep this case working (until it becomes
4924-
# too big of a burden, or we make the API public).
4925+
# To protect users, we try to keep this case working (see ).
49254926
# See also: GH-156002 and GH-113756.
49264927
COMPRESSION = 99
49274928

@@ -4972,7 +4973,9 @@ def test_roundtrip_monkeypatched_decompressor(self):
49724973
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
49734974
zf.writestr("member", data)
49744975
self.assertIn(data.swapcase(), buf.getvalue())
4975-
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
4976+
with (ignore_warnings(category=DeprecationWarning,
4977+
message='.*two argumentzs.*'),
4978+
zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf):
49764979
self.assertEqual(zf.read("member"), data)
49774980
with zf.open("member") as f:
49784981
self.assertEqual(f.read(100), data[:100])

Lib/zipfile/__init__.py

Lines changed: 20 additions & 35 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,25 +893,6 @@ def _get_compressor(compress_type, compresslevel=None):
893893
return None
894894

895895

896-
def _decompressor_needs_input(decompressor, default):
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-
# A decompressor with neither attribute reports *default*.
900-
needs_input = getattr(decompressor, "needs_input", None)
901-
if needs_input is None:
902-
needs_input = getattr(decompressor, "_needs_input", default)
903-
return needs_input
904-
905-
906-
def _decompressor_bounds_output(decompressor):
907-
# The stdlib bzip2/LZMA/Zstandard decompressors report needs_input and
908-
# accept decompress(data, max_length). A third-party decompressor
909-
# installed by replacing _get_decompressor() may support neither; it is
910-
# then read unbounded, as before the bounded-decompression fix.
911-
return (hasattr(decompressor, "needs_input")
912-
or hasattr(decompressor, "_needs_input"))
913-
914-
915896
def _get_decompressor(compress_type):
916897
_check_compression(compress_type)
917898
if compress_type == ZIP_STORED:
@@ -1019,7 +1000,7 @@ def __init__(self, fileobj, mode, zipinfo, pwd=None,
10191000
self._compress_left = zipinfo.compress_size
10201001
self._left = zipinfo.file_size
10211002

1022-
self._set_decompressor()
1003+
self._decompressor = _get_decompressor(self._compress_type)
10231004

10241005
self._eof = False
10251006
self._readbuffer = b''
@@ -1202,11 +1183,6 @@ def read1(self, n):
12021183
break
12031184
return buf
12041185

1205-
def _set_decompressor(self):
1206-
self._decompressor = _get_decompressor(self._compress_type)
1207-
self._decompress_bounded = _decompressor_bounds_output(
1208-
self._decompressor)
1209-
12101186
def _read1(self, n):
12111187
# Read up to n compressed bytes with at most one read() system call,
12121188
# decrypt and decompress them.
@@ -1224,7 +1200,7 @@ def _read1(self, n):
12241200
else:
12251201
# bzip2/lzma/zstd: a bounded decompress() call may leave input
12261202
# buffered inside the decompressor; drain that before reading more.
1227-
if _decompressor_needs_input(self._decompressor, default=True):
1203+
if getattr(self._decompressor, "needs_input", True):
12281204
data = self._read2(n)
12291205
else:
12301206
data = b''
@@ -1239,18 +1215,27 @@ def _read1(self, n):
12391215
not self._decompressor.unconsumed_tail)
12401216
if self._eof:
12411217
data += self._decompressor.flush()
1242-
elif self._decompress_bounded:
1218+
else:
12431219
# Bound the output of a single decompress() call (mirroring the
12441220
# DEFLATE path above) so that a small compressed member cannot
12451221
# expand into one unbounded read.
1246-
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)
12471236
self._eof = (self._decompressor.eof or
12481237
self._compress_left <= 0 and
1249-
_decompressor_needs_input(self._decompressor,
1250-
default=True))
1251-
else:
1252-
data = self._decompressor.decompress(data)
1253-
self._eof = self._decompressor.eof or self._compress_left <= 0
1238+
getattr(self._decompressor, "needs_input", True))
12541239

12551240
data = data[:self._left]
12561241
self._left -= len(data)
@@ -1339,7 +1324,7 @@ def seek(self, offset, whence=os.SEEK_SET):
13391324
self._left = self._orig_file_size
13401325
self._readbuffer = b''
13411326
self._offset = 0
1342-
self._set_decompressor()
1327+
self._decompressor = _get_decompressor(self._compress_type)
13431328
self._eof = False
13441329
read_offset = new_pos
13451330
if self._decrypter is not None:
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
:mod:`zipfile` again reads members through a third-party decompressor
2-
installed by replacing the private ``_get_decompressor()``, unbounded as
3-
before the bounded-decompression fix, instead of raising
4-
:exc:`AttributeError`. The bound still applies to the standard library's
5-
bzip2, LZMA and Zstandard decompressors.
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 to 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)