Artifact c28bf210176eec4ca7232f256187f00d273c3ecafe7ed2271eaca3bd15b748ba:
- File
src/artifact_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: 19817) [annotate] [blame] [check-ins using] [more...]
//! Seeded, parameterized fuzz targets for the artifact format //! (draft-fossil-artifact-format-00). Registered in fuzz_tests.zig. //! //! Contract for every target here, matching the module's own "every //! rejection reason collapses to null/false/an error" posture: no panic, no //! UB -- only a declared error, a null/false, or a value whose invariants //! this file checks. `parseGarbage` drives the real §4/§5 `parse()` //! pipeline (framing through classification) on arbitrary bytes; //! `parseStructured` instead reaches the sixteen `parseXCard` entry points //! directly, exercising each card's own §4.7.x grammar in isolation rather //! than through the card-loop's dispatch. const std = @import("std"); const zit = @import("zit"); const artifact = zit.artifact; /// Fill `buf` with a mix of plausible card bytes (letters, digits, the /// grammar's punctuation) and pure noise, biased toward reaching past the /// first gate of whatever parser sees it. fn fillGarbage(rand: std.Random, buf: []u8) void { const plausible = "ABCDEFGHIJKLMNPQRTUWZ 0123456789abcdef\n+-.:T"; for (buf) |*b| b.* = if (rand.uintLessThan(u8, 3) == 0) plausible[rand.uintLessThan(usize, plausible.len)] else rand.int(u8); } // --------------------------------------------------------------------------- // parseGarbage — §4 top-level entry point. // --------------------------------------------------------------------------- /// `parse` on arbitrary bytes never crashes: only a declared `ParseError` or /// a `Manifest` value comes back. /// /// No reachability assertion: the outcome space does not partition here. Random /// bytes never satisfy §4 framing/syntax, so `parse` returns `SyntaxError` /// every time — measured 20_000/20_000 SyntaxError at seed 1, zero accepts and /// zero classification-stage (`ClassifyError`) rejections. The accept arm and /// the classify variants are reached where structure exists: `createRoundTrip` /// for accept, the deterministic §5 classify tests for the rest. pub fn parseGarbage(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void { var prng = std.Random.DefaultPrng.init(seed); const rand = prng.random(); var round: usize = 0; while (round < rounds) : (round += 1) { var buf: [512]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); fillGarbage(rand, buf[0..len]); if (artifact.parse(gpa, buf[0..len])) |m| { var mm = m; mm.deinit(); } else |_| {} } } // --------------------------------------------------------------------------- // parseStructured — the sixteen §4.7.x parseXCard entry points. // --------------------------------------------------------------------------- /// The card letters that have a `parseXCard` entry point today (the 21-letter /// closed alphabet minus R/T/U/W, not yet implemented, and Z, which has no /// per-card parser at all). const parseable_letters = "ABCDEFGHIJKLMNPQ"; comptime { std.debug.assert(parseable_letters.len == 16); } /// One SP-joined token: a structured draw (hash-length hex, a timestamp /// literal, a `q-sign` byte) or pure noise. Returns the written length, /// capped to `buf.len`. fn genOneToken(rand: std.Random, buf: []u8) usize { if (buf.len == 0) return 0; return switch (rand.uintLessThan(u8, 4)) { 0 => blk: { const want: usize = if (rand.boolean()) 40 else 64; const n = @min(want, buf.len); for (buf[0..n]) |*b| b.* = "0123456789abcdef"[rand.uintLessThan(usize, 16)]; break :blk n; }, 1 => blk: { const lit = timestamp_literals[rand.uintLessThan(usize, timestamp_literals.len)]; const n = @min(lit.len, buf.len); @memcpy(buf[0..n], lit[0..n]); break :blk n; }, 2 => blk: { buf[0] = if (rand.boolean()) '+' else '-'; break :blk 1; }, else => blk: { const n = rand.uintLessThan(usize, @min(buf.len, 24) + 1); fillGarbage(rand, buf[0..n]); break :blk n; }, }; } const timestamp_literals = [_][]const u8{ "2017-12-06T15:36:24.287", "2017-12-06", "15:36:24", "now", "2451545", }; /// Fill `buf` with 0-4 SP-joined tokens (`genOneToken`). Returns the written /// length. fn genTokens(rand: std.Random, buf: []u8) usize { const token_count = rand.uintLessThan(usize, 5); var w: usize = 0; var t: usize = 0; while (t < token_count and w < buf.len) : (t += 1) { if (t != 0) { buf[w] = ' '; w += 1; if (w >= buf.len) break; } w += genOneToken(rand, buf[w..]); } return w; } /// Call the one `parseXCard` matching `card.letter` -- every parser's sole /// caller precondition beyond argument shape (`std.debug.assert(card.letter /// == '<X>')`), upheld here since `letter` is always drawn from /// `parseable_letters`. `decode_buf` sized to the raw args length is always /// sufficient: every parser's decoded fields are a subset of the same raw /// bytes (decodeEscapes never grows), so no per-letter sizing is needed. fn dispatchParse(card: artifact.CardLine, decode_buf: []u8) void { switch (card.letter) { 'A' => _ = artifact.parseACard(card, decode_buf), 'B' => _ = artifact.parseBCard(card), 'C' => _ = artifact.parseCCard(card, decode_buf), 'D' => _ = artifact.parseDCard(card), 'E' => _ = artifact.parseECard(card), 'F' => _ = artifact.parseFCard(card, decode_buf), 'G' => _ = artifact.parseGCard(card), 'H' => _ = artifact.parseHCard(card, decode_buf), 'I' => _ = artifact.parseICard(card), 'J' => _ = artifact.parseJCard(card, decode_buf), 'K' => _ = artifact.parseKCard(card), 'L' => _ = artifact.parseLCard(card, decode_buf), 'M' => _ = artifact.parseMCard(card), 'N' => _ = artifact.parseNCard(card, decode_buf), 'P' => _ = artifact.parsePCard(card), 'Q' => _ = artifact.parseQCard(card), else => unreachable, // letter always drawn from parseable_letters } } /// Every `parseXCard` on a structured-or-garbage card line never crashes: /// only a declared per-card struct or null comes back (§4.7.2-§4.7.14). /// /// No reachability assertion: this is sixteen heterogeneous per-card parsers /// (optionals and error unions over distinct struct types), not one binary /// outcome — there is no single accept/reject split to require, and each /// card's accept and reject arms are pinned by its own deterministic §4.7.x /// test in artifact.zig. pub fn parseStructured(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 argbuf: [256]u8 = undefined; var decode_buf: [256]u8 = undefined; const letter = parseable_letters[rand.uintLessThan(usize, parseable_letters.len)]; const args: ?[]const u8 = if (rand.boolean()) null else argbuf[0..genTokens(rand, &argbuf)]; const card: artifact.CardLine = .{ .letter = letter, .args = args }; dispatchParse(card, decode_buf[0..if (args) |a| a.len else 0]); } } // --------------------------------------------------------------------------- // cardLine — §4.2 tokenizeCardLine. // --------------------------------------------------------------------------- /// Fill `buf` with a well-formed line for `tokenizeCardLine`'s precondition /// (non-empty, no embedded LF, terminated by exactly one LF); content before /// the LF is otherwise arbitrary. Returns the written length. fn genLine(rand: std.Random, buf: []u8) usize { const body_len = 1 + rand.uintLessThan(usize, buf.len - 1); for (buf[0 .. body_len - 1]) |*b| { b.* = rand.int(u8); if (b.* == '\n') b.* = ' '; } buf[body_len - 1] = '\n'; return body_len; } /// `tokenizeCardLine` on an arbitrary well-formed line never crashes, and /// any returned `args` is a subslice of the input (§4.2). /// /// No reachability assertion: `tokenize` needs a valid card letter in a /// specific shape that a random first byte rarely forms — measured 38/20_000 /// tokenized at seed 1 — so the accept arm is too thin to floor robustly, and /// it is pinned by the deterministic §4.2 tokenizer tests. pub fn cardLine(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 buf: [128]u8 = undefined; const len = genLine(rand, &buf); const line = buf[0..len]; if (artifact.tokenizeCardLine(line)) |card| { try std.testing.expect(artifact.isCardLetter(card.letter)); if (card.args) |a| try std.testing.expect(a.len <= line.len); } } } // --------------------------------------------------------------------------- // escapes — §4.3 decodeEscapes. // --------------------------------------------------------------------------- /// `decodeEscapes` on an arbitrary token never crashes; the decoded length /// never exceeds the token's, and a backslash-free token decodes to itself /// (§4.3). /// /// No reachability assertion: `decodeEscapes` is total — it always returns a /// decoded slice, there is no accept/reject arm to partition; the invariant is /// the length/identity bound. pub fn escapes(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 tok_buf: [128]u8 = undefined; var out_buf: [128]u8 = undefined; const len = rand.uintLessThan(usize, tok_buf.len + 1); fillGarbage(rand, tok_buf[0..len]); const token = tok_buf[0..len]; const out = artifact.decodeEscapes(token, out_buf[0..len]); try std.testing.expect(out.len <= token.len); if (std.mem.indexOfScalar(u8, token, '\\') == null) { try std.testing.expectEqualSlices(u8, token, out); } } } // --------------------------------------------------------------------------- // timestamp — §4.6.4 classifyTimestamp. // --------------------------------------------------------------------------- /// `classifyTimestamp` on arbitrary bytes never crashes (§4.6.4). /// /// No reachability assertion: of the five `TimestampForm`s only `.julian` (a /// bare digit run) is reachable from random bytes — measured 29 julian / /// 19_971 null per 20_000 at seed 1, and zero date/time/date_time/relative, /// which need `-`/`:`/`T` structure random input essentially never forms. One /// thin arm plus null is not a partition worth flooring; the five forms are /// pinned by the deterministic §4.6.4 classify tests. pub fn timestamp(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 buf: [64]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); fillGarbage(rand, buf[0..len]); _ = artifact.classifyTimestamp(buf[0..len]); } } // --------------------------------------------------------------------------- // framing — §4.1/§4.1.1/§6.2 frame, stripSignatureWrapper, verifyZCard. // --------------------------------------------------------------------------- /// The framing trio on arbitrary bytes never crashes; `stripSignatureWrapper` /// and `frame` never grow their input (§4.1.1, §4.1). /// /// No reachability assertion: random bytes never form a §4.1-framable body — /// measured `frame` returned null 20_000/20_000 at seed 1 — and `verifyZCard` /// only ever reaches `.absent` (plus a 2/20_000 incidental `.mismatch`), never /// `.match`, which needs a real Z card. The accept and match arms are pinned by /// `createRoundTrip` and the deterministic §6.2 verify tests. pub fn framing(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 buf: [256]u8 = undefined; const len = rand.uintLessThan(usize, buf.len + 1); fillGarbage(rand, buf[0..len]); const bytes = buf[0..len]; const stripped = artifact.stripSignatureWrapper(bytes); try std.testing.expect(stripped.len <= bytes.len); if (artifact.frame(bytes)) |framed| try std.testing.expect(framed.len <= bytes.len); _ = artifact.verifyZCard(bytes); } } // --------------------------------------------------------------------------- // predicates — §4.5.3/§4.6 compare*, isSimplePathname, isWikiName, isReservedFilename. // --------------------------------------------------------------------------- fn reverseOrder(o: std.math.Order) std.math.Order { return switch (o) { .lt => .gt, .gt => .lt, .eq => .eq, }; } /// The pathname/wiki-name/reserved-name/compare predicates on arbitrary /// bytes never crash; `compareBytes` is reflexive and its swap is exactly /// order-reversing (§4.5.3, §4.6.1-§4.6.3). /// /// Reachability, asserted rather than assumed (the `serve_fuzz` idea): a run /// must reach BOTH accept and reject for `isSimplePathname` and `isWikiName`, /// or the generator has drifted so a predicate's true (or false) arm is never /// exercised. Measured at seed 1, 20_000 rounds: simple-pathname 435 reject / /// 19_565 accept, wiki-name 17_007 reject / 2_993 accept; at 512 rounds the /// thinnest arm (simple-pathname reject) holds ≥6 across seeds 1-3, the floor. /// `isReservedFilename` is deliberately excluded: it is true only for a fixed /// name set random bytes never hit (measured 0/20_000 accept), so its accept /// arm is unreachable here and is pinned by the deterministic §4.6.3 tests. const predicates_min_rounds: usize = 512; pub fn predicates(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void { _ = gpa; var prng = std.Random.DefaultPrng.init(seed); const rand = prng.random(); var sp = [_]usize{0} ** 2; var wiki = [_]usize{0} ** 2; var round: usize = 0; while (round < rounds) : (round += 1) { var abuf: [64]u8 = undefined; var bbuf: [64]u8 = undefined; const alen = rand.uintLessThan(usize, abuf.len + 1); const blen = rand.uintLessThan(usize, bbuf.len + 1); fillGarbage(rand, abuf[0..alen]); fillGarbage(rand, bbuf[0..blen]); const a = abuf[0..alen]; const b = bbuf[0..blen]; sp[@intFromBool(artifact.isSimplePathname(a))] += 1; wiki[@intFromBool(artifact.isWikiName(a))] += 1; _ = artifact.isReservedFilename(a); try std.testing.expectEqual(std.math.Order.eq, artifact.compareBytes(a, a)); try std.testing.expectEqual(artifact.compareBytes(a, b), reverseOrder(artifact.compareBytes(b, a))); } if (rounds >= predicates_min_rounds and (sp[0] == 0 or sp[1] == 0 or wiki[0] == 0 or wiki[1] == 0)) { std.debug.print( "artifact_predicates: {d} rounds reached simple-pathname[reject={d} accept={d}] " ++ "wiki-name[reject={d} accept={d}] — a predicate no longer sees both arms\n", .{ rounds, sp[0], sp[1], wiki[0], wiki[1] }, ); return error.OutcomeUnreached; } } // --------------------------------------------------------------------------- // createRoundTrip — §6/§4 create(), the writer. // --------------------------------------------------------------------------- const hex_chars = "0123456789abcdef"; /// Fill `buf` with random lowercase hex. fn fillHex(rand: std.Random, buf: []u8) void { for (buf) |*b| b.* = hex_chars[rand.uintLessThan(usize, 16)]; } /// Assemble a small, always-parseable MANIFEST-type artifact from a bounded /// pool of random-but-valid card choices, with a correctly computed Z /// checksum -- structured generation, not blind noise, so `create`'s /// per-card writers actually get exercised (pure noise essentially never /// satisfies the Z-checksum gate, so `parseGarbage`'s approach would drive /// almost no traffic through `create` at all). Always emits at least one F /// card, pinning classification to MANIFEST deterministically (§5.3) so the /// rest of the pool (C/P/R/T/U) stays free to vary without touching §5.4's /// per-type semantic checks. fn genManifestCandidate(rand: std.Random, out: *std.ArrayList(u8), allocator: std.mem.Allocator) !void { out.clearRetainingCapacity(); if (rand.boolean()) try out.appendSlice(allocator, "C generated\\stest\\scase\n"); try out.appendSlice(allocator, "D 2026-07-02T11:18:42.940\n"); const f_count = rand.uintLessThan(usize, 4) + 1; // >=1: keeps classification deterministic var i: usize = 0; while (i < f_count) : (i += 1) { var hbuf: [64]u8 = undefined; const hlen: usize = if (rand.boolean()) 40 else 64; fillHex(rand, hbuf[0..hlen]); try out.print(allocator, "F f{d:0>3}.txt {s}\n", .{ i, hbuf[0..hlen] }); } if (rand.boolean()) { try out.appendSlice(allocator, "P\n"); } else { var pbuf: [64]u8 = undefined; const plen: usize = if (rand.boolean()) 40 else 64; fillHex(rand, pbuf[0..plen]); try out.print(allocator, "P {s}\n", .{pbuf[0..plen]}); } if (rand.boolean()) { var rbuf: [32]u8 = undefined; fillHex(rand, &rbuf); try out.print(allocator, "R {s}\n", .{rbuf}); } if (rand.boolean()) { var tbuf: [64]u8 = undefined; const tlen: usize = if (rand.boolean()) 40 else 64; fillHex(rand, tbuf[0..tlen]); try out.print(allocator, "T +tag {s}\n", .{tbuf[0..tlen]}); } if (rand.boolean()) try out.appendSlice(allocator, "U tester\n"); var digest: [32]u8 = undefined; _ = artifact.selfChecksum(out.items, &digest); try out.print(allocator, "Z {s}\n", .{digest}); } /// `create` on every generated-parseable candidate never crashes or leaks, /// and its output is always itself re-parseable (§6/§4 writer round-trip) /// with a matching self-checksum (§6.2), and a second `create` over the /// re-parsed data is byte-identical to the first (`create` is a pure /// function of the Manifest's data, §6.3.4). /// /// No reachability assertion: every candidate is generated parseable, so a /// successful create/parse/create round-trip is the only outcome — there is no /// reject arm to reach (a candidate that fails to parse is skipped, not an /// outcome the target is testing). pub fn createRoundTrip(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void { var prng = std.Random.DefaultPrng.init(seed); const rand = prng.random(); var gen: std.ArrayList(u8) = .empty; defer gen.deinit(gpa); var round: usize = 0; while (round < rounds) : (round += 1) { try genManifestCandidate(rand, &gen, gpa); var m = artifact.parse(gpa, gen.items) catch continue; defer m.deinit(); const out1 = try artifact.create(&m, gpa); defer gpa.free(out1); try std.testing.expectEqual(artifact.ZVerdict.match, artifact.verifyZCard(out1)); var m1 = try artifact.parse(gpa, out1); defer m1.deinit(); const out2 = try artifact.create(&m1, gpa); defer gpa.free(out2); try std.testing.expectEqualSlices(u8, out1, out2); } }