Files
browser/src/network/adblock/Regex.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

226 lines
9.6 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/>.
//! A compiled `/regex/` filter body. Filter lists write them in JavaScript
//! `RegExp` syntax and uBO runs them with `new RegExp(src, 'i')` against the
//! raw request URL (no flag under `$match-case`); PCRE2 reads that syntax
//! as-is, escapes like `\/` included.
//!
//! A compiled pattern and its `Context` are never modified after `compile`,
//! so one `Regex` can be shared by every HTTP client thread; the per-call
//! match data is what PCRE2 requires to be private.
const std = @import("std");
const lp = @import("lightpanda");
const pcre2 = @import("pcre2");
const Allocator = std.mem.Allocator;
const log = lp.log;
const Regex = @This();
code: *pcre2.pcre2_code_8,
context: *const Context,
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,
compile_context: *pcre2.pcre2_compile_context_8,
match_context: *pcre2.pcre2_match_context_8,
// A pattern from a list that backtracks this much on one URL is broken,
// not slow; giving up costs a false negative on that request, nothing
// more.
const MATCH_LIMIT = 100_000;
const DEPTH_LIMIT = 10_000;
pub fn init(allocator: Allocator) Allocator.Error!*Context {
const self = try allocator.create(Context);
errdefer allocator.destroy(self);
self.allocator = allocator;
// 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;
errdefer pcre2.pcre2_compile_context_free_8(compile_context);
// JavaScript without the `u` flag reads an unknown escape as the
// literal character, and that is the mode uBO compiles filters in.
_ = pcre2.pcre2_set_compile_extra_options_8(compile_context, pcre2.PCRE2_EXTRA_BAD_ESCAPE_IS_LITERAL);
const match_context = pcre2.pcre2_match_context_create_8(general) orelse return error.OutOfMemory;
_ = pcre2.pcre2_set_match_limit_8(match_context, MATCH_LIMIT);
_ = pcre2.pcre2_set_depth_limit_8(match_context, DEPTH_LIMIT);
self.general = general;
self.compile_context = compile_context;
self.match_context = match_context;
return self;
}
pub fn deinit(self: *Context) void {
pcre2.pcre2_match_context_free_8(self.match_context);
pcre2.pcre2_compile_context_free_8(self.compile_context);
pcre2.pcre2_general_context_free_8(self.general);
self.allocator.destroy(self);
}
// PCRE2 frees without a size, so every block carries its own in a
// header that keeps the payload at malloc's alignment.
const HEADER = 16;
const alignment: std.mem.Alignment = .fromByteUnits(HEADER);
fn cMalloc(size: usize, data: ?*anyopaque) callconv(.c) ?*anyopaque {
const allocator: *const Allocator = @ptrCast(@alignCast(data.?));
const total = std.math.add(usize, size, HEADER) 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 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);
allocator.free(base[0..total]);
}
};
pub fn compile(context: *const Context, pattern: []const u8, case_insensitive: bool) Error!Regex {
const options: u32 = if (case_insensitive) pcre2.PCRE2_CASELESS else 0;
var err_code: c_int = 0;
var err_offset: usize = 0;
const code = pcre2.pcre2_compile_8(
pattern.ptr,
pattern.len,
options,
&err_code,
&err_offset,
context.compile_context,
) orelse {
// Compile errors are positive codes; 21 is the one for a failed
// allocation, and that is ours, not the pattern's.
if (err_code == 21) return error.OutOfMemory;
var buf: [256]u8 = undefined;
const len = pcre2.pcre2_get_error_message_8(err_code, &buf, buf.len);
const message: []const u8 = if (len < 0) "unknown error" else buf[0..@intCast(len)];
log.debug(.app, "adblock regex rejected", .{
.pattern = pattern,
.err = message,
.offset = err_offset,
});
return error.InvalidRegex;
};
return .{ .code = code, .context = context };
}
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, 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);
return rc >= 0;
}
const testing = @import("../../testing.zig");
test "adblock.Regex: JavaScript escapes and unanchored search" {
const context: *Context = try .init(testing.allocator);
defer context.deinit();
const regex = try Regex.compile(context, "^https?:\\/\\/[0-9a-z]{5,}\\.com\\/.*", true);
defer regex.deinit();
try testing.expect(regex.matches("https://abcde.com/x"));
try testing.expect(regex.matches("HTTPS://ABCDE.COM/X"));
try testing.expect(!regex.matches("https://abcd.com/x"));
try testing.expect(!regex.matches("https://abcde.org/x"));
const invoke = try Regex.compile(context, "\\/[0-9a-f]{32}\\/invoke\\.js", true);
defer invoke.deinit();
try testing.expect(invoke.matches("https://host.com/0123456789abcdef0123456789abcdef/invoke.js"));
try testing.expect(!invoke.matches("https://host.com/0123456789abcdef0123456789abcde/invoke.js"));
const dash = try Regex.compile(context, "[a-z\\-]+\\?s=", true);
defer dash.deinit();
try testing.expect(dash.matches("https://x.com/a-b?s=1"));
try testing.expect(!dash.matches("https://x.com/?s=1"));
}
test "adblock.Regex: $match-case keeps the case" {
const context: *Context = try .init(testing.allocator);
defer context.deinit();
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"));
try testing.expect(!exact.matches("https://x.com/ABCDEF123456/aBcDeFgHiJkLmNoPqRsTuV"));
}
test "adblock.Regex: invalid patterns are errors, runaway ones no match" {
const context: *Context = try .init(testing.allocator);
defer context.deinit();
try testing.expectError(error.InvalidRegex, Regex.compile(context, "(", true));
try testing.expectError(error.InvalidRegex, Regex.compile(context, "a{2,1}", true));
// An unknown alphanumeric escape is the literal, as in JavaScript.
const literal = try Regex.compile(context, "\\q", true);
defer literal.deinit();
try testing.expect(literal.matches("https://x.com/q"));
// Exponential backtracking stops at the match limit instead of stalling
// the request.
const runaway = try Regex.compile(context, "^(a+)+$", true);
defer runaway.deinit();
const subject = "a" ** 64 ++ "b";
try testing.expect(!runaway.matches(subject));
try testing.expect(runaway.matches("a" ** 64));
}