Skip to content
zit Artifact [3f419ec3e3]
Login

Artifact [3f419ec3e3]

Artifact 3f419ec3e3c0b4ecf99376e46d111c4fae9826d08f9968e649ffdf84d10f9e61:


//! Seeded, parameterized fuzz targets for the sync protocol
//! (draft-fossil-sync-protocol-00). Each target takes its seed and round count
//! from the caller (`zig build fuzz -- <target> [--seed N] [--rounds N]`) so an
//! out-of-band runner sweeps many seeds at high round counts; a fixed-seed
//! smoke `test` per target (below) delegates to the same pub fn so the same
//! invariant also runs under `zig build test`. Registered in fuzz_tests.zig.
//!
//! Every parser here is fed untrusted wire tokens: the invariant is it never
//! crashes — only a declared error or a value whose shape postconditions hold —
//! and, where an encoder exists, that write -> parse round-trips exactly.

const std = @import("std");
const sync = @import("zit").sync;
const container = @import("zit").container;

/// Widest random token field these adversarial-token generators emit.
const max_field: usize = 24;
const FieldBuf = [sync.max_tokens][max_field]u8;

/// §4.1 — a random payload survives the zlib container encode -> decode at
/// either level unchanged, and arbitrary garbage into the decoder always
/// resolves to a declared error, never a panic.
pub fn containerRoundTrip(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
    var prng = std.Random.DefaultPrng.init(seed);
    const rand = prng.random();
    const max_len: usize = 96;
    var buf: [max_len]u8 = undefined;
    var garbage_buf: [max_len]u8 = undefined;
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        const len = rand.uintLessThan(usize, max_len + 1);
        for (buf[0..len]) |*b| b.* = rand.int(u8);
        const level: container.Level = if (rand.boolean()) .default else .best;

        const encoded = try container.encode(gpa, buf[0..len], level);
        defer gpa.free(encoded);
        const decoded = try container.decode(gpa, encoded, sync.reply_body_cap);
        defer gpa.free(decoded);
        try std.testing.expectEqualSlices(u8, buf[0..len], decoded);

        const glen = rand.uintLessThan(usize, max_len + 1);
        for (garbage_buf[0..glen]) |*b| b.* = rand.int(u8);
        if (container.decode(gpa, garbage_buf[0..glen], sync.reply_body_cap)) |ok| {
            gpa.free(ok);
        } else |_| {}
    }
}

/// §11.1/§11.2 (signal), §3.6 (cookie), §6.5 (cookie-login) — arbitrary TEXT
/// survives every write -> tokenize -> parse round-trip unchanged. Every
/// Fossil-whitespace/LF octet is a Table 1 escape member, so an encoded TEXT
/// never splits into a second token; and the cookie-login decoder tolerates
/// arbitrary bytes.
pub fn signalCookieRoundTrip(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
    _ = gpa;
    var prng = std.Random.DefaultPrng.init(seed);
    const rand = prng.random();
    const max_len: usize = 32;
    var text_buf: [max_len]u8 = undefined;
    var card_buf: [8 + 2 * max_len]u8 = undefined;
    var decode_buf: [2 * max_len]u8 = undefined;
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        const len = rand.uintLessThan(usize, max_len + 1);
        for (text_buf[0..len]) |*b| b.* = rand.int(u8);
        const text = text_buf[0..len];

        const kind: sync.SignalKind = if (rand.boolean()) .err else .message;
        try expectTextRoundTrip(sync.writeSignalCard(kind, text, &card_buf), text, &decode_buf);

        var store: sync.CookieStore = .{};
        store.store(text);
        try expectTextRoundTrip(sync.writeCookieCard(store.value().?, &card_buf), text, &decode_buf);
    }
    try cookieLoginRoundTrip(rand, rounds);
}

/// One TEXT round-trip: `written` tokenizes to a single card token that decodes
/// back to `text` (or to no token, hence `null`, when `text` is empty).
fn expectTextRoundTrip(written: []const u8, text: []const u8, decode_buf: []u8) !void {
    const tokens = sync.tokenizeCard(written);
    try std.testing.expectEqual(@as(usize, if (text.len == 0) 1 else 2), tokens.len);
    const decoded = sync.parseSignalText(tokens, decode_buf);
    if (text.len == 0) {
        try std.testing.expect(decoded == null);
    } else {
        try std.testing.expectEqualSlices(u8, text, decoded.?);
    }
}

/// §6.5 — parseCookieLogin never crashes on arbitrary bytes, and a well-formed
/// card round-trips through writeCookieLogin -> parseCookieLogin. USER excludes
/// Fossil-whitespace/LF: those fail §6.1's USER check after decoding, not the
/// encoder's round-trip.
fn cookieLoginRoundTrip(rand: std.Random, rounds: usize) !void {
    const max_user: usize = 24;
    var garbage_buf: [96]u8 = undefined;
    var decode_buf: [200]u8 = undefined;
    var user_buf: [max_user]u8 = undefined;
    var cookie_buf: [200]u8 = undefined;
    const hex_digits = "0123456789abcdef";
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        const glen = rand.uintLessThan(usize, garbage_buf.len + 1);
        for (garbage_buf[0..glen]) |*b| b.* = rand.int(u8);
        _ = sync.parseCookieLogin(garbage_buf[0..glen], &decode_buf);

        const ulen = 1 + rand.uintLessThan(usize, max_user);
        for (user_buf[0..ulen]) |*b| {
            const raw = rand.int(u8);
            b.* = if (sync.isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        var nonce_hex: [40]u8 = undefined;
        var sig_hex: [40]u8 = undefined;
        for (&nonce_hex) |*c| c.* = hex_digits[rand.uintLessThan(u8, 16)];
        for (&sig_hex) |*c| c.* = hex_digits[rand.uintLessThan(u8, 16)];
        const card: sync.LoginCard = .{ .user = user_buf[0..ulen], .nonce = &nonce_hex, .signature = &sig_hex };
        const cookie = sync.writeCookieLogin(card, &cookie_buf).?;
        const round_tripped = sync.parseCookieLogin(cookie, &decode_buf).?;
        try std.testing.expectEqualStrings(card.user, round_tripped.user);
        try std.testing.expectEqualStrings(card.nonce, round_tripped.nonce);
        try std.testing.expectEqualStrings(card.signature, round_tripped.signature);
    }
}

/// §7.1/§7.2/§7.3, §8.2/§9.1, §9.2 — every public token-driven parser tolerates
/// adversarial tokens: it returns a declared error or a card whose arity-derived
/// postcondition holds, never crashes. A trailing loop proves the push/pull
/// encoder round-trips well-formed servercode/projectcode.
pub fn parsersAdversarial(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
    _ = gpa;
    var prng = std.Random.DefaultPrng.init(seed);
    const rand = prng.random();
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        var field_buf: FieldBuf = undefined;
        var tokens = adversarialTokens(rand, &field_buf);
        const n = tokens.len;

        tokens.items[0] = "clone_seqno";
        if (sync.parseCloneSeqno(tokens)) |_| {
            try std.testing.expect(n >= 2);
        } else |_| {}
        tokens.items[0] = "push";
        if (sync.parsePushPull(tokens)) |card| {
            try std.testing.expect(n >= 3);
            try std.testing.expect(card.servercode.len <= max_field);
            try std.testing.expect(card.projectcode.len <= max_field);
        }

        tokens.items[0] = "file";
        if (sync.parseFileCard(tokens)) |card| {
            try std.testing.expect((card.deltasrc == null) == (n == 3));
        } else |_| {}
        tokens.items[0] = "cfile";
        if (sync.parseCfileCard(tokens)) |card| {
            try std.testing.expect((card.deltasrc == null) == (n == 4));
        } else |_| {}

        tokens.items[0] = "igot";
        if (sync.parseIgotCard(tokens)) |card| {
            try std.testing.expect(!card.private or n == 3);
        } else |_| {}

        tokens.items[0] = "uvfile";
        if (sync.parseUvfileCard(tokens)) |card| {
            const follows = sync.uvfilePayloadFollows(card);
            try std.testing.expect(!follows or (!card.isDeletion() and !card.contentOmitted() and card.size > 0));
        } else |_| {}
        tokens.items[0] = "uvigot";
        if (sync.parseUvigotCard(tokens)) |_| {} else |_| {}
        tokens.items[0] = "uvgimme";
        if (sync.parseUvgimmeCard(tokens)) |_| {} else |_| {}
    }
    try pushPullRoundTrip(rand, rounds);
}

/// Fill `field_buf` with `1..max_tokens` random-length token fields and return a
/// `Tokens` referencing them; `items[0]` is left for the caller to set to the
/// operator under test. Backing memory is caller-owned (`field_buf`).
fn adversarialTokens(rand: std.Random, field_buf: *FieldBuf) sync.Tokens {
    var tokens: sync.Tokens = .{};
    const n = 1 + rand.uintLessThan(usize, sync.max_tokens);
    tokens.len = n;
    var i: usize = 1;
    while (i < n) : (i += 1) {
        const len = rand.uintLessThan(usize, max_field + 1);
        for (field_buf[i][0..len]) |*b| b.* = rand.int(u8);
        tokens.items[i] = field_buf[i][0..len];
    }
    return tokens;
}

/// §8.2/§9.1 — write -> parse round-trips for well-formed servercode/projectcode
/// (Fossil-whitespace/LF excluded: they would re-split the card line).
fn pushPullRoundTrip(rand: std.Random, rounds: usize) !void {
    var out_buf: [80]u8 = undefined;
    var sc_buf: [16]u8 = undefined;
    var pc_buf: [16]u8 = undefined;
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        const sc_len = 1 + rand.uintLessThan(usize, sc_buf.len);
        const pc_len = 1 + rand.uintLessThan(usize, pc_buf.len);
        for (sc_buf[0..sc_len]) |*b| {
            const raw = rand.int(u8);
            b.* = if (sync.isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        for (pc_buf[0..pc_len]) |*b| {
            const raw = rand.int(u8);
            b.* = if (sync.isFossilWhitespace(raw) or raw == '\n') 'x' else raw;
        }
        const dir: sync.PushPull = if (rand.boolean()) .push else .pull;
        const wire = try sync.writePushPullCard(dir, sc_buf[0..sc_len], pc_buf[0..pc_len], &out_buf);
        const parsed = sync.parsePushPull(sync.tokenizeCard(wire)).?;
        try std.testing.expectEqualStrings(sc_buf[0..sc_len], parsed.servercode);
        try std.testing.expectEqualStrings(pc_buf[0..pc_len], parsed.projectcode);
    }
}

/// §7 — consumeContentPayload never overruns on an adversarial (start, size):
/// it either fails with a declared error or returns a payload of exactly `size`
/// bytes whose `next` cursor stays within the reply body.
pub fn consumeReplyAdversarial(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void {
    _ = gpa;
    var prng = std.Random.DefaultPrng.init(seed);
    const rand = prng.random();
    var body_buf: [64]u8 = undefined;
    for (&body_buf) |*b| b.* = rand.int(u8);
    var round: usize = 0;
    while (round < rounds) : (round += 1) {
        const start = rand.uintLessThan(usize, body_buf.len + 1);
        const size: u32 = rand.int(u32);
        if (sync.consumeContentPayload(&body_buf, start, size)) |payload| {
            try std.testing.expect(payload.next <= body_buf.len);
            try std.testing.expectEqual(@as(usize, size), payload.bytes.len);
        } else |_| {}
    }
}

// Fixed-seed smoke tests: each delegates to its pub fn at a small round count so
// the invariant runs under `zig build test`; deep runs pass `--rounds`.

test "sync §4.1 fuzz: container round-trip + garbage-decode (smoke)" {
    try containerRoundTrip(std.testing.allocator, 0xC0FF_EE00, 32);
}

test "sync §11.1/§11.2/§3.6/§6.5 fuzz: signal+cookie round-trip (smoke)" {
    try signalCookieRoundTrip(std.testing.allocator, 0x5E9A_11CA, 32);
}

test "sync §7.1/§7.2/§7.3/§8.2/§9.1/§9.2 fuzz: card parsers reject adversarial tokens (smoke)" {
    try parsersAdversarial(std.testing.allocator, 0x9A5E_C10E, 32);
}

test "sync §7 fuzz: consumeContentPayload never overruns (smoke)" {
    try consumeReplyAdversarial(std.testing.allocator, 0xC0FF_EE07, 32);
}