Artifact adc4ea5e22f821d868ffa57e3aafd42834f358b26d4b317cd6dbe8c76c6f733b:
- File
src/transport_fuzz.zig
— part of check-in
[cec4afaf89]
at
2026-07-19 00:10:09
on branch release-0.1
— Add transport_frame_garbage and serve_request_garbage seeded fuzz targets
The 24 existing fuzz targets cover the delta, artifact, sync-parser and logstore layers, but not the two CLI-layer modules that consume bytes an untrusted remote peer chose. `sync_parsers_adversarial` covers the layer above; the HTTP framing itself was unfuzzed. These are the last two wire surfaces on the release bar, and they are what a hostile peer reaches first.
What landed:
- `src/transport_fuzz.zig` — `frameGarbage`, registered as `transport_frame_garbage`. Client-side §4/§4.1 framing and redirect policy: the round-trip law (whatever `frame` produces, `unframe` returns exactly those plaintext bytes), mutation of framed containers (bit damage 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), and garbage `Location` headers through `resolveRedirect`. Totality is the asserted contract: every input yields a value whose postconditions hold or a declared member of `TransportError`.
- `src/serve_fuzz.zig` — `requestGarbage`, registered as `serve_request_garbage`. Server-side HTTP request path: raw bytes into `std.http.Server.receiveHead`, then `handleHttpRequest`'s dispatch (`classifyXfer` -> body read -> responder -> reply framing). It does not re-feed `handleXfer` in isolation — that decoding is already fuzzed twice — it reaches it only as the tail of a full-path dispatch, which is the point. Asserted contract: any `receiveHead` error is a clean rejection, `handleHttpRequest` returns void, and any produced response begins with an HTTP/1.1 status line.
- `src/serve.zig`: `driveHttp` becomes `pub`. It is the request-path seam two callers need and neither should reimplement — the §4 smoke tests and this fuzz target. In-memory reader/writer on both sides, so the target binds no port and cannot flake in CI.
- `src/fuzz_tests.zig`: both targets registered in the `targets` table, and `targets` becomes `pub` for a new structural registration pin. An unregistered target compiles, passes review, and is then never run by any invocation; the pin asserts every target the release bar names is actually reachable from the runner, so the registration cannot rot.
- `src/dst.zig`: aggregates both new files plus `fuzz_tests.zig` into `zig build test`. This root is the one that reaches serve.zig and transport.zig relatively while carrying the `zit` dep those two import; `zig build fuzz` has no test step, so it is also the only place the registration pin can run.
No bug found, so no fix and no pinned-seed regression in this commit — both surfaces held over the seeded runs below. The targets are the standing check, not a one-time sweep.
Test evidence (this worktree, after `fossil update` absorbed the sibling `zit open` commit 5ba418801d and its 8 modified files):
- `zig build fuzz -- transport_frame_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build fuzz -- serve_request_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build test` — green, including the new registration pin and the fixed-seed smoke tests at the foot of both new files. - `zig build` and `zig fmt --check .` — green.
No borrowed technique in this change. (user: dmestas, size: 9372) [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. 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 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. try unframeMutated(gpa, rand, container); // (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]); } } /// 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) !void { 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); } else |err| try expectDeclared(err); } /// §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); } }