Skip to content

zlib.decompressobj: unconsumed_tail keeps stale input when a max_length-bounded call reaches end of stream #8906

Description

@JMLX42

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.rs and crates/stdlib/src/zlib.rs on main
    (a7d75d25a7b2c291720411dc65445accd972e69d, 2026-09-29) are byte-identical to d31ccae, 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions