milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//! Fuzz targets for the blocklist line parsers and the wildcard matcher
|
||||
//! (`src/filter/parsers.zig` and its siblings).
|
||||
//!
|
||||
//! Every target holds the same contract: any byte string is a legal blocklist
|
||||
//! line, so a parser may classify it however it likes but may not panic, may
|
||||
//! not read out of bounds and may not fail to return. Where a classification
|
||||
//! succeeds the target then checks the invariant the compiler is entitled to
|
||||
//! rely on:
|
||||
//!
|
||||
//! - `Line.text` is always a slice of the caller's line, never a copy and
|
||||
//! never a dangling pointer into a temporary;
|
||||
//! - `covers_apex` is set only on a `.wildcard` line, because the compiler
|
||||
//! reads it only there;
|
||||
//! - `wildcard.matches` terminates for any pattern, validated or not, and a
|
||||
//! match implies the domain has at least as many labels as the pattern,
|
||||
//! since every pattern label consumes at least one domain label.
|
||||
//!
|
||||
//! `parsers.parseLine` documents that its line carries no `\n` and no `\r`, so
|
||||
//! each target cuts the fuzzer's bytes at the first one rather than handing the
|
||||
//! parser input the compiler could never produce.
|
||||
//!
|
||||
//! This file is the root of its own test artifact and reaches the parsers
|
||||
//! through the `parsers` module, which is why those five files import nothing
|
||||
//! outside `src/filter/`.
|
||||
//!
|
||||
//! 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 parsers = @import("parsers");
|
||||
|
||||
const wildcard = parsers.wildcard;
|
||||
const Smith = std.testing.Smith;
|
||||
|
||||
/// Long enough to hold a line past `compiler.max_line_len`, which is the
|
||||
/// longest line the compiler ever hands a parser.
|
||||
const max_input = 8192;
|
||||
|
||||
/// `Smith` entity ids. The two-slice target needs stable, distinct ids for its
|
||||
/// pattern and its domain; the single-slice targets take the first.
|
||||
const pattern_hash: u32 = 1;
|
||||
const domain_hash: u32 = 2;
|
||||
|
||||
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
|
||||
|
||||
test "fuzz parser_hosts.parseLine" {
|
||||
try std.testing.fuzz(parsers.Format.hosts, formatTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz parser_domains.parseLine" {
|
||||
try std.testing.fuzz(parsers.Format.domains, formatTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz parser_abp.parseLine" {
|
||||
try std.testing.fuzz(parsers.Format.abp, formatTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz parsers.detectFormat" {
|
||||
try std.testing.fuzz({}, detectTarget, fuzz_options);
|
||||
}
|
||||
|
||||
test "fuzz wildcard.validate and wildcard.matches" {
|
||||
try std.testing.fuzz({}, wildcardTarget, fuzz_options);
|
||||
}
|
||||
|
||||
/// One format's parser, reached through the dispatcher the compiler uses.
|
||||
fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)];
|
||||
const line = upToNewline(input);
|
||||
|
||||
const parsed = parsers.parseLine(format, line);
|
||||
try expectBorrowed(parsed, line);
|
||||
}
|
||||
|
||||
/// The sniffer reads whole files, so this one keeps the line breaks.
|
||||
fn detectTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var buf: [max_input]u8 = undefined;
|
||||
const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)];
|
||||
|
||||
const format = parsers.detectFormat(input);
|
||||
|
||||
// Whatever the sniffer decides, every line of the same bytes must survive
|
||||
// that format's parser: this is the pairing the manager performs.
|
||||
var it = std.mem.splitScalar(u8, input, '\n');
|
||||
while (it.next()) |raw| {
|
||||
const line = upToNewline(raw);
|
||||
try expectBorrowed(parsers.parseLine(format, line), line);
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcardTarget(_: void, smith: *Smith) anyerror!void {
|
||||
var pattern_buf: [max_input]u8 = undefined;
|
||||
var domain_buf: [max_input]u8 = undefined;
|
||||
const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)];
|
||||
const domain = domain_buf[0..smith.sliceWithHash(&domain_buf, domain_hash)];
|
||||
|
||||
// `matches` is total on unvalidated input by design, so both the accepted
|
||||
// and the rejected pattern are fed in. Production only ever reaches it with
|
||||
// an accepted one, which is why the accepted case carries the invariant.
|
||||
const accepted = if (wildcard.validate(pattern)) |_| true else |_| false;
|
||||
const matched = wildcard.matches(pattern, domain);
|
||||
|
||||
if (accepted and matched) {
|
||||
try std.testing.expect(labelCount(domain) >= labelCount(pattern));
|
||||
}
|
||||
}
|
||||
|
||||
/// The parser contract: `text` is a window into the caller's line, so the
|
||||
/// compiler may keep it for the length of that line and no longer.
|
||||
fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void {
|
||||
if (parsed.covers_apex) try std.testing.expectEqual(parsers.Kind.wildcard, parsed.kind);
|
||||
if (parsed.text.len == 0) return;
|
||||
|
||||
const start = @intFromPtr(parsed.text.ptr);
|
||||
const line_start = @intFromPtr(line.ptr);
|
||||
try std.testing.expect(start >= line_start);
|
||||
try std.testing.expect(start + parsed.text.len <= line_start + line.len);
|
||||
}
|
||||
|
||||
fn upToNewline(input: []const u8) []const u8 {
|
||||
const end = std.mem.findAny(u8, input, "\r\n") orelse input.len;
|
||||
return input[0..end];
|
||||
}
|
||||
|
||||
fn labelCount(text: []const u8) usize {
|
||||
return std.mem.count(u8, text, ".") + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 five targets share one
|
||||
// corpus: each starts with a slice, and the wildcard target reads a second one
|
||||
// that falls back to empty when an entry carries only the first.
|
||||
|
||||
/// A hosts line with a sink address, two names and a trailing comment.
|
||||
const hosts_line = "0.0.0.0 ads.example.com tracker.example.com # advertising";
|
||||
|
||||
/// An ABP domain rule, which covers the apex as well as the subdomains.
|
||||
const abp_line = "||ads.example.net^";
|
||||
|
||||
/// A regex rule, which every parser counts and skips (PLAN §2.2).
|
||||
const regex_line = "/^ads[0-9]+\\.example\\.org$/";
|
||||
|
||||
/// Past `compiler.max_line_len`, so the over-long path is a seed rather than a
|
||||
/// discovery.
|
||||
const long_line = "a" ** 5000 ++ ".example.com";
|
||||
|
||||
/// An element-hiding rule and a scheme anchor: the two `.unsupported` shapes
|
||||
/// that carry a domain in front of them.
|
||||
const element_hiding = "example.com##.ad-banner";
|
||||
const scheme_anchor = "|https://ads.example.com/track";
|
||||
|
||||
/// 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 wildcard
|
||||
/// target reads.
|
||||
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(hosts_line),
|
||||
sliceInput(abp_line),
|
||||
sliceInput(regex_line),
|
||||
sliceInput(long_line),
|
||||
sliceInput(element_hiding),
|
||||
sliceInput(scheme_anchor),
|
||||
// A whole small file, so `detectFormat` sees more than one line.
|
||||
sliceInput("# a hosts list\n" ++ hosts_line ++ "\n" ++ regex_line ++ "\n"),
|
||||
// The two wildcard shapes PLAN §3.9 names, each with a name that matches.
|
||||
pairInput("*.doubleclick.net", "a.b.doubleclick.net"),
|
||||
pairInput("ads.*.example.com", "ads.eu.west.example.com"),
|
||||
// A pattern that no name matches, and one `validate` rejects.
|
||||
pairInput("*.example.com", "example.com.evil.net"),
|
||||
pairInput("ad*.example.com", "ads.example.com"),
|
||||
};
|
||||
|
||||
test "a corpus entry carries its own length" {
|
||||
const encoded = sliceInput(abp_line);
|
||||
try std.testing.expectEqual(@as(u32, abp_line.len), std.mem.readInt(u32, encoded[0..4], .little));
|
||||
try std.testing.expectEqualSlices(u8, abp_line, encoded[4..]);
|
||||
}
|
||||
|
||||
test "a paired corpus entry carries both lengths" {
|
||||
const encoded = pairInput("*.a.b", "x.a.b");
|
||||
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[0..4], .little));
|
||||
try std.testing.expectEqualSlices(u8, "*.a.b", encoded[4..9]);
|
||||
try std.testing.expectEqual(@as(u32, 5), std.mem.readInt(u32, encoded[9..13], .little));
|
||||
try std.testing.expectEqualSlices(u8, "x.a.b", encoded[13..]);
|
||||
}
|
||||
Reference in New Issue
Block a user