Skip to content
zit Artifact [7db2ce6177]
Login

Artifact [7db2ce6177]

Artifact 7db2ce6177024f5a16de80878d7bb5c03892171775242902c476abed635661f6:


//! 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);
}