milestone 21: abp list exceptions and a regex rule kind

This commit is contained in:
2026-08-13 19:14:47 +02:00
parent b340521716
commit 2ab7c1f1de
51 changed files with 4016 additions and 465 deletions
+24 -3
View File
@@ -9,8 +9,10 @@
//!
//! - `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;
//! - `covers_apex` is set only on a `.wildcard` or an `.exception` line,
//! because those are the two anchored forms it describes; the compiler acts
//! on it for `.wildcard`, where it emits the apex entry beside the suffix
//! one;
//! - `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.
@@ -75,6 +77,11 @@ fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void {
const parsed = parsers.parseLine(format, line);
try expectBorrowed(parsed, line);
// Exception syntax belongs to the ABP parser alone. A hosts or domains line
// that produced one would open an allow hole in a format that has no way to
// write one.
if (format != .abp) try std.testing.expect(parsed.kind != .exception);
}
/// The sniffer reads whole files, so this one keeps the line breaks.
@@ -113,7 +120,12 @@ fn wildcardTarget(_: void, smith: *Smith) anyerror!void {
/// 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.covers_apex) {
try std.testing.expect(parsed.kind == .wildcard or parsed.kind == .exception);
}
// An exception with no name would compile to an empty allow entry, which
// `addCandidate` would then reject as invalid rather than honour.
if (parsed.kind == .exception) try std.testing.expect(parsed.text.len != 0);
if (parsed.text.len == 0) return;
const start = @intFromPtr(parsed.text.ptr);
@@ -146,6 +158,12 @@ 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^";
/// The exception forms: the two anchored spellings, the one tolerated modifier,
/// and a bare `@@` name, which stays unsupported.
const abp_exception = "@@||good.ads.example.net^";
const abp_exception_important = "@@||good.ads.example.net^$important";
const abp_exception_unanchored = "@@good.ads.example.net";
/// A regex rule, which every parser counts and skips (PLAN §2.2).
const regex_line = "/^ads[0-9]+\\.example\\.org$/";
@@ -161,6 +179,9 @@ const scheme_anchor = "|https://ads.example.com/track";
const corpus = [_][]const u8{
sliceInput(hosts_line),
sliceInput(abp_line),
sliceInput(abp_exception),
sliceInput(abp_exception_important),
sliceInput(abp_exception_unanchored),
sliceInput(regex_line),
sliceInput(long_line),
sliceInput(element_hiding),
+8 -1
View File
@@ -94,6 +94,8 @@ fn compileOnce(
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);
var allow_sink: [0]u8 = .{};
var allow_w: std.Io.Writer.Discarding = .init(&allow_sink);
return compiler.compile(
std.testing.allocator,
@@ -101,6 +103,7 @@ fn compileOnce(
format,
&list_w.writer,
&wild_w.writer,
&allow_w.writer,
) catch |err| switch (err) {
error.OutOfMemory,
error.TooManyDomains,
@@ -122,12 +125,13 @@ fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void {
// 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 +
const candidates = @as(u64, counts.domains) + counts.wildcards + counts.exceptions +
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);
try std.testing.expect(counts.exceptions <= compiler.max_domains);
}
// ---------------------------------------------------------------------------
@@ -154,6 +158,9 @@ const corpus = [_][]const u8{
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"),
// The three exception shapes: the two accepted spellings with the one
// tolerated modifier, and a form that stays unsupported.
sliceInput("@@||a.example.net^$important\n@@||b.example.net\n@@c.example.net\n"),
sliceInput("*.wild.example.org\nlocalhost\nAdS.Example.COM.\n"),
};
+149
View File
@@ -0,0 +1,149 @@
//! Fuzz target for the regex engine (`src/filter/regex.zig`, milestone-21
//! ruling 10).
//!
//! The contract: any byte string is a legal pattern, so `compile` may reject it
//! however it likes but must return — never panic, never loop forever, never
//! read out of bounds. Where it returns a program the target then checks what
//! the filter is entitled to rely on:
//!
//! - the program obeys ruling 5's limits: it is non-empty, at most
//! `max_program_len` instructions, and it came from a pattern of at most
//! `max_pattern_len` bytes;
//! - `matches` terminates on any input, and the VM's step count never exceeds
//! program length × (input length + 1), which is the linearity claim the
//! whole design rests on;
//! - compile-then-match is deterministic: the same pattern compiled twice
//! gives the same program length and the same verdict on the same input,
//! and two runs of one program agree step for step.
//!
//! `regex.zig` imports only `std`, so this target's module roots directly at
//! that file — no aggregator needed.
//!
//! 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 regex = @import("regex");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const Smith = std.testing.Smith;
/// Twice `regex.max_pattern_len`, so `error.PatternTooLong` is reachable rather
/// than the only thing the target ever sees.
const max_pattern = 2 * regex.max_pattern_len;
/// Past the 253 bytes of the longest text name, which is the longest input the
/// filter ever hands the engine.
const max_name = 512;
/// `Smith` entity ids: the pattern and the name it is matched against.
const pattern_hash: u32 = 1;
const name_hash: u32 = 2;
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
test "fuzz regex.compile and regex.matches" {
try std.testing.fuzz({}, regexTarget, fuzz_options);
}
fn regexTarget(_: void, smith: *Smith) anyerror!void {
var pattern_buf: [max_pattern]u8 = undefined;
var name_buf: [max_name]u8 = undefined;
const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)];
const input = name_buf[0..smith.sliceWithHash(&name_buf, name_hash)];
var prog = (try compileOnce(pattern)) orelse return;
defer prog.deinit(std.testing.allocator);
// Ruling 5's limits, read off the program the compiler agreed to build.
try std.testing.expect(pattern.len <= regex.max_pattern_len);
try std.testing.expect(prog.insts.len > 0);
try std.testing.expect(prog.insts.len <= regex.max_program_len);
const first = regex.run(&prog, input);
try expectLinear(first, prog.insts.len, input.len);
try std.testing.expectEqual(first.matched, regex.matches(&prog, input));
// The empty name is the cheapest way to reach the position-zero closure with
// no consuming step behind it, so every pattern is run against it too.
try expectLinear(regex.run(&prog, ""), prog.insts.len, 0);
const again = regex.run(&prog, input);
try std.testing.expectEqual(first.matched, again.matched);
try std.testing.expectEqual(first.steps, again.steps);
// Compiling is a pure function of the pattern bytes: the second program
// matches the first instruction for instruction and answers the same.
var second = (try compileOnce(pattern)) orelse return error.TestSecondCompileFailed;
defer second.deinit(std.testing.allocator);
try std.testing.expectEqual(prog.insts.len, second.insts.len);
try std.testing.expectEqual(prog.classes.len, second.classes.len);
try std.testing.expectEqual(first.matched, regex.matches(&second, input));
}
/// One compile, or null when the engine rejected the pattern. Every member of
/// `regex.Error` is a legitimate rejection: unparsable syntax, a pattern past
/// the byte limit, a program past the instruction limit, and an allocator that
/// ran out.
fn compileOnce(pattern: []const u8) anyerror!?regex.Program {
return regex.compile(std.testing.allocator, pattern) catch |err| switch (err) {
error.OutOfMemory,
error.BadPattern,
error.PatternTooLong,
error.PatternTooComplex,
=> null,
};
}
fn expectLinear(result: regex.Run, program_len: usize, input_len: usize) !void {
try std.testing.expect(result.steps <= program_len * (input_len + 1));
}
// ---------------------------------------------------------------------------
// corpus
// ---------------------------------------------------------------------------
//
// `Smith` does not consume a corpus entry as raw input, so every entry below
// goes through the `smith_encode.zig` encoders. An entry that carries only the
// pattern leaves the name empty, which is the position-zero closure on its own.
/// The backtracker killers: exponential for a backtracking engine, linear here.
const nested_plus = "(a+)+b";
const nested_alternation = "^(a|aa)+$";
const nested_star = "^(a*)*(b*)*$";
/// An epsilon cycle: the loop body consumes nothing, so only the VM's
/// one-admission-per-position rule ends the walk.
const empty_loop = "^((a*)*)*$";
/// A name long enough that a quadratic step count would show against the bound.
const long_name = "a" ** 252 ++ "X";
/// Emits nothing at all, so only the emitter's visit budget ends the compile.
const empty_body_blowup = "((((x{0}){900}){900}){900}){900}";
const corpus = [_][]const u8{
pairInput(nested_plus, "a" ** 20 ++ "X"),
pairInput(nested_alternation, long_name),
pairInput(nested_star, long_name),
pairInput(empty_loop, long_name),
pairInput("^ad[0-9]+-", "ad42-serve.example.com"),
pairInput("^(ads|track)\\.example\\.(com|net)$", "track.example.net"),
pairInput("[^.]+\\.doubleclick\\.net$", "static.doubleclick.net"),
pairInput("^\\w{1,8}\\.\\d{2}\\.example$", "ads_42.13.example"),
// Every rejection path, so the corpus replays them rather than waiting on a
// discovery: bad syntax, an over-long pattern, an over-large program, and a
// compile that only the visit budget stops.
sliceInput("(a"),
sliceInput("[z-a]"),
sliceInput("a{2,1}"),
sliceInput("ads\\"),
sliceInput("(?:ab)"),
sliceInput("a+?"),
sliceInput("a" ** (regex.max_pattern_len + 1)),
sliceInput("(abcd){400}"),
sliceInput(empty_body_blowup),
};