Visitar URL original
zlib: return output inflate still holds after the last input byte by JMLX42 · Pull Request #8909 · RustPython/RustPython · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
zlib: return output inflate still holds after the last input byte
decompress_chunks returned before it called inflate when the input was
empty, and returned once the input was consumed even when the output
buffer was full. Raw deflate has no trailer, so inflate can consume the
last input byte and still hold output. Decompress.decompress(b"", n)
and Decompress.flush() then returned nothing, and zlib.decompress with a
small bufsize raised "incomplete or truncated stream".

Mirror CPython's zlibmodule.c: call inflate for empty input, and call it
again while it fills the output buffer. A stream with no pending output
answers Z_BUF_ERROR, so an empty input before any compressed data still
leaves eof false.

Assisted-by: Claude Code:claude-opus-5-5
Fixes #8907
  • Loading branch information
JMLX42 committed Sep 29, 2026
commit 2614dbf1a675d45ce281f61bc415978ce24b1b44
63 changes: 60 additions & 3 deletions crates/common/src/compression/zlib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,9 @@ fn decompress_chunks(
max_length: Option<usize>,
calc_flush: impl Fn(bool) -> InflateFlush,
) -> Result<(Vec<u8>, bool), String> {
if data.is_empty() {
return Ok((Vec::new(), false));
}
// Empty input still reaches inflate, like CPython: inflate can hold output
// after it consumed the last input byte. A stream with no pending output
// answers `BufError`, not `StreamEnd`, so `eof` stays false.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Empty input still reaches inflate, like CPython: inflate can hold output
// after it consumed the last input byte. A stream with no pending output
// answers `BufError`, not `StreamEnd`, so `eof` stays false.

these comments will look weird after patch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b39f875: the comment is gone. zlib unit tests: 19 passed.


🤖 Written by Claude Code (2.1.283) using model claude-opus-5-5

let max_length = max_length.unwrap_or(usize::MAX);
let mut buf = Vec::new();

Expand All @@ -378,6 +378,11 @@ fn decompress_chunks(
match res {
Ok(status) => {
let stream_end = status == Status::StreamEnd;
if produced == additional && !stream_end {
// A full output buffer can leave output pending in
// inflate, even with no input left.
continue 'outer;
}
if stream_end || data.is_empty() {
buf.shrink_to_fit();
return Ok((buf, stream_end));
Expand Down Expand Up @@ -1005,4 +1010,56 @@ mod tests {
assert_eq!(d.decompress(&encoded, None).unwrap(), b"later input");
assert!(d.eof());
}

/// Decode raw deflate of `b"x" * 1168` in 100-byte steps until all
/// input is consumed. Inflate then still holds the last 68 bytes.
fn drained_raw_deflate() -> (Decompressor, Vec<u8>) {
let mut co = Compressor::new(6, 8, -MAX_WBITS, 8, 0, None).unwrap();
let mut raw = co.compress(&[b'x'; 1168]).unwrap();
raw.extend(co.flush(Z_FINISH).unwrap());
let mut d = Decompressor::new(-MAX_WBITS, None).unwrap();
let mut out = d.decompress(&raw, Some(100)).unwrap();
while !d.unconsumed_tail().is_empty() {
let tail = d.unconsumed_tail().to_vec();
out.extend(d.decompress(&tail, Some(100)).unwrap());
}
assert_eq!(out.len(), 1100);
assert!(!d.eof());
(d, out)
}

#[test]
fn empty_input_returns_pending_output() {
let (mut d, mut out) = drained_raw_deflate();
out.extend(d.decompress(b"", Some(100)).unwrap());
assert_eq!(out, [b'x'; 1168]);
assert!(d.eof());
}

#[test]
fn flush_returns_pending_output() {
let (mut d, mut out) = drained_raw_deflate();
out.extend(d.flush(DEF_BUF_SIZE).unwrap());
assert_eq!(out, [b'x'; 1168]);
assert!(d.eof());
}

#[test]
fn small_bufsize_returns_pending_output() {
let data = [b'x'; 1168];
let raw = compress(&data, 6, -MAX_WBITS).unwrap();
for bufsize in [1, 100] {
assert_eq!(decompress(&raw, -MAX_WBITS, bufsize).unwrap(), data);
}
}

#[test]
fn empty_flush_does_not_finish_a_stream() {
let encoded = compress(b"later input", -1, MAX_WBITS).unwrap();
let mut d = Decompressor::new(MAX_WBITS, None).unwrap();
assert_eq!(d.flush(DEF_BUF_SIZE).unwrap(), b"");
assert!(!d.eof());
assert_eq!(d.decompress(&encoded, None).unwrap(), b"later input");
assert!(d.eof());
}
}
42 changes: 42 additions & 0 deletions extra_tests/snippets/stdlib_zlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,45 @@

assert_raises(zlib.error, lambda: zlib.compress(b"123", -40))
assert_raises(zlib.error, lambda: zlib.compress(b"123", 10))


# Raw deflate has no trailer, so inflate can consume the last input byte and
# still hold output. decompress(b"", n) and flush() must return that output.
def drained_raw_deflate():
c = zlib.compressobj(6, zlib.DEFLATED, -15)
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)
assert len(out) == 1100
assert not d.eof
return d, out


d, out = drained_raw_deflate()
more = d.decompress(b"", 100)
assert len(more) == 68
assert d.eof
assert d.flush() == b""
assert out + more == b"x" * 1168

d, out = drained_raw_deflate()
rest = d.flush()
assert len(rest) == 68
assert d.eof
assert out + rest == b"x" * 1168

# An empty input before any compressed data does not end the stream.
d = zlib.decompressobj()
assert d.decompress(b"") == b""
assert not d.eof
assert d.flush() == b""
assert not d.eof
assert d.decompress(zlib.compress(b"later input")) == b"later input"
assert d.eof

# The one-shot decompress drains the same pending output with a small bufsize.
raw = zlib.compress(b"x" * 1168, 6, -15)
for bufsize in (1, 100):
assert zlib.decompress(raw, -15, bufsize) == b"x" * 1168
Loading