From 195e301c40de1f39b023c99730d6e9e6aeb594fd Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Thu, 24 Sep 2026 20:05:04 +0200 Subject: [PATCH] Report truncated documents as EndOfStream A document that ends before its value is complete now fails with error.EndOfStream, matching msgpack.zig, instead of UnexpectedEndOfInput. Literals, numbers and surrogate pairs cut off at the end used to surface as UnexpectedToken, InvalidNumber or InvalidSurrogatePair; they are now EndOfStream too. The slice functions return SliceDecodeError, which leaves out ReadFailed, since a fixed reader has nothing underneath it to fail. --- CHANGELOG.md | 6 ++++++ README.md | 5 +++++ src/decode.zig | 37 +++++++++++++++++++++++++++---------- src/json.zig | 21 +++++++++++++++------ src/test.zig | 31 +++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d8ba2..f342ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- A truncated document is now `error.EndOfStream` instead of `error.UnexpectedEndOfInput`, including a literal, number or surrogate pair cut off at the end. The slice functions return the new `SliceDecodeError`, which does not include `ReadFailed`. + ## [0.1.0] - 2026-09-16 Initial release. diff --git a/README.md b/README.md index bf3bc08..f320d67 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ The reader's buffer may be far smaller than the values being decoded. Strings, numbers and skipped values are all consumed incrementally, so a 1 MB string decodes fine through a 64-byte buffer. The buffer must hold at least 8 bytes. +A document that ends before its value is complete is `error.EndOfStream`, +whether it came from a slice or a reader. `error.ReadFailed` means the reader +itself failed, and the reader's own error field says why. The slice functions +return `json.SliceDecodeError`, which leaves `ReadFailed` out. + ## Struct options By default a struct is an object keyed by field name, and optional fields that diff --git a/src/decode.zig b/src/decode.zig index 2a5259a..56f6dbd 100644 --- a/src/decode.zig +++ b/src/decode.zig @@ -15,11 +15,13 @@ const Allocator = std.mem.Allocator; const json = @import("json.zig"); const escapedLiteral = @import("encode.zig").escapedLiteral; -pub const DecodeError = error{ +/// Errors from decoding a complete buffer, which can only turn out too short, +/// never fail to read. +pub const SliceDecodeError = error{ /// A byte appeared where the grammar does not allow it. UnexpectedToken, - /// The document ended in the middle of a value. - UnexpectedEndOfInput, + /// The document ended before the value was complete. + EndOfStream, /// Trailing content after the top-level value. TrailingData, InvalidNumber, @@ -54,10 +56,14 @@ pub const DecodeError = error{ UnknownUnionVariant, /// An `as_tagged` union object did not start with its tag field. MissingUnionTag, - ReadFailed, OutOfMemory, }; +pub const DecodeError = SliceDecodeError || error{ + /// The reader failed; the error it recorded says why. + ReadFailed, +}; + /// Widest mantissa and decimal exponent for which Clinger's exact path holds: /// both the mantissa and 10^|exp| must be exactly representable, so the single /// multiply (or divide) rounds once and is therefore correctly rounded. @@ -261,10 +267,10 @@ pub const Decoder = struct { // ------------------------------------------------------------ scanning /// Errors from the reader, mapped onto our set. `EndOfStream` in the - /// middle of a value is malformed input, not a read failure. + /// middle of a value means the document was cut short. inline fn mapRead(err: Reader.Error) DecodeError { return switch (err) { - error.EndOfStream => error.UnexpectedEndOfInput, + error.EndOfStream => error.EndOfStream, error.ReadFailed => error.ReadFailed, }; } @@ -366,7 +372,10 @@ pub const Decoder = struct { fn expectLiteralSlow(d: *Decoder, comptime lit: []const u8) DecodeError!bool { const window = d.reader.peek(lit.len) catch |err| switch (err) { - error.EndOfStream => return false, + error.EndOfStream => { + if (std.mem.startsWith(u8, lit, d.reader.buffered())) return error.EndOfStream; + return false; + }, error.ReadFailed => return error.ReadFailed, }; if (!std.mem.eql(u8, window[0..lit.len], lit)) return false; @@ -473,8 +482,13 @@ pub const Decoder = struct { } d.reader.fillMore() catch |err| switch (err) { error.EndOfStream => { - if (len == 0) return error.UnexpectedEndOfInput; - return buf[0..len]; + // No number ends in a sign, a point or an exponent + // marker, so one that stops there was cut short. + if (len == 0) return error.EndOfStream; + switch (buf[len - 1]) { + '-', '+', '.', 'e', 'E' => return error.EndOfStream, + else => return buf[0..len], + } }, error.ReadFailed => return error.ReadFailed, }; @@ -761,7 +775,10 @@ pub const Decoder = struct { var code: u21 = first; if (first >= 0xd800 and first <= 0xdbff) { const pair = d.reader.peek(2) catch |err| switch (err) { - error.EndOfStream => return error.InvalidSurrogatePair, + error.EndOfStream => { + if (std.mem.startsWith(u8, "\\u", d.reader.buffered())) return error.EndOfStream; + return error.InvalidSurrogatePair; + }, error.ReadFailed => return error.ReadFailed, }; if (pair[0] != '\\' or pair[1] != 'u') return error.InvalidSurrogatePair; diff --git a/src/json.zig b/src/json.zig index c6642eb..2a6b7c2 100644 --- a/src/json.zig +++ b/src/json.zig @@ -15,6 +15,7 @@ pub const EncodeError = encode_mod.EncodeError; pub const Encoder = encode_mod.Encoder; pub const DecodeError = decode_mod.DecodeError; +pub const SliceDecodeError = decode_mod.SliceDecodeError; pub const DecodeOptions = decode_mod.DecodeOptions; pub const Decoder = decode_mod.Decoder; @@ -162,9 +163,9 @@ pub fn validate(reader: *std.Io.Reader) DecodeError!void { try decoder.endOfDocument(); } -pub fn validateFromSlice(bytes: []const u8) DecodeError!void { +pub fn validateFromSlice(bytes: []const u8) SliceDecodeError!void { var reader: std.Io.Reader = .fixed(bytes); - return validate(&reader); + return validate(&reader) catch |err| sliceError(err); } /// Validation never keeps anything, so it never needs to allocate. Handing it @@ -188,9 +189,9 @@ pub fn decodeFromSlice( gpa: std.mem.Allocator, bytes: []const u8, options: DecodeOptions, -) (DecodeError || std.mem.Allocator.Error)!Decoded(T) { +) SliceDecodeError!Decoded(T) { var reader: std.Io.Reader = .fixed(bytes); - return decode(T, gpa, &reader, options); + return decode(T, gpa, &reader, options) catch |err| sliceError(err); } pub fn decodeFromSliceLeaky( @@ -198,9 +199,17 @@ pub fn decodeFromSliceLeaky( gpa: std.mem.Allocator, bytes: []const u8, options: DecodeOptions, -) DecodeError!T { +) SliceDecodeError!T { var reader: std.Io.Reader = .fixed(bytes); - return decodeLeaky(T, gpa, &reader, options); + return decodeLeaky(T, gpa, &reader, options) catch |err| sliceError(err); +} + +/// A fixed reader has nothing underneath it to fail. +fn sliceError(err: DecodeError) SliceDecodeError { + return switch (err) { + error.ReadFailed => unreachable, + else => |e| e, + }; } test { diff --git a/src/test.zig b/src/test.zig index e16ece8..a1bab44 100644 --- a/src/test.zig +++ b/src/test.zig @@ -1160,3 +1160,34 @@ test "a repeated field is an error" { const n = try json.decodeFromSliceLeaky(Nested, alloc, "{\"inner\":{\"a\":1,\"b\":2},\"other\":{\"a\":3,\"b\":4}}", .{}); try testing.expectEqual(@as(u8, 3), n.other.a); } + +test "every truncation of a document is EndOfStream" { + const T = struct { + flag: bool, + none: ?u32, + n: i64, + x: f64, + s: []const u8, + list: []const u32, + }; + const doc = + \\{"flag":false,"none":null,"n":-123,"x":1.5e3, + \\"s":"a\"b\u00e9\ud83d\ude00 é","list":[1,2,3]} + ; + var buf: [4096]u8 = undefined; + for (0..doc.len) |len| { + var fba: std.heap.FixedBufferAllocator = .init(&buf); + try testing.expectError(error.EndOfStream, json.decodeFromSliceLeaky(T, fba.allocator(), doc[0..len], .{})); + try testing.expectError(error.EndOfStream, json.validateFromSlice(doc[0..len])); + + var rbuf: [8]u8 = undefined; + var trickle: TrickleReader = .init(&rbuf, doc[0..len]); + fba.reset(); + try testing.expectError(error.EndOfStream, json.decodeLeaky(T, fba.allocator(), &trickle.reader, .{})); + } + + // Input that is wrong before it runs out keeps its own error. + try testing.expectError(error.UnexpectedToken, json.validateFromSlice("tx")); + try testing.expectError(error.InvalidSurrogatePair, json.validateFromSlice("\"\\ud83dx")); + try testing.expectError(error.InvalidNumber, json.validateFromSlice("01")); +}