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")); +}