From 1f96e090e2c7ff1aec310da306dc4c04fffc6ece Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 29 Apr 2026 22:33:50 +0800 Subject: [PATCH] fix: accept identifier-only stream as empty payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode and decodeFromReader rejected a stream that contained only the 10-byte stream-identifier chunk with FrameError.NotFramed, even though the Snappy framing spec treats it as a valid representation of an empty payload. Go's snappy.NewReader and Rust's snap::read::FrameDecoder both accept the same input and decode it to an empty slice; cross-client interop fixtures (e.g. leanSpec) emit exactly this 10-byte form for empty input. The terminal post-loop check in both decode paths now requires that both saw_stream_identifier and saw_data_chunk are unset to declare the input unframed. A stream with the identifier alone — and no data chunks — returns an empty slice. Adds two regression tests against the canonical 10-byte "\xff\x06\x00\x00sNaPpY" input (decode and decodeFromReader). The existing "frame roundtrip samples" test already covered round-tripping "" through the lib's own encoder, but the encoder appends an empty data chunk in finish(), which masked the gap on the decode side. Closes #7. --- src/frames.zig | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/frames.zig b/src/frames.zig index 0b758d7..d9d3861 100644 --- a/src/frames.zig +++ b/src/frames.zig @@ -212,7 +212,10 @@ pub fn decodeFromReader(allocator: Allocator, reader: anytype, writer: anytype) chunk_buf.clearRetainingCapacity(); } - if (!saw_data_chunk) return FrameError.NotFramed; + // A stream that contained the identifier but no data chunks is a valid + // empty payload per the Snappy framing spec. Only treat the input as + // unframed when neither was seen. + if (!saw_stream_identifier and !saw_data_chunk) return FrameError.NotFramed; } fn decodeFramed(allocator: Allocator, data: []const u8) ![]u8 { @@ -273,7 +276,10 @@ fn decodeFramed(allocator: Allocator, data: []const u8) ![]u8 { return FrameError.UnsupportedUnskippableChunkType; } - if (!saw_data_chunk) return FrameError.NotFramed; + // A stream that contained the identifier but no data chunks is a valid + // empty payload per the Snappy framing spec. Only treat the input as + // unframed when neither was seen. + if (!saw_stream_identifier and !saw_data_chunk) return FrameError.NotFramed; // Some producers may omit the identifier. Only enforce when data present with mismatched chunk. return output.toOwnedSlice(allocator); @@ -526,6 +532,33 @@ test "decode falls back to raw snappy payloads" { try std.testing.expectEqualSlices(u8, sample, decoded); } +test "decode accepts identifier-only stream as empty payload" { + // A frame containing only the stream identifier (no data chunks) is a + // valid empty payload per the Snappy framing spec. Go's snappy.NewReader + // and Rust's snap::read::FrameDecoder both decode this 10-byte input to + // an empty slice; cross-client interop fixtures (e.g. leanSpec) emit + // exactly this representation for empty input. + const allocator = std.testing.allocator; + const identifier_only = "\xff\x06\x00\x00sNaPpY"; + + const decoded = try decode(allocator, identifier_only); + defer allocator.free(decoded); + + try std.testing.expectEqual(@as(usize, 0), decoded.len); +} + +test "decodeFromReader accepts identifier-only stream as empty payload" { + const allocator = std.testing.allocator; + const identifier_only = "\xff\x06\x00\x00sNaPpY"; + + var input_stream = std.io.fixedBufferStream(identifier_only); + var output = std.ArrayListUnmanaged(u8).empty; + defer output.deinit(allocator); + + try decodeFromReader(allocator, input_stream.reader(), output.writer(allocator)); + try std.testing.expectEqual(@as(usize, 0), output.items.len); +} + test "decode rejects invalid stream identifier" { const allocator = std.testing.allocator; const sample = "identifier";