184 lines
6.4 KiB
Zig
184 lines
6.4 KiB
Zig
//! 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, and PLAN §2.2 keeps one regex dialect rather than two. An operator who
|
||
//! needs one writes a `.regex` rule, which `filter/regex.zig` compiles.
|
||
|
||
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: PLAN §3.9 defines the wildcard as a
|
||
/// label pattern, and the `.regex` kind covers what partial globbing was
|
||
/// wanted for.
|
||
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));
|
||
}
|