Files
bun.sh/src/http/Decompressor.zig
robobun 72490281e5 fix: handle empty chunked gzip responses correctly (#22360)
## Summary
Fixes #18413 - Empty chunked gzip responses were causing `Decompression
error: ShortRead`

## The Issue
When a server sends an empty response with `Content-Encoding: gzip` and
`Transfer-Encoding: chunked`, Bun was throwing a `ShortRead` error. This
occurred because the code was checking if `avail_in == 0` (no input
data) and immediately returning an error, without attempting to
decompress what could be a valid empty gzip stream.

## The Fix
Instead of checking `avail_in == 0` before calling `inflate()`, we now:
1. Always call `inflate()` even when `avail_in == 0` 
2. Check the return code from `inflate()`
3. If it returns `BufError` with `avail_in == 0`, then we truly need
more data and return `ShortRead`
4. If it returns `StreamEnd`, it was a valid empty gzip stream and we
finish successfully

This approach correctly distinguishes between "no data yet" and "valid
empty gzip stream".

## Why This Works
- A valid empty gzip stream still has headers and trailers (~20 bytes)
- The zlib `inflate()` function can handle empty streams correctly  
- `BufError` with `avail_in == 0` specifically means "need more input
data"

## Test Plan
 Added regression test in `test/regression/issue/18413.test.ts`
covering:
- Empty chunked gzip response
- Empty non-chunked gzip response  
- Empty chunked response without gzip

 Verified all existing gzip-related tests still pass
 Tested with the original failing case from the issue

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-03 18:57:39 -07:00

123 lines
4.8 KiB
Zig

pub const Decompressor = union(enum) {
zlib: *Zlib.ZlibReaderArrayList,
brotli: *Brotli.BrotliReaderArrayList,
zstd: *zstd.ZstdReaderArrayList,
none: void,
pub fn deinit(this: *Decompressor) void {
switch (this.*) {
inline .brotli, .zlib, .zstd => |that| {
that.deinit();
this.* = .{ .none = {} };
},
.none => {},
}
}
pub fn updateBuffers(this: *Decompressor, encoding: Encoding, buffer: []const u8, body_out_str: *MutableString) !void {
if (!encoding.isCompressed()) {
return;
}
if (this.* == .none) {
switch (encoding) {
.gzip, .deflate => {
this.* = .{
.zlib = try Zlib.ZlibReaderArrayList.initWithOptionsAndListAllocator(
buffer,
&body_out_str.list,
body_out_str.allocator,
bun.http.default_allocator,
.{
// zlib.MAX_WBITS = 15
// to (de-)compress deflate format, use wbits = -zlib.MAX_WBITS
// to (de-)compress deflate format with headers we use wbits = 0 (we can detect the first byte using 120)
// to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16
.windowBits = if (encoding == Encoding.gzip) Zlib.MAX_WBITS | 16 else (if (buffer.len > 1 and buffer[0] == 120) 0 else -Zlib.MAX_WBITS),
},
),
};
return;
},
.brotli => {
this.* = .{
.brotli = try Brotli.BrotliReaderArrayList.newWithOptions(
buffer,
&body_out_str.list,
body_out_str.allocator,
.{},
),
};
return;
},
.zstd => {
this.* = .{
.zstd = try zstd.ZstdReaderArrayList.initWithListAllocator(
buffer,
&body_out_str.list,
body_out_str.allocator,
bun.http.default_allocator,
),
};
return;
},
else => @panic("Invalid encoding. This code should not be reachable"),
}
}
switch (this.*) {
.zlib => |reader| {
bun.assert(reader.zlib.avail_in == 0);
reader.zlib.next_in = buffer.ptr;
reader.zlib.avail_in = @as(u32, @truncate(buffer.len));
const initial = body_out_str.list.items.len;
body_out_str.list.expandToCapacity();
if (body_out_str.list.capacity == initial) {
try body_out_str.list.ensureUnusedCapacity(body_out_str.allocator, 4096);
body_out_str.list.expandToCapacity();
}
reader.list = body_out_str.list;
reader.zlib.next_out = @ptrCast(&body_out_str.list.items[initial]);
reader.zlib.avail_out = @as(u32, @truncate(body_out_str.list.capacity - initial));
// we reset the total out so we can track how much we decompressed this time
reader.zlib.total_out = @truncate(initial);
},
.brotli => |reader| {
reader.input = buffer;
reader.total_in = 0;
const initial = body_out_str.list.items.len;
reader.list = body_out_str.list;
reader.total_out = @truncate(initial);
},
.zstd => |reader| {
reader.input = buffer;
reader.total_in = 0;
const initial = body_out_str.list.items.len;
reader.list = body_out_str.list;
reader.total_out = @truncate(initial);
},
else => @panic("Invalid encoding. This code should not be reachable"),
}
}
pub fn readAll(this: *Decompressor, is_done: bool) !void {
switch (this.*) {
.zlib => |zlib| try zlib.readAll(is_done),
.brotli => |brotli| try brotli.readAll(is_done),
.zstd => |reader| try reader.readAll(is_done),
.none => {},
}
}
};
const Zlib = @import("../zlib.zig");
const Encoding = @import("./Encoding.zig").Encoding;
const bun = @import("bun");
const Brotli = bun.brotli;
const MutableString = bun.MutableString;
const zstd = bun.zstd;