milestone 5: blocklist filtering, local records and conditional forwarding

This commit is contained in:
2026-08-01 16:43:55 +02:00
parent 3baf5d6581
commit 59d94df722
29 changed files with 10257 additions and 81 deletions
+349
View File
@@ -0,0 +1,349 @@
//! One group's explicit rules (PLAN §3.10 levels 14), compiled once into an
//! immutable form the query path can read without allocating.
//!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
//! array of strings and are scanned linearly. Operator-authored wildcards are
//! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over
//! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap.
//!
//! Pure: an allocator and plain values, no `std.Io`, no clock, no entropy
//! source. The hash seed arrives as a parameter.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const wildcard = @import("wildcard.zig");
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error;
/// Both wildcard lists of one group together. The cap exists so a rules table
/// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096;
pub const RuleSet = struct {
exact_allow: domain_set.DomainSet = .empty,
exact_block: domain_set.DomainSet = .empty,
/// Normalized and sorted, so a rebuild of the same rows produces the same
/// order and the same first match.
wildcard_allow: []const []const u8 = &.{},
wildcard_block: []const []const u8 = &.{},
/// One block holding the bytes of both wildcard lists; freed as a unit.
wildcard_bytes: []const u8 = &.{},
pub const empty: RuleSet = .{};
/// `rows` are one group's rules only; splitting `listRules` output by group
/// belongs to the caller, which is the only holder of the group table.
///
/// Patterns are normalized (lowercase over ASCII, one trailing dot
/// stripped) and validated: `.exact` through `dns.name.fromText`,
/// `.wildcard` through `wildcard.validate`. An invalid pattern is
/// `error.BadPattern`, not a skipped row — every pattern passed
/// `config/validate.zig` on the way in, so an invalid one here means the
/// rows were edited underneath nxdns and a silently dropped allow rule
/// would block a domain the operator unblocked.
pub fn build(gpa: Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet {
if (rows.len == 0) return .empty;
var scratch: std.ArrayList(u8) = .empty;
defer scratch.deinit(gpa);
var spans: [4]std.ArrayList(Span) = .{ .empty, .empty, .empty, .empty };
defer for (&spans) |*bucket| bucket.deinit(gpa);
var wildcards: usize = 0;
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const pattern = normalize(row.pattern, &buf) catch return error.BadPattern;
switch (row.kind) {
.exact => _ = name.fromText(pattern) catch return error.BadPattern,
.wildcard => {
wildcard.validate(pattern) catch return error.BadPattern;
wildcards += 1;
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
},
}
const bucket = &spans[bucketOf(row.kind, row.action)];
try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len });
try scratch.appendSlice(gpa, pattern);
}
// `scratch` stops growing here, so spans can become slices of it.
var sorted: [4]std.ArrayList([]const u8) = .{ .empty, .empty, .empty, .empty };
defer for (&sorted) |*bucket| bucket.deinit(gpa);
for (&spans, &sorted) |*bucket, *out| {
try out.ensureTotalCapacityPrecise(gpa, bucket.items.len);
for (bucket.items) |span| {
out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]);
}
std.mem.sort([]const u8, out.items, {}, lessThanBytes);
dedupSorted(out);
}
var self: RuleSet = .empty;
errdefer self.deinit(gpa);
self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed);
self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed);
const allow = sorted[bucketOf(.wildcard, .allow)].items;
const block = sorted[bucketOf(.wildcard, .block)].items;
var total: usize = 0;
for (allow) |pattern| total += pattern.len;
for (block) |pattern| total += pattern.len;
const bytes = try gpa.alloc(u8, total);
self.wildcard_bytes = bytes;
var at: usize = 0;
self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at);
self.wildcard_block = try copyPatterns(gpa, block, bytes, &at);
return self;
}
pub fn deinit(self: *RuleSet, gpa: Allocator) void {
self.exact_allow.deinit(gpa);
self.exact_block.deinit(gpa);
gpa.free(self.wildcard_allow);
gpa.free(self.wildcard_block);
gpa.free(self.wildcard_bytes);
self.* = .empty;
}
pub fn memoryBytes(self: *const RuleSet) usize {
return self.exact_allow.memoryBytes() +
self.exact_block.memoryBytes() +
self.wildcard_bytes.len +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8);
}
};
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
/// Patterns are recorded as offsets because `scratch` reallocates while it
/// grows; they become slices only after the collecting pass ends.
const Span = struct { offset: usize, len: usize };
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
const kind_bit: usize = switch (kind) {
.exact => 0,
.wildcard => 2,
};
const action_bit: usize = switch (action) {
.allow => 0,
.block => 1,
};
return kind_bit + action_bit;
}
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
/// Duplicate rows are removed rather than rejected: two identical block rules
/// are not a corrupt database, and `DomainSet.build` requires a strictly
/// ascending body.
fn dedupSorted(list: *std.ArrayList([]const u8)) void {
var kept: usize = 0;
for (list.items) |item| {
if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue;
list.items[kept] = item;
kept += 1;
}
list.shrinkRetainingCapacity(kept);
}
fn buildSet(gpa: Allocator, patterns: []const []const u8, seed: u64) Error!domain_set.DomainSet {
if (patterns.len == 0) return .empty;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa);
for (patterns) |pattern| {
try body.appendSlice(gpa, pattern);
try body.append(gpa, '\n');
}
return domain_set.DomainSet.build(gpa, body.items, seed);
}
fn copyPatterns(
gpa: Allocator,
patterns: []const []const u8,
bytes: []u8,
at: *usize,
) Error![]const []const u8 {
if (patterns.len == 0) return &.{};
const out = try gpa.alloc([]const u8, patterns.len);
for (out, patterns) |*slot, pattern| {
@memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = bytes[at.*..][0..pattern.len];
at.* += pattern.len;
}
return out;
}
const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
var rest = text;
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
for (rest, 0..) |byte, i| {
if (byte >= 0x80 or byte < 0x21) return error.BadName;
buf[i] = std.ascii.toLower(byte);
}
return buf[0..rest.len];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
}
test "exact rules land in the matching set" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow),
};
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expect(!set.exact_block.contains("good.example.com"));
try testing.expect(set.exact_allow.contains("good.example.com"));
try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len);
try testing.expectEqual(@as(usize, 0), set.wildcard_block.len);
}
test "wildcard rules land in the matching list, sorted" {
const rows = [_]model.Rule{
rule("*.z.example.com", .wildcard, .block),
rule("*.a.example.com", .wildcard, .block),
rule("*.allowed.example.com", .wildcard, .allow),
};
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]);
try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]);
try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len);
try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]);
}
test "patterns are normalized to lowercase without a trailing dot" {
const rows = [_]model.Rule{
rule("ADS.Example.COM.", .exact, .block),
rule("*.Tracker.NET.", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}
test "duplicate rows collapse to one entry" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("ads.example.com.", .exact, .block),
rule("*.x.example.com", .wildcard, .block),
rule("*.x.example.com", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 1), set.exact_block.count);
try testing.expectEqual(@as(usize, 1), set.wildcard_block.len);
}
test "an invalid exact pattern is an error" {
for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .exact, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
}
}
test "an invalid wildcard pattern is an error" {
for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
}
}
test "too many wildcards is an error" {
const gpa = testing.allocator;
const rows = try gpa.alloc(model.Rule, max_wildcards_per_group + 1);
defer gpa.free(rows);
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
for (rows, 0..) |*row, i| {
const pattern = try std.fmt.allocPrint(gpa, "*.n{d}.example.com", .{i});
try patterns.append(gpa, pattern);
row.* = rule(pattern, .wildcard, .block);
}
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0));
}
test "an empty rule list builds the empty set" {
var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0);
defer set.deinit(testing.allocator);
try testing.expect(!set.exact_block.contains("ads.example.com"));
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
}
test "the empty rule set owns nothing" {
var set: RuleSet = .empty;
try testing.expect(!set.exact_allow.contains("x.example.com"));
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
set.deinit(testing.allocator);
}
test "memoryBytes counts every part" {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("*.tracker.net", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
try testing.expect(set.memoryBytes() >= "*.tracker.net".len);
}
fn buildUnderFailure(gpa: Allocator) !void {
const rows = [_]model.Rule{
rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow),
rule("*.tracker.net", .wildcard, .block),
rule("*.ok.tracker.net", .wildcard, .allow),
};
var set = try RuleSet.build(gpa, &rows, 0x5eed);
defer set.deinit(gpa);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}
test "build leaks nothing under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}