zlib: clear unconsumed_tail when a bounded decompress reaches stream end (#8908)

* zlib: clear unconsumed_tail when a bounded decompress reaches stream end

When a max_length-bounded Decompress.decompress() call reached the end
of the stream, the bytes after the stream went to unused_data, but
unconsumed_tail kept the previous call's tail, including input the call
had already consumed. A later flush() then moved those stale bytes into
unused_data.

Empty unconsumed_tail at end of stream, as CPython's
save_unconsumed_input() does since gh-158169. CPython releases before
that change report the bytes after the stream in unconsumed_tail, so
the snippet accepts either value when it runs under CPython.

Fixes #8906

Assisted-by: Claude Code:claude-opus-5-5

* zlib: bind the unconsumed input with an if expression

Choose between the empty slice and the rest of the input in one
expression instead of reassigning a mutable binding.

Assisted-by: Claude Code:claude-opus-5-5

* zlib: accept both valid tails in the snippet on every implementation

The snippet no longer branches on sys.implementation. RustPython and
CPython after gh-158169 give an empty tail; CPython before it gives the
bytes after the stream. The stale tail of gh-8906 fails either way.

Assisted-by: Claude Code:claude-opus-5-5
This commit is contained in:
Jean-Marc Le Roux authored and GitHub committed 2026-09-30 22:15:40 +09:00
1 parent e8919b9632
commit 3805038dec
2 files changed
+45 -11

No files matched your search

+28 -11
View File
@@ -670,17 +670,16 @@ impl Decompressor {
};
let consumed = (d.total_in() - prev_in) as usize;
// save unused input
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();
}
// save unused input: past the end of the stream it is unused_data and
// unconsumed_tail is emptied, otherwise it is the new unconsumed_tail
let unconsumed: &[u8] = if stream_end {
unused_data.extend_from_slice(&data[consumed..]);
&[]
} else {
&data[consumed..]
};
unconsumed_tail.clear();
unconsumed_tail.extend_from_slice(unconsumed);
Ok((ret, stream_end))
}
@@ -1005,4 +1004,22 @@ mod tests {
assert_eq!(d.decompress(&encoded, None).unwrap(), b"later input");
assert!(d.eof());
}
#[test]
fn stream_end_clears_unconsumed_tail() {
let source = b"x".repeat(20000);
let data = [compress(&source, -1, MAX_WBITS).unwrap(), b"NEXT".to_vec()].concat();
let mut d = Decompressor::new(MAX_WBITS, None).unwrap();
let first = d.decompress(&data, Some(100)).unwrap();
assert!(!d.eof());
let tail = d.unconsumed_tail().to_vec();
let rest = d.decompress(&tail, Some(100_000)).unwrap();
assert!(d.eof());
assert_eq!([first, rest].concat(), source);
assert_eq!(d.unconsumed_tail(), b"");
assert_eq!(d.unused_data(), b"NEXT");
assert_eq!(d.flush(DEF_BUF_SIZE).unwrap(), b"");
assert_eq!(d.unconsumed_tail(), b"");
assert_eq!(d.unused_data(), b"NEXT");
}
}
+17
View File
@@ -62,3 +62,20 @@ for text in compressed_lorem_list:
assert_raises(zlib.error, lambda: zlib.compress(b"123", -40))
assert_raises(zlib.error, lambda: zlib.compress(b"123", 10))
# A max_length-bounded call that reaches the end of the stream keeps no
# stale input in unconsumed_tail (gh-8906). The tail is empty, or, as in
# CPython before gh-158169, the bytes after the stream.
source = b"x" * 20000
d = zlib.decompressobj()
out = d.decompress(zlib.compress(source) + b"NEXT", 100)
assert not d.eof
out += d.decompress(d.unconsumed_tail, 100000)
assert d.eof
assert out == source
assert d.unused_data == b"NEXT"
tail = d.unconsumed_tail
assert tail in (b"", b"NEXT")
assert d.flush() == b""
assert d.unconsumed_tail == tail
assert d.unused_data == b"NEXT" + tail