1045 lines
39 KiB
Zig
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, .{});
|
|
}
|