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
+181
View File
@@ -0,0 +1,181 @@
//! Label-pattern wildcards for filtering rules (PLAN §3.9). Pure: no
//! allocation, no `std.Io`, no recursion.
//!
//! A pattern is a domain name in which one or more labels are exactly `*`.
//! Each `*` label matches one or more labels of the queried name. Partial-label
//! globbing (`ad*.example.com`) is deliberately absent: it is regex by another
//! name, which PLAN §2.2 rules out.
const std = @import("std");
pub const max_labels = 128;
/// The longest domain name is 255 wire bytes, which is 253 bytes of text.
const max_pattern_len = 253;
const max_label_len = 63;
pub const PatternError = error{
/// No label is exactly "*".
NoWildcard,
/// A label contains '*' but is not exactly "*". Partial-label globbing
/// (`ad*.example.com`) is out of scope: it is regex by another name, and
/// PLAN §3.9 defines the wildcard as a label pattern.
PartialWildcardLabel,
EmptyLabel,
LabelTooLong,
PatternTooLong,
TooManyLabels,
};
/// Syntax only. A valid pattern has at least one label that is exactly "*",
/// every other label is 163 bytes with no '*' inside it, and the whole
/// pattern is at most 253 bytes over at most `max_labels` labels.
pub fn validate(pattern: []const u8) PatternError!void {
// The label count is checked before the byte length so that both bounds
// stay individually reportable: any pattern with more than `max_labels`
// labels also exceeds `max_pattern_len`.
if (std.mem.count(u8, pattern, ".") + 1 > max_labels) return error.TooManyLabels;
if (pattern.len > max_pattern_len) return error.PatternTooLong;
var star = false;
var it = std.mem.splitScalar(u8, pattern, '.');
while (it.next()) |label| {
if (label.len == 0) return error.EmptyLabel;
if (label.len > max_label_len) return error.LabelTooLong;
if (std.mem.eql(u8, label, "*")) {
star = true;
} else if (std.mem.findScalar(u8, label, '*') != null) {
return error.PartialWildcardLabel;
}
}
if (!star) return error.NoWildcard;
}
/// `domain` is already normalized: lowercase, no trailing dot. `pattern` is
/// lowercase. Each "*" label matches ONE OR MORE labels.
/// Allocation-free; the backtracking is bounded by `max_labels` on both sides.
pub fn matches(pattern: []const u8, domain: []const u8) bool {
var pattern_labels: [max_labels][]const u8 = undefined;
var domain_labels: [max_labels][]const u8 = undefined;
// `validate` rejects a pattern above the label bound and the 253-byte name
// limit bounds the domain, so neither overflow can reach here from the
// matcher. Both are re-checked so that unvalidated input still terminates.
const pattern_len = split(pattern, &pattern_labels) orelse return false;
const domain_len = split(domain, &domain_labels) orelse return false;
var d: usize = 0;
var p: usize = 0;
var star: ?usize = null;
var star_end: usize = 0;
while (d < domain_len) {
if (p < pattern_len and isStar(pattern_labels[p])) {
// A '*' takes one label now and grows by one on each backtrack.
star = p;
p += 1;
d += 1;
star_end = d;
} else if (p < pattern_len and std.mem.eql(u8, pattern_labels[p], domain_labels[d])) {
p += 1;
d += 1;
} else if (star) |s| {
p = s + 1;
star_end += 1;
d = star_end;
} else {
return false;
}
}
// A trailing '*' has already consumed its label; nothing may be left over.
return p == pattern_len;
}
fn isStar(label: []const u8) bool {
return label.len == 1 and label[0] == '*';
}
/// Null when `text` holds more than `max_labels` labels.
fn split(text: []const u8, out: *[max_labels][]const u8) ?usize {
var n: usize = 0;
var it = std.mem.splitScalar(u8, text, '.');
while (it.next()) |label| {
if (n == max_labels) return null;
out[n] = label;
n += 1;
}
return n;
}
const testing = std.testing;
test "validate accepts a leading wildcard label" {
try validate("*.doubleclick.net");
}
test "validate accepts an interior wildcard label" {
try validate("ads.*.example.com");
}
test "validate rejects a pattern with no wildcard label" {
try testing.expectError(error.NoWildcard, validate("example.com"));
}
test "validate rejects a partial wildcard label" {
try testing.expectError(error.PartialWildcardLabel, validate("a*b.com"));
}
test "validate rejects an empty label" {
try testing.expectError(error.EmptyLabel, validate("a..b"));
}
test "validate rejects an oversize label" {
const pattern = "*." ++ ("a" ** 64);
try testing.expectError(error.LabelTooLong, validate(pattern));
}
test "validate rejects an oversize pattern" {
const label = "a" ** 60;
const pattern = "*." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label;
try testing.expect(pattern.len > 253);
try testing.expectError(error.PatternTooLong, validate(pattern));
}
test "validate rejects too many labels" {
const pattern = "*." ++ ("a." ** 199) ++ "com";
try testing.expectError(error.TooManyLabels, validate(pattern));
}
test "matches one label under a leading wildcard" {
try testing.expect(matches("*.doubleclick.net", "a.doubleclick.net"));
}
test "matches several labels under a leading wildcard" {
try testing.expect(matches("*.doubleclick.net", "a.b.doubleclick.net"));
}
test "a leading wildcard does not match the apex" {
try testing.expect(!matches("*.doubleclick.net", "doubleclick.net"));
}
test "matches one label at an interior wildcard" {
try testing.expect(matches("ads.*.example.com", "ads.eu.example.com"));
}
test "matches several labels at an interior wildcard" {
try testing.expect(matches("ads.*.example.com", "ads.eu.west.example.com"));
}
test "an interior wildcard requires at least one label" {
try testing.expect(!matches("ads.*.example.com", "ads.example.com"));
}
test "a pattern does not match a name that only contains it" {
try testing.expect(!matches("*.example.com", "example.com.evil.net"));
}
test "a pathological pattern terminates" {
const pattern = ("*." ** 8) ++ "example.com";
const domain = ("a." ** 98) ++ "example.net";
try testing.expect(!matches(pattern, domain));
}