//! One group's explicit rules (PLAN §3.10 levels 1–6), compiled once into an //! immutable form the query path can read without allocating. //! //! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted //! array of strings and are scanned linearly. Operator-authored wildcards are //! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over //! that many short patterns is cheaper than an index that would have to be //! rebuilt on every snapshot swap. //! //! Regex patterns are compiled here, once per snapshot, into the linear-time //! programs of `regex.zig` and scanned the same way. `max_regex_per_group` caps //! them far lower, at 256: a regex costs a whole VM run where a wildcard costs a //! label comparison, and the matcher reaches them only after every hash and //! wildcard level has missed. //! //! Pure: allocators and plain values, no `std.Io`, no clock, no entropy //! source. The hash seed arrives as a parameter. const std = @import("std"); const Allocator = std.mem.Allocator; const model = @import("../config/model.zig"); const name = @import("../dns/name.zig"); const types = @import("../dns/types.zig"); const domain_set = @import("domain_set.zig"); const regex = @import("regex.zig"); const wildcard = @import("wildcard.zig"); pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards, TooManyRegexRules, } || domain_set.DomainSet.Error; /// Both wildcard lists of one group together. The cap exists so a rules table /// edited into the millions cannot turn every query into a linear scan. pub const max_wildcards_per_group: usize = 4096; /// Both regex lists of one group together, capped well below the wildcards: a /// miss at this level runs every program to its end. pub const max_regex_per_group: usize = 256; /// A compiled operator regex beside the text it was written as. The text is what /// `Decision.matched` reports, so the query log names the rule the operator /// wrote rather than an instruction count. pub const RegexRule = struct { pattern: []const u8, program: regex.Program, /// Frees through a copy of the program: the slices holding these rules are /// `const`, and `Program.deinit` wants a mutable pointer only to blank the /// struct it is finished with. fn free(self: RegexRule, gpa: Allocator) void { var program = self.program; program.deinit(gpa); } }; pub const RuleSet = struct { exact_allow: domain_set.DomainSet = .empty, exact_block: domain_set.DomainSet = .empty, /// Normalized and sorted, so a rebuild of the same rows produces the same /// order and the same first match. wildcard_allow: []const []const u8 = &.{}, wildcard_block: []const []const u8 = &.{}, /// Sorted and deduplicated like the wildcards, so the first regex to match a /// name is the same one on every rebuild of the same rows. regex_allow: []const RegexRule = &.{}, regex_block: []const RegexRule = &.{}, /// One block holding the pattern bytes of all four lists; freed as a unit. pattern_bytes: []const u8 = &.{}, pub const empty: RuleSet = .{}; /// `rows` are one group's rules only; splitting `listRules` output by group /// belongs to the caller, which is the only holder of the group table. /// /// Name patterns are normalized (lowercase over ASCII, one trailing dot /// stripped) and validated: `.exact` through `dns.name.fromText`, /// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An /// invalid pattern is `error.BadPattern`, not a skipped row — every pattern /// passed `config/validate.zig` on the way in, so an invalid one here means /// the rows were edited underneath nxdns and a silently dropped allow rule /// would block a domain the operator unblocked. /// /// Two allocators, because the caller's `perm` is a snapshot arena: an /// arena reclaims only its most recent allocation, so every temporary taken /// from it would live as long as the snapshot and go unreported by /// `memoryBytes`. `perm` owns what the returned set retains and is what /// `deinit` frees; `scratch` owns the build's working storage, which is /// released by the time `build` returns. Passing one allocator as both is /// correct wherever freeing works normally. pub fn build( perm: Allocator, scratch: Allocator, rows: []const model.Rule, seed: u64, ) Error!RuleSet { if (rows.len == 0) return .empty; var joined: std.ArrayList(u8) = .empty; defer joined.deinit(scratch); var spans: [6]std.ArrayList(Span) = @splat(.empty); defer for (&spans) |*bucket| bucket.deinit(scratch); var wildcards: usize = 0; var regexes: usize = 0; var buf: [types.max_name_len]u8 = undefined; for (rows) |row| { const pattern = switch (row.kind) { .exact => blk: { const text = normalize(row.pattern, &buf) catch return error.BadPattern; _ = name.fromText(text) catch return error.BadPattern; break :blk text; }, .wildcard => blk: { const text = normalize(row.pattern, &buf) catch return error.BadPattern; wildcard.validate(text) catch return error.BadPattern; wildcards += 1; if (wildcards > max_wildcards_per_group) return error.TooManyWildcards; break :blk text; }, // A regex is not a name, so `normalize` must not touch it: it // strips a trailing `.`, which here is the any-byte atom, and it // lowercases, which turns the rejected `\D` into the accepted // `\d`. Either would silently change what the rule matches. The // bytes stay as the operator wrote them — the same bytes // `config/validate.zig` compiled at the edge. Compiling waits // until after the sort, so a duplicate is compiled once. .regex => blk: { regexes += 1; if (regexes > max_regex_per_group) return error.TooManyRegexRules; break :blk row.pattern; }, }; const bucket = &spans[bucketOf(row.kind, row.action)]; try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len }); try joined.appendSlice(scratch, pattern); } // `joined` stops growing here, so spans can become slices of it. var sorted: [6]std.ArrayList([]const u8) = @splat(.empty); defer for (&sorted) |*bucket| bucket.deinit(scratch); for (&spans, &sorted) |*bucket, *out| { try out.ensureTotalCapacityPrecise(scratch, bucket.items.len); for (bucket.items) |span| { out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]); } std.mem.sort([]const u8, out.items, {}, lessThanBytes); dedupSorted(out); } var self: RuleSet = .empty; errdefer self.deinit(perm); self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed); self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed); const wild_allow = sorted[bucketOf(.wildcard, .allow)].items; const wild_block = sorted[bucketOf(.wildcard, .block)].items; const re_allow = sorted[bucketOf(.regex, .allow)].items; const re_block = sorted[bucketOf(.regex, .block)].items; var total: usize = 0; for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| { for (list) |pattern| total += pattern.len; } const bytes = try perm.alloc(u8, total); self.pattern_bytes = bytes; var at: usize = 0; self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at); self.wildcard_block = try copyPatterns(perm, wild_block, bytes, &at); self.regex_allow = try compilePatterns(perm, scratch, re_allow, bytes, &at); self.regex_block = try compilePatterns(perm, scratch, re_block, bytes, &at); return self; } pub fn deinit(self: *RuleSet, gpa: Allocator) void { self.exact_allow.deinit(gpa); self.exact_block.deinit(gpa); gpa.free(self.wildcard_allow); gpa.free(self.wildcard_block); freeRules(gpa, self.regex_allow); freeRules(gpa, self.regex_block); gpa.free(self.pattern_bytes); self.* = .empty; } pub fn memoryBytes(self: *const RuleSet) usize { var programs: usize = 0; for (self.regex_allow) |item| programs += item.program.memoryBytes(); for (self.regex_block) |item| programs += item.program.memoryBytes(); return self.exact_allow.memoryBytes() + self.exact_block.memoryBytes() + self.pattern_bytes.len + programs + (self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) + (self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule); } }; /// The first regex of `list` that matches `domain`, or null. `list` is sorted, /// so "first" is stable across rebuilds of the same rows. The caller checks the /// allow list before the block list, as it does for wildcards. pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 { for (list) |item| { if (regex.matches(&item.program, domain)) return item.pattern; } return null; } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- /// Patterns are recorded as offsets because `scratch` reallocates while it /// grows; they become slices only after the collecting pass ends. const Span = struct { offset: usize, len: usize }; fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize { const kind_base: usize = switch (kind) { .exact => 0, .wildcard => 2, .regex => 4, }; const action_offset: usize = switch (action) { .allow => 0, .block => 1, }; return kind_base + action_offset; } fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } /// Duplicate rows are removed rather than rejected: two identical block rules /// are not a corrupt database, and `DomainSet.build` requires a strictly /// ascending body. fn dedupSorted(list: *std.ArrayList([]const u8)) void { var kept: usize = 0; for (list.items) |item| { if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue; list.items[kept] = item; kept += 1; } list.shrinkRetainingCapacity(kept); } fn buildSet( perm: Allocator, scratch: Allocator, patterns: []const []const u8, seed: u64, ) Error!domain_set.DomainSet { if (patterns.len == 0) return .empty; var body: std.ArrayList(u8) = .empty; defer body.deinit(scratch); for (patterns) |pattern| { try body.appendSlice(scratch, pattern); try body.append(scratch, '\n'); } return domain_set.DomainSet.build(perm, body.items, seed); } fn copyPatterns( perm: Allocator, patterns: []const []const u8, bytes: []u8, at: *usize, ) Error![]const []const u8 { if (patterns.len == 0) return &.{}; const out = try perm.alloc([]const u8, patterns.len); for (out, patterns) |*slot, pattern| { @memcpy(bytes[at.*..][0..pattern.len], pattern); slot.* = bytes[at.*..][0..pattern.len]; at.* += pattern.len; } return out; } /// Copies the pattern texts into `bytes` like `copyPatterns` and compiles each /// one. A compile failure is `error.BadPattern` whichever of the engine's three /// refusals fired: the pattern already passed `config/validate.zig`, which names /// the limit, so a row that fails here was written around that check. /// /// Compiling into `scratch` and cloning across is what keeps a parse-time AST /// out of `perm`: `regex.compile` builds the AST, the child lists and the /// growing instruction buffer through the allocator it returns the program on. fn compilePatterns( perm: Allocator, scratch: Allocator, patterns: []const []const u8, bytes: []u8, at: *usize, ) Error![]const RegexRule { if (patterns.len == 0) return &.{}; const out = try perm.alloc(RegexRule, patterns.len); var built: usize = 0; errdefer { for (out[0..built]) |item| item.free(perm); perm.free(out); } for (out, patterns) |*slot, pattern| { var compiled = regex.compile(scratch, pattern) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.BadPattern, }; defer compiled.deinit(scratch); const program = try compiled.clone(perm); @memcpy(bytes[at.*..][0..pattern.len], pattern); slot.* = .{ .pattern = bytes[at.*..][0..pattern.len], .program = program }; at.* += pattern.len; built += 1; } return out; } fn freeRules(gpa: Allocator, list: []const RegexRule) void { for (list) |item| item.free(gpa); gpa.free(list); } const NameError = error{BadName}; /// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is /// rejected: query names reach the matcher ASCII-lowercased, so a pattern /// carrying a high byte could never match anything. /// /// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which /// `name.fromText` would reject, so this variant skips that check and rejects /// control bytes and space instead. fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 { var rest = text; if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName; for (rest, 0..) |byte, i| { if (byte >= 0x80 or byte < 0x21) return error.BadName; buf[i] = std.ascii.toLower(byte); } return buf[0..rest.len]; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule { return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action }; } test "exact rules land in the matching set" { const rows = [_]model.Rule{ rule("ads.example.com", .exact, .block), rule("good.example.com", .exact, .allow), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed); defer set.deinit(testing.allocator); try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expect(!set.exact_block.contains("good.example.com")); try testing.expect(set.exact_allow.contains("good.example.com")); try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len); try testing.expectEqual(@as(usize, 0), set.wildcard_block.len); } test "wildcard rules land in the matching list, sorted" { const rows = [_]model.Rule{ rule("*.z.example.com", .wildcard, .block), rule("*.a.example.com", .wildcard, .block), rule("*.allowed.example.com", .wildcard, .allow), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed); defer set.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), set.wildcard_block.len); try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]); try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]); try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len); try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]); } test "patterns are normalized to lowercase without a trailing dot" { const rows = [_]model.Rule{ rule("ADS.Example.COM.", .exact, .block), rule("*.Tracker.NET.", .wildcard, .block), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0); defer set.deinit(testing.allocator); try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]); } test "duplicate rows collapse to one entry" { const rows = [_]model.Rule{ rule("ads.example.com", .exact, .block), rule("ads.example.com.", .exact, .block), rule("*.x.example.com", .wildcard, .block), rule("*.x.example.com", .wildcard, .block), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0); defer set.deinit(testing.allocator); try testing.expectEqual(@as(u32, 1), set.exact_block.count); try testing.expectEqual(@as(usize, 1), set.wildcard_block.len); } test "an invalid exact pattern is an error" { for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| { const rows = [_]model.Rule{rule(pattern, .exact, .block)}; try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0)); } } test "regex rules land in their own buckets, compiled and sorted" { const rows = [_]model.Rule{ rule("^zz", .regex, .block), rule("^aa", .regex, .block), rule("ok$", .regex, .allow), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed); defer set.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), set.regex_block.len); try testing.expectEqualStrings("^aa", set.regex_block[0].pattern); try testing.expectEqualStrings("^zz", set.regex_block[1].pattern); try testing.expectEqual(@as(usize, 1), set.regex_allow.len); try testing.expectEqualStrings("ok$", set.regex_allow[0].pattern); try testing.expectEqualStrings("^aa", matchRegex(set.regex_block, "aabb.example").?); try testing.expect(matchRegex(set.regex_block, "bbaa.example") == null); try testing.expectEqualStrings("ok$", matchRegex(set.regex_allow, "example.ok").?); } test "a regex pattern keeps the bytes the operator wrote" { // `normalize` would strip the trailing dot and lowercase the escape, and // either edit would change what the pattern matches. The exact and wildcard // kinds still normalize; only this one is exempt. const rows = [_]model.Rule{ rule("ADS\\.Example\\.", .regex, .block), rule("ADS.Example.", .exact, .block), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0); defer set.deinit(testing.allocator); try testing.expectEqualStrings("ADS\\.Example\\.", set.regex_block[0].pattern); try testing.expect(set.exact_block.contains("ads.example")); } test "duplicate regex rows collapse to one compiled program" { const rows = [_]model.Rule{ rule("^ad[0-9]+-", .regex, .block), rule("^ad[0-9]+-", .regex, .block), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0); defer set.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), set.regex_block.len); } test "a regex pattern the engine refuses is an error, not a skipped row" { for ([_][]const u8{ "(", "", "a+?", "[z-a]", "\\s" }) |pattern| { const rows = [_]model.Rule{rule(pattern, .regex, .block)}; try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0)); } // The size limits arrive as `BadPattern` too: which one fired is // `config/validate.zig`'s to report, and by here the row is simply wrong. const long = [_]model.Rule{rule("a" ** 300, .regex, .block)}; try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &long, 0)); const complex = [_]model.Rule{rule("(abcdefghij){200}", .regex, .block)}; try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &complex, 0)); } test "too many regex rules is an error" { const gpa = testing.allocator; const rows = try gpa.alloc(model.Rule, max_regex_per_group + 1); defer gpa.free(rows); var patterns: std.ArrayList([]u8) = .empty; defer { for (patterns.items) |p| gpa.free(p); patterns.deinit(gpa); } for (rows, 0..) |*row, i| { const pattern = try std.fmt.allocPrint(gpa, "^n{d}-", .{i}); try patterns.append(gpa, pattern); row.* = rule(pattern, .regex, .block); } try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0)); // The cap counts both actions together, like the wildcard one. rows[0].action = .allow; try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0)); try testing.expectEqual(@as(usize, 256), max_regex_per_group); } test "an invalid wildcard pattern is an error" { for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| { const rows = [_]model.Rule{rule(pattern, .wildcard, .block)}; try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0)); } } test "too many wildcards is an error" { const gpa = testing.allocator; const rows = try gpa.alloc(model.Rule, max_wildcards_per_group + 1); defer gpa.free(rows); var patterns: std.ArrayList([]u8) = .empty; defer { for (patterns.items) |p| gpa.free(p); patterns.deinit(gpa); } for (rows, 0..) |*row, i| { const pattern = try std.fmt.allocPrint(gpa, "*.n{d}.example.com", .{i}); try patterns.append(gpa, pattern); row.* = rule(pattern, .wildcard, .block); } try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0)); } test "an empty rule list builds the empty set" { var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0); defer set.deinit(testing.allocator); try testing.expect(!set.exact_block.contains("ads.example.com")); try testing.expectEqual(@as(usize, 0), set.memoryBytes()); } test "the empty rule set owns nothing" { var set: RuleSet = .empty; try testing.expect(!set.exact_allow.contains("x.example.com")); try testing.expectEqual(@as(usize, 0), set.memoryBytes()); set.deinit(testing.allocator); } test "memoryBytes counts every part" { const rows = [_]model.Rule{ rule("ads.example.com", .exact, .block), rule("*.tracker.net", .wildcard, .block), }; var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0); defer set.deinit(testing.allocator); try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes()); try testing.expect(set.memoryBytes() >= "*.tracker.net".len); // A compiled program is the largest thing a rule set holds, so leaving it // out would make the snapshot's memory report a fiction. const with_regex = [_]model.Rule{ rows[0], rows[1], rule("^ad[0-9]+-", .regex, .block) }; var wider = try RuleSet.build(testing.allocator, testing.allocator, &with_regex, 0); defer wider.deinit(testing.allocator); try testing.expect(wider.memoryBytes() > set.memoryBytes() + "^ad[0-9]+-".len); try testing.expect(wider.memoryBytes() >= wider.regex_block[0].program.memoryBytes()); } test "the build's temporaries stay out of the permanent allocator" { // The property the two-allocator split exists for. An arena reclaims only // its most recent allocation, so a temporary taken from `perm` would live // as long as the arena and be invisible to `memoryBytes`. Two checks, one // per direction: `testing.allocator` fails the test if anything the set // retains was taken from `scratch`, and the arena's capacity fails it if // the build's working storage was taken from `perm`. const gpa = testing.allocator; var patterns: std.ArrayList([]u8) = .empty; defer { for (patterns.items) |p| gpa.free(p); patterns.deinit(gpa); } var rows: std.ArrayList(model.Rule) = .empty; defer rows.deinit(gpa); var i: usize = 0; while (i < 64) : (i += 1) { const regex_pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.ads\\.invalid$", .{i}); try patterns.append(gpa, regex_pattern); try rows.append(gpa, rule(regex_pattern, .regex, .block)); const wild = try std.fmt.allocPrint(gpa, "*.w{d:0>5}.example.com", .{i}); try patterns.append(gpa, wild); try rows.append(gpa, rule(wild, .wildcard, .block)); const exact = try std.fmt.allocPrint(gpa, "e{d:0>5}.example.com", .{i}); try patterns.append(gpa, exact); try rows.append(gpa, rule(exact, .exact, .block)); } var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const set = try RuleSet.build(arena.allocator(), gpa, rows.items, 0x5eed); try testing.expectEqual(@as(usize, 64), set.regex_block.len); try testing.expect(set.exact_block.contains("e00007.example.com")); // Whole-arena capacity against what the set says it holds. The slack is the // allocator's page rounding; the defect this guards against was a factor of // twelve. try testing.expect(arena.queryCapacity() < 2 * set.memoryBytes()); } fn buildUnderFailure(gpa: Allocator) !void { const rows = [_]model.Rule{ rule("ads.example.com", .exact, .block), rule("good.example.com", .exact, .allow), rule("*.tracker.net", .wildcard, .block), rule("*.ok.tracker.net", .wildcard, .allow), rule("^ad[0-9]+-", .regex, .block), rule("\\.ok\\.", .regex, .allow), }; var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed); defer set.deinit(gpa); try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]); try testing.expectEqualStrings("^ad[0-9]+-", set.regex_block[0].pattern); } test "build leaks nothing under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{}); }