185 lines
8.1 KiB
Zig
185 lines
8.1 KiB
Zig
//! 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..]);
|
|
}
|