Files
browser/src/network/adblock/Engine.zig
T
Adrià Arrufat 433ca9b747 adblock: fold the regex path into the existing mechanisms
The raw URL lives on `pattern.Url` next to the lowercased one, so
`pattern.matches` owns the `.regex` arm like every other kind and the
engine stops special-casing it. `Request.init` does the lowercasing
itself, as `fromHttp` already had to, instead of asking callers for
both spellings.

The regex shape now spells its uncertain marker as `*` and keeps
non-token literals as one marker, so it is read by the same
bounded-token loop as a plain pattern rather than a copy of it. The
quantifier parser keeps only what it uses: whether the atom may be
absent.

`Regex.matches` runs on a stack-first allocator: PCRE2 wants a match
data block and 20KB of backtracking frames per call, which no longer
touches the heap in the common case. A filter holds a pointer to its
regex, keeping `NetworkFilter` at its previous size.
2026-09-09 13:13:18 +02:00

786 lines
30 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! One indexed set of network filters — the blocking ones, the `$important`
//! ones or the exceptions — and the lookup that finds which of them a request
//! matches. A port of adblock-rust's `NetworkFilterList`.
//!
//! Checking every filter against every request is not affordable: EasyList
//! alone carries tens of thousands. Instead each filter is filed under one
//! token of its pattern — a run of alphanumerics that any URL it matches is
//! bound to contain — and a request only ever pays for the filters filed
//! under the tokens its own URL happens to have.
//!
//! Which token decides how much that costs, so each filter takes its rarest
//! one across the whole loaded corpus: "gampad" narrows to a handful of
//! filters where "com" would narrow to nothing. Filters whose pattern offers
//! no token that is guaranteed to survive into the URL (see `collectTokens`)
//! go to `fallback`, which every request pays for.
const std = @import("std");
const URL = @import("../../browser/URL.zig");
const HttpClient = @import("../HttpClient.zig");
const domain = @import("domain.zig");
const pattern = @import("pattern.zig");
const NetworkFilter = @import("NetworkFilter.zig");
const Allocator = std.mem.Allocator;
const Engine = @This();
/// The shared filter array; `buckets` and `fallback` hold indices into it.
filters: []const NetworkFilter,
buckets: std.AutoHashMapUnmanaged(u32, []const u32),
/// Filters no token can stand for; checked on every request.
fallback: []const u32,
pub const empty: Engine = .{
.filters = &.{},
.buckets = .empty,
.fallback = &.{},
};
pub fn deinit(self: *Engine, allocator: Allocator) void {
self.buckets.deinit(allocator);
self.* = .empty;
}
/// Everything the filters need to know about a request. Built once per
/// request and shared by all three engines.
pub const Request = struct {
url: pattern.Url,
/// The hostname of the document the request belongs to. Falls back to the
/// request's own hostname, which is what uBO does for top-level loads.
source_hostname: []const u8,
/// Exactly one bit set.
kind: NetworkFilter.ResourceTypes,
third_party: bool,
/// The URL's first tokens, hashed once here so no engine retokenizes.
tokens_buf: [URL_TOKENS_MAX]u32,
tokens_len: usize,
/// The rest of the URL, past the last token that fit.
/// Each engine tokenizes it itself, so no token is ever lost. Token-free for
/// all but the longest URLs.
tail: []const u8,
const URL_TOKENS_MAX = 128;
/// Longest URL `fromHttp` will normalize. Anything past this is not a
/// resource a filter list has an opinion about.
const URL_MAX = 8 * 1024;
/// DNS's own hostname limit.
const SOURCE_MAX = 253;
/// Backs the normalized text of a `fromHttp` request, which stays valid
/// only as long as the buffers do.
pub const Buffers = struct {
url: [URL_MAX]u8,
source: [SOURCE_MAX]u8,
};
/// `buf` backs the lowercased URL; null when `url` is longer than it.
/// `source_hostname` may be empty when there is no document context.
pub fn init(
url: []const u8,
buf: []u8,
source_hostname: []const u8,
kind: NetworkFilter.ResourceTypes,
) ?Request {
// No request URL carries a fragment onto the wire.
const raw = URL.stripFragment(url);
const lowered = lowercase(raw, buf) orelse return null;
const parsed: pattern.Url = .init(lowered, raw);
const source = if (source_hostname.len == 0) parsed.hostname() else source_hostname;
var request: Request = .{
.url = parsed,
.source_hostname = source,
.kind = kind,
.third_party = domain.isThirdParty(parsed.hostname(), source),
.tokens_buf = undefined,
.tokens_len = 0,
.tail = "",
};
var it: Tokens = .{ .text = lowered };
while (request.tokens_len < request.tokens_buf.len) {
const token = it.next() orelse break;
request.tokens_buf[request.tokens_len] = token;
request.tokens_len += 1;
}
request.tail = lowered[it.i..];
return request;
}
pub fn fromHttp(transfer: *const HttpClient.Transfer, buffers: *Buffers) ?Request {
const req = &transfer.req;
var owner: ?*const HttpClient.Owner = transfer.owner;
var subframe = false;
if (req.resource_type == .document) {
owner = if (owner) |o| o.parent else null;
subframe = owner != null;
}
const source_url = if (owner) |o| o.documentUrl() else null;
const source_host = if (source_url) |u| URL.getOriginHostname(u) else "";
if (source_host.len > buffers.source.len) return null;
const source = std.ascii.lowerString(&buffers.source, source_host);
const resource_type: NetworkFilter.ResourceTypes = switch (req.resource_type) {
.document => if (subframe) .{ .subdocument = true } else .{ .document = true },
.script => .{ .script = true },
.stylesheet => .{ .stylesheet = true },
.xhr, .fetch => .{ .xmlhttprequest = true },
.image => .{ .image = true },
.eventsource => .{ .other = true },
.worker => .{ .script = true },
};
return .init(req.url, &buffers.url, source, resource_type);
}
inline fn tokens(self: *const Request) []const u32 {
return self.tokens_buf[0..self.tokens_len];
}
/// Lowercases `url` into `buf`, as patterns are stored lowercased.
fn lowercase(url: []const u8, buf: []u8) ?[]const u8 {
const upper = for (url, 0..) |c, i| {
if (std.ascii.isUpper(c)) break i;
} else return url;
if (url.len > buf.len) return null;
const out = buf[0..url.len];
@memcpy(out[0..upper], url[0..upper]);
_ = std.ascii.lowerString(out[upper..], url[upper..]);
return out;
}
};
/// How often each token appears across every indexed filter. Shared by the
/// engines so that "rarest" means rarest overall, not within one class.
pub const Histogram = std.AutoHashMapUnmanaged(u32, u32);
/// Tokens considered per filter. Long query-string patterns can exceed this;
/// taking the first few costs a wider bucket, never a wrong answer.
const TOKENS_MAX = 32;
/// The first filter that matches, or null.
pub fn match(self: *const Engine, request: *const Request) ?*const NetworkFilter {
if (self.buckets.count() != 0) {
for (request.tokens()) |token| {
if (self.matchToken(token, request)) |filter| return filter;
}
// Whatever the request could not hold is hashed again.
var it: Tokens = .{ .text = request.tail };
while (it.next()) |token| {
if (self.matchToken(token, request)) |filter| return filter;
}
}
return self.matchIn(self.fallback, request);
}
fn matchToken(self: *const Engine, token: u32, request: *const Request) ?*const NetworkFilter {
const bucket = self.buckets.get(token) orelse return null;
return self.matchIn(bucket, request);
}
fn matchIn(self: *const Engine, bucket: []const u32, request: *const Request) ?*const NetworkFilter {
for (bucket) |index| {
const filter = &self.filters[index];
if (matchesFilter(filter, request)) return filter;
}
return null;
}
/// Cheapest constraint first: the type and party bits are two comparisons,
/// the pattern walk is the expensive one.
fn matchesFilter(filter: *const NetworkFilter, request: *const Request) bool {
if (filter.types.bits() & request.kind.bits() == 0) return false;
if (request.third_party) {
if (!filter.third_party) return false;
} else if (!filter.first_party) {
return false;
}
if (!filter.domains.matches(request.source_hostname)) return false;
return pattern.matches(filter, request.url);
}
/// Adds `filter`'s tokens to the corpus counts.
pub fn count(
histogram: *Histogram,
allocator: Allocator,
filter: *const NetworkFilter,
) Allocator.Error!void {
var buf: [TOKENS_MAX]u32 = undefined;
for (collectTokens(filter, &buf)) |token| {
const gop = try histogram.getOrPut(allocator, token);
gop.value_ptr.* = if (gop.found_existing) gop.value_ptr.* + 1 else 1;
}
}
/// Indexes `members` (indices into `filters`) by their rarest token.
/// `allocator` backs the bucket map, `arena` the index slices it points at.
pub fn build(
allocator: Allocator,
arena: Allocator,
filters: []const NetworkFilter,
members: []const u32,
histogram: *const Histogram,
) Allocator.Error!Engine {
var groups: std.AutoHashMapUnmanaged(u32, std.ArrayList(u32)) = .empty;
defer {
var it = groups.valueIterator();
while (it.next()) |list| list.deinit(allocator);
groups.deinit(allocator);
}
var fallback: std.ArrayList(u32) = .empty;
defer fallback.deinit(allocator);
var buf: [TOKENS_MAX]u32 = undefined;
for (members) |index| {
var rarest: ?u32 = null;
var lowest: u32 = std.math.maxInt(u32);
for (collectTokens(&filters[index], &buf)) |token| {
const seen = histogram.get(token) orelse 0;
if (seen < lowest) {
lowest = seen;
rarest = token;
}
}
const token = rarest orelse {
try fallback.append(allocator, index);
continue;
};
const gop = try groups.getOrPut(allocator, token);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(allocator, index);
}
var buckets: std.AutoHashMapUnmanaged(u32, []const u32) = .empty;
errdefer buckets.deinit(allocator);
try buckets.ensureTotalCapacity(allocator, groups.count());
var it = groups.iterator();
while (it.next()) |entry| {
buckets.putAssumeCapacity(entry.key_ptr.*, try arena.dupe(u32, entry.value_ptr.items));
}
return .{
.filters = filters,
.buckets = buckets,
.fallback = try arena.dupe(u32, fallback.items),
};
}
/// Runs of alphanumerics. Everything else ('.', '/', '-', '%', '_', '?') is a boundary.
const Tokens = struct {
text: []const u8,
i: usize = 0,
pub fn next(self: *Tokens) ?u32 {
while (self.i < self.text.len and !isTokenChar(self.text[self.i])) self.i += 1;
if (self.i == self.text.len) return null;
const start = self.i;
while (self.i < self.text.len and isTokenChar(self.text[self.i])) self.i += 1;
return hash(self.text[start..self.i]);
}
};
fn isTokenChar(c: u8) bool {
return std.ascii.isAlphanumeric(c);
}
fn hash(token: []const u8) u32 {
return @truncate(std.hash.Wyhash.hash(0, token));
}
fn collectTokens(filter: *const NetworkFilter, buf: []u32) []u32 {
var n: usize = 0;
if (filter.hostname.len != 0) {
var it: Tokens = .{ .text = filter.hostname };
while (it.next()) |token| {
if (n == buf.len) return buf[0..n];
buf[n] = token;
n += 1;
}
}
// A /regex/ literal has no hostname, so `n` is still 0 here.
if (filter.kind == .regex) return regexTokens(filter.pattern[1 .. filter.pattern.len - 1], buf);
// `.any` has no pattern at all.
if (filter.pattern.len == 0) return buf[0..n];
const left_anchored = filter.left_anchor or filter.hostname_anchor;
return buf[0..boundedTokens(filter.pattern, left_anchored, filter.right_anchor, buf, n)];
}
/// Appends the tokens of `text` that any URL matching it carries whole: the
/// alphanumeric runs no `*` may extend, and that the open ends of the text
/// may not extend either unless anchored. Returns the new count.
fn boundedTokens(text: []const u8, left_anchored: bool, right_anchored: bool, buf: []u32, from: usize) usize {
var n = from;
var i: usize = 0;
while (i < text.len) {
if (!isTokenChar(text[i])) {
i += 1;
continue;
}
const start = i;
while (i < text.len and isTokenChar(text[i])) i += 1;
const left_bounded = if (start == 0) left_anchored else text[start - 1] != '*';
const right_bounded = if (i == text.len) right_anchored else text[i] != '*';
if (left_bounded and right_bounded) {
if (n == buf.len) return n;
buf[n] = hash(text[start..i]);
n += 1;
}
}
return n;
}
/// The tokens a regex is sure to have wherever it matches, uBO's
/// `tokenizableStrFromRegex`: the pattern is flattened into a plain pattern
/// where literal token characters stay and everything else becomes a marker
/// saying only whether it could be one. Anything the flattening does not
/// follow yields no token at all, which is never wrong: the filter then
/// rides the fallback bucket.
fn regexTokens(source: []const u8, buf: []u32) []u32 {
// A pattern's shape is never longer than the pattern.
var shape_buf: [8 * 1024]u8 = undefined;
if (source.len > shape_buf.len) return buf[0..0];
var shape: RegexShape = .{ .source = source, .out = &shape_buf };
const text = shape.flatten() catch return buf[0..0];
// Anchors are in the shape itself; its ends are open.
return buf[0..boundedTokens(text, false, false, buf, 0)];
}
const RegexShape = struct {
source: []const u8,
out: []u8,
i: usize = 0,
n: usize = 0,
/// Whatever matches here is not a token character: a non-token literal,
/// an anchor, `\b`.
const not_token = 0x00;
/// Whatever matches here may be a token character: `.`, `[a-z]`, `\d`, a
/// quantified literal. Spelled as a plain pattern's wildcard so the shape
/// reads as one.
const maybe_token = '*';
// The same two for a stretch that may match nothing at all; resolved
// once both neighbours are known.
const not_token_optional = 0x02;
const maybe_token_optional = 0x03;
const Error = error{Unsupported};
fn flatten(self: *RegexShape) Error![]const u8 {
try self.alternation(false);
if (self.i != self.source.len) return error.Unsupported;
self.resolveOptional();
return self.out[0..self.n];
}
/// Parses alternatives up to the closing parenthesis (or the end). More
/// than one collapses to two markers: all that is sure about `a|bc` is
/// how it starts and ends.
fn alternation(self: *RegexShape, nested: bool) Error!void {
const start = self.n;
var branches: usize = 1;
var branch_start = start;
var first = false;
var last = false;
while (true) {
try self.sequence(nested);
const branch = self.out[branch_start..self.n];
first = first or startsTokenish(branch);
last = last or endsTokenish(branch);
if (self.i == self.source.len or self.source[self.i] != '|') break;
self.i += 1;
branches += 1;
branch_start = self.n;
}
if (branches == 1) return;
self.n = start;
self.emit(if (first) maybe_token else not_token);
self.emit(if (last) maybe_token else not_token);
}
fn sequence(self: *RegexShape, nested: bool) Error!void {
while (self.i < self.source.len) {
const atom_start = self.n;
const c = self.source[self.i];
switch (c) {
'|' => return,
')' => {
if (!nested) return error.Unsupported;
return;
},
'(' => try self.group(),
'[' => try self.class(),
'\\' => try self.escape(),
'.' => {
self.i += 1;
self.emit(maybe_token);
},
'^', '$' => {
self.i += 1;
self.emit(not_token);
},
'*', '+', '?' => return error.Unsupported,
else => {
self.i += 1;
self.emitLiteral(c);
},
}
try self.quantifier(atom_start);
}
}
fn group(self: *RegexShape) Error!void {
const start = self.n;
self.i += 1;
var lookaround: enum { none, positive, negative } = .none;
if (self.i < self.source.len and self.source[self.i] == '?') {
self.i += 1;
const kind = self.take() orelse return error.Unsupported;
switch (kind) {
':' => {},
'=' => lookaround = .positive,
'!' => lookaround = .negative,
'<' => {
const next = self.take() orelse return error.Unsupported;
switch (next) {
'=' => lookaround = .positive,
'!' => lookaround = .negative,
else => {
// A named group is a plain group with a label.
const close = std.mem.indexOfScalarPos(u8, self.source, self.i, '>') orelse return error.Unsupported;
self.i = close + 1;
},
}
},
else => return error.Unsupported,
}
}
try self.alternation(true);
if (self.take() != ')') return error.Unsupported;
switch (lookaround) {
.none => {},
// Consumes nothing, so the neighbours touch; what it asserts
// could still be anything, and that is all a token may rely on.
.positive => {
self.n = start;
self.emit(maybe_token);
},
// Consumes nothing and rules text out: the neighbours touch.
.negative => self.n = start,
}
}
/// `[...]` is one character; a token character can come out of it if any
/// member is one, or if it is negated.
fn class(self: *RegexShape) Error!void {
self.i += 1;
var maybe = false;
if (self.i < self.source.len and self.source[self.i] == '^') {
self.i += 1;
maybe = true;
}
var first = true;
while (true) {
const c = self.take() orelse return error.Unsupported;
if (c == ']' and !first) break;
first = false;
if (c == '\\') {
const e = self.take() orelse return error.Unsupported;
switch (e) {
'd', 'D', 'w', 'W', 's', 'S' => maybe = true,
'b', 'n', 'r', 't', 'f', 'v' => {},
else => if (std.ascii.isAlphanumeric(e)) return error.Unsupported,
}
continue;
}
// A range is assumed to reach token characters.
if (c == '-' or std.ascii.isAlphanumeric(c)) maybe = true;
}
self.emit(if (maybe) maybe_token else not_token);
}
fn escape(self: *RegexShape) Error!void {
self.i += 1;
const e = self.take() orelse return error.Unsupported;
switch (e) {
'd', 'D', 'w', 'W', 's', 'S', 'B' => self.emit(maybe_token),
'b', 'n', 'r', 't', 'f', 'v' => self.emit(not_token),
// Code points, backreferences, properties: not worth following.
'x', 'u', 'c', 'k', 'p', 'P', '0'...'9' => return error.Unsupported,
// Anything else escaped is itself, as JavaScript reads it.
else => self.emitLiteral(e),
}
}
/// Applies a quantifier, if one follows, to what was just emitted. Only
/// the first and last character classes survive a repeat: `ab+` may match
/// "abbb", and its token is not "ab". Whether the atom may be absent is
/// all that matters beyond that.
fn quantifier(self: *RegexShape, atom_start: usize) Error!void {
if (self.i == self.source.len) return;
var optional = true;
switch (self.source[self.i]) {
'*', '?' => self.i += 1,
'+' => {
self.i += 1;
optional = false;
},
'{' => {
const close = std.mem.indexOfScalarPos(u8, self.source, self.i, '}') orelse return;
const body = self.source[self.i + 1 .. close];
const comma = std.mem.indexOfScalar(u8, body, ',') orelse body.len;
// Not a quantifier at all: JavaScript reads the `{` literally.
const min = std.fmt.parseUnsigned(usize, body[0..comma], 10) catch return;
if (comma + 1 < body.len) _ = std.fmt.parseUnsigned(usize, body[comma + 1 ..], 10) catch return;
optional = min == 0;
self.i = close + 1;
},
else => return,
}
// A lazy `?` changes nothing about what can match.
if (self.i < self.source.len and self.source[self.i] == '?') self.i += 1;
const atom = self.out[atom_start..self.n];
const first = startsTokenish(atom);
const last = endsTokenish(atom);
self.n = atom_start;
if (!optional) {
self.emit(if (first) maybe_token else not_token);
self.emit(if (last) maybe_token else not_token);
} else {
self.emit(if (first) maybe_token_optional else not_token_optional);
self.emit(if (last) maybe_token_optional else not_token_optional);
}
}
/// An optional stretch may vanish, letting its neighbours touch: it
/// resolves to markers that carry whichever side could be a token
/// character, so no run is read as bounded by something that may be
/// gone.
fn resolveOptional(self: *RegexShape) void {
var i: usize = 0;
while (i < self.n) {
if (!isOptional(self.out[i])) {
i += 1;
continue;
}
var end = i;
while (end < self.n and isOptional(self.out[end])) end += 1;
const left = self.out[0..i];
const middle = self.out[i..end];
const right = self.out[end..self.n];
const head: u8 = if (startsTokenish(right) or startsTokenish(middle)) maybe_token else not_token;
const tail: u8 = if (endsTokenish(left) or endsTokenish(middle)) maybe_token else not_token;
// Quantifiers emit markers in pairs, so a stretch is never shorter
// than what replaces it.
std.mem.copyForwards(u8, self.out[i + 2 .. self.n - (middle.len - 2)], right);
self.out[i] = head;
self.out[i + 1] = tail;
self.n -= middle.len - 2;
i += 2;
}
}
fn take(self: *RegexShape) ?u8 {
if (self.i == self.source.len) return null;
defer self.i += 1;
return self.source[self.i];
}
fn emit(self: *RegexShape, c: u8) void {
self.out[self.n] = c;
self.n += 1;
}
/// A literal is kept only as far as tokens care: a token character,
/// lowercased like the URL it is looked up in, or the fact that it is not
/// one.
fn emitLiteral(self: *RegexShape, c: u8) void {
self.emit(if (std.ascii.isAlphanumeric(c)) std.ascii.toLower(c) else not_token);
}
fn isOptional(c: u8) bool {
return c == not_token_optional or c == maybe_token_optional;
}
fn isTokenish(c: u8) bool {
return c == maybe_token or c == maybe_token_optional or isTokenChar(c);
}
fn startsTokenish(s: []const u8) bool {
return s.len != 0 and isTokenish(s[0]);
}
fn endsTokenish(s: []const u8) bool {
return s.len != 0 and isTokenish(s[s.len - 1]);
}
};
const testing = @import("../../testing.zig");
fn tokensOf(arena: Allocator, line: []const u8, buf: []u32) ![]u32 {
const filter = try NetworkFilter.parse(arena, line);
return collectTokens(&filter, buf);
}
fn contains(tokens: []const u32, token: []const u8) bool {
for (tokens) |t| {
if (t == hash(token)) return true;
}
return false;
}
test "adblock.Engine: regex filters yield the tokens every match carries" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var buf: [TOKENS_MAX]u32 = undefined;
// Literal runs between literal non-token characters; the open end of an
// unanchored pattern bounds nothing.
var tokens = try tokensOf(arena, "/\\/[0-9a-f]{32}\\/invoke\\.js/", &buf);
try testing.expectEqual(1, tokens.len);
try testing.expect(contains(tokens, "invoke"));
// Anchors bound; `https?` may be either, so "http" is no token.
tokens = try tokensOf(arena, "/^https?:\\/\\/[0-9a-z]{5,}\\.com\\/.*/", &buf);
try testing.expectEqual(1, tokens.len);
try testing.expect(contains(tokens, "com"));
tokens = try tokensOf(arena, "/[a-z]{2,}\\.gif$/", &buf);
try testing.expectEqual(1, tokens.len);
try testing.expect(contains(tokens, "gif"));
tokens = try tokensOf(arena, "/about:blank.*/", &buf);
try testing.expectEqual(0, tokens.len);
// `\b` bounds, as uBO reads it: `/\bads\b/` is "ads", not "bads".
tokens = try tokensOf(arena, "/\\bads\\b/", &buf);
try testing.expect(contains(tokens, "ads"));
// Hashed lowercased, like the URL it is looked up in.
tokens = try tokensOf(arena, "/\\/Ads\\//$match-case", &buf);
try testing.expect(contains(tokens, "ads"));
// An optional stretch may vanish and glue its neighbours: "adsbanner".
tokens = try tokensOf(arena, "/\\/ads\\/?banner\\//", &buf);
try testing.expectEqual(0, tokens.len);
// A repeat is not the literal it repeats.
tokens = try tokensOf(arena, "/\\/ab+c\\//", &buf);
try testing.expectEqual(0, tokens.len);
// Alternation and the text under a quantified group are uncertain.
tokens = try tokensOf(arena, "/^https?:\\/\\/(35|104)\\.(\\d){1,3}\\//", &buf);
try testing.expectEqual(0, tokens.len);
// ... but a group with one branch is transparent.
tokens = try tokensOf(arena, "/\\/(?:ads)\\//", &buf);
try testing.expect(contains(tokens, "ads"));
// A negative lookaround consumes nothing; a positive one is not
// trusted to spell anything.
tokens = try tokensOf(arena, "/\\/(?!ads)banner\\//", &buf);
try testing.expect(contains(tokens, "banner"));
tokens = try tokensOf(arena, "/\\/(?=ads)ads\\//", &buf);
try testing.expectEqual(0, tokens.len);
// What is not followed yields nothing rather than something wrong.
tokens = try tokensOf(arena, "/\\/\\x41ds\\//", &buf);
try testing.expectEqual(0, tokens.len);
tokens = try tokensOf(arena, "/\\/(ads\\//", &buf);
try testing.expectEqual(0, tokens.len);
}
test "adblock.Engine: a request keeps its first tokens, the rest as tail" {
const max = Request.URL_TOKENS_MAX;
const kind: NetworkFilter.ResourceTypes = .{ .script = true };
// Exactly as many tokens as the buffer holds: nothing is left to walk...
const full = "x/" ** (max - 1) ++ "x";
var buf: [512]u8 = undefined;
var request: Request = Request.init(full, &buf, "", kind).?;
try testing.expectEqual(max, request.tokens_len);
try testing.expectEqual(0, request.tail.len);
// ...one more, and only that one is in the tail.
request = Request.init(full ++ "/y", &buf, "", kind).?;
try testing.expectEqual(max, request.tokens_len);
try testing.expectString("/y", request.tail);
var it: Tokens = .{ .text = request.tail };
try testing.expectEqual(hash("y"), it.next().?);
try testing.expect(it.next() == null);
// Fewer than the buffer holds: the tail is empty.
request = Request.init("https://example.com/a", &buf, "", kind).?;
try testing.expectEqual(4, request.tokens_len);
try testing.expectEqual(0, request.tail.len);
}
test "adblock.Engine: only tokens the URL must reproduce are collected" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var buf: [TOKENS_MAX]u32 = undefined;
// Every hostname label is bounded, the last one by the matcher's
// label-boundary requirement — with or without '^'.
var tokens = try tokensOf(arena, "||ads.example.com^", &buf);
try testing.expect(contains(tokens, "ads"));
try testing.expect(contains(tokens, "example"));
try testing.expect(contains(tokens, "com"));
tokens = try tokensOf(arena, "||ads.example.com", &buf);
try testing.expect(contains(tokens, "example"));
try testing.expect(contains(tokens, "com"));
// A path following the hostname bounds it just as well.
tokens = try tokensOf(arena, "||youtube.com/pagead/", &buf);
try testing.expect(contains(tokens, "com"));
try testing.expect(contains(tokens, "pagead"));
// A trailing token is unbounded unless the pattern is right-anchored:
// "/ads" also matches "/adserver".
tokens = try tokensOf(arena, "||example.com/ads", &buf);
try testing.expect(!contains(tokens, "ads"));
tokens = try tokensOf(arena, "||example.com/ads|", &buf);
try testing.expect(contains(tokens, "ads"));
// A '*' on either side disqualifies the token beside it, so this one is
// carried entirely by its hostname.
tokens = try tokensOf(arena, "||example.com/a*banner*c^", &buf);
try testing.expect(!contains(tokens, "banner"));
try testing.expect(contains(tokens, "example"));
// Nothing to index: option-only filters ride the fallback bucket.
tokens = try tokensOf(arena, "$script,domain=example.com", &buf);
try testing.expectEqual(0, tokens.len);
}