Artifact 0ab9b7195535468db561cdcb405b7bf17536e2c6cb77a115ab1fe2badb001194:
- File
src/sync_fuzz.zig
— part of check-in
[1abe1089c1]
at
2026-07-20 04:06:34
on branch h9-fuzzcov
— fuzz: floor outcome reachability across the fuzz targets
serve_fuzz already fails a run when its generator stops reaching the dispatch arms it exists to exercise, and merge3 does the same for conflict shapes. Generalize that guard so a generator that has drifted to one outcome can no longer pass every no-crash round while testing nothing on the arm it dropped.
Six targets gain a measured reached-outcomes floor: delta decodeInt and targetSize (a decoded value vs MalformedDelta), artifact predicates (simple-pathname and wiki-name each accepting and rejecting), sync card parsers (push/igot/uvgimme each accepting and rejecting), logstore open (torn-tail recovery vs Corrupt), and transport deframe (a damaged container both decoding and being rejected). Every threshold is counted at a fixed seed, stated in the comment, and set below the arm's measured frequency; each floor gates above a minimum round count so short runs never trip it.
Every other target carries a one-line note on why its outcome space does not partition: round-trips have only a success arm, and several garbage generators reach only their reject arm, the accept arm covered by the round-trip and deterministic tests. (user: dmestas, size: 14944) [annotate] [blame] [check-ins using] [more...]
//! 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); }