Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 27 additions & 10 deletions src/decode.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 15 additions & 6 deletions src/json.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -188,19 +189,27 @@ 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(
comptime T: type,
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 {
Expand Down
31 changes: 31 additions & 0 deletions src/test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Loading