Skip to content
zit Artifact [bb2b5f5adb]
Login

Artifact [bb2b5f5adb]

Artifact bb2b5f5adb490a8f21ae88a610eec2dad792ec314f4a87957cd9744d2bd4efc4:


//! 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 } };
    }
    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" } };
    }
};

/// 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, 7)) {
        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 } };
        },
        else => .status,
    };
    return .{ .id = id, .op = op };
}

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