From 78022c0e5c926ff8546d1a552a016db1eafb0813 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Thu, 24 Sep 2026 20:00:58 +0200 Subject: [PATCH 1/2] Fail decoding with explicit error sets decode and decodeLeaky returned inferred error sets, made of whatever the reader and each type's unpacker happened to produce. They now fail with DecodeError, and the slice functions with SliceDecodeError, which leaves out the reader's failures: a slice cannot fail to read, and a value too big for its buffer, which is the whole slice, is a slice that ends too soon. That last one used to come back as ReaderBufferTooSmall when a slice ended inside an integer wider than what was left of it. --- src/msgpack.zig | 107 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/src/msgpack.zig b/src/msgpack.zig index 45ddfe9..1186bb2 100644 --- a/src/msgpack.zig +++ b/src/msgpack.zig @@ -248,7 +248,40 @@ pub fn encode(value: anytype, writer: *std.Io.Writer) !void { pub const Decoded = std.json.Parsed; -pub fn decode(comptime T: type, allocator: Allocator, reader: *std.Io.Reader) !Decoded(T) { +/// What decoding a message from a slice can fail with. +pub const SliceDecodeError = error{ + /// The message was not all there: the input ended before it did, or + /// before it began. + EndOfStream, + /// Bytes that are not MessagePack, or not the kind of value the + /// destination type expects. + InvalidFormat, + /// A number that does not fit the destination type. + IntegerOverflow, + /// A nil where the destination type is not optional. + Null, + InvalidEnumTag, + UnknownStructField, + MissingStructFields, + UnknownUnionField, + InvalidUnionFieldCount, + InvalidTaggedUnionFieldCount, + InvalidTagField, + /// An `.as_tagged` union variant that is neither `void` nor a struct. + TaggedUnionUnsupportedFieldType, + OutOfMemory, +}; + +/// What decoding a message from a reader can fail with: everything decoding +/// from a slice can, and the reader's own failures. +pub const DecodeError = SliceDecodeError || error{ + /// The reader failed, and its own error says why. + ReadFailed, + /// A value had to be buffered whole, and the reader's buffer is smaller. + ReaderBufferTooSmall, +}; + +pub fn decode(comptime T: type, allocator: Allocator, reader: *std.Io.Reader) DecodeError!Decoded(T) { var parsed = Decoded(T){ .arena = try allocator.create(ArenaAllocator), .value = undefined, @@ -262,18 +295,28 @@ pub fn decode(comptime T: type, allocator: Allocator, reader: *std.Io.Reader) !D return parsed; } -pub fn decodeLeaky(comptime T: type, allocator: ?Allocator, reader: *std.Io.Reader) !T { +pub fn decodeLeaky(comptime T: type, allocator: ?Allocator, reader: *std.Io.Reader) DecodeError!T { return try unpacker(reader, allocator).read(T); } -pub fn decodeFromSlice(comptime T: type, allocator: Allocator, data: []const u8) !Decoded(T) { +pub fn decodeFromSlice(comptime T: type, allocator: Allocator, data: []const u8) SliceDecodeError!Decoded(T) { var reader = std.Io.Reader.fixed(data); - return try decode(T, allocator, &reader); + return decode(T, allocator, &reader) catch |err| return sliceError(err); } -pub fn decodeFromSliceLeaky(comptime T: type, allocator: ?Allocator, data: []const u8) !T { +pub fn decodeFromSliceLeaky(comptime T: type, allocator: ?Allocator, data: []const u8) SliceDecodeError!T { var reader = std.Io.Reader.fixed(data); - return try decodeLeaky(T, allocator, &reader); + return decodeLeaky(T, allocator, &reader) catch |err| return sliceError(err); +} + +/// A reader over a slice cannot fail, and its buffer is the whole slice, so +/// a value too big for the buffer is a slice that ends before the value does. +fn sliceError(err: DecodeError) SliceDecodeError { + return switch (err) { + error.ReadFailed => unreachable, + error.ReaderBufferTooSmall => error.EndOfStream, + else => |e| e, + }; } test { @@ -323,6 +366,58 @@ fn encodeToBuffer(value: anytype, buffer: []u8) ![]const u8 { return writer.buffered(); } +test "a slice that ends before its message is EndOfStream" { + const Message = struct { id: u64, name: []const u8 }; + var buffer: [64]u8 = undefined; + const encoded = try encodeToBuffer(Message{ .id = 0x1_0000_0000, .name = "hello" }, &buffer); + + try std.testing.expectError(error.EndOfStream, decodeFromSliceLeaky(Message, std.testing.allocator, "")); + // Cut inside the name, and inside the id. + for ([_]usize{ encoded.len - 2, 8 }) |len| { + try std.testing.expectError(error.EndOfStream, decodeFromSliceLeaky(Message, std.testing.allocator, encoded[0..len])); + try std.testing.expectError(error.EndOfStream, decodeFromSlice(Message, std.testing.allocator, encoded[0..len])); + } + var int_buffer: [16]u8 = undefined; + // A slice shorter than the integer it is cut inside used to be reported + // as the reader's buffer being too small, when the buffer of a slice is + // the whole slice. + const int = try encodeToBuffer(@as(u64, 0x1_0000_0000), &int_buffer); + try std.testing.expectError(error.EndOfStream, decodeFromSliceLeaky(u64, null, int[0..5])); +} + +test "a reader that fails is ReadFailed, not a malformed message" { + var buffer: [16]u8 = undefined; + var reader: std.Io.Reader = .{ .vtable = std.Io.Reader.failing.vtable, .buffer = &buffer, .seek = 0, .end = 0 }; + try std.testing.expectError(error.ReadFailed, decodeLeaky(u32, null, &reader)); +} + +test "reading a sequence of messages: a clean end, and a message cut short" { + const Message = struct { a: u8 }; + // Two whole messages, then the first two bytes of a third. + const stream = "\x81\xa1a\x01\x81\xa1a\x02\x81\xa1"; + + for ([_][]const u8{ stream[0..8], stream }) |data| { + var reader = std.Io.Reader.fixed(data); + var count: usize = 0; + const result: anyerror!void = while (true) { + // Nothing left before a message begins: the sequence is over. + _ = reader.peekByte() catch |err| switch (err) { + error.EndOfStream => break {}, + else => |e| break e, + }; + // From here a message has begun, and has to be all there. + _ = decodeLeaky(Message, null, &reader) catch |err| break err; + count += 1; + }; + try std.testing.expectEqual(2, count); + if (data.len == 8) { + try result; + } else { + try std.testing.expectError(error.EndOfStream, result); + } + } +} + test "decode from a streaming reader whose buffer holds the largest value" { const Numeric = struct { id: u64, seq: i32, ratio: f64, ok: bool }; const value = Numeric{ .id = 0xdead_beef_cafe_1234, .seq = -4242, .ratio = 3.14159, .ok = true }; From 75a66516fa6846e36b58634ac36a8e1e7305ab1a Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 22:18:04 +0200 Subject: [PATCH 2/2] fix: decode recursive types within DecodeError Recursion through a pointer to a struct or union went through a function returning anyerror, to break the inferred error set's dependency on itself (#53). anyerror does not coerce to the explicit DecodeError, so decoding a recursive type no longer compiled. Return DecodeError there instead; the pack side has no explicit set yet and keeps anyerror. The existing recursive test went through unpackAny directly, so add one through decodeFromSlice. --- src/any.zig | 6 ++++-- src/msgpack.zig | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/any.zig b/src/any.zig index 556b915..5c52485 100644 --- a/src/any.zig +++ b/src/any.zig @@ -24,6 +24,7 @@ const unpackArray = @import("array.zig").unpackArray; const unpackFixedArray = @import("array.zig").unpackFixedArray; const unpackNullIfPresent = @import("null.zig").unpackNullIfPresent; +const DecodeError = @import("msgpack.zig").DecodeError; const isOptional = @import("utils.zig").isOptional; const packStruct = @import("struct.zig").packStruct; @@ -136,7 +137,8 @@ pub fn unpackAny(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime /// A struct or union reached through a pointer may be recursive, holding a /// pointer to its own type. Its inferred error set would then depend on itself, /// which Zig rejects as a dependency loop, so the recursion through such a -/// pointer goes through a function with an explicit error set. +/// pointer goes through a function with an explicit error set: `DecodeError` +/// for unpacking, and `anyerror` for packing, which has no explicit set yet. fn isContainer(comptime T: type) bool { return switch (@typeInfo(T)) { .@"struct", .@"union" => true, @@ -148,7 +150,7 @@ fn packPointee(writer: *std.Io.Writer, value: anytype) anyerror!void { return packAny(writer, value.*); } -fn unpackPointerToContainer(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime T: type) anyerror!T { +fn unpackPointerToContainer(reader: *std.Io.Reader, allocator: std.mem.Allocator, comptime T: type) DecodeError!T { return unpackPointer(reader, allocator, T); } diff --git a/src/msgpack.zig b/src/msgpack.zig index c52cf66..da693ca 100644 --- a/src/msgpack.zig +++ b/src/msgpack.zig @@ -419,6 +419,23 @@ test "reading a sequence of messages: a clean end, and a message cut short" { } } +test "decode a recursive type" { + const Node = struct { + value: u32, + next: ?*@This() = null, + }; + var tail: Node = .{ .value = 2 }; + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try encode(Node{ .value = 1, .next = &tail }, &aw.writer); + + const decoded = try decodeFromSlice(Node, std.testing.allocator, aw.written()); + defer decoded.deinit(); + try std.testing.expectEqual(1, decoded.value.value); + try std.testing.expectEqual(2, decoded.value.next.?.value); + try std.testing.expectEqual(null, decoded.value.next.?.next); +} + test "decode from a streaming reader whose buffer holds the largest value" { const Numeric = struct { id: u64, seq: i32, ratio: f64, ok: bool }; const value = Numeric{ .id = 0xdead_beef_cafe_1234, .seq = -4242, .ratio = 3.14159, .ok = true };