Files
nxdns/src/filter/matcher.zig
T

1045 lines
39 KiB
Zig

//! The filtering decision (PLAN §3.10, §7.1, §7.2): query-name normalization,
//! the candidate chain, and the immutable snapshot every query is evaluated
//! against.
//!
//! `Snapshot.evaluate` allocates nothing, opens nothing and reads no clock. It
//! is callable from an `std.Io` task holding one stack buffer and the reader
//! lock its caller already took.
//!
//! The decision is keyed on `{domain, group}`. The qtype travels with the query
//! for logging and for response synthesis; no step of §3.10 reads it, so it is
//! not a parameter here.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const address = @import("../platform/address.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const rules = @import("rules.zig");
const wildcard = @import("wildcard.zig");
pub const Reason = enum {
none,
rule_allow_exact,
rule_block_exact,
rule_allow_wildcard,
rule_block_wildcard,
blocklist_domain,
blocklist_wildcard,
};
pub const Decision = struct {
blocked: bool,
reason: Reason,
/// The candidate (for the exact and blocklist levels) or the pattern (for
/// the wildcard levels) that decided it. Borrowed from the caller's
/// normalized buffer or from the snapshot. "" when `reason == .none`.
matched: []const u8,
/// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and
/// the UI can name the list that blocked the query.
source: ?u32 = null,
};
/// Lowercase ASCII, trailing dot stripped, written into `buf`. Returns a slice
/// of `buf`. The root name normalizes to "".
pub fn normalize(qname: name.Name, buf: *[types.max_name_len]u8) []const u8 {
const wire = qname.wire();
var at: usize = 0;
var out: usize = 0;
while (at < wire.len) {
const label_len = wire[at];
if (label_len == 0) break;
if (out != 0) {
buf[out] = '.';
out += 1;
}
for (wire[at + 1 ..][0..label_len]) |byte| {
buf[out] = std.ascii.toLower(byte);
out += 1;
}
at += 1 + label_len;
}
return buf[0..out];
}
/// The full name, then each parent, ending at the last two-label suffix. The
/// TLD alone is not a candidate: a rule or a list entry on `com` is a
/// configuration mistake that would take the whole internet with it, and
/// refusing to walk that far costs nothing real.
pub const Candidates = struct {
rest: []const u8,
pub fn init(domain: []const u8) Candidates {
return .{ .rest = domain };
}
pub fn next(self: *Candidates) ?[]const u8 {
const dot = std.mem.indexOfScalar(u8, self.rest, '.') orelse {
self.rest = "";
return null;
};
const current = self.rest;
self.rest = self.rest[dot + 1 ..];
return current;
}
};
pub const SourceSets = struct {
/// `blocklist_sources` row id, so the manager can map a decision back to
/// the source row.
id: i64,
/// Snapshot-arena-owned; the source's display name for the UI.
name: []const u8,
domains: domain_set.DomainSet,
wildcards: domain_set.DomainSet,
};
pub const Group = struct {
id: i64,
name: []const u8,
safe_search: bool,
rules: rules.RuleSet,
/// Indices into `Snapshot.sources`, ascending, deduplicated.
sources: []const u32,
};
pub const ClientEntry = struct { key: address.NetAddress.Key, group: u32 };
pub const PrefixEntry = struct { prefix: address.Prefix, group: u32, priority: i32 };
pub const Snapshot = struct {
arena: std.heap.ArenaAllocator,
groups: []Group,
sources: []SourceSets,
clients: []ClientEntry,
prefixes: []PrefixEntry,
/// Index into `groups` of the group named "default". Always valid: `build`
/// returns `error.MissingDefaultGroup` otherwise.
default_group: u32,
/// Monotonic, assigned by the manager. Logged on every swap so an operator
/// can tell which generation answered a query.
generation: u64,
pub const Compiled = struct { list_body: []const u8, wild_body: []const u8 };
pub const Input = struct {
groups: []const model.Group,
/// One `groups` row id per `groups[i]`, in the same order. The config
/// model carries no ids, and a decision has to be mappable back to a
/// database row, so the caller — which read both — supplies them.
group_ids: []const i64,
group_sources: []const model.GroupSource,
sources: []const model.BlocklistSource,
/// One `blocklist_sources` row id per `sources[i]`, in the same order.
source_ids: []const i64,
rules: []const model.Rule,
clients: []const model.Client,
prefixes: []const model.ClientPrefix,
/// One entry per `sources[i]`, in the same order: the compiled bodies
/// already read from disk with their headers stripped. `null` means the
/// files were absent or unreadable; for an enabled source that is
/// `error.MissingCompiledSource`, because a silently unenforced
/// blocklist is exactly the failure PLAN §1.3 exists to prevent. A
/// disabled source needs no entry read.
compiled: []const ?Compiled,
seed: u64,
generation: u64,
};
pub const Error = error{
OutOfMemory,
MissingDefaultGroup,
UnknownGroup,
UnknownSource,
MissingCompiledSource,
BadClientIp,
BadClientPrefix,
} || rules.Error;
/// Builds an immutable snapshot. Every string is copied into the arena, so
/// the caller may free the repository lists immediately afterwards.
/// Disabled sources are skipped entirely — they cost no memory.
pub fn build(gpa: Allocator, input: Input) Error!Snapshot {
std.debug.assert(input.group_ids.len == input.groups.len);
std.debug.assert(input.source_ids.len == input.sources.len);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// `position[i]` is where `input.sources[i]` landed in `sources`, or
// null when the source is disabled and therefore not loaded.
const position = try arena.alloc(?u32, input.sources.len);
var enabled: u32 = 0;
for (input.sources, position) |row, *slot| {
if (!row.enabled) {
slot.* = null;
continue;
}
slot.* = enabled;
enabled += 1;
}
const sources = try arena.alloc(SourceSets, enabled);
for (input.sources, input.source_ids, position, 0..) |row, id, slot, i| {
const at = slot orelse continue;
if (i >= input.compiled.len) return error.MissingCompiledSource;
const bodies = input.compiled[i] orelse return error.MissingCompiledSource;
sources[at] = .{
.id = id,
.name = try arena.dupe(u8, row.name),
.domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
.wildcards = try domain_set.DomainSet.build(arena, bodies.wild_body, input.seed),
};
}
const groups = try arena.alloc(Group, input.groups.len);
var group_rules: std.ArrayList(model.Rule) = .empty;
defer group_rules.deinit(gpa);
var group_sources: std.ArrayList(u32) = .empty;
defer group_sources.deinit(gpa);
for (groups, input.groups, input.group_ids) |*group, row, id| {
group_rules.clearRetainingCapacity();
for (input.rules) |rule_row| {
if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
}
group_sources.clearRetainingCapacity();
for (input.group_sources) |link| {
if (!std.mem.eql(u8, link.group, row.name)) continue;
const at = try findSource(input.sources, position, link.source_url);
// A link to a disabled source is not an error: the source
// exists, it is simply not loaded.
if (at) |index| try group_sources.append(gpa, index);
}
std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));
group.* = .{
.id = id,
.name = try arena.dupe(u8, row.name),
.safe_search = row.safe_search,
.rules = try rules.RuleSet.build(arena, group_rules.items, input.seed),
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
};
}
// Every `group_sources` row must name a group that exists, whether or
// not that group also owns rules.
for (input.group_sources) |link| {
if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
}
const clients = try arena.alloc(ClientEntry, input.clients.len);
for (clients, input.clients) |*entry, row| {
const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
entry.* = .{
.key = addr.key(),
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
};
}
const prefixes = try arena.alloc(PrefixEntry, input.prefixes.len);
for (prefixes, input.prefixes) |*entry, row| {
entry.* = .{
.prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
.priority = row.priority,
};
}
return .{
.arena = arena_state,
.groups = groups,
.sources = sources,
.clients = clients,
.prefixes = prefixes,
.default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup,
.generation = input.generation,
};
}
pub fn deinit(self: *Snapshot) void {
self.arena.deinit();
self.* = undefined;
}
/// PLAN §3.10 precedence, allow winning at equal specificity:
/// 1. exact/parent allow rules 2. exact/parent block rules
/// 3. wildcard allow rules 4. wildcard block rules
/// 5. blocklist domains 6. blocklist wildcards
///
/// The order is level-by-level over the whole candidate chain, not
/// candidate-by-candidate over the levels: level 1 is checked against every
/// candidate before level 2 is checked against any. That is what makes an
/// allow rule on the parent beat a block rule on the child, which is the
/// behaviour an allow list is written for.
///
/// Level 5 tests only the full name and level 6 tests only proper parents:
/// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y`
/// means. Both walk the group's sources in ascending index order, so the
/// reported source is stable for a given snapshot.
///
/// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision {
const g = &self.groups[group];
var level1: Candidates = .init(domain);
while (level1.next()) |candidate| {
if (g.rules.exact_allow.contains(candidate)) {
return .{ .blocked = false, .reason = .rule_allow_exact, .matched = candidate };
}
}
var level2: Candidates = .init(domain);
while (level2.next()) |candidate| {
if (g.rules.exact_block.contains(candidate)) {
return .{ .blocked = true, .reason = .rule_block_exact, .matched = candidate };
}
}
if (matchWildcard(g.rules.wildcard_allow, domain)) |pattern| {
return .{ .blocked = false, .reason = .rule_allow_wildcard, .matched = pattern };
}
if (matchWildcard(g.rules.wildcard_block, domain)) |pattern| {
return .{ .blocked = true, .reason = .rule_block_wildcard, .matched = pattern };
}
for (g.sources) |index| {
if (self.sources[index].domains.contains(domain)) {
return .{
.blocked = true,
.reason = .blocklist_domain,
.matched = domain,
.source = index,
};
}
}
var parents: Candidates = .init(domain);
// The full name is not a proper parent of itself.
_ = parents.next();
while (parents.next()) |parent| {
for (g.sources) |index| {
if (self.sources[index].wildcards.contains(parent)) {
return .{
.blocked = true,
.reason = .blocklist_wildcard,
.matched = parent,
.source = index,
};
}
}
}
return .{ .blocked = false, .reason = .none, .matched = "" };
}
/// PLAN §7.2 matching half: exact client row, else longest-prefix match
/// (ties broken by longer prefix, then by the preferred `priority`, which
/// is the lower number — the convention `upstreams.priority` already uses),
/// else the default group. Inserting the unseen client row needs a clock
/// and a database write on the query path and belongs to the handler.
pub fn groupForClient(self: *const Snapshot, addr: address.NetAddress) u32 {
const key = addr.key();
for (self.clients) |entry| {
if (std.mem.eql(u8, &entry.key, &key)) return entry.group;
}
if (address.matchLongest(PrefixEntry, self.prefixes, addr)) |entry| return entry.group;
return self.default_group;
}
pub fn groupIndexById(self: *const Snapshot, id: i64) ?u32 {
for (self.groups, 0..) |group, i| {
if (group.id == id) return @intCast(i);
}
return null;
}
pub fn groupIndexByName(self: *const Snapshot, name_text: []const u8) ?u32 {
return indexOfGroup(self.groups, name_text);
}
pub fn safeSearch(self: *const Snapshot, group: u32) bool {
return self.groups[group].safe_search;
}
/// The structures this snapshot holds. The arena's own slack is excluded:
/// this number is the regression guard on the compiled data, not a report
/// on the allocator.
pub fn memoryBytes(self: *const Snapshot) usize {
var total: usize = 0;
for (self.sources) |*source| {
total += @sizeOf(SourceSets) + source.name.len +
source.domains.memoryBytes() + source.wildcards.memoryBytes();
}
for (self.groups) |*group| {
total += @sizeOf(Group) + group.name.len +
group.rules.memoryBytes() + group.sources.len * @sizeOf(u32);
}
total += self.clients.len * @sizeOf(ClientEntry);
total += self.prefixes.len * @sizeOf(PrefixEntry);
return total;
}
};
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
fn matchWildcard(patterns: []const []const u8, domain: []const u8) ?[]const u8 {
var it: Candidates = .init(domain);
while (it.next()) |candidate| {
for (patterns) |pattern| {
if (wildcard.matches(pattern, candidate)) return pattern;
}
}
return null;
}
fn indexOfGroup(groups: []const Group, group_name: []const u8) ?u32 {
for (groups, 0..) |group, i| {
if (std.mem.eql(u8, group.name, group_name)) return @intCast(i);
}
return null;
}
/// The position in the loaded `sources` array, or null when the named source
/// exists but is disabled. A URL naming no configured source is an error: the
/// group would silently enforce one list fewer than the operator configured.
fn findSource(
rows: []const model.BlocklistSource,
position: []const ?u32,
url: []const u8,
) error{UnknownSource}!?u32 {
for (rows, position) |row, slot| {
if (std.mem.eql(u8, row.url, url)) return slot;
}
return error.UnknownSource;
}
fn dedupSorted(items: []u32) []const u32 {
var kept: usize = 0;
for (items) |item| {
if (kept > 0 and items[kept - 1] == item) continue;
items[kept] = item;
kept += 1;
}
return items[0..kept];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const Fixture = struct {
groups: []const model.Group = &.{.{ .name = "default" }},
group_ids: []const i64 = &.{1},
group_sources: []const model.GroupSource = &.{},
sources: []const model.BlocklistSource = &.{},
source_ids: []const i64 = &.{},
compiled: []const ?Snapshot.Compiled = &.{},
rules: []const model.Rule = &.{},
clients: []const model.Client = &.{},
prefixes: []const model.ClientPrefix = &.{},
seed: u64 = 0x5eed,
};
fn build(gpa: Allocator, fixture: Fixture) Snapshot.Error!Snapshot {
return Snapshot.build(gpa, .{
.groups = fixture.groups,
.group_ids = fixture.group_ids,
.group_sources = fixture.group_sources,
.sources = fixture.sources,
.source_ids = fixture.source_ids,
.rules = fixture.rules,
.clients = fixture.clients,
.prefixes = fixture.prefixes,
.compiled = fixture.compiled,
.seed = fixture.seed,
.generation = 7,
});
}
fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
}
// --- normalization and the candidate chain ---------------------------------
test "normalize lowercases, strips the trailing dot and empties the root" {
var buf: [types.max_name_len]u8 = undefined;
try testing.expectEqualStrings(
"ads.example.com",
normalize(try name.fromText("ADS.Example.COM."), &buf),
);
try testing.expectEqualStrings("", normalize(try name.fromText("."), &buf));
try testing.expectEqualStrings("com", normalize(try name.fromText("com"), &buf));
}
test "the candidate chain stops before the TLD" {
var it: Candidates = .init("a.b.example.com");
try testing.expectEqualStrings("a.b.example.com", it.next().?);
try testing.expectEqualStrings("b.example.com", it.next().?);
try testing.expectEqualStrings("example.com", it.next().?);
try testing.expect(it.next() == null);
var single: Candidates = .init("com");
try testing.expect(single.next() == null);
var root: Candidates = .init("");
try testing.expect(root.next() == null);
}
// --- precedence table (PLAN §3.10) -----------------------------------------
test "precedence: an exact block rule blocks its own name" {
const rows = [_]model.Rule{rule("ads.example.com", .exact, .block)};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ads.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_exact, decision.reason);
try testing.expectEqualStrings("ads.example.com", decision.matched);
}
test "precedence: an exact block rule on a parent blocks the child" {
const rows = [_]model.Rule{rule("example.com", .exact, .block)};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ads.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_exact, decision.reason);
try testing.expectEqualStrings("example.com", decision.matched);
}
test "precedence: an allow rule on the child beats a block rule on the parent" {
const rows = [_]model.Rule{
rule("example.com", .exact, .block),
rule("ads.example.com", .exact, .allow),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ads.example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
try testing.expectEqualStrings("ads.example.com", decision.matched);
}
test "precedence: an allow rule on the parent beats a block rule on the child" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("example.com", .exact, .allow),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ads.example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
try testing.expectEqualStrings("example.com", decision.matched);
}
test "precedence: an exact block rule beats a wildcard allow rule" {
const rows = [_]model.Rule{
rule("*.example.com", .wildcard, .allow),
rule("ads.example.com", .exact, .block),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ads.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_exact, decision.reason);
}
test "precedence: an allow wildcard beats an identical block wildcard" {
const rows = [_]model.Rule{
rule("*.example.com", .wildcard, .allow),
rule("*.example.com", .wildcard, .block),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "a.example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_wildcard, decision.reason);
try testing.expectEqualStrings("*.example.com", decision.matched);
}
test "precedence: a block wildcard with no allow blocks" {
const rows = [_]model.Rule{rule("*.example.com", .wildcard, .block)};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "a.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_wildcard, decision.reason);
try testing.expectEqualStrings("*.example.com", decision.matched);
}
const one_source = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
const one_source_id = [_]i64{11};
const one_link = [_]model.GroupSource{
.{ .group = "default", .source_url = "https://lists.test/a" },
};
/// Holds the `compiled` array itself: a `Fixture` borrows it, so it has to
/// outlive the `build` call rather than live in a helper's frame.
const Lists = struct {
compiled: [1]?Snapshot.Compiled,
fn init(list_body: []const u8, wild_body: []const u8) Lists {
return .{ .compiled = .{.{ .list_body = list_body, .wild_body = wild_body }} };
}
fn fixture(self: *const Lists) Fixture {
return .{
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &one_link,
.compiled = &self.compiled,
};
}
};
test "precedence: a list entry blocks its own name" {
const lists: Lists = .init("tracker.net\n", "");
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "tracker.net");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.blocklist_domain, decision.reason);
try testing.expectEqualStrings("tracker.net", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: a list entry does not block a subdomain" {
const lists: Lists = .init("tracker.net\n", "");
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "sub.tracker.net");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.none, decision.reason);
}
test "precedence: a wild entry blocks a subdomain" {
const lists: Lists = .init("", "tracker.net\n");
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "sub.tracker.net");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.blocklist_wildcard, decision.reason);
try testing.expectEqualStrings("tracker.net", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: a wild entry does not block the apex" {
const lists: Lists = .init("", "tracker.net\n");
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "tracker.net");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.none, decision.reason);
}
test "precedence: an allow rule beats a wild entry" {
const lists: Lists = .init("", "tracker.net\n");
var fixture = lists.fixture();
const rows = [_]model.Rule{rule("sub.tracker.net", .exact, .allow)};
fixture.rules = &rows;
var snapshot = try build(testing.allocator, fixture);
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "sub.tracker.net");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
}
test "precedence: nothing configured allows with reason none" {
var snapshot = try build(testing.allocator, .{});
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.none, decision.reason);
try testing.expectEqualStrings("", decision.matched);
try testing.expect(decision.source == null);
}
test "precedence: a source assigned to one group does not filter another" {
const groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
const ids = [_]i64{ 1, 2 };
const links = [_]model.GroupSource{
.{ .group = "kids", .source_url = "https://lists.test/a" },
};
var snapshot = try build(testing.allocator, .{
.groups = &groups,
.group_ids = &ids,
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &links,
.compiled = &.{.{ .list_body = "tracker.net\n", .wild_body = "" }},
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expect(snapshot.evaluate(kids, "tracker.net").blocked);
try testing.expect(!snapshot.evaluate(snapshot.default_group, "tracker.net").blocked);
}
test "precedence: a disabled source filters nothing" {
const sources = [_]model.BlocklistSource{
.{ .url = "https://lists.test/a", .name = "list a", .enabled = false },
};
var snapshot = try build(testing.allocator, .{
.sources = &sources,
.source_ids = &one_source_id,
.group_sources = &one_link,
.compiled = &.{null},
});
defer snapshot.deinit();
try testing.expectEqual(@as(usize, 0), snapshot.sources.len);
try testing.expect(!snapshot.evaluate(0, "tracker.net").blocked);
}
// --- group assignment ------------------------------------------------------
const two_groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
const two_group_ids = [_]i64{ 1, 2 };
test "groupForClient matches an exact IPv4 client" {
const clients = [_]model.Client{.{ .ip = "192.168.1.10", .group = "kids" }};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.clients = &clients,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.10")));
}
test "groupForClient matches an exact IPv6 client through the canonical key" {
const clients = [_]model.Client{.{ .ip = "fd00:0:0::1", .group = "kids" }};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.clients = &clients,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("fd00::1")));
}
test "groupForClient matches a prefix" {
const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/24", .group = "kids" }};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.prefixes = &prefixes,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
try testing.expectEqual(
snapshot.default_group,
snapshot.groupForClient(try address.NetAddress.parse("192.168.2.7")),
);
}
test "groupForClient prefers the longer prefix" {
const prefixes = [_]model.ClientPrefix{
.{ .prefix = "192.168.0.0/16", .group = "default" },
.{ .prefix = "192.168.1.0/24", .group = "kids" },
};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.prefixes = &prefixes,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
}
test "groupForClient breaks a prefix tie by the preferred priority" {
const prefixes = [_]model.ClientPrefix{
.{ .prefix = "192.168.1.0/24", .group = "default", .priority = 100 },
.{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 10 },
};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.prefixes = &prefixes,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
}
test "groupForClient prefers an exact client row over a prefix" {
const clients = [_]model.Client{.{ .ip = "192.168.1.7", .group = "default" }};
const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/24", .group = "kids" }};
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
.clients = &clients,
.prefixes = &prefixes,
});
defer snapshot.deinit();
try testing.expectEqual(
snapshot.default_group,
snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")),
);
}
test "groupForClient falls back to the default group" {
var snapshot = try build(testing.allocator, .{});
defer snapshot.deinit();
try testing.expectEqual(
snapshot.default_group,
snapshot.groupForClient(try address.NetAddress.parse("10.0.0.1")),
);
}
// --- build errors ----------------------------------------------------------
test "build without a default group is an error" {
const groups = [_]model.Group{.{ .name = "kids" }};
const ids = [_]i64{2};
try testing.expectError(
error.MissingDefaultGroup,
build(testing.allocator, .{ .groups = &groups, .group_ids = &ids }),
);
}
test "build with a link to an unknown source is an error" {
const links = [_]model.GroupSource{
.{ .group = "default", .source_url = "https://lists.test/missing" },
};
try testing.expectError(error.UnknownSource, build(testing.allocator, .{
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &links,
.compiled = &.{.{ .list_body = "", .wild_body = "" }},
}));
}
test "build with a link from an unknown group is an error" {
const links = [_]model.GroupSource{
.{ .group = "ghosts", .source_url = "https://lists.test/a" },
};
try testing.expectError(error.UnknownGroup, build(testing.allocator, .{
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &links,
.compiled = &.{.{ .list_body = "", .wild_body = "" }},
}));
}
test "build with an enabled source that has no compiled bodies is an error" {
try testing.expectError(error.MissingCompiledSource, build(testing.allocator, .{
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &one_link,
.compiled = &.{null},
}));
}
test "build with a bad client address is an error" {
const clients = [_]model.Client{.{ .ip = "not-an-ip" }};
try testing.expectError(
error.BadClientIp,
build(testing.allocator, .{ .clients = &clients }),
);
}
test "build with a bad client prefix is an error" {
const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/33" }};
try testing.expectError(
error.BadClientPrefix,
build(testing.allocator, .{ .prefixes = &prefixes }),
);
}
test "build with a client in an unknown group is an error" {
const clients = [_]model.Client{.{ .ip = "192.168.1.1", .group = "ghosts" }};
try testing.expectError(
error.UnknownGroup,
build(testing.allocator, .{ .clients = &clients }),
);
}
// --- snapshot properties ---------------------------------------------------
test "the snapshot owns copies of every input string" {
const gpa = testing.allocator;
const group_name = try gpa.dupe(u8, "default");
const source_url = try gpa.dupe(u8, "https://lists.test/a");
const source_name = try gpa.dupe(u8, "list a");
const pattern = try gpa.dupe(u8, "ads.example.com");
const body = try gpa.dupe(u8, "tracker.net\n");
var snapshot = blk: {
const groups = [_]model.Group{.{ .name = group_name }};
const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
const links = [_]model.GroupSource{.{ .group = group_name, .source_url = source_url }};
const rows = [_]model.Rule{
.{ .group = group_name, .pattern = pattern, .kind = .exact, .action = .block },
};
break :blk try build(gpa, .{
.groups = &groups,
.group_ids = &.{1},
.sources = &sources,
.source_ids = &one_source_id,
.group_sources = &links,
.compiled = &.{.{ .list_body = body, .wild_body = "" }},
.rules = &rows,
});
};
defer snapshot.deinit();
for ([_][]u8{ group_name, source_url, source_name, pattern, body }) |owned| {
@memset(owned, 'x');
gpa.free(owned);
}
try testing.expectEqualStrings("default", snapshot.groups[0].name);
try testing.expectEqualStrings("list a", snapshot.sources[0].name);
try testing.expect(snapshot.evaluate(0, "ads.example.com").blocked);
try testing.expect(snapshot.evaluate(0, "tracker.net").blocked);
}
test "decisions do not depend on the seed" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("*.wild.example.com", .wildcard, .block),
rule("ok.wild.example.com", .exact, .allow),
};
const lists: Lists = .init("tracker.net\n", "wildlist.net\n");
var fixture = lists.fixture();
fixture.rules = &rows;
var a = try build(testing.allocator, fixture);
defer a.deinit();
fixture.seed = 0xdead_beef_cafe_f00d;
var b = try build(testing.allocator, fixture);
defer b.deinit();
var buf: [64]u8 = undefined;
var i: usize = 0;
while (i < 40) : (i += 1) {
const domain = try std.fmt.bufPrint(&buf, "n{d}.wild.example.com", .{i});
const da = a.evaluate(0, domain);
const db = b.evaluate(0, domain);
try testing.expectEqual(da.blocked, db.blocked);
try testing.expectEqual(da.reason, db.reason);
try testing.expectEqualStrings(da.matched, db.matched);
}
for ([_][]const u8{ "ads.example.com", "ok.wild.example.com", "tracker.net", "x.wildlist.net", "unrelated.org" }) |domain| {
const da = a.evaluate(0, domain);
const db = b.evaluate(0, domain);
try testing.expectEqual(da.blocked, db.blocked);
try testing.expectEqual(da.reason, db.reason);
try testing.expectEqualStrings(da.matched, db.matched);
}
}
test "group lookup by id and by name" {
var snapshot = try build(testing.allocator, .{
.groups = &two_groups,
.group_ids = &two_group_ids,
});
defer snapshot.deinit();
try testing.expectEqual(@as(?u32, 0), snapshot.groupIndexById(1));
try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexById(2));
try testing.expect(snapshot.groupIndexById(99) == null);
try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexByName("kids"));
try testing.expect(snapshot.groupIndexByName("ghosts") == null);
try testing.expectEqual(@as(u64, 7), snapshot.generation);
}
test "safeSearch reports the group setting" {
const groups = [_]model.Group{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
};
var snapshot = try build(testing.allocator, .{
.groups = &groups,
.group_ids = &two_group_ids,
});
defer snapshot.deinit();
try testing.expect(!snapshot.safeSearch(0));
try testing.expect(snapshot.safeSearch(1));
}
test "memoryBytes stays within the compiled-data bound" {
const gpa = testing.allocator;
const count = 10_000;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
var line: [64]u8 = undefined;
var i: usize = 0;
while (i < count) : (i += 1) {
try body.appendSlice(gpa, try std.fmt.bufPrint(&line, "d{d:0>5}.example.com\n", .{i}));
}
const lists: Lists = .init(body.items, "");
var snapshot = try build(gpa, lists.fixture());
defer snapshot.deinit();
// arena + index, over both bodies, plus a kilobyte of struct overhead.
const index_bytes = 2 * 16 * 1024 * @sizeOf(u32);
try testing.expect(snapshot.memoryBytes() < 2 * body.items.len + index_bytes + 1024);
try testing.expect(snapshot.evaluate(0, "d00042.example.com").blocked);
}
fn buildUnderFailure(gpa: Allocator) !void {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("*.tracker.net", .wildcard, .block),
};
const clients = [_]model.Client{.{ .ip = "192.168.1.10" }};
const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.2.0/24" }};
const lists: Lists = .init("tracker.net\n", "wildlist.net\n");
var fixture = lists.fixture();
fixture.rules = &rows;
fixture.clients = &clients;
fixture.prefixes = &prefixes;
var snapshot = try build(gpa, fixture);
defer snapshot.deinit();
try testing.expect(snapshot.evaluate(0, "x.wildlist.net").blocked);
}
test "build leaks nothing under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}