Skip to content
zit Artifact [43c310c51a]
Login

Artifact [43c310c51a]

Artifact 43c310c51a6502fb2e049e6f43dfa151b1a632e6c885554ec6edce4ec62a8a48:


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