Files
nxdns/src/filter/wildcard.zig
T

184 lines
6.4 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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));
}