//! 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 repeat = @import("zit").repeat;
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 = repeat("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 L818-819 — 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,
front: serve.Front,
request_compressed: bool,
) serve.AnswerError!serve.XferReply {
_ = front;
const self: *const FuzzResponder = @ptrCast(@alignCast(ctx));
return serve.handleXfer(gpa, self.server, self.store, request_container, cookie, request_compressed);
}
};
/// §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 = @as([required_statuses.len]bool, @splat(false));
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);
}