//! Seeded fuzz targets for the daemon's wire protocol, the bytes a local
//! client controls entirely.
//!
//! `daemon_protocol_garbage` feeds random and mutated request streams through
//! `readRequest` and `dispatch` against a stand-in backend. The framing rule
//! under test is the one the socket server relies on to stay in step with its
//! client: each request consumes its header line and exactly the trailer bytes
//! that header declared, so the reader's position after a request is the
//! header's length plus the declared sizes, never more; and a header whose
//! sizes cannot be read consumes nothing past its line. Replies must be one
//! JSON line each, whatever came in.
//!
//! `daemon_protocol_round_trip` builds random requests, writes them as a
//! client does, and requires `parseHeader` to read back the same request.
//!
//! Registered in fuzz_tests.zig; fixed-seed smoke tests at the foot run the
//! same functions under `zig build test`.
const std = @import("std");
const zit = @import("zit");
const protocol = zit.daemon_protocol;
const feed = zit.feed;
const checkin = zit.checkin;
/// The outcomes a garbage run must reach to be testing anything: a request
/// that dispatches, one refused with its trailers still skippable, one whose
/// framing is lost, and a stream that ends inside a request.
const Reached = struct {
dispatched: bool = false,
refused: bool = false,
framing_lost: bool = false,
truncated: bool = false,
};
/// Rounds below this are an inner-loop smoke run, too short to require every
/// outcome; the coverage check applies at or above it.
const min_rounds_for_coverage: usize = 256;
const max_garbage: usize = 512;
pub fn garbage(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
var reached: Reached = .{};
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
var round: usize = 0;
while (round < rounds) : (round += 1) {
_ = arena_state.reset(.retain_capacity);
const a = arena_state.allocator();
const input = try stream(a, rand);
try drive(a, input, .{ .max_file_bytes = rand.uintAtMost(u64, 32), .max_request_bytes = rand.uintAtMost(u64, 96) }, &reached);
}
if (rounds < min_rounds_for_coverage) return;
inline for (@typeInfo(Reached).@"struct".field_names) |name| {
if (!@field(reached, name)) {
std.debug.print("daemon_protocol_garbage: no round reached '{s}' in {d} rounds\n", .{ name, rounds });
return error.OutcomeUnreached;
}
}
}
/// Read requests off `input` until the stream ends or is lost, checking the
/// framing rule after each, and answer each one.
fn drive(a: std.mem.Allocator, input: []const u8, limits: protocol.Limits, reached: *Reached) !void {
const max_file_bytes = limits.max_file_bytes;
var reader: std.Io.Reader = .fixed(input);
var fake: Fake = .{};
var requests: usize = 0;
// Each request consumes at least its newline, so the input bounds this.
while (requests <= input.len) : (requests += 1) {
const start = reader.seek;
var progress: protocol.Progress = .{};
const incoming = protocol.readRequest(&reader, a, limits, &progress) catch |e| switch (e) {
error.Closed => return,
error.Truncated => {
reached.truncated = true;
return;
},
error.HeaderTooLarge, error.ReadFailed => return,
error.OutOfMemory => return error.OutOfMemory,
};
const line_end = std.mem.indexOfScalarPos(u8, input, start, '\n').?;
const header_len = line_end + 1 - start;
var declared: u64 = 0;
if (incoming.header.trailers) |sizes| {
try std.testing.expectEqual(sizes.len, incoming.trailers.len);
for (sizes, incoming.trailers) |size, t| {
declared += size;
if (t) |bytes| {
try std.testing.expectEqual(size, bytes.len);
try std.testing.expect(size <= max_file_bytes);
} else try std.testing.expect(size > max_file_bytes);
}
} else try std.testing.expectEqual(@as(usize, 0), incoming.trailers.len);
try std.testing.expectEqual(@as(u64, header_len) + declared, @as(u64, reader.seek - start));
try std.testing.expectEqual(declared, progress.bytes.load(.monotonic));
try std.testing.expect(!progress.in_trailers.load(.monotonic));
var out: std.Io.Writer.Allocating = .init(a);
try protocol.dispatch(a, fake.backend(), &incoming, limits, &out.writer);
try expectReplyLine(a, out.written());
switch (incoming.header.body) {
.request => reached.dispatched = true,
.fault => if (incoming.header.trailers != null) {
reached.refused = true;
},
}
if (incoming.header.trailers == null) {
reached.framing_lost = true;
return;
}
}
return error.RequestLoopUnbounded;
}
/// A reply begins with one JSON object on one line carrying `id` and `ok`;
/// whatever follows the newline is a `get`'s raw bytes.
fn expectReplyLine(a: std.mem.Allocator, reply: []const u8) !void {
const nl = std.mem.indexOfScalar(u8, reply, '\n') orelse return error.ReplyWithoutNewline;
const v = try std.json.parseFromSliceLeaky(std.json.Value, a, reply[0..nl], .{});
try std.testing.expect(v == .object);
try std.testing.expect(v.object.get("id") != null);
try std.testing.expect(v.object.get("ok").? == .bool);
}
/// A stand-in store: commits succeed, gets find what is asked for unless it
/// is the all-zero name, reads answer empty.
const Fake = struct {
fn backend(self: *Fake) protocol.Backend {
return .{ .ctx = self, .vtable = &.{ .hello = hello, .commit = commit, .feed = feedFn, .get = get, .status = status, .lookup = lookup } };
}
fn hello(_: *anyopaque, _: std.mem.Allocator) protocol.Answer(protocol.Hello) {
return .{ .ok = .{ .zit = "0", .store = "/s\xff", .tip = .{ .branches = &.{}, .cursor = "0" } } };
}
fn commit(_: *anyopaque, _: std.mem.Allocator, works: []const protocol.CommitWork, results: []protocol.ItemResult) void {
std.debug.assert(works.len == results.len);
for (results) |*r| r.* = .{ .ok = .{ .outcome = .recorded, .hash = "h", .parent = null, .branch = "trunk", .forked = false } };
}
fn feedFn(_: *anyopaque, _: std.mem.Allocator, cursor: []const u8) protocol.Answer(feed.Feed) {
return .{ .ok = .{ .cursor = cursor, .changes = &.{}, .forks = &.{} } };
}
fn get(_: *anyopaque, _: std.mem.Allocator, uuid: []const u8) protocol.Answer([]const u8) {
if (uuid.len == 0) return .{ .fault = .{ .code = "not_found", .exit = 1, .message = "" } };
return .{ .ok = uuid };
}
fn status(_: *anyopaque, _: std.mem.Allocator) protocol.Answer(feed.Status) {
return .{ .ok = .{ .branches = &.{}, .cursor = "0" } };
}
fn lookup(_: *anyopaque, arena: std.mem.Allocator, queries: []const protocol.Lookup) protocol.Answer([]const ?protocol.Found) {
const found = arena.alloc(?protocol.Found, queries.len) catch return .{ .fault = .{ .code = "out_of_memory", .exit = 1, .message = "" } };
for (queries, found) |q, *f| f.* = if (q.user.len % 2 == 0) null else .{ .hash = "h", .parent = q.parent, .branch = q.branch orelse "trunk\xff", .forked = q.comment.len % 2 == 0 };
return .{ .ok = found };
}
};
/// One input stream: random bytes, or valid requests (with their trailers)
/// mutated, concatenated, or cut short.
fn stream(a: std.mem.Allocator, rand: std.Random) ![]const u8 {
switch (rand.uintLessThan(u8, 4)) {
0 => {
const buf = try a.alloc(u8, rand.uintAtMost(usize, max_garbage));
fillRandom(rand, buf);
return buf;
},
else => {
var out: std.ArrayList(u8) = .empty;
const n = 1 + rand.uintLessThan(usize, 4);
for (0..n) |_| {
var aw: std.Io.Writer.Allocating = .init(a);
const req = try randomRequest(a, rand);
try protocol.writeRequest(&aw.writer, req);
try out.appendSlice(a, aw.written());
try appendTrailers(a, rand, &out, req);
}
mutate(rand, &out);
if (rand.uintLessThan(u8, 4) == 0 and out.items.len != 0) out.shrinkRetainingCapacity(rand.uintLessThan(usize, out.items.len));
return out.items;
},
}
}
/// The trailers a request declares, of the declared length, except now and
/// then one byte short or long, which the next read must make sense of.
fn appendTrailers(a: std.mem.Allocator, rand: std.Random, out: *std.ArrayList(u8), req: protocol.Request) !void {
const commits: []const protocol.Commit = switch (req.op) {
.commit => |*c| c[0..1],
.batch => |cs| cs,
else => &.{},
};
for (commits) |c| for (c.edits) |e| if (e == .put) {
var len: usize = @intCast(e.put.size);
if (rand.uintLessThan(u8, 16) == 0) len = if (rand.boolean() and len > 0) len - 1 else len + 1;
const start = out.items.len;
try out.resize(a, start + len);
rand.bytes(out.items[start..]);
};
}
fn mutate(rand: std.Random, out: *std.ArrayList(u8)) void {
if (out.items.len == 0) return;
const flips = rand.uintLessThan(usize, 3);
for (0..flips) |_| {
const at = rand.uintLessThan(usize, out.items.len);
out.items[at] = switch (rand.uintLessThan(u8, 4)) {
0 => '\n',
1 => '"',
2 => '9',
else => rand.int(u8),
};
}
}
fn fillRandom(rand: std.Random, buf: []u8) void {
const interesting = "{}[]\":,\n0123456789-op\\ ";
for (buf) |*b| {
b.* = switch (rand.uintLessThan(u8, 3)) {
0, 1 => interesting[rand.uintLessThan(usize, interesting.len)],
else => rand.int(u8),
};
}
}
pub fn roundTrip(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
var arena_state = std.heap.ArenaAllocator.init(gpa);
defer arena_state.deinit();
var round: usize = 0;
while (round < rounds) : (round += 1) {
_ = arena_state.reset(.retain_capacity);
const a = arena_state.allocator();
const req = try randomRequest(a, rand);
var aw: std.Io.Writer.Allocating = .init(a);
try protocol.writeRequest(&aw.writer, req);
const line = aw.written();
try std.testing.expectEqual(@as(u8, '\n'), line[line.len - 1]);
try std.testing.expect(std.mem.indexOfScalar(u8, line[0 .. line.len - 1], '\n') == null);
const header = try protocol.parseHeader(a, line[0 .. line.len - 1]);
const back = switch (header.body) {
.request => |r| r,
.fault => |f| {
std.debug.print("daemon_protocol_round_trip: refused its own encoding: {s}\n{s}\n", .{ f.message, line });
return error.RoundTripRefused;
},
};
if (!protocol.eqlRequest(req, back)) {
std.debug.print("daemon_protocol_round_trip: read back a different request from\n{s}\n", .{line});
return error.RoundTripChanged;
}
}
}
fn randomRequest(a: std.mem.Allocator, rand: std.Random) !protocol.Request {
const id: ?i64 = if (rand.uintLessThan(u8, 8) == 0) null else switch (rand.uintLessThan(u8, 3)) {
0 => rand.int(i64),
else => @as(i64, @intCast(rand.uintLessThan(u64, 1000))),
};
const op: protocol.Op = switch (rand.uintLessThan(u8, 8)) {
0 => .{ .hello = if (rand.boolean()) protocol.version else rand.int(i64) },
1, 2 => .{ .commit = try randomCommit(a, rand) },
3 => blk: {
const commits = try a.alloc(protocol.Commit, rand.uintLessThan(usize, 4));
for (commits) |*c| c.* = try randomCommit(a, rand);
break :blk .{ .batch = commits };
},
4 => .{ .feed = try randomText(a, rand) },
5 => if (rand.boolean()) .{ .get = .{ .one = try randomText(a, rand) } } else blk: {
const names = try a.alloc([]const u8, rand.uintLessThan(usize, 4));
for (names) |*n| n.* = try randomText(a, rand);
break :blk .{ .get = .{ .many = names } };
},
6 => .status,
else => if (rand.boolean()) .{ .lookup = .{ .one = try randomLookup(a, rand) } } else blk: {
const items = try a.alloc(protocol.Lookup, rand.uintLessThan(usize, 4));
for (items) |*q| q.* = try randomLookup(a, rand);
break :blk .{ .lookup = .{ .many = items } };
},
};
return .{ .id = id, .op = op };
}
fn randomLookup(a: std.mem.Allocator, rand: std.Random) !protocol.Lookup {
const c = try randomCommit(a, rand);
return .{ .time = c.time, .user = c.user, .comment = c.comment, .parent = c.parent, .branch = c.branch };
}
fn randomCommit(a: std.mem.Allocator, rand: std.Random) !protocol.Commit {
const edits = try a.alloc(protocol.Edit, rand.uintLessThan(usize, 5));
for (edits) |*e| e.* = switch (rand.uintLessThan(u8, 3)) {
0 => .{ .delete = try randomText(a, rand) },
1 => .{ .rename = .{ .old = try randomText(a, rand), .new = try randomText(a, rand) } },
else => .{ .put = .{
.name = try randomText(a, rand),
.size = rand.uintLessThan(u64, 24),
.hash = if (rand.boolean()) try randomHex(a, rand) else null,
} },
};
return .{
.key = if (rand.boolean()) try randomText(a, rand) else null,
.parent = if (rand.boolean()) try randomText(a, rand) else null,
.branch = if (rand.uintLessThan(u8, 4) == 0) try randomText(a, rand) else null,
.user = try randomText(a, rand),
.comment = try randomText(a, rand),
.time = .{
.epoch = if (rand.boolean()) @as(i64, @intCast(rand.uintAtMost(u64, @intCast(zit.artifact.max_writer_epoch)))) else 1_700_000_000 + @as(i64, @intCast(rand.uintLessThan(u64, 1 << 30))),
.ms = rand.uintAtMost(u16, 999),
},
.allow_fork = rand.boolean(),
.no_checks = rand.boolean(),
.edits = edits,
};
}
/// Valid UTF-8 that exercises every JSON escape: quotes, backslashes,
/// control bytes and multi-byte characters.
fn randomText(a: std.mem.Allocator, rand: std.Random) ![]const u8 {
const pieces = [_][]const u8{ "a", "z", "/", ".", " ", "\"", "\\", "\n", "\t", "\x00", "\x1f", "\u{e9}", "\u{2028}", "\u{1f600}", "0", "{", "}" };
var out: std.ArrayList(u8) = .empty;
const n = rand.uintLessThan(usize, 12);
for (0..n) |_| try out.appendSlice(a, pieces[rand.uintLessThan(usize, pieces.len)]);
return out.items;
}
fn randomHex(a: std.mem.Allocator, rand: std.Random) ![]const u8 {
var digest: [32]u8 = undefined;
rand.bytes(&digest);
const hex = std.fmt.bytesToHex(digest, .lower);
return a.dupe(u8, &hex);
}
test "daemon protocol fuzz: garbage and mutated request streams keep the framing rule (smoke)" {
try garbage(std.testing.allocator, 0xd43e, 512);
}
test "daemon protocol fuzz: every request a client writes reads back unchanged (smoke)" {
try roundTrip(std.testing.allocator, 0xd43f, 512);
}