Files
nxdns/src/filter/rules.zig
T

631 lines
26 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! One group's explicit rules (PLAN §3.10 levels 16), compiled once into an
//! immutable form the query path can read without allocating.
//!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
//! array of strings and are scanned linearly. Operator-authored wildcards are
//! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over
//! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap.
//!
//! Regex patterns are compiled here, once per snapshot, into the linear-time
//! programs of `regex.zig` and scanned the same way. `max_regex_per_group` caps
//! them far lower, at 256: a regex costs a whole VM run where a wildcard costs a
//! label comparison, and the matcher reaches them only after every hash and
//! wildcard level has missed.
//!
//! Pure: allocators and plain values, no `std.Io`, no clock, no entropy
//! source. The hash seed arrives as a parameter.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const regex = @import("regex.zig");
const wildcard = @import("wildcard.zig");
pub const Error = error{
OutOfMemory,
BadPattern,
TooManyWildcards,
TooManyRegexRules,
} || domain_set.DomainSet.Error;
/// Both wildcard lists of one group together. The cap exists so a rules table
/// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096;
/// Both regex lists of one group together, capped well below the wildcards: a
/// miss at this level runs every program to its end.
pub const max_regex_per_group: usize = 256;
/// A compiled operator regex beside the text it was written as. The text is what
/// `Decision.matched` reports, so the query log names the rule the operator
/// wrote rather than an instruction count.
pub const RegexRule = struct {
pattern: []const u8,
program: regex.Program,
/// Frees through a copy of the program: the slices holding these rules are
/// `const`, and `Program.deinit` wants a mutable pointer only to blank the
/// struct it is finished with.
fn free(self: RegexRule, gpa: Allocator) void {
var program = self.program;
program.deinit(gpa);
}
};
pub const RuleSet = struct {
exact_allow: domain_set.DomainSet = .empty,
exact_block: domain_set.DomainSet = .empty,
/// Normalized and sorted, so a rebuild of the same rows produces the same
/// order and the same first match.
wildcard_allow: []const []const u8 = &.{},
wildcard_block: []const []const u8 = &.{},
/// Sorted and deduplicated like the wildcards, so the first regex to match a
/// name is the same one on every rebuild of the same rows.
regex_allow: []const RegexRule = &.{},
regex_block: []const RegexRule = &.{},
/// One block holding the pattern bytes of all four lists; freed as a unit.
pattern_bytes: []const u8 = &.{},
pub const empty: RuleSet = .{};
/// `rows` are one group's rules only; splitting `listRules` output by group
/// belongs to the caller, which is the only holder of the group table.
///
/// Name patterns are normalized (lowercase over ASCII, one trailing dot
/// stripped) and validated: `.exact` through `dns.name.fromText`,
/// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An
/// invalid pattern is `error.BadPattern`, not a skipped row — every pattern
/// passed `config/validate.zig` on the way in, so an invalid one here means
/// the rows were edited underneath nxdns and a silently dropped allow rule
/// would block a domain the operator unblocked.
///
/// Two allocators, because the caller's `perm` is a snapshot arena: an
/// arena reclaims only its most recent allocation, so every temporary taken
/// from it would live as long as the snapshot and go unreported by
/// `memoryBytes`. `perm` owns what the returned set retains and is what
/// `deinit` frees; `scratch` owns the build's working storage, which is
/// released by the time `build` returns. Passing one allocator as both is
/// correct wherever freeing works normally.
pub fn build(
perm: Allocator,
scratch: Allocator,
rows: []const model.Rule,
seed: u64,
) Error!RuleSet {
if (rows.len == 0) return .empty;
var joined: std.ArrayList(u8) = .empty;
defer joined.deinit(scratch);
var spans: [6]std.ArrayList(Span) = @splat(.empty);
defer for (&spans) |*bucket| bucket.deinit(scratch);
var wildcards: usize = 0;
var regexes: usize = 0;
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const pattern = switch (row.kind) {
.exact => blk: {
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
_ = name.fromText(text) catch return error.BadPattern;
break :blk text;
},
.wildcard => blk: {
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
wildcard.validate(text) catch return error.BadPattern;
wildcards += 1;
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
break :blk text;
},
// A regex is not a name, so `normalize` must not touch it: it
// strips a trailing `.`, which here is the any-byte atom, and it
// lowercases, which turns the rejected `\D` into the accepted
// `\d`. Either would silently change what the rule matches. The
// bytes stay as the operator wrote them — the same bytes
// `config/validate.zig` compiled at the edge. Compiling waits
// until after the sort, so a duplicate is compiled once.
.regex => blk: {
regexes += 1;
if (regexes > max_regex_per_group) return error.TooManyRegexRules;
break :blk row.pattern;
},
};
const bucket = &spans[bucketOf(row.kind, row.action)];
try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len });
try joined.appendSlice(scratch, pattern);
}
// `joined` stops growing here, so spans can become slices of it.
var sorted: [6]std.ArrayList([]const u8) = @splat(.empty);
defer for (&sorted) |*bucket| bucket.deinit(scratch);
for (&spans, &sorted) |*bucket, *out| {
try out.ensureTotalCapacityPrecise(scratch, bucket.items.len);
for (bucket.items) |span| {
out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]);
}
std.mem.sort([]const u8, out.items, {}, lessThanBytes);
dedupSorted(out);
}
var self: RuleSet = .empty;
errdefer self.deinit(perm);
self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed);
self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed);
const wild_allow = sorted[bucketOf(.wildcard, .allow)].items;
const wild_block = sorted[bucketOf(.wildcard, .block)].items;
const re_allow = sorted[bucketOf(.regex, .allow)].items;
const re_block = sorted[bucketOf(.regex, .block)].items;
var total: usize = 0;
for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| {
for (list) |pattern| total += pattern.len;
}
const bytes = try perm.alloc(u8, total);
self.pattern_bytes = bytes;
var at: usize = 0;
self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at);
self.wildcard_block = try copyPatterns(perm, wild_block, bytes, &at);
self.regex_allow = try compilePatterns(perm, scratch, re_allow, bytes, &at);
self.regex_block = try compilePatterns(perm, scratch, re_block, bytes, &at);
return self;
}
pub fn deinit(self: *RuleSet, gpa: Allocator) void {
self.exact_allow.deinit(gpa);
self.exact_block.deinit(gpa);
gpa.free(self.wildcard_allow);
gpa.free(self.wildcard_block);
freeRules(gpa, self.regex_allow);
freeRules(gpa, self.regex_block);
gpa.free(self.pattern_bytes);
self.* = .empty;
}
pub fn memoryBytes(self: *const RuleSet) usize {
var programs: usize = 0;
for (self.regex_allow) |item| programs += item.program.memoryBytes();
for (self.regex_block) |item| programs += item.program.memoryBytes();
return self.exact_allow.memoryBytes() +
self.exact_block.memoryBytes() +
self.pattern_bytes.len +
programs +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) +
(self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule);
}
};
/// The first regex of `list` that matches `domain`, or null. `list` is sorted,
/// so "first" is stable across rebuilds of the same rows. The caller checks the
/// allow list before the block list, as it does for wildcards.
pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 {
for (list) |item| {
if (regex.matches(&item.program, domain)) return item.pattern;
}
return null;
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
/// Patterns are recorded as offsets because `scratch` reallocates while it
/// grows; they become slices only after the collecting pass ends.
const Span = struct { offset: usize, len: usize };
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
const kind_base: usize = switch (kind) {
.exact => 0,
.wildcard => 2,
.regex => 4,
};
const action_offset: usize = switch (action) {
.allow => 0,
.block => 1,
};
return kind_base + action_offset;
}
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
/// Duplicate rows are removed rather than rejected: two identical block rules
/// are not a corrupt database, and `DomainSet.build` requires a strictly
/// ascending body.
fn dedupSorted(list: *std.ArrayList([]const u8)) void {
var kept: usize = 0;
for (list.items) |item| {
if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue;
list.items[kept] = item;
kept += 1;
}
list.shrinkRetainingCapacity(kept);
}
fn buildSet(
perm: Allocator,
scratch: Allocator,
patterns: []const []const u8,
seed: u64,
) Error!domain_set.DomainSet {
if (patterns.len == 0) return .empty;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(scratch);
for (patterns) |pattern| {
try body.appendSlice(scratch, pattern);
try body.append(scratch, '\n');
}
return domain_set.DomainSet.build(perm, body.items, seed);
}
fn copyPatterns(
perm: Allocator,
patterns: []const []const u8,
bytes: []u8,
at: *usize,
) Error![]const []const u8 {
if (patterns.len == 0) return &.{};
const out = try perm.alloc([]const u8, patterns.len);
for (out, patterns) |*slot, pattern| {
@memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = bytes[at.*..][0..pattern.len];
at.* += pattern.len;
}
return out;
}
/// Copies the pattern texts into `bytes` like `copyPatterns` and compiles each
/// one. A compile failure is `error.BadPattern` whichever of the engine's three
/// refusals fired: the pattern already passed `config/validate.zig`, which names
/// the limit, so a row that fails here was written around that check.
///
/// Compiling into `scratch` and cloning across is what keeps a parse-time AST
/// out of `perm`: `regex.compile` builds the AST, the child lists and the
/// growing instruction buffer through the allocator it returns the program on.
fn compilePatterns(
perm: Allocator,
scratch: Allocator,
patterns: []const []const u8,
bytes: []u8,
at: *usize,
) Error![]const RegexRule {
if (patterns.len == 0) return &.{};
const out = try perm.alloc(RegexRule, patterns.len);
var built: usize = 0;
errdefer {
for (out[0..built]) |item| item.free(perm);
perm.free(out);
}
for (out, patterns) |*slot, pattern| {
var compiled = regex.compile(scratch, pattern) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.BadPattern,
};
defer compiled.deinit(scratch);
const program = try compiled.clone(perm);
@memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = .{ .pattern = bytes[at.*..][0..pattern.len], .program = program };
at.* += pattern.len;
built += 1;
}
return out;
}
fn freeRules(gpa: Allocator, list: []const RegexRule) void {
for (list) |item| item.free(gpa);
gpa.free(list);
}
const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything.
///
/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which
/// `name.fromText` would reject, so this variant skips that check and rejects
/// control bytes and space instead.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80 or byte < 0x21) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
return buf[0..rest.len];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
}
test "exact rules land in the matching set" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expect(!set.exact_block.contains("good.example.com"));
try testing.expect(set.exact_allow.contains("good.example.com"));
try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len);
try testing.expectEqual(@as(usize, 0), set.wildcard_block.len);
}
test "wildcard rules land in the matching list, sorted" {
const rows = [_]model.Rule{
rule("*.z.example.com", .wildcard, .block),
rule("*.a.example.com", .wildcard, .block),
rule("*.allowed.example.com", .wildcard, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]);
try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]);
try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len);
try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]);
}
test "patterns are normalized to lowercase without a trailing dot" {
const rows = [_]model.Rule{
rule("ADS.Example.COM.", .exact, .block),
rule("*.Tracker.NET.", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}
test "duplicate rows collapse to one entry" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("ads.example.com.", .exact, .block),
rule("*.x.example.com", .wildcard, .block),
rule("*.x.example.com", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 1), set.exact_block.count);
try testing.expectEqual(@as(usize, 1), set.wildcard_block.len);
}
test "an invalid exact pattern is an error" {
for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .exact, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
}
}
test "regex rules land in their own buckets, compiled and sorted" {
const rows = [_]model.Rule{
rule("^zz", .regex, .block),
rule("^aa", .regex, .block),
rule("ok$", .regex, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.regex_block.len);
try testing.expectEqualStrings("^aa", set.regex_block[0].pattern);
try testing.expectEqualStrings("^zz", set.regex_block[1].pattern);
try testing.expectEqual(@as(usize, 1), set.regex_allow.len);
try testing.expectEqualStrings("ok$", set.regex_allow[0].pattern);
try testing.expectEqualStrings("^aa", matchRegex(set.regex_block, "aabb.example").?);
try testing.expect(matchRegex(set.regex_block, "bbaa.example") == null);
try testing.expectEqualStrings("ok$", matchRegex(set.regex_allow, "example.ok").?);
}
test "a regex pattern keeps the bytes the operator wrote" {
// `normalize` would strip the trailing dot and lowercase the escape, and
// either edit would change what the pattern matches. The exact and wildcard
// kinds still normalize; only this one is exempt.
const rows = [_]model.Rule{
rule("ADS\\.Example\\.", .regex, .block),
rule("ADS.Example.", .exact, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqualStrings("ADS\\.Example\\.", set.regex_block[0].pattern);
try testing.expect(set.exact_block.contains("ads.example"));
}
test "duplicate regex rows collapse to one compiled program" {
const rows = [_]model.Rule{
rule("^ad[0-9]+-", .regex, .block),
rule("^ad[0-9]+-", .regex, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), set.regex_block.len);
}
test "a regex pattern the engine refuses is an error, not a skipped row" {
for ([_][]const u8{ "(", "", "a+?", "[z-a]", "\\s" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
}
// The size limits arrive as `BadPattern` too: which one fired is
// `config/validate.zig`'s to report, and by here the row is simply wrong.
const long = [_]model.Rule{rule("a" ** 300, .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &long, 0));
const complex = [_]model.Rule{rule("(abcdefghij){200}", .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &complex, 0));
}
test "too many regex rules is an error" {
const gpa = testing.allocator;
const rows = try gpa.alloc(model.Rule, max_regex_per_group + 1);
defer gpa.free(rows);
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
for (rows, 0..) |*row, i| {
const pattern = try std.fmt.allocPrint(gpa, "^n{d}-", .{i});
try patterns.append(gpa, pattern);
row.* = rule(pattern, .regex, .block);
}
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
// The cap counts both actions together, like the wildcard one.
rows[0].action = .allow;
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
try testing.expectEqual(@as(usize, 256), max_regex_per_group);
}
test "an invalid wildcard pattern is an error" {
for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
}
}
test "too many wildcards is an error" {
const gpa = testing.allocator;
const rows = try gpa.alloc(model.Rule, max_wildcards_per_group + 1);
defer gpa.free(rows);
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
for (rows, 0..) |*row, i| {
const pattern = try std.fmt.allocPrint(gpa, "*.n{d}.example.com", .{i});
try patterns.append(gpa, pattern);
row.* = rule(pattern, .wildcard, .block);
}
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0));
}
test "an empty rule list builds the empty set" {
var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0);
defer set.deinit(testing.allocator);
try testing.expect(!set.exact_block.contains("ads.example.com"));
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
}
test "the empty rule set owns nothing" {
var set: RuleSet = .empty;
try testing.expect(!set.exact_allow.contains("x.example.com"));
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
set.deinit(testing.allocator);
}
test "memoryBytes counts every part" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("*.tracker.net", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
try testing.expect(set.memoryBytes() >= "*.tracker.net".len);
// A compiled program is the largest thing a rule set holds, so leaving it
// out would make the snapshot's memory report a fiction.
const with_regex = [_]model.Rule{ rows[0], rows[1], rule("^ad[0-9]+-", .regex, .block) };
var wider = try RuleSet.build(testing.allocator, testing.allocator, &with_regex, 0);
defer wider.deinit(testing.allocator);
try testing.expect(wider.memoryBytes() > set.memoryBytes() + "^ad[0-9]+-".len);
try testing.expect(wider.memoryBytes() >= wider.regex_block[0].program.memoryBytes());
}
test "the build's temporaries stay out of the permanent allocator" {
// The property the two-allocator split exists for. An arena reclaims only
// its most recent allocation, so a temporary taken from `perm` would live
// as long as the arena and be invisible to `memoryBytes`. Two checks, one
// per direction: `testing.allocator` fails the test if anything the set
// retains was taken from `scratch`, and the arena's capacity fails it if
// the build's working storage was taken from `perm`.
const gpa = testing.allocator;
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
var rows: std.ArrayList(model.Rule) = .empty;
defer rows.deinit(gpa);
var i: usize = 0;
while (i < 64) : (i += 1) {
const regex_pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.ads\\.invalid$", .{i});
try patterns.append(gpa, regex_pattern);
try rows.append(gpa, rule(regex_pattern, .regex, .block));
const wild = try std.fmt.allocPrint(gpa, "*.w{d:0>5}.example.com", .{i});
try patterns.append(gpa, wild);
try rows.append(gpa, rule(wild, .wildcard, .block));
const exact = try std.fmt.allocPrint(gpa, "e{d:0>5}.example.com", .{i});
try patterns.append(gpa, exact);
try rows.append(gpa, rule(exact, .exact, .block));
}
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const set = try RuleSet.build(arena.allocator(), gpa, rows.items, 0x5eed);
try testing.expectEqual(@as(usize, 64), set.regex_block.len);
try testing.expect(set.exact_block.contains("e00007.example.com"));
// Whole-arena capacity against what the set says it holds. The slack is the
// allocator's page rounding; the defect this guards against was a factor of
// twelve.
try testing.expect(arena.queryCapacity() < 2 * set.memoryBytes());
}
fn buildUnderFailure(gpa: Allocator) !void {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow),
rule("*.tracker.net", .wildcard, .block),
rule("*.ok.tracker.net", .wildcard, .allow),
rule("^ad[0-9]+-", .regex, .block),
rule("\\.ok\\.", .regex, .allow),
};
var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed);
defer set.deinit(gpa);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
try testing.expectEqualStrings("^ad[0-9]+-", set.regex_block[0].pattern);
}
test "build leaks nothing under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}