631 lines
26 KiB
Zig
631 lines
26 KiB
Zig
//! One group's explicit rules (PLAN §3.10 levels 1–6), 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, .{});
|
||
}
|