Artifact 43c310c51a6502fb2e049e6f43dfa151b1a632e6c885554ec6edce4ec62a8a48:
- File
src/transport_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: 10934) [annotate] [blame] [check-ins using] [more...]
//! Seeded fuzz target for `transport.zig` — the client-side §4/§4.1 framing //! and redirect policy. //! //! Of the two CLI-layer modules that touch the wire, this is the one that //! consumes bytes an untrusted *server* chose: a reply container it must //! deframe (`unframe`) and a `Location` header it must resolve (`resolveRedirect`). //! Both are total over their inputs by contract — every input either yields a //! value whose postconditions hold or one of `TransportError`'s declared //! members — and this target is the adversarial check of that totality. //! //! `frame`/`unframe` also carry a round-trip law: whatever plaintext the //! client frames, deframing the container returns exactly those bytes. Bit //! damage to a framed container never survives as *different* plaintext; it //! either fails the container's Adler-32/zlib checks or (in the length-field //! range the §4.1 compression-length erratum makes a capacity bound) decodes //! back to the same bytes. //! //! Registered as `transport_frame_garbage` in fuzz_tests.zig; a fixed-seed //! smoke `test` at the foot delegates to the same pub fn so the invariant also //! runs under `zig build test` (and therefore in CI). const std = @import("std"); const transport = @import("transport.zig"); const sync = @import("zit").sync; /// Largest plaintext a round generates before framing. Small on purpose: the /// interesting behavior is in the header/checksum edges, and small bodies buy /// many more rounds per second. const max_plaintext: usize = 192; /// Largest raw garbage buffer fed straight to `unframe`. const max_garbage: usize = 96; /// §4/§4.1 — the client's deframe and redirect-resolution seams are total over /// hostile input: never a crash, never a leak, only a declared `TransportError` /// or a value whose postconditions hold. Each round exercises three populations: /// /// 1. Round-trip: random plaintext -> `frame` -> `unframe` returns it exactly. /// 2. Mutated container: the framed bytes from (1) damaged at a random offset /// (byte flip, truncation, or a rewritten 4-byte length prefix) and fed to /// `unframe` — an error is the expected outcome, and any success MUST NOT /// invent bytes beyond the §4.1 cap. /// 3. Pure garbage: random bytes (including empty) straight into `unframe`, /// plus a random `Location` string into `resolveRedirect` from each of the /// four transports at a random redirect count. /// /// Reachability, asserted rather than assumed (the `serve_fuzz` idea): the /// mutated-container population must reach BOTH a `unframe` that still decodes /// (the "success MUST NOT invent bytes" arm) and a rejection, or damaging a /// valid container no longer straddles the deframe accept/reject boundary. /// Measured at seed 1, 20_000 rounds: 7_546 decoded / 12_454 rejected, and at /// 256 rounds each holds ≥83 across seeds 1-3 and the smoke seed — the floor is /// the smoke's own 256 rounds, so the shortest run still proves both arms. The /// other two garbage arms are not floored, on measured grounds: `unframe` of /// pure garbage essentially never decodes (a random buffer is not a valid /// length-prefixed zlib stream), and `resolveRedirect` never resolves a garbage /// Location (0 resolved in 20_000 — random bytes are not a valid http(s) URL); /// those accept arms are pinned by the deterministic §4/§4.1 transport tests. const frame_garbage_min_rounds: usize = 256; pub fn frameGarbage(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void { var prng = std.Random.DefaultPrng.init(seed); const rand = prng.random(); var plain_buf: [max_plaintext]u8 = undefined; var garbage_buf: [max_garbage]u8 = undefined; var mut_ok: usize = 0; var mut_err: usize = 0; var round: usize = 0; while (round < rounds) : (round += 1) { // (1) round-trip law. const plain_len = rand.uintLessThan(usize, max_plaintext + 1); fillRandom(rand, plain_buf[0..plain_len]); const plaintext = plain_buf[0..plain_len]; const container = try transport.frame(gpa, plaintext); defer gpa.free(container); { const back = try transport.unframe(gpa, container); defer gpa.free(back); try std.testing.expectEqualSlices(u8, plaintext, back); } // (2) the same container, damaged. if (try unframeMutated(gpa, rand, container)) mut_ok += 1 else mut_err += 1; // (3) pure garbage into the deframer, and a hostile Location. const glen = rand.uintLessThan(usize, max_garbage + 1); fillRandom(rand, garbage_buf[0..glen]); if (transport.unframe(gpa, garbage_buf[0..glen])) |ok| { gpa.free(ok); } else |err| try expectDeclared(err); try resolveGarbageRedirect(rand, garbage_buf[0..glen]); } if (rounds >= frame_garbage_min_rounds and (mut_ok == 0 or mut_err == 0)) { std.debug.print( "transport_frame_garbage: {d} rounds reached mutated-unframe[decoded={d} rejected={d}] — " ++ "damaging a valid container no longer straddles the deframe accept/reject boundary\n", .{ rounds, mut_ok, mut_err }, ); return error.OutcomeUnreached; } } /// Damage one framed container and deframe it. A mutated container is /// overwhelmingly an error; when it does decode (the length field is a /// capacity bound per the §4.1 compression-length erratum, so a raised /// declared length still decodes), the only invariant asserted is the §4 r8/r9 /// size cap — the decoded length never exceeds `sync.reply_body_cap`. fn unframeMutated(gpa: std.mem.Allocator, rand: std.Random, container: []const u8) !bool { var copy = try gpa.dupe(u8, container); defer gpa.free(copy); var mutated: []u8 = copy; switch (rand.uintLessThan(u8, 3)) { // Flip one bit anywhere, header or zlib stream. 0 => { const at = rand.uintLessThan(usize, copy.len); copy[at] ^= @as(u8, 1) << rand.int(u3); }, // Truncate at a random offset, including to nothing (a container // shorter than its 4-byte prefix must be ReplyTruncated, not a read // past the end). 1 => mutated = copy[0..rand.uintLessThan(usize, copy.len + 1)], // Lie in the 4-byte big-endian declared length: over, under, absurd. else => if (copy.len >= 4) { const lie: u32 = switch (rand.uintLessThan(u8, 4)) { 0 => 0, 1 => rand.int(u32), 2 => std.math.maxInt(u32), else => rand.uintLessThan(u32, @intCast(sync.reply_body_cap)), }; std.mem.writeInt(u32, copy[0..4], lie, .big); }, } if (transport.unframe(gpa, mutated)) |plain| { defer gpa.free(plain); try std.testing.expect(plain.len <= sync.reply_body_cap); return true; } else |err| { try expectDeclared(err); return false; } } /// §4 r10/r12/r13 — resolve a redirect whose Location is arbitrary bytes, from /// each of the four transports, at a redirect count that straddles the limit. /// Pure and allocation-free; the invariant is totality plus the two policy /// postconditions: a resolved hop is always http/https, and it never downgrades /// an https origin to plaintext http. fn resolveGarbageRedirect(rand: std.Random, garbage: []const u8) !void { const transports = [_]sync.Transport{ .http, .https, .ssh, .file }; const from = transports[rand.uintLessThan(usize, transports.len)]; // Straddle the §4 r10 limit rather than sampling uniformly over a huge // range: the boundary is the interesting value. const followed = rand.uintLessThan(usize, 24); if (transport.resolveRedirect(from, garbage, followed)) |step| { try std.testing.expect(step.transport == .http or step.transport == .https); if (from == .https) try std.testing.expect(step.transport == .https); // `base_url` aliases the Location (an `/xfer`-stripped prefix), so it // is never longer than what it was cut from. try std.testing.expect(step.base_url.len <= garbage.len); } else |err| try expectDeclared(err); } /// Reject an undeclared error at the boundary. `TransportError` is closed by /// design so the CLI can switch it exhaustively; a target that swallowed /// `else => {}` would not notice the set growing a member the CLI cannot /// handle. Every member below is a legitimate outcome of hostile input. fn expectDeclared(err: transport.TransportError) !void { switch (err) { error.OutOfMemory, error.RequestTooLarge, error.ReplyTruncated, error.ReplyMalformed, error.ReplyTooLarge, error.RedirectLimit, error.InsecureRedirect, error.RedirectNotAllowed, error.InvalidUrl, error.UnsupportedScheme, error.MissingLocation, error.ProtocolError, error.ConnectionFailed, => {}, } } /// Random bytes with a bias toward ASCII and the structural octets that matter /// to a URL parser and to zlib headers. Uniform `u8` almost never produces a /// parseable scheme, so a purely uniform generator would leave /// `resolveRedirect`'s interesting paths (scheme match, `/xfer` strip, /// downgrade check) unreached. fn fillRandom(rand: std.Random, buf: []u8) void { const interesting = "htps:/.xfer\x00\r\n?#@[]%"; for (buf) |*b| { b.* = switch (rand.uintLessThan(u8, 4)) { 0 => interesting[rand.uintLessThan(usize, interesting.len)], 1 => rand.intRangeAtMost(u8, 'a', 'z'), else => rand.int(u8), }; } } // --------------------------------------------------------------------------- // Fixed-seed smoke: the same invariant under `zig build test` (and CI), at a // round count sized for the inner loop rather than for a sweep. // --------------------------------------------------------------------------- test "transport §4/§4.1 fuzz: framing + redirect resolution stay total on garbage (smoke)" { try frameGarbage(std.testing.allocator, 0x7a11e5, 256); } // The `frame`/`unframe` round-trip is a law, not a sampling: pin the two ends // (empty and a body long enough to compress) so a regression in either // direction fails deterministically, not one seed in a thousand. test "transport §4.1: frame -> unframe round-trips empty and long plaintext" { const gpa = std.testing.allocator; for ([_]usize{ 0, 1, max_plaintext }) |len| { const plain = try gpa.alloc(u8, len); defer gpa.free(plain); @memset(plain, 'z'); const container = try transport.frame(gpa, plain); defer gpa.free(container); const back = try transport.unframe(gpa, container); defer gpa.free(back); try std.testing.expectEqualSlices(u8, plain, back); } }