//! 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)); }