//! 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, .{}); }