Repository navigation
zlib.decompressobj: unconsumed_tail keeps stale input when a max_length-bounded call reaches end of stream #8906
Description
Environment
- RustPython
d31ccae04d039ca3ab294742f2493aa5f2b63ff7, host build (x86_64-unknown-linux-gnu,
debug),zlib.ZLIB_VERSION=1.3.0-zlib-rs-0.6.8. crates/common/src/compression/zlib.rsandcrates/stdlib/src/zlib.rsonmain
(a7d75d25a7b2c291720411dc65445accd972e69d, 2026-09-29) are byte-identical tod31ccae, so the
line numbers below hold for both.- Reference: CPython 3.13.5.
Reproducer
import zlib
data = zlib.compress(b"x" * 20000) + b"NEXT"
d = zlib.decompressobj()
out = d.decompress(data, 100)
print("call 1:", len(out), d.eof, len(d.unconsumed_tail), d.unused_data)
out = d.decompress(d.unconsumed_tail, 100000)
print("call 2:", len(out), d.eof, d.unconsumed_tail, d.unused_data)
print("flush:", d.flush(), d.unconsumed_tail, d.unused_data)CPython 3.13.5:
call 1: 100 False 28 b''
call 2: 19900 True b'NEXT' b'NEXT'
flush: b'' b'NEXT' b'NEXTNEXT'
RustPython d31ccae:
call 1: 100 False 29 b''
call 2: 19900 True b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\xc0\x00eG\xa1\x1dNEXT' b'NEXT'
flush: b'' b'' b'NEXT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe8\xc0\x00eG\xa1\x1dNEXT'
After call 2 the stream is at its end. CPython reports only the bytes after the stream end in
unconsumed_tail. RustPython reports the whole unconsumed_tail of call 1: compressed bytes that
call 2 already consumed, followed by NEXT. flush() then appends those stale bytes to
unused_data. (The call 1 tail length, 28 vs 29, is a separate difference in how far inflate reads
ahead; this report does not depend on it.)
Source
crates/common/src/compression/zlib.rs, Decompressor::decompress_inner, lines 674–683:
let unconsumed = &data[consumed..];
if !unconsumed.is_empty() {
if stream_end {
unused_data.extend_from_slice(unconsumed);
} else {
*unconsumed_tail = unconsumed.to_vec();
}
} else if !unconsumed_tail.is_empty() {
unconsumed_tail.clear();
}When stream_end is true and input is left over, the branch updates unused_data and leaves
unconsumed_tail unchanged, so it still holds the previous call's tail. Decompressor::flush
(line 702) takes that stale unconsumed_tail as its input, and decompress_inner moves it to
unused_data. CPython's observed result sets unconsumed_tail to the bytes after the stream end.
Impact
urllib3 2.x (checked against 2.8.0) GzipDecoder.decompress continues a multi-member body with
self._obj.unconsumed_tail or self._obj.unused_data and opens a new decompressobj when
self._obj.eof is true. On RustPython the stale tail is fed to the new object as the next gzip
member. Measured with urllib3 2.8.0's GzipDecoder class copied into a script (no network),
body gzip.compress(b"x" * 20000) * 12, drained with max_length=10240:
| output bytes | final _state |
|
|---|---|---|
| CPython 3.13.5 | 240 000 | OTHER_MEMBERS |
RustPython d31ccae |
20 000 | SWALLOW_DATA |
On RustPython the second member raises zlib.error inside the decoder, the decoder switches to
SWALLOW_DATA, and 11 of 12 members are dropped with no error to the caller.
The reproducers above were run locally on RustPython and CPython.
🤖 Written by Claude Code (2.1.283) using model claude-opus-5-5
Activity
zlib: accept both valid tails in the snippet on every implementation
zlib: clear unconsumed_tail when a bounded decompress reaches stream …