milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -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 1–63 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));
|
||||
}
|
||||
Reference in New Issue
Block a user