mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-15 15:32:03 -04:00
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.
This commit is contained in:
1 parent
39974e461d
commit
433ca9b747
6 files changed
+110
-126
No files matched your search
@@ -4314,10 +4314,6 @@ test "HttpClient: adblock verdicts apply per request" {
|
||||
.url = "https://cdn.example.com/ABCD.js",
|
||||
.resource_type = .script,
|
||||
}));
|
||||
try testing.expect(!testIsUrlBlocked(&client, .{
|
||||
.url = "https://cdn.example.com/abcd.js",
|
||||
.resource_type = .xhr,
|
||||
}));
|
||||
|
||||
try testing.expect(testIsUrlBlocked(&client, .{ .url = "https://ads.example.com/pixel.gif" }));
|
||||
// Hostnames are matched case-insensitively and without the port.
|
||||
|
||||
@@ -50,7 +50,7 @@ filters: std.ArrayList(NetworkFilter),
|
||||
badfilters: std.AutoHashMapUnmanaged(u64, void),
|
||||
/// Every regex compiled for a filter, whether or not `build` kept the
|
||||
/// filter; they are freed here, not through `filters`.
|
||||
regexes: std.ArrayList(Regex),
|
||||
regexes: std.ArrayList(*const Regex),
|
||||
/// What the regexes are compiled and run with; PCRE2 allocates through it.
|
||||
regex_context: *Regex.Context,
|
||||
built: bool,
|
||||
@@ -225,14 +225,16 @@ pub fn parse(self: *AdBlocker, reader: *Io.Reader) !void {
|
||||
|
||||
if (filter.kind == .regex) {
|
||||
const body = filter.pattern[1 .. filter.pattern.len - 1];
|
||||
const regex = Regex.compile(self.regex_context, body, !filter.match_case) catch |err| switch (err) {
|
||||
const compiled = Regex.compile(self.regex_context, body, !filter.match_case) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.InvalidRegex => {
|
||||
self.rules_skipped += 1;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
errdefer regex.deinit();
|
||||
errdefer compiled.deinit();
|
||||
const regex = try arena.create(Regex);
|
||||
regex.* = compiled;
|
||||
try self.regexes.append(self.allocator, regex);
|
||||
filter.regex = regex;
|
||||
}
|
||||
@@ -467,12 +469,8 @@ fn expectVerdict(
|
||||
source: []const u8,
|
||||
kind: ResourceTypes,
|
||||
) !void {
|
||||
// `match` takes the URL both raw and lowercased, as the real caller hands
|
||||
// it; patterns are lowercased too, so a rule spelled "/embed/C-iDzdvIg1Y"
|
||||
// still lands.
|
||||
var buf: [512]u8 = undefined;
|
||||
const lowered = std.ascii.lowerString(buf[0..url.len], url);
|
||||
const request: Request = .init(url, lowered, source, kind);
|
||||
const request: Request = Request.init(url, &buf, source, kind).?;
|
||||
try testing.expectEqual(expected, blocker.match(&request));
|
||||
}
|
||||
|
||||
@@ -608,7 +606,8 @@ test "adblock.AdBlocker: tokens past the request buffer still match" {
|
||||
// 140 tokens of query noise push the filter's token ("utm", its rarest)
|
||||
// past what the request holds; the engine walks the rest of the URL.
|
||||
const noise = "https://example.com/?" ++ "a=1&" ** 70;
|
||||
const overflowing: Request = .init(noise ++ "utm_tracker=1", noise ++ "utm_tracker=1", "a.com", script);
|
||||
var buf: [512]u8 = undefined;
|
||||
const overflowing: Request = Request.init(noise ++ "utm_tracker=1", &buf, "a.com", script).?;
|
||||
try testing.expect(overflowing.tail.len != 0);
|
||||
try testing.expectEqual(.blocked, blocker.match(&overflowing));
|
||||
// A token past the buffer finds its bucket, but the pattern does not fit.
|
||||
|
||||
@@ -66,9 +66,6 @@ pub fn deinit(self: *Engine, allocator: Allocator) void {
|
||||
/// request and shared by all three engines.
|
||||
pub const Request = struct {
|
||||
url: pattern.Url,
|
||||
/// The URL as requested, fragment stripped but case kept: what a regex
|
||||
/// filter reads, since `$match-case` is only meaningful on the original.
|
||||
raw: []const u8,
|
||||
/// 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,
|
||||
@@ -98,20 +95,21 @@ pub const Request = struct {
|
||||
source: [SOURCE_MAX]u8,
|
||||
};
|
||||
|
||||
/// `raw` and `url` are the same fragment-free URL, the second one
|
||||
/// lowercased; `source_hostname` may be empty when there is no document
|
||||
/// context.
|
||||
/// `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(
|
||||
raw: []const u8,
|
||||
url: []const u8,
|
||||
buf: []u8,
|
||||
source_hostname: []const u8,
|
||||
kind: NetworkFilter.ResourceTypes,
|
||||
) Request {
|
||||
const parsed: pattern.Url = .init(url);
|
||||
) ?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,
|
||||
.raw = raw,
|
||||
.source_hostname = source,
|
||||
.kind = kind,
|
||||
.third_party = domain.isThirdParty(parsed.hostname(), source),
|
||||
@@ -119,22 +117,18 @@ pub const Request = struct {
|
||||
.tokens_len = 0,
|
||||
.tail = "",
|
||||
};
|
||||
var it: Tokens = .{ .text = url };
|
||||
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 = url[it.i..];
|
||||
request.tail = lowered[it.i..];
|
||||
return request;
|
||||
}
|
||||
|
||||
pub fn fromHttp(transfer: *const HttpClient.Transfer, buffers: *Buffers) ?Request {
|
||||
const req = &transfer.req;
|
||||
// No request URL carries a fragment onto the wire.
|
||||
const fragment = std.mem.indexOfScalar(u8, req.url, '#') orelse req.url.len;
|
||||
const raw = req.url[0..fragment];
|
||||
const url = normalizeUrl(raw, &buffers.url) orelse return null;
|
||||
|
||||
var owner: ?*const HttpClient.Owner = transfer.owner;
|
||||
var subframe = false;
|
||||
@@ -157,7 +151,7 @@ pub const Request = struct {
|
||||
.worker => .{ .script = true },
|
||||
};
|
||||
|
||||
return .init(raw, url, source, resource_type);
|
||||
return .init(req.url, &buffers.url, source, resource_type);
|
||||
}
|
||||
|
||||
inline fn tokens(self: *const Request) []const u32 {
|
||||
@@ -165,7 +159,7 @@ pub const Request = struct {
|
||||
}
|
||||
|
||||
/// Lowercases `url` into `buf`, as patterns are stored lowercased.
|
||||
fn normalizeUrl(url: []const u8, buf: []u8) ?[]const u8 {
|
||||
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;
|
||||
@@ -224,8 +218,6 @@ fn matchesFilter(filter: *const NetworkFilter, request: *const Request) bool {
|
||||
return false;
|
||||
}
|
||||
if (!filter.domains.matches(request.source_hostname)) return false;
|
||||
// uBO tests the raw URL; the case-insensitive flag is on the pattern.
|
||||
if (filter.kind == .regex) return filter.regex.?.matches(request.raw);
|
||||
return pattern.matches(filter, request.url);
|
||||
}
|
||||
|
||||
@@ -338,7 +330,15 @@ fn collectTokens(filter: *const NetworkFilter, buf: []u32) []u32 {
|
||||
// `.any` has no pattern at all.
|
||||
if (filter.pattern.len == 0) return buf[0..n];
|
||||
|
||||
const text = filter.pattern;
|
||||
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])) {
|
||||
@@ -348,56 +348,32 @@ fn collectTokens(filter: *const NetworkFilter, buf: []u32) []u32 {
|
||||
const start = i;
|
||||
while (i < text.len and isTokenChar(text[i])) i += 1;
|
||||
|
||||
const left_bounded = if (start == 0)
|
||||
filter.left_anchor or filter.hostname_anchor
|
||||
else
|
||||
text[start - 1] != '*';
|
||||
const right_bounded = if (i == text.len) filter.right_anchor else text[i] != '*';
|
||||
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 buf[0..n];
|
||||
if (n == buf.len) return n;
|
||||
buf[n] = hash(text[start..i]);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return buf[0..n];
|
||||
return n;
|
||||
}
|
||||
|
||||
/// The tokens a regex is sure to have wherever it matches, uBO's
|
||||
/// `tokenizableStrFromRegex`: the pattern is flattened into a string where
|
||||
/// literal characters stay and everything else becomes a marker saying only
|
||||
/// whether it could be a token character, then read like a plain pattern.
|
||||
/// Anything the flattening does not follow yields no token at all, which is
|
||||
/// never wrong: the filter then rides the fallback bucket.
|
||||
/// `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];
|
||||
|
||||
var n: usize = 0;
|
||||
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;
|
||||
|
||||
// The pattern's ends are open unless anchored, and a marker that may
|
||||
// be a token character would extend the run.
|
||||
const left_bounded = start != 0 and text[start - 1] != RegexShape.maybe_token;
|
||||
const right_bounded = i != text.len and text[i] != RegexShape.maybe_token;
|
||||
if (left_bounded and right_bounded) {
|
||||
if (n == buf.len) return buf[0..n];
|
||||
buf[n] = hash(text[start..i]);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return buf[0..n];
|
||||
// Anchors are in the shape itself; its ends are open.
|
||||
return buf[0..boundedTokens(text, false, false, buf, 0)];
|
||||
}
|
||||
|
||||
const RegexShape = struct {
|
||||
@@ -406,12 +382,13 @@ const RegexShape = struct {
|
||||
i: usize = 0,
|
||||
n: usize = 0,
|
||||
|
||||
/// Whatever matches here is not a token character: an anchor, `\b`, a
|
||||
/// quantified non-token literal.
|
||||
/// 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.
|
||||
const maybe_token = 0x01;
|
||||
/// 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;
|
||||
@@ -475,7 +452,7 @@ const RegexShape = struct {
|
||||
'*', '+', '?' => return error.Unsupported,
|
||||
else => {
|
||||
self.i += 1;
|
||||
self.emit(std.ascii.toLower(c));
|
||||
self.emitLiteral(c);
|
||||
},
|
||||
}
|
||||
try self.quantifier(atom_start);
|
||||
@@ -561,38 +538,31 @@ const RegexShape = struct {
|
||||
// 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.emit(std.ascii.toLower(e)),
|
||||
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".
|
||||
/// "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 min: usize = 0;
|
||||
var max: ?usize = null;
|
||||
var optional = true;
|
||||
switch (self.source[self.i]) {
|
||||
'*' => self.i += 1,
|
||||
'*', '?' => self.i += 1,
|
||||
'+' => {
|
||||
self.i += 1;
|
||||
min = 1;
|
||||
},
|
||||
'?' => {
|
||||
self.i += 1;
|
||||
max = 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, ',');
|
||||
const min_text = if (comma) |at| body[0..at] else body;
|
||||
const comma = std.mem.indexOfScalar(u8, body, ',') orelse body.len;
|
||||
// Not a quantifier at all: JavaScript reads the `{` literally.
|
||||
min = std.fmt.parseUnsigned(usize, min_text, 10) catch return;
|
||||
max = if (comma) |at|
|
||||
(if (at + 1 == body.len) null else std.fmt.parseUnsigned(usize, body[at + 1 ..], 10) catch return)
|
||||
else
|
||||
min;
|
||||
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,
|
||||
@@ -604,8 +574,7 @@ const RegexShape = struct {
|
||||
const first = startsTokenish(atom);
|
||||
const last = endsTokenish(atom);
|
||||
self.n = atom_start;
|
||||
if (max == 0) return;
|
||||
if (min != 0) {
|
||||
if (!optional) {
|
||||
self.emit(if (first) maybe_token else not_token);
|
||||
self.emit(if (last) maybe_token else not_token);
|
||||
} else {
|
||||
@@ -653,6 +622,13 @@ const RegexShape = struct {
|
||||
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;
|
||||
}
|
||||
@@ -703,6 +679,8 @@ test "adblock.Engine: regex filters yield the tokens every match carries" {
|
||||
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);
|
||||
@@ -722,8 +700,6 @@ test "adblock.Engine: regex filters yield the tokens every match carries" {
|
||||
// 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);
|
||||
tokens = try tokensOf(arena, "/ads|banner/", &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"));
|
||||
@@ -740,8 +716,6 @@ test "adblock.Engine: regex filters yield the tokens every match carries" {
|
||||
try testing.expectEqual(0, tokens.len);
|
||||
tokens = try tokensOf(arena, "/\\/(ads\\//", &buf);
|
||||
try testing.expectEqual(0, tokens.len);
|
||||
tokens = try tokensOf(arena, "/about:blank.*/", &buf);
|
||||
try testing.expectEqual(0, tokens.len);
|
||||
}
|
||||
|
||||
test "adblock.Engine: a request keeps its first tokens, the rest as tail" {
|
||||
@@ -750,12 +724,13 @@ test "adblock.Engine: a request keeps its first tokens, the rest as tail" {
|
||||
|
||||
// Exactly as many tokens as the buffer holds: nothing is left to walk...
|
||||
const full = "x/" ** (max - 1) ++ "x";
|
||||
var request: Request = .init(full, full, "", kind);
|
||||
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 = .init(full ++ "/y", full ++ "/y", "", kind);
|
||||
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 };
|
||||
@@ -763,7 +738,7 @@ test "adblock.Engine: a request keeps its first tokens, the rest as tail" {
|
||||
try testing.expect(it.next() == null);
|
||||
|
||||
// Fewer than the buffer holds: the tail is empty.
|
||||
request = .init("https://example.com/a", "https://example.com/a", "", kind);
|
||||
request = Request.init("https://example.com/a", &buf, "", kind).?;
|
||||
try testing.expectEqual(4, request.tokens_len);
|
||||
try testing.expectEqual(0, request.tail.len);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ badfilter: bool = false,
|
||||
match_case: bool = false,
|
||||
/// The compiled `.regex` pattern, set by the blocker once it has read the
|
||||
/// literal; the parser never runs one.
|
||||
regex: ?Regex = null,
|
||||
regex: ?*const Regex = null,
|
||||
first_party: bool = true,
|
||||
third_party: bool = true,
|
||||
hostname_anchor: bool = false,
|
||||
|
||||
@@ -42,6 +42,10 @@ pub const Error = error{ InvalidRegex, OutOfMemory };
|
||||
|
||||
/// What every `Regex` compiled through it shares: the allocator PCRE2 draws
|
||||
/// from, and the compile and match settings. Outlives the regexes.
|
||||
///
|
||||
/// PCRE2 would happily use libc's malloc; it is handed the blocker's
|
||||
/// allocator so that a compiled pattern nobody freed fails a test the way
|
||||
/// any other leak does.
|
||||
pub const Context = struct {
|
||||
allocator: Allocator,
|
||||
general: *pcre2.pcre2_general_context_8,
|
||||
@@ -59,9 +63,10 @@ pub const Context = struct {
|
||||
errdefer allocator.destroy(self);
|
||||
self.allocator = allocator;
|
||||
|
||||
// PCRE2 hands `self` back to the callbacks, so the context has to be
|
||||
// at its final address before anything is allocated through it.
|
||||
const general = pcre2.pcre2_general_context_create_8(cMalloc, cFree, self) orelse return error.OutOfMemory;
|
||||
// PCRE2 hands the allocator back to the callbacks by address, so the
|
||||
// context has to be at its final one before anything is allocated
|
||||
// through it.
|
||||
const general = pcre2.pcre2_general_context_create_8(cMalloc, cFree, &self.allocator) orelse return error.OutOfMemory;
|
||||
errdefer pcre2.pcre2_general_context_free_8(general);
|
||||
|
||||
const compile_context = pcre2.pcre2_compile_context_create_8(general) orelse return error.OutOfMemory;
|
||||
@@ -93,19 +98,19 @@ pub const Context = struct {
|
||||
const alignment: std.mem.Alignment = .fromByteUnits(HEADER);
|
||||
|
||||
fn cMalloc(size: usize, data: ?*anyopaque) callconv(.c) ?*anyopaque {
|
||||
const self: *const Context = @ptrCast(@alignCast(data.?));
|
||||
const allocator: *const Allocator = @ptrCast(@alignCast(data.?));
|
||||
const total = std.math.add(usize, size, HEADER) catch return null;
|
||||
const block = self.allocator.alignedAlloc(u8, alignment, total) catch return null;
|
||||
const block = allocator.alignedAlloc(u8, alignment, total) catch return null;
|
||||
std.mem.writeInt(usize, block[0..@sizeOf(usize)], total, .little);
|
||||
return block.ptr + HEADER;
|
||||
}
|
||||
|
||||
fn cFree(ptr: ?*anyopaque, data: ?*anyopaque) callconv(.c) void {
|
||||
const payload = ptr orelse return;
|
||||
const self: *const Context = @ptrCast(@alignCast(data.?));
|
||||
const allocator: *const Allocator = @ptrCast(@alignCast(data.?));
|
||||
const base: [*]align(HEADER) u8 = @ptrCast(@alignCast(@as([*]u8, @ptrCast(payload)) - HEADER));
|
||||
const total = std.mem.readInt(usize, base[0..@sizeOf(usize)], .little);
|
||||
self.allocator.free(base[0..total]);
|
||||
allocator.free(base[0..total]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,12 +146,22 @@ pub fn deinit(self: Regex) void {
|
||||
pcre2.pcre2_code_free_8(self.code);
|
||||
}
|
||||
|
||||
// What one match allocates: its match data and the 20KB of backtracking
|
||||
// frames PCRE2 starts with, which only a deeply nested pattern outgrows.
|
||||
const MATCH_SCRATCH = 24 * 1024;
|
||||
|
||||
/// Whether the pattern matches anywhere in `text`, as `RegExp.test` would
|
||||
/// answer. A match that hits the backtracking limits counts as no match.
|
||||
pub fn matches(self: Regex, text: []const u8) bool {
|
||||
// This runs per request; the scratch keeps the common case off the heap.
|
||||
var scratch = std.heap.stackFallback(MATCH_SCRATCH, self.context.allocator);
|
||||
var allocator = scratch.get();
|
||||
const general = pcre2.pcre2_general_context_create_8(Context.cMalloc, Context.cFree, &allocator) orelse return false;
|
||||
defer pcre2.pcre2_general_context_free_8(general);
|
||||
|
||||
// One pair is the whole-match span, all a test needs; capture groups in
|
||||
// the pattern are simply not recorded.
|
||||
const match_data = pcre2.pcre2_match_data_create_8(1, self.context.general) orelse return false;
|
||||
const match_data = pcre2.pcre2_match_data_create_8(1, general) orelse return false;
|
||||
defer pcre2.pcre2_match_data_free_8(match_data);
|
||||
|
||||
const rc = pcre2.pcre2_match_8(self.code, text.ptr, text.len, 0, 0, match_data, self.context.match_context);
|
||||
@@ -178,15 +193,10 @@ test "adblock.Regex: JavaScript escapes and unanchored search" {
|
||||
try testing.expect(!dash.matches("https://x.com/?s=1"));
|
||||
}
|
||||
|
||||
test "adblock.Regex: case sensitivity follows the flag" {
|
||||
test "adblock.Regex: $match-case keeps the case" {
|
||||
const context: *Context = try .init(testing.allocator);
|
||||
defer context.deinit();
|
||||
|
||||
const caseless = try Regex.compile(context, "\\/Ads\\/", true);
|
||||
defer caseless.deinit();
|
||||
try testing.expect(caseless.matches("https://x.com/ads/1.js"));
|
||||
try testing.expect(caseless.matches("https://x.com/ADS/1.js"));
|
||||
|
||||
const exact = try Regex.compile(context, "\\/[a-z0-9]{12}\\/[a-zA-Z0-9]{20,}$", false);
|
||||
defer exact.deinit();
|
||||
try testing.expect(exact.matches("https://x.com/abcdef123456/aBcDeFgHiJkLmNoPqRsTuV"));
|
||||
|
||||
@@ -28,8 +28,11 @@ const NetworkFilter = @import("NetworkFilter.zig");
|
||||
/// The URL a request is matched against, with its hostname located once so
|
||||
/// every filter can reuse the offsets.
|
||||
pub const Url = struct {
|
||||
/// Lowercased, fragment stripped.
|
||||
/// Lowercased, fragment stripped: what patterns, stored lowercased, walk.
|
||||
text: []const u8,
|
||||
/// Fragment stripped, case kept: what a regex reads, since `$match-case`
|
||||
/// is only meaningful on the original.
|
||||
raw: []const u8,
|
||||
host_start: u32,
|
||||
host_end: u32,
|
||||
|
||||
@@ -40,7 +43,7 @@ pub const Url = struct {
|
||||
/// Locates the hostname inside `url`: after "scheme://", up to the port,
|
||||
/// path, query or end. Offsets, not a slice, because the matcher needs to
|
||||
/// resume the pattern right where the hostname stops.
|
||||
pub fn init(url: []const u8) Url {
|
||||
pub fn init(url: []const u8, raw: []const u8) Url {
|
||||
var start: usize = 0;
|
||||
if (std.mem.indexOf(u8, url, "://")) |scheme| start = scheme + 3;
|
||||
|
||||
@@ -74,6 +77,7 @@ pub const Url = struct {
|
||||
|
||||
return .{
|
||||
.text = url,
|
||||
.raw = raw,
|
||||
.host_start = @intCast(start),
|
||||
.host_end = @intCast(host_end),
|
||||
};
|
||||
@@ -86,8 +90,8 @@ pub fn matches(filter: *const NetworkFilter, url: Url) bool {
|
||||
switch (filter.kind) {
|
||||
// Option-only filters ("$script,domain=x") match any URL.
|
||||
.any => return true,
|
||||
// The engine runs these against the raw URL before it gets here.
|
||||
.regex => unreachable,
|
||||
// uBO tests the raw URL; the case-insensitive flag is on the pattern.
|
||||
.regex => return filter.regex.?.matches(url.raw),
|
||||
.hostname, .plain, .wildcard => {},
|
||||
}
|
||||
|
||||
@@ -235,28 +239,28 @@ const testing = @import("../../testing.zig");
|
||||
|
||||
fn testMatch(arena: std.mem.Allocator, line: []const u8, url: []const u8) !bool {
|
||||
const filter = try NetworkFilter.parse(arena, line);
|
||||
return matches(&filter, .init(url));
|
||||
return matches(&filter, .init(url, url));
|
||||
}
|
||||
|
||||
test "adblock.pattern: hostname location" {
|
||||
var u: Url = .init("https://ads.example.com/x?y=1");
|
||||
var u: Url = .init("https://ads.example.com/x?y=1", "");
|
||||
try testing.expectString("ads.example.com", u.hostname());
|
||||
|
||||
u = .init("https://ads.example.com:8443/x");
|
||||
u = .init("https://ads.example.com:8443/x", "");
|
||||
try testing.expectString("ads.example.com", u.hostname());
|
||||
|
||||
u = .init("https://example.com");
|
||||
u = .init("https://example.com", "");
|
||||
try testing.expectString("example.com", u.hostname());
|
||||
|
||||
u = .init("http://[::1]:9222/json");
|
||||
u = .init("http://[::1]:9222/json", "");
|
||||
try testing.expectString("[::1]", u.hostname());
|
||||
|
||||
// Credentials are not part of the host.
|
||||
u = .init("https://user:pass@ads.example.com/x");
|
||||
u = .init("https://user:pass@ads.example.com/x", "");
|
||||
try testing.expectString("ads.example.com", u.hostname());
|
||||
|
||||
// A pattern is matched against whatever it is given, even a bare path.
|
||||
u = .init("/relative/path");
|
||||
u = .init("/relative/path", "");
|
||||
try testing.expectString("", u.hostname());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user