Skip to content

zlib.decompressobj: decompress(b"", n) and flush() never return output that inflate still holds (raw deflate loses its last bytes under max_length) #8907

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 on main (a7d75d25a7b2c291720411dc65445accd972e69d,
    2026-09-29) is byte-identical to d31ccae; the line numbers below hold for both.
  • Reference: CPython 3.13.5.

Reproducer

import zlib
c = zlib.compressobj(6, zlib.DEFLATED, -15)  # raw deflate, no trailer
raw = c.compress(b"x" * 1168) + c.flush()
d = zlib.decompressobj(-15)
out = d.decompress(raw, 100)
while d.unconsumed_tail:
    out += d.decompress(d.unconsumed_tail, 100)
print("all input consumed:", len(out), "of 1168, eof", d.eof)
more = d.decompress(b"", 100)
print("decompress(b'', 100):", len(more))
rest = d.flush()
print("flush():", len(rest), "total:", len(out + more + rest))

CPython 3.13.5:

all input consumed: 1100 of 1168, eof False
decompress(b'', 100): 68
flush(): 0 total: 1168

RustPython d31ccae:

all input consumed: 1100 of 1168, eof False
decompress(b'', 100): 0
flush(): 0 total: 1100

The last 68 bytes stay inside the inflate state. The input is all consumed, so unconsumed_tail is
empty, and neither decompress(b"", n) nor flush() runs inflate again.

Frequency, the same drain loop plus flush(), sizes n in range(1000, 3000, 7), max_length
in (64, 100, 256, 1024) (1 144 cases):

raw deflate (wbits=-15) short zlib (15) and gzip (31) short
CPython 3.13.5 0 / 1 144 0 / 2 288
RustPython d31ccae 196 / 1 144 0 / 2 288

With a zlib or gzip wrapper the trailer bytes are still unconsumed while output is pending, so
unconsumed_tail is non-empty and the loop calls inflate again. Raw deflate has no trailer, so
the pending output can outlive the last input byte.

Source

crates/common/src/compression/zlib.rs, decompress_chunks, lines 353–355:

if data.is_empty() {
    return Ok((Vec::new(), false));
}

Decompressor::decompress (line 690) passes b"" through decompress_inner →
decompress_all → decompress_chunks, which returns before it calls inflate.
Decompressor::flush (lines 701–709) takes unconsumed_tail, which is empty here, and reaches
the same early return, so the InflateFlush::Finish call never runs either. CPython calls inflate
for an empty input and returns the pending output.

A fix has to keep the existing unit test empty_input_does_not_finish_a_stream (line 1000):
an empty input before any compressed data MUST NOT set eof. #8752 aligned lzma to this same early
return for that reason.

Impact

Any consumer that decodes raw deflate with a bounded max_length and drains with
decompress(b"", n) or flush() gets a truncated result with no error. One example is HTTP
Content-Encoding: deflate sent as raw deflate, which urllib3's DeflateDecoder accepts after its
first zlib-header attempt fails. I have not measured urllib3 end to end for this case.

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