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