Artifact deeb2663a631b8020f5ef21711163317634943bcbcaf9e40c897d87b790de169:
- File
src/serve_fuzz.zig
— part of check-in
[63ec7d6dc8]
at
2026-08-26 16:14:20
on branch serve-protected-branches
— serve: refuse membership changes to protected branches, per request, from a trusted front
A front that authenticates every push knows who is pushing; the store knows which branch a pushed check-in resolves to. The front sends only the rule -- X-Zit-Protect, a glob list -- and zit serve --fronted refuses the whole round when a check-in would join one of those branches or a tag would move a held check-in in or out of one. Same error card as a content-policy refusal, so a stock fossil client reads it.
Without --fronted the header is ignored and the server is unchanged. Nothing new on the wire or in the repository. Resolution is crosslink's own, through the fast-read image, which maintain keeps warm between connections. A parent not yet held is admitted: it is judged on arrival. Resolver faults fail closed. Issue #151. (user: dmestas, size: 14114) [annotate] [blame] [check-ins using] [more...]
//! Seeded fuzz target for `serve.zig` — the server-side HTTP request path. //! //! This is the only entry an untrusted remote peer reaches: raw request bytes //! into `std.http.Server.receiveHead`, then `handleHttpRequest`'s dispatch //! (`classifyXfer` -> body read -> responder -> reply framing). The container //! decoding *below* that dispatch is already fuzzed twice — the inline //! `handleXfer` target in serve.zig and `sync_parsers_adversarial` — so this //! target deliberately does not re-feed `handleXfer` in isolation; it reaches //! it only as the tail of a full-path dispatch, which is the point. //! //! The harness is the in-memory `serve.driveHttp` seam the §4 smoke tests //! already use: a `std.Io.Reader.fixed` over the fuzz input and an allocating //! writer for the response, driving the real `std.http.Server`. No sockets, no //! loopback ports, fully deterministic — the same reason the smoke tests are //! written that way. //! //! Contract under fuzz: any error out of `receiveHead` is a *clean rejection* //! (a malformed or oversize head is exactly what this code is supposed to //! refuse), and `handleHttpRequest` returns void. So the asserted invariants //! are the fuzz invariants only — no crash, no leak, bounded work — plus the //! one cheap shape check that a produced response begins with an HTTP/1.1 //! status line. Which specific status each malformed request earns is pinned //! by the deterministic tests in serve.zig, not re-litigated here. //! //! Registered as `serve_request_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 serve = @import("serve.zig"); const zit = @import("zit"); const sync = zit.sync; const codec = zit.container; const repostate = zit.repostate; /// A 64-hex artifact name and its content, seeded into the store so a `gimme` /// in the valid population is satisfiable and the success path completes end /// to end (the same fixture the serve.zig smoke tests use). const held_hash = "deadbeef" ** 8; const held_content = "hello from zit\n"; /// Largest pure-garbage request generated. A request head longer than this /// adds no new head-parse behavior, and short inputs buy more rounds. const max_garbage: usize = 512; /// The dispatch outcomes a run must actually reach for the target to be worth /// running: the success path and each `classifyXfer` rejection. A generator /// that drifts until every input dies in `receiveHead` still passes every /// no-crash assertion while testing nothing — this list is what makes that /// silent failure loud. Measured at seed 1: 5_000 rounds produced 828x 200, /// 316x 400, 198x 405, 327x 415 (and 165x 413, left out as incidental to the /// Content-Length mutations rather than a distinct dispatch arm). const required_statuses = [_][3]u8{ "200".*, // the framed reply round-trips end to end "400".*, // §4.1 — the responder rejected a corrupt container "405".*, // §4 L762-763 — not a POST "415".*, // §4 r2/r3 — not a request-valid Content-Type }; /// Rounds below this are an inner-loop smoke run, too short to expect the full /// status spread; the coverage assertion applies at or above it. The 256-round /// smoke test sits exactly at this threshold deliberately, so the shortest run /// in the suite is still one that proves it reached every dispatch arm. const min_rounds_for_coverage: usize = 256; /// The store-reading side of the server, wired to the same `handleXfer` the /// CLI's `zit serve` uses — so the fuzzed path is the production path, not a /// stub that would let a dispatch bug through. const FuzzResponder = struct { server: *const sync.Server, store: repostate.ArtifactStore, fn answer( ctx: *anyopaque, gpa: std.mem.Allocator, request_container: []const u8, cookie: ?[]const u8, protect: ?[]const u8, ) serve.AnswerError!serve.XferReply { _ = protect; const self: *const FuzzResponder = @ptrCast(@alignCast(ctx)); return serve.handleXfer(gpa, self.server, self.store, request_container, cookie); } }; /// §4 — raw request bytes through `receiveHead` -> `handleHttpRequest` never /// crash, never leak, and never run unbounded. /// /// Each round draws from one of two populations, roughly half and half: /// /// a. Pure garbage — random bytes of random length, including empty input, /// sweeping head-parse failure modes. /// b. A mutated valid request — a well-formed `POST /xfer` with a correctly /// framed sync body, then damaged (see `mutate`). /// /// Population (b) is load-bearing. Pure garbage essentially never survives /// `receiveHead`, so without it every line below the head parse — dispatch, /// the body read, the responder call, reply framing, the error-respond paths — /// would go unreached and the target would fuzz `std.http`'s parser rather /// than this server. pub fn requestGarbage(gpa: std.mem.Allocator, seed: u64, rounds: usize) !void { var prng = std.Random.DefaultPrng.init(seed); const rand = prng.random(); var ms = repostate.MemStore.init(gpa); defer ms.deinit(); _ = try ms.store().put(held_hash, held_content); const server = sync.Server{ .version = serve.server_version }; var ctx = FuzzResponder{ .server = &server, .store = ms.store() }; const responder = serve.Responder{ .ctx = &ctx, .answerFn = FuzzResponder.answer }; // Built once: every mutated round starts from this same well-formed // request, so a failing seed replays against a fixed baseline. const valid = try validRequest(gpa); defer gpa.free(valid); var garbage_buf: [max_garbage]u8 = undefined; var seen = [_]bool{false} ** required_statuses.len; var round: usize = 0; while (round < rounds) : (round += 1) { // The mutated population allocates; the garbage population is a view // into the stack buffer. `owned` is what needs freeing, so ownership is // stated once instead of being re-derived from a pointer comparison. var owned: ?[]u8 = null; defer if (owned) |o| gpa.free(o); const raw: []const u8 = if (rand.boolean()) blk: { const len = rand.uintLessThan(usize, max_garbage + 1); fillRandom(rand, garbage_buf[0..len]); break :blk garbage_buf[0..len]; } else blk: { const m = try mutate(gpa, rand, valid); owned = m; break :blk m; }; const response = serve.driveHttp(gpa, raw, responder) catch |err| switch (err) { // A head that will not parse is a clean rejection — the expected // outcome for most of population (a). OutOfMemory is the only // cause worth surfacing. error.OutOfMemory => return err, else => continue, }; defer gpa.free(response); // The one shape assertion: if bytes came back at all, they are an // HTTP/1.1 response. Status correctness is pinned deterministically in // serve.zig, not sampled here. if (response.len != 0) { try std.testing.expect(std.mem.startsWith(u8, response, "HTTP/1.1 ")); const code = response["HTTP/1.1 ".len..][0..3]; for (required_statuses, &seen) |want, *hit| { if (std.mem.eql(u8, &want, code)) hit.* = true; } } } // Coverage, asserted rather than assumed — see `required_statuses`. if (rounds >= min_rounds_for_coverage) { for (required_statuses, seen) |want, hit| { if (!hit) { std.debug.print( "serve_request_garbage: no round reached status {s} in {d} rounds — " ++ "the request generator no longer gets past the head parse\n", .{ want, rounds }, ); return error.DispatchUnreached; } } } } /// A well-formed `POST /xfer` carrying a §4.1-framed `gimme` for the held /// artifact: the baseline every mutated round damages, and the one input that /// reaches the full success path (200 + framed reply). fn validRequest(gpa: std.mem.Allocator) ![]u8 { const body = try codec.encode( gpa, "pragma client-version 20000\ngimme " ++ held_hash ++ "\n", .default, ); defer gpa.free(body); const head = try std.fmt.allocPrint( gpa, "POST /xfer HTTP/1.1\r\nHost: z\r\nContent-Type: application/x-fossil\r\n" ++ "Content-Length: {d}\r\nConnection: close\r\n\r\n", .{body.len}, ); defer gpa.free(head); return std.mem.concat(gpa, u8, &.{ head, body }); } /// Damage a valid request in one of the ways a hostile or broken peer would. /// One mutation per round rather than a storm of them: a single defect keeps /// the request close enough to valid to get *past* the head parse, which is /// where the coverage this target exists for lives. Caller owns the result. fn mutate(gpa: std.mem.Allocator, rand: std.Random, valid: []const u8) ![]u8 { const body_at = (std.mem.indexOf(u8, valid, "\r\n\r\n") orelse valid.len - 4) + 4; return switch (rand.uintLessThan(u8, 8)) { // Flip one bit anywhere — head or framed body. 0 => blk: { const copy = try gpa.dupe(u8, valid); const at = rand.uintLessThan(usize, copy.len); copy[at] ^= @as(u8, 1) << rand.int(u3); break :blk copy; }, // Truncate at a random offset (a head cut mid-field, or a body shorter // than its Content-Length promises). 1 => gpa.dupe(u8, valid[0..rand.uintLessThan(usize, valid.len + 1)]), // Corrupt the framed body only, leaving a valid head: this is what // drives the responder's RequestMalformed -> 400 path. 2 => blk: { const copy = try gpa.dupe(u8, valid); if (copy.len > body_at) { const at = body_at + rand.uintLessThan(usize, copy.len - body_at); copy[at] ^= rand.int(u8); } break :blk copy; }, // Lie about, or remove, the Content-Length. 3 => replaceHeader(gpa, valid, "Content-Length: ", switch (rand.uintLessThan(u8, 5)) { 0 => "0", 1 => "4294967295", 2 => "-1", 3 => "not-a-number", else => "999999", }), 4 => dropHeader(gpa, valid, "Content-Length: "), // Substitute the method or the target: the §4 405 path. 5 => blk: { const verbs = [_][]const u8{ "GET", "PUT", "DELETE", "HEAD", "BREW", "" }; const copy = try std.mem.concat(gpa, u8, &.{ verbs[rand.uintLessThan(usize, verbs.len)], valid["POST".len..], }); break :blk copy; }, // Substitute the Content-Type: the §4 415 path, including the // request-invalid reply-only and debug media types. 6 => replaceHeader(gpa, valid, "Content-Type: ", switch (rand.uintLessThan(u8, 5)) { 0 => "text/plain", 1 => "application/x-fossil-uncompressed", 2 => "application/x-fossil-debug", 3 => "application/x-fossil; charset=binary", else => "", }), // Damage the CRLF framing itself, or duplicate a header line. else => blk: { const copy = try gpa.dupe(u8, valid); if (rand.boolean()) { // Turn one CR into something else, unterminating a line. if (std.mem.indexOfScalar(u8, copy, '\r')) |at| copy[at] = rand.int(u8); break :blk copy; } gpa.free(copy); break :blk try std.mem.concat(gpa, u8, &.{ valid[0..body_at], "Host: dup\r\nHost: dup\r\n", valid[body_at..], }); }, }; } /// Replace the value of the first `name:` header line with `value`. A missing /// header (a prior mutation removed it) yields a plain copy. fn replaceHeader(gpa: std.mem.Allocator, request: []const u8, name: []const u8, value: []const u8) ![]u8 { const start = std.mem.indexOf(u8, request, name) orelse return gpa.dupe(u8, request); const value_at = start + name.len; const end = std.mem.indexOfPos(u8, request, value_at, "\r\n") orelse return gpa.dupe(u8, request); return std.mem.concat(gpa, u8, &.{ request[0..value_at], value, request[end..] }); } /// Remove the first `name:` header line entirely. fn dropHeader(gpa: std.mem.Allocator, request: []const u8, name: []const u8) ![]u8 { const start = std.mem.indexOf(u8, request, name) orelse return gpa.dupe(u8, request); const end = std.mem.indexOfPos(u8, request, start, "\r\n") orelse return gpa.dupe(u8, request); return std.mem.concat(gpa, u8, &.{ request[0..start], request[end + 2 ..] }); } /// Random bytes biased toward the octets an HTTP head parser branches on. /// Uniform `u8` yields a request line that dies at the first token every time; /// the bias buys occasional near-requests that reach deeper into the parse. fn fillRandom(rand: std.Random, buf: []u8) void { const interesting = "POST GET /xfer HTTP/1.1\r\n:; \x00"; for (buf) |*b| { b.* = switch (rand.uintLessThan(u8, 4)) { 0, 1 => interesting[rand.uintLessThan(usize, interesting.len)], 2 => 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 "serve §4 fuzz: garbage and mutated requests through the HTTP path stay bounded (smoke)" { try requestGarbage(std.testing.allocator, 0x5e4e, 256); }