milestone 15: make a green run mean a real pass
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
//! Fuzz target for the blocklist compiler's streaming line loop
|
||||
//! (`src/filter/compiler.zig`).
|
||||
//!
|
||||
//! `blocklist_fuzz.zig` covers the line parsers; this file covers the loop that
|
||||
//! drives them. That loop is where the reader and the parsers meet, and it holds
|
||||
//! three arms no parser target can reach: `takeDelimiter` returning a line,
|
||||
//! `error.StreamTooLong` followed by `discardDelimiterInclusive`, and that
|
||||
//! discard hitting end of stream on a final over-long line with no newline.
|
||||
//!
|
||||
//! The contract: any byte string is a legal blocklist, so `compile` may classify
|
||||
//! it however it likes but must return — never panic, never loop forever, never
|
||||
//! read out of bounds. Where it returns the target then checks what the manager
|
||||
//! is entitled to rely on:
|
||||
//!
|
||||
//! - every per-line counter is bounded by the number of lines in the input,
|
||||
//! and every per-candidate counter by its length, so no line is counted
|
||||
//! twice and the discard arm cannot re-read bytes it already consumed;
|
||||
//! - the written counts never exceed `max_domains`;
|
||||
//! - a compiled body is a pure function of (bytes, format): the same input
|
||||
//! compiled twice gives the same counts and the same checksum.
|
||||
//!
|
||||
//! `compiler.zig` imports `../dns/`, so a module rooted under `src/filter/`
|
||||
//! fails with `ImportOutsideModulePath`. The root here is the staged-copy
|
||||
//! aggregator `build.zig` already builds for the bench harness, imported as
|
||||
//! `core`.
|
||||
//!
|
||||
//! Runner semantics: under a plain `zig build test` the target runs once per
|
||||
//! corpus entry plus once on empty input, which makes the corpus a regression
|
||||
//! suite. `zig build test --fuzz=<n>` gives it `n` generated inputs.
|
||||
|
||||
const std = @import("std");
|
||||
const core = @import("core");
|
||||
|
||||
const compiler = core.compiler;
|
||||
const Smith = std.testing.Smith;
|
||||
|
||||
/// The aggregator exposes `compiler`, and `compiler.zig` keeps its own
|
||||
/// `parsers` import private, so the format enum is read off the signature of the
|
||||
/// function under test rather than imported. That also keeps the target honest
|
||||
/// if a format is ever added: `formats` grows with the enum.
|
||||
const Format = @typeInfo(@TypeOf(compiler.compile)).@"fn".params[2].type.?;
|
||||
const formats = std.enums.values(Format);
|
||||
|
||||
/// Comfortably past `compiler.max_line_len`, so a single generated input can
|
||||
/// hold an over-long line and the lines around it.
|
||||
const max_input = 16384;
|
||||
|
||||
/// The upper bound on the reader buffer the target hands `compile`. A buffer
|
||||
/// under `max_line_len` reports an over-long line as `error.StreamTooLong` and
|
||||
/// takes the discard arm; a buffer over it reports the line whole and takes the
|
||||
/// length check at compiler.zig:84. Both are reachable inside this range.
|
||||
const max_reader_buf = 8192;
|
||||
|
||||
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
|
||||
|
||||
test "fuzz compiler.compile" {
|
||||
try std.testing.fuzz({}, compileTarget, fuzz_options);
|
||||
}
|
||||
|
||||
fn compileTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const bytes = buf[0..smith.slice(&buf)];
|
||||
const format = formats[smith.index(formats.len)];
|
||||
const reader_buf_len = smith.valueRangeAtMost(u32, 64, max_reader_buf);
|
||||
|
||||
const first = (try compileOnce(bytes, format, reader_buf_len)) orelse return;
|
||||
|
||||
try expectConsistent(first.counts, bytes);
|
||||
|
||||
// Determinism is the property the whole design rests on (compiler.zig's
|
||||
// header comment): two runs over the same bytes agree byte for byte.
|
||||
const second = (try compileOnce(bytes, format, reader_buf_len)) orelse
|
||||
return error.TestSecondRunFailed;
|
||||
try std.testing.expectEqual(first.counts, second.counts);
|
||||
try std.testing.expectEqualSlices(u8, &first.checksum, &second.checksum);
|
||||
}
|
||||
|
||||
/// One compile into discarding writers, or null when the compiler rejected the
|
||||
/// input. Every member of `compiler.Error` is a legitimate rejection: an
|
||||
/// allocator that ran out, a list past `max_domains`, and the two stream
|
||||
/// failures the fixed reader and the discarding writers cannot actually raise.
|
||||
fn compileOnce(
|
||||
bytes: []const u8,
|
||||
format: Format,
|
||||
reader_buf_len: u32,
|
||||
) anyerror!?compiler.Result {
|
||||
var backing: std.Io.Reader = .fixed(bytes);
|
||||
var reader_buf: [max_reader_buf]u8 = undefined;
|
||||
var limited = backing.limited(.unlimited, reader_buf[0..reader_buf_len]);
|
||||
|
||||
var list_sink: [0]u8 = .{};
|
||||
var list_w: std.Io.Writer.Discarding = .init(&list_sink);
|
||||
var wild_sink: [0]u8 = .{};
|
||||
var wild_w: std.Io.Writer.Discarding = .init(&wild_sink);
|
||||
|
||||
return compiler.compile(
|
||||
std.testing.allocator,
|
||||
&limited.interface,
|
||||
format,
|
||||
&list_w.writer,
|
||||
&wild_w.writer,
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory,
|
||||
error.TooManyDomains,
|
||||
error.ReadFailed,
|
||||
error.WriteFailed,
|
||||
=> null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The counters have to add up against the input that produced them.
|
||||
fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void {
|
||||
// Every line the loop classifies ends at a newline or at the end of the
|
||||
// input, so no per-line counter can exceed the number of lines.
|
||||
const lines = std.mem.count(u8, bytes, "\n") + 1;
|
||||
try std.testing.expect(counts.long_lines <= lines);
|
||||
try std.testing.expect(counts.skipped_regex <= lines);
|
||||
try std.testing.expect(counts.skipped_unsupported <= lines);
|
||||
|
||||
// A candidate is a non-empty whitespace-separated field or a whole wildcard
|
||||
// line, so every candidate consumes at least one byte of the input, and a
|
||||
// written name is a candidate that survived.
|
||||
const candidates = @as(u64, counts.domains) + counts.wildcards +
|
||||
counts.duplicates + counts.invalid;
|
||||
try std.testing.expect(candidates <= bytes.len + 1);
|
||||
|
||||
try std.testing.expect(counts.domains <= compiler.max_domains);
|
||||
try std.testing.expect(counts.wildcards <= compiler.max_domains);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// corpus
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `Smith` does not consume a corpus entry as raw input. It reads a byte stream
|
||||
// in which a slice is a little-endian `u32` length followed by that many bytes,
|
||||
// so every entry below is length-prefixed. An entry that carries only the slice
|
||||
// leaves the format index and the reader-buffer length at the low end of their
|
||||
// ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the
|
||||
// common case.
|
||||
|
||||
/// Past `compiler.max_line_len`, so the discard arm at compiler.zig:72 replays
|
||||
/// from the corpus rather than waiting on a discovery.
|
||||
const long_line = "a" ** 5000 ++ ".example.com";
|
||||
|
||||
/// The same line as the last line of the input, with nothing behind it: the
|
||||
/// discard then hits end of stream, which is the `break` at compiler.zig:73.
|
||||
const long_line_unterminated = "0.0.0.0 kept.example.com\n" ++ long_line;
|
||||
const long_line_terminated = long_line_unterminated ++ "\n0.0.0.0 after.example.com\n";
|
||||
|
||||
/// Encodes `bytes` as a single `Smith.slice` value.
|
||||
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
|
||||
return &struct {
|
||||
const value: [4 + bytes.len]u8 = blk: {
|
||||
var buf: [4 + bytes.len]u8 = undefined;
|
||||
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
|
||||
buf[4..].* = bytes[0..bytes.len].*;
|
||||
break :blk buf;
|
||||
};
|
||||
}.value;
|
||||
}
|
||||
|
||||
const corpus = [_][]const u8{
|
||||
sliceInput(long_line_unterminated),
|
||||
sliceInput(long_line_terminated),
|
||||
sliceInput("# a hosts list\n0.0.0.0 ads.example.com # advertising\n"),
|
||||
sliceInput("||ads.example.net^\n@@||allow.example.net^\n/re[0-9]+/\n"),
|
||||
sliceInput("*.wild.example.org\nlocalhost\nAdS.Example.COM.\n"),
|
||||
};
|
||||
|
||||
test "the unterminated corpus entry ends on an over-long line" {
|
||||
try std.testing.expect(!std.mem.endsWith(u8, long_line_unterminated, "\n"));
|
||||
const last = std.mem.findScalarLast(u8, long_line_unterminated, '\n').? + 1;
|
||||
try std.testing.expect(long_line_unterminated.len - last > compiler.max_line_len);
|
||||
}
|
||||
|
||||
test "a corpus entry carries its own length" {
|
||||
const encoded = sliceInput(long_line);
|
||||
try std.testing.expectEqual(
|
||||
@as(u32, long_line.len),
|
||||
std.mem.readInt(u32, encoded[0..4], .little),
|
||||
);
|
||||
try std.testing.expectEqualSlices(u8, long_line, encoded[4..]);
|
||||
}
|
||||
+57
-1
@@ -10,7 +10,10 @@
|
||||
//! - a `Name` that `parse` accepted ends inside the packet and survives a
|
||||
//! round trip through presentation form;
|
||||
//! - a buffer that `decrementTtls` aged still parses, and no record it aged
|
||||
//! holds a TTL below the minimum it reported.
|
||||
//! holds a TTL below the minimum it reported;
|
||||
//! - a query that `stripEcs` rewrote still parses, still carries a valid OPT
|
||||
//! record, no longer carries an ECS option, and kept all four of its
|
||||
//! section counts.
|
||||
//!
|
||||
//! The targets stay inside the documented safe entry points. `setId` is called
|
||||
//! only on a buffer long enough to hold a header, because it asserts that
|
||||
@@ -54,6 +57,10 @@ test "fuzz packet.decrementTtls" {
|
||||
try std.testing.fuzz({}, ttlTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz edns.stripEcs" {
|
||||
try std.testing.fuzz({}, stripEcsTarget, fuzz_options);
|
||||
}
|
||||
|
||||
fn parseTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const bytes = buf[0..smith.slice(&buf)];
|
||||
@@ -136,6 +143,55 @@ fn ttlTarget(_: void, smith: *Smith) anyerror!void {
|
||||
}
|
||||
}
|
||||
|
||||
/// `stripEcs` is the only attacker-facing entry point that rewrites a packet, so
|
||||
/// it is the only one where a finding can be a wrong output rather than a crash.
|
||||
///
|
||||
/// The input is derived exactly as `parseTarget` derives it, because `stripEcs`
|
||||
/// asserts its preconditions rather than returning an error: `query` must be the
|
||||
/// same bytes `pkt` was parsed from, and `out` must not overlap them. `out` is a
|
||||
/// separate stack buffer for that reason, and tripping either assertion from a
|
||||
/// hand-built argument would report a fault no packet can cause.
|
||||
fn stripEcsTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const bytes = buf[0..smith.slice(&buf)];
|
||||
|
||||
const p = packet.parse(bytes) catch return;
|
||||
const opt_record = packet.findOptRecord(p) orelse return;
|
||||
const opt = edns.parseOpt(bytes, opt_record) catch return;
|
||||
|
||||
// Removing an option only ever shortens the query, so a buffer the size of
|
||||
// the input always holds the rewrite.
|
||||
var out: [max_input]u8 = undefined;
|
||||
const result = edns.stripEcs(bytes, p, opt, &out) catch return;
|
||||
const rewritten = switch (result) {
|
||||
.unchanged => return,
|
||||
.rewritten => |message| message,
|
||||
};
|
||||
|
||||
try std.testing.expect(rewritten.len <= bytes.len);
|
||||
|
||||
const stripped = try packet.parse(rewritten);
|
||||
try std.testing.expectEqual(p.header.id, stripped.header.id);
|
||||
try std.testing.expectEqual(p.header.qdcount, stripped.header.qdcount);
|
||||
try std.testing.expectEqual(p.header.ancount, stripped.header.ancount);
|
||||
try std.testing.expectEqual(p.header.nscount, stripped.header.nscount);
|
||||
try std.testing.expectEqual(p.header.arcount, stripped.header.arcount);
|
||||
|
||||
const stripped_record = packet.findOptRecord(stripped) orelse
|
||||
return error.TestOptRecordLost;
|
||||
const stripped_opt = try edns.parseOpt(rewritten, stripped_record);
|
||||
try std.testing.expectEqual(opt.udp_payload_size, stripped_opt.udp_payload_size);
|
||||
try std.testing.expectEqual(opt.do_bit, stripped_opt.do_bit);
|
||||
|
||||
var options = edns.options(rewritten, stripped_opt);
|
||||
while (try options.next()) |option| {
|
||||
try std.testing.expect(option.code != edns.ecs_option_code);
|
||||
}
|
||||
try std.testing.expect(
|
||||
(try edns.findOption(rewritten, stripped_opt, edns.ecs_option_code)) == null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs every typed RDATA accessor over a record. Each one rejects a record of
|
||||
/// the wrong type or a truncated RDATA, so only a panic is a finding here.
|
||||
fn sweepRdata(bytes: []const u8, rec: record.Record) void {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Fuzz targets for the HTTP request parsers (`src/web/http_util.zig`).
|
||||
//!
|
||||
//! These read the third untrusted-byte family nxdns accepts: the request line
|
||||
//! and the query string a browser — or anything else on the LAN — sends. Every
|
||||
//! target holds the same contract as the DNS and blocklist targets: rejecting
|
||||
//! bytes with an error is correct, panicking or reading out of bounds is not.
|
||||
//!
|
||||
//! Two invariants the file itself states are what a success has to satisfy:
|
||||
//!
|
||||
//! - split before decode. `parsePath` cuts segments at `/` and only then
|
||||
//! percent-decodes each one, so a `%2F` inside a segment stays inside it.
|
||||
//! The decoded segment does hold a literal `/` — `http_util.zig`'s own test
|
||||
//! asserts that — but it is one segment, not two. So the property is a
|
||||
//! count: the segmentation is the one the *raw* bytes describe, and a
|
||||
//! decoder that ran before the split would hand back more segments than
|
||||
//! the raw bytes have.
|
||||
//! - decode only shrinks. `decodeInPlace` writes behind its own read cursor,
|
||||
//! so the result is never longer than its input and always aliases the front
|
||||
//! of the same buffer. If either ever stopped holding, the write cursor
|
||||
//! would have passed the read cursor and the decoder would be reading bytes
|
||||
//! it had already overwritten.
|
||||
//!
|
||||
//! `http_util.zig` imports only `std`, so the module here roots straight at it;
|
||||
//! no staged aggregator is involved.
|
||||
//!
|
||||
//! Runner semantics: under a plain `zig build test` each target runs once per
|
||||
//! corpus entry plus once on empty input, which makes the corpus a regression
|
||||
//! suite. `zig build test --fuzz=<n>` gives each target `n` generated inputs.
|
||||
|
||||
const std = @import("std");
|
||||
const http_util = @import("http_util");
|
||||
|
||||
const Smith = std.testing.Smith;
|
||||
|
||||
/// A target longer than this is a 414 before it reaches any parser
|
||||
/// (`http_util.max_target_len`), so a longer input buys no new code paths.
|
||||
const max_input = 4096;
|
||||
|
||||
/// `Smith` entity ids. The query target needs stable, distinct ids for its query
|
||||
/// string and its key; the single-slice targets take the first.
|
||||
const primary_hash: u32 = 1;
|
||||
const secondary_hash: u32 = 2;
|
||||
|
||||
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
|
||||
|
||||
test "fuzz http_util.parsePath" {
|
||||
try std.testing.fuzz({}, pathTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz http_util.decodeInPlace" {
|
||||
try std.testing.fuzz({}, decodeTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz http_util.queryValue" {
|
||||
try std.testing.fuzz({}, queryTarget, fuzz_options);
|
||||
}
|
||||
|
||||
fn pathTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const len = smith.sliceWithHash(&buf, primary_hash);
|
||||
const input = buf[0..len];
|
||||
|
||||
// `parsePath` decodes in place, so the raw request line is kept: it is what
|
||||
// the segmentation has to agree with.
|
||||
var raw_buf: [max_input]u8 = undefined;
|
||||
@memcpy(raw_buf[0..len], input);
|
||||
const raw = raw_buf[0..len];
|
||||
|
||||
const path = http_util.parsePath(input) catch return;
|
||||
|
||||
try std.testing.expect(path.len <= http_util.max_path_segments);
|
||||
|
||||
// Every non-empty run between two `/` in the raw bytes is one segment, in
|
||||
// order. A non-empty run always decodes to at least one byte, so the two
|
||||
// sequences are the same length and pair up.
|
||||
var chunks = std.mem.splitScalar(u8, raw, '/');
|
||||
var i: usize = 0;
|
||||
while (chunks.next()) |chunk| {
|
||||
if (chunk.len == 0) continue;
|
||||
try std.testing.expect(i < path.len);
|
||||
const segment = path.segments()[i];
|
||||
// Decode only shrinks, per segment.
|
||||
try std.testing.expect(segment.len <= chunk.len);
|
||||
try expectAliases(segment, input);
|
||||
i += 1;
|
||||
}
|
||||
try std.testing.expectEqual(path.len, i);
|
||||
}
|
||||
|
||||
fn decodeTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const len = smith.sliceWithHash(&buf, primary_hash);
|
||||
const input = buf[0..len];
|
||||
|
||||
// Both rules are fed the same bytes: `+` is a space in a query string and an
|
||||
// ordinary character in a path, and neither reading may change the bound.
|
||||
for ([_]http_util.PlusRule{ .literal_plus, .plus_is_space }) |rule| {
|
||||
var scratch: [max_input]u8 = undefined;
|
||||
@memcpy(scratch[0..len], input);
|
||||
const decoded = http_util.decodeInPlace(scratch[0..len], rule) catch continue;
|
||||
try std.testing.expect(decoded.len <= len);
|
||||
try expectPrefixOf(decoded, scratch[0..len]);
|
||||
}
|
||||
}
|
||||
|
||||
fn queryTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var query_buf: [max_input]u8 = undefined;
|
||||
var key_buf: [max_input]u8 = undefined;
|
||||
const query = query_buf[0..smith.sliceWithHash(&query_buf, primary_hash)];
|
||||
const key = key_buf[0..smith.sliceWithHash(&key_buf, secondary_hash)];
|
||||
|
||||
var out: [http_util.max_query_value_len]u8 = undefined;
|
||||
const value = (http_util.queryValue(query, key, &out) catch return) orelse return;
|
||||
|
||||
// The decoded value lives at the front of the caller's buffer, which is what
|
||||
// lets a handler keep it for the length of the request.
|
||||
try expectPrefixOf(value, &out);
|
||||
|
||||
// Decode only shrinks, measured against the raw pair the walker found.
|
||||
var it = http_util.queryPairs(query);
|
||||
while (it.next()) |pair| {
|
||||
if (!std.mem.eql(u8, pair.key, key)) continue;
|
||||
try std.testing.expect(value.len <= pair.value.len);
|
||||
return;
|
||||
}
|
||||
return error.TestValueWithoutPair;
|
||||
}
|
||||
|
||||
/// A result the parsers hand back is a window into the caller's buffer, never a
|
||||
/// copy and never a pointer into a temporary.
|
||||
fn expectAliases(result: []const u8, buffer: []const u8) !void {
|
||||
if (result.len == 0) return;
|
||||
const start = @intFromPtr(result.ptr);
|
||||
const base = @intFromPtr(buffer.ptr);
|
||||
try std.testing.expect(start >= base);
|
||||
try std.testing.expect(start + result.len <= base + buffer.len);
|
||||
}
|
||||
|
||||
/// The stronger form the in-place decoders owe: the result starts where the
|
||||
/// input started. A decoder that writes ahead of its read cursor cannot satisfy
|
||||
/// this and a shorter-but-moved slice would slip past `expectAliases`.
|
||||
fn expectPrefixOf(result: []const u8, buffer: []const u8) !void {
|
||||
try std.testing.expect(result.len <= buffer.len);
|
||||
if (buffer.len == 0) return;
|
||||
try std.testing.expectEqual(@intFromPtr(buffer.ptr), @intFromPtr(result.ptr));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// corpus
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `Smith` does not consume a corpus entry as raw parser input. It reads a byte
|
||||
// stream in which a slice is a little-endian `u32` length followed by that many
|
||||
// bytes, so every entry below is length-prefixed. The three targets share one
|
||||
// corpus: each starts with a slice, and the query target reads a second one that
|
||||
// falls back to empty when an entry carries only the first.
|
||||
|
||||
/// A route the router actually matches, with an id capture.
|
||||
const api_path = "/api/groups/12";
|
||||
|
||||
/// The encoded slash: the byte pattern the split-before-decode rule exists for.
|
||||
/// It decodes to `a/b/../etc` inside one segment and must stay one segment.
|
||||
const encoded_slash = "/api/rules/a%2Fb%2F..%2Fetc";
|
||||
|
||||
/// One segment past `max_path_segments`.
|
||||
const deep_path = "/1/2/3/4/5/6/7/8/9";
|
||||
|
||||
/// The three malformed escapes `decodeInPlace` refuses rather than passes
|
||||
/// through, and a plus that means different things under the two rules.
|
||||
const bad_escapes = "/%2/%/%zz/a+b";
|
||||
|
||||
/// Encodes `bytes` as a single `Smith.slice` value.
|
||||
fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 {
|
||||
return &struct {
|
||||
const value: [4 + bytes.len]u8 = blk: {
|
||||
var buf: [4 + bytes.len]u8 = undefined;
|
||||
std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little);
|
||||
buf[4..].* = bytes[0..bytes.len].*;
|
||||
break :blk buf;
|
||||
};
|
||||
}.value;
|
||||
}
|
||||
|
||||
/// Encodes two `Smith.slice` values back to back, which is what the query target
|
||||
/// reads as its query string and its key.
|
||||
fn pairInput(comptime a: []const u8, comptime b: []const u8) *const [8 + a.len + b.len]u8 {
|
||||
return &struct {
|
||||
const value: [8 + a.len + b.len]u8 = blk: {
|
||||
var buf: [8 + a.len + b.len]u8 = undefined;
|
||||
buf[0 .. 4 + a.len].* = sliceInput(a).*;
|
||||
buf[4 + a.len ..].* = sliceInput(b).*;
|
||||
break :blk buf;
|
||||
};
|
||||
}.value;
|
||||
}
|
||||
|
||||
const corpus = [_][]const u8{
|
||||
sliceInput(api_path),
|
||||
sliceInput(encoded_slash),
|
||||
sliceInput(deep_path),
|
||||
sliceInput(bad_escapes),
|
||||
sliceInput("/api//groups/"),
|
||||
// The query shapes the API defines, each with the key that reads it.
|
||||
pairInput("domain=a+b&limit=250", "domain"),
|
||||
pairInput("domain=%61%2Fb&blocked=1", "domain"),
|
||||
pairInput("a=1&&b&c=", "c"),
|
||||
pairInput("domain=" ++ "x" ** 1024, "domain"),
|
||||
pairInput("domain=%zz", "domain"),
|
||||
};
|
||||
|
||||
test "a corpus entry carries its own length" {
|
||||
const encoded = sliceInput(api_path);
|
||||
try std.testing.expectEqual(
|
||||
@as(u32, api_path.len),
|
||||
std.mem.readInt(u32, encoded[0..4], .little),
|
||||
);
|
||||
try std.testing.expectEqualSlices(u8, api_path, encoded[4..]);
|
||||
}
|
||||
|
||||
test "a paired corpus entry carries both lengths" {
|
||||
const encoded = pairInput("a=1", "a");
|
||||
try std.testing.expectEqual(@as(u32, 3), std.mem.readInt(u32, encoded[0..4], .little));
|
||||
try std.testing.expectEqualSlices(u8, "a=1", encoded[4..7]);
|
||||
try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, encoded[7..11], .little));
|
||||
try std.testing.expectEqualSlices(u8, "a", encoded[11..]);
|
||||
}
|
||||
Reference in New Issue
Block a user