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
Environment
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.rsonmain(a7d75d25a7b2c291720411dc65445accd972e69d,2026-09-29) is byte-identical to
d31ccae; the line numbers below hold for both.Reproducer
CPython 3.13.5:
RustPython
d31ccae:The last 68 bytes stay inside the inflate state. The input is all consumed, so
unconsumed_tailisempty, and neither
decompress(b"", n)norflush()runs inflate again.Frequency, the same drain loop plus
flush(), sizesninrange(1000, 3000, 7),max_lengthin
(64, 100, 256, 1024)(1 144 cases):wbits=-15) short15) and gzip (31) shortd31ccaeWith a zlib or gzip wrapper the trailer bytes are still unconsumed while output is pending, so
unconsumed_tailis non-empty and the loop calls inflate again. Raw deflate has no trailer, sothe pending output can outlive the last input byte.
Source
crates/common/src/compression/zlib.rs,decompress_chunks, lines 353–355:Decompressor::decompress(line 690) passesb""throughdecompress_inner→decompress_all→decompress_chunks, which returns before it calls inflate.Decompressor::flush(lines 701–709) takesunconsumed_tail, which is empty here, and reachesthe same early return, so the
InflateFlush::Finishcall never runs either. CPython calls inflatefor 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 earlyreturn for that reason.
Impact
Any consumer that decodes raw deflate with a bounded
max_lengthand drains withdecompress(b"", n)orflush()gets a truncated result with no error. One example is HTTPContent-Encoding: deflatesent as raw deflate, which urllib3'sDeflateDecoderaccepts after itsfirst 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