//! 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.PeerBusy,
error.ConnectionFailed,
error.InsecureCredential,
=> {},
}
}
/// 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);
}
}