Artifact 1074473ceb2aba56872e79607ecd4eb19f07be0d2c6c749e19a02ee2db8570d9:
- File
src/fuzz_tests.zig
— part of check-in
[21ca364fb3]
at
2026-07-19 01:54:32
on branch release-0.1
— Add merge3_properties: algebraic-property fuzzing over the three-way merge
Silent merge corruption is the worst failure a version-control system can have — it produces a tree that looks right — and merge3 had no fuzz target. This adds one, driving the merger with generated base/current/other triples and asserting the properties that must hold for any input.
Oracle 4 (no invented lines) needs a carve-out, because the merger is supposed to fabricate lines inside a conflict envelope. That carve-out is only sound if the recorded spans really are envelopes, so expectNoInventedLines validates the claim before subtracting anything: each span must be in-bounds, open at a line boundary with conflict_sentinel, close with conflict_terminator, start at or after the previous span's end, and carry no second terminator inside it.
The ordering and interior-terminator checks are load-bearing, not belt-and- braces. inAnySpan matches on overlap, so an over-long span exempts every output line up to its end. End-pinning alone admits a single span stretched from the first envelope's start to the last one's end — it opens and closes correctly and overlaps nothing — which would exempt the clean lines between two envelopes from the oracle entirely. closes_once catches that case on the first envelope's terminator. merge3 asserts ordering itself, but only under runtime_safety, which a ReleaseFast sweep — the build a fuzz run uses — erases; this loop holds in every optimize mode.
merge3.zig gains one export: conflict_terminator, the closing counterpart of the existing conflict_sentinel, with a comptime tripwire pinning its shape. Additive only, no behavior change — it lets the oracle pin both envelope ends against the module that produces them instead of re-typing the literal, so the two cannot drift apart.
Battery green in the worktree: zig fmt --check ., zig build, zig build test, zig build e2e, zig build dst (6 seeds x 1000 steps). The new target runs clean at seed 1 over 2000 rounds; no bug was found, so no regression seed is pinned. (user: dmestas, size: 8233) [annotate] [blame] [check-ins using] [more...]
//! Seeded fuzz-target runner. //! //! `zig build fuzz -- <target> [--seed N] [--rounds N]` //! //! One binary; targets are named-tuple fields dispatched through //! `std.meta.FieldEnum`, so a module registers a new target by adding one //! field to `targets` below. The seed is explicit (`--seed`, decimal or //! `0x`-prefixed) or drawn from the process's csprng; either way it is //! always printed as `reproduce with seed=0x...` before the target runs, so //! a failure replays exactly. `--rounds` bounds the run explicitly (default //! 10_000); an out-of-band runner sweeps higher counts. const std = @import("std"); const delta_fuzz = @import("delta_fuzz.zig"); const artifact_fuzz = @import("artifact_fuzz.zig"); const sync_fuzz = @import("sync_fuzz.zig"); const logstore_fuzz = @import("logstore_fuzz.zig"); const transport_fuzz = @import("transport_fuzz.zig"); const serve_fuzz = @import("serve_fuzz.zig"); const merge3_fuzz = @import("merge3_fuzz.zig"); /// One field per fuzz target: `pub fn(allocator, seed: u64, rounds: usize) !void`. /// The field name is the argv target name. `pub` so the registration pin at /// the foot of this file (aggregated into `zig build test` through dst.zig) /// can assert a target is actually reachable from the runner — a target that /// exists but is unregistered is invisible to every invocation. pub const targets = .{ .delta_apply_garbage = delta_fuzz.applyGarbage, .delta_round_trip = delta_fuzz.roundTrip, .delta_decode_int_bounded = delta_fuzz.decodeIntBounded, .delta_target_size_garbage = delta_fuzz.targetSizeGarbage, .delta_create_large_sizes = delta_fuzz.createLargeSizes, .artifact_parse_garbage = artifact_fuzz.parseGarbage, .artifact_parse_structured = artifact_fuzz.parseStructured, .artifact_card_line = artifact_fuzz.cardLine, .artifact_escapes = artifact_fuzz.escapes, .artifact_timestamp = artifact_fuzz.timestamp, .artifact_framing = artifact_fuzz.framing, .artifact_predicates = artifact_fuzz.predicates, .artifact_create_round_trip = artifact_fuzz.createRoundTrip, .sync_container_round_trip = sync_fuzz.containerRoundTrip, .sync_signal_cookie_round_trip = sync_fuzz.signalCookieRoundTrip, .sync_parsers_adversarial = sync_fuzz.parsersAdversarial, .sync_consume_reply_adversarial = sync_fuzz.consumeReplyAdversarial, .logstore_open_garbage = logstore_fuzz.openGarbage, .logstore_zlib_round_trip = logstore_fuzz.zlibRoundTrip, .logstore_delta_round_trip = logstore_fuzz.deltaRoundTrip, .logstore_pick_base_round_trip = logstore_fuzz.pickBaseRoundTrip, .logstore_pack_round_trip = logstore_fuzz.packRoundTrip, .logstore_pack_same_path_round_trip = logstore_fuzz.packSamePathRoundTrip, .logstore_parse_config = logstore_fuzz.parseConfig, // The two CLI-layer wire surfaces: bytes chosen by an untrusted peer. .transport_frame_garbage = transport_fuzz.frameGarbage, .serve_request_garbage = serve_fuzz.requestGarbage, // The merge engine's algebraic laws: no hostile input, but the worst // failure mode (silent corruption of merged content). .merge3_properties = merge3_fuzz.properties, }; const Target = std.meta.FieldEnum(@TypeOf(targets)); const default_rounds: usize = 10_000; /// Most targets do microsecond-scale work per round and run at `default_rounds`. /// The two pack round-trip targets are ~100x heavier: each builds AND repacks a /// full store every round (dozens of deflate/inflate cycles plus the best-of-N /// `pickBase` search over the whole store), so `default_rounds` runs a single /// seed far past the 10-minute execution cap. They get reduced defaults sized to /// finish a seed with margin under that cap while still covering thousands of /// distinct stores; an explicit `--rounds` still sweeps higher out of band. /// /// `logstore_pack_round_trip` measures ~0.073 s/round in Debug (~2.4 min/seed at /// this count). Its same-path sibling adds a per-round manifest walk (F-card /// parse + `path → versions` grouping) on top of the same pack, making it ~4.4x /// heavier at ~0.31 s/round; sharing the 2_000 default pushed it to ~10.3 min/ /// seed — over the cap. It therefore gets its own lower default (~5.2 min/seed, /// ~1.9x headroom) rather than the shared one. const pack_round_trip_default_rounds: usize = 2_000; const pack_same_path_round_trip_default_rounds: usize = 1_000; /// The per-target default round count applied when `--rounds` is absent. fn defaultRoundsFor(target: Target) usize { return switch (target) { .logstore_pack_round_trip => pack_round_trip_default_rounds, .logstore_pack_same_path_round_trip => pack_same_path_round_trip_default_rounds, else => default_rounds, }; } pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const args = try init.minimal.args.toSlice(init.arena.allocator()); var stderr_buffer: [1024]u8 = undefined; var stderr_writer: std.Io.File.Writer = .init(.stderr(), init.io, &stderr_buffer); const err = &stderr_writer.interface; if (args.len < 2) { try usage(err); try err.flush(); std.process.exit(2); } const target = std.meta.stringToEnum(Target, args[1]) orelse { try err.print("fuzz: unknown target '{s}'\n\n", .{args[1]}); try usage(err); try err.flush(); std.process.exit(2); }; var seed: u64 = undefined; init.io.random(std.mem.asBytes(&seed)); var rounds: usize = defaultRoundsFor(target); try parseFlags(args[2..], &seed, &rounds); try err.print("reproduce with seed=0x{x}\n", .{seed}); try err.flush(); switch (target) { inline else => |tag| try @field(targets, @tagName(tag))(gpa, seed, rounds), } } /// Parse `[--seed N] [--rounds N]` in any order, overwriting the defaults /// already in `seed`/`rounds`. Each flag consumes exactly one value; an /// unknown flag or a missing value is an explicit error, never ignored. fn parseFlags(args: []const []const u8, seed: *u64, rounds: *usize) !void { var i: usize = 0; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--seed")) { i += 1; if (i >= args.len) return error.MissingSeedValue; seed.* = try std.fmt.parseInt(u64, args[i], 0); } else if (std.mem.eql(u8, args[i], "--rounds")) { i += 1; if (i >= args.len) return error.MissingRoundsValue; rounds.* = try std.fmt.parseInt(usize, args[i], 0); } else { return error.UnknownArgument; } } } fn usage(w: *std.Io.Writer) !void { try w.writeAll("usage: zig build fuzz -- <target> [--seed N] [--rounds N]\n\ntargets:\n"); inline for (@typeInfo(Target).@"enum".fields) |f| { try w.print(" {s}\n", .{f.name}); } } // --------------------------------------------------------------------------- // Registration pin — the one test in this file. It runs in `zig build test` // via dst.zig's aggregation, not in the fuzz binary (which has no test step). // --------------------------------------------------------------------------- // Every fuzz target the release bar names must be *reachable*: an unregistered // target compiles, passes review, and is then never run by any invocation. // This pin is structural, so the registration cannot silently rot away. test "fuzz: every named release target is registered in the runner table" { const required = [_][]const u8{ // P4-F6 — the two surfaces an untrusted remote peer can reach. "transport_frame_garbage", "serve_request_garbage", // P4-F7 — the algebraic laws of the three-way line merge. Silent merge // corruption is the worst VCS failure mode, and it is invisible to the // wire-surface targets above: `merge3` never sees a byte from a peer, // only content the repository already accepted. "merge3_properties", }; inline for (required) |name| { if (!@hasField(@TypeOf(targets), name)) { std.debug.print("fuzz target '{s}' is not registered in fuzz_tests.zig targets\n", .{name}); return error.TargetNotRegistered; } } }