Artifact a630589c38daaff199ca4deb31672dcf589c1b8700af72e0727a0f9c7ba230f6:
- File
src/sync_fuzz.zig
— part of check-in
[af292c3091]
at
2026-09-18 12:31:08
on branch trunk
— fuzz: reach the igot accept arm again, and sweep at the floor in ci
The adversarial token generator capped fields at 24 bytes, and the igot parser has required a 40- or 64-byte name since the shun work, so the parser's accept arm was unreachable and the harness's own reachability floor said so at 256 rounds. CI swept 100 rounds, below the floor, and never saw it; the first bounded soak did. One field in four is now hash-shaped, and ci sweeps every target at the floor's round count. (user: dmestas, size: 15773) [annotate] [blame] [check-ins using]
//! 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; /// The two complete artifact-name widths (artifact-format §3.2). The card /// parsers that carry a HASH reject any other length, so a generator capped /// below 40 could never reach their accept arm: the reachability floor below /// found exactly that after the igot parser learned the rule, and the soak /// runner flagged it at 256 rounds while CI's shallower sweep never could. const hash_widths = [_]usize{ 40, 64 }; /// The widest field the generator emits: the longer hash width. const max_field_width: usize = 64; const FieldBuf = [sync.max_tokens][max_field_width]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_width); try std.testing.expect(card.projectcode.len <= max_field_width); 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) { // One field in four is hash-shaped, so parsers that demand a complete // name see their accept arm; the rest sweep the short adversarial range. const len = if (rand.uintLessThan(u8, 4) == 0) hash_widths[rand.uintLessThan(usize, hash_widths.len)] else 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); }