Skip to content
zit Artifact [0ab9b71955]
Login

Artifact [0ab9b71955]

Artifact 0ab9b7195535468db561cdcb405b7bf17536e2c6cb77a115ab1fe2badb001194:


//! 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.
///
/// No reachability assertion: the round-trip half succeeds by construction, and
/// the garbage-decode half is a never-crash probe whose rare success is
/// incidental (random bytes almost never form a valid zlib stream) — there is
/// no interesting outcome arm to floor.
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.
///
/// No reachability assertion: every round is a well-formed write -> parse
/// round-trip, so success is the only outcome — there is no reject arm to
/// reach.
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();
    // Reachability, asserted rather than assumed (the `serve_fuzz` idea): the
    // adversarial-token generator must keep landing BOTH an accepted card and a
    // rejection for each parser whose accept arm is reachably common, or it has
    // drifted to one arm and is testing nothing on the other. Only the three
    // parsers whose accept arm the random-arity generator hits densely are
    // floored — measured at seed 1, 20_000 rounds: push/pull 13_316 ok / 6_684
    // rej, igot 3_332 / 16_668, uvgimme 3_332 / 16_668 — and at 256 rounds each
    // holds ≥39 of each arm across seeds 1-3 and the smoke seed, the floor.
    // The other parsers are excluded on measured grounds, not asserted
    // vacuously: parseCfileCard/parseUvfileCard/parseUvigotCard never accept an
    // adversarial token (0 accepts in 20_000 — their shapes need structure this
    // generator does not build), and parseCloneSeqno/parseFileCard accept far
    // too thinly (25 and 13 per 20_000) to floor. Those accept arms are pinned
    // by the deterministic §7-§9 parser tests.
    var push_ok: usize = 0;
    var push_rej: usize = 0;
    var igot_ok: usize = 0;
    var igot_rej: usize = 0;
    var uvgimme_ok: usize = 0;
    var uvgimme_rej: usize = 0;
    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);
            push_ok += 1;
        } else push_rej += 1;

        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);
            igot_ok += 1;
        } else |_| igot_rej += 1;

        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)) |_| {
            uvgimme_ok += 1;
        } else |_| uvgimme_rej += 1;
    }
    if (rounds >= parsers_min_rounds and
        (push_ok == 0 or push_rej == 0 or igot_ok == 0 or igot_rej == 0 or
            uvgimme_ok == 0 or uvgimme_rej == 0))
    {
        std.debug.print(
            "sync_parsers_adversarial: {d} rounds reached push[ok={d} rej={d}] " ++
                "igot[ok={d} rej={d}] uvgimme[ok={d} rej={d}] — a parser no longer sees both arms\n",
            .{ rounds, push_ok, push_rej, igot_ok, igot_rej, uvgimme_ok, uvgimme_rej },
        );
        return error.OutcomeUnreached;
    }
    try pushPullRoundTrip(rand, rounds);
}

/// The smoke (32 rounds) sits below this, so the coverage floor bites only at
/// soak depth; measured, all three floored parsers hold ≥39 of each arm by 256
/// rounds across seeds 1-3 and the smoke seed.
const parsers_min_rounds: usize = 256;

/// 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.
///
/// No reachability assertion: `size` is a full random u32 against a 64-byte
/// body, so a satisfiable `(start, size)` — needing `size ≤ 64` — is drawn with
/// probability ≈ 64/2³², effectively never (measured 0 payloads / 100_000
/// truncations at seed 1). This target only exercises the overrun-rejection
/// path; the success path is covered by the deterministic §7 consume tests. A
/// success-reaching generator would need `size` biased into the body range —
/// noted as a follow-up, not fixed here.
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);
}