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:
Adrià Arrufat committed 2026-09-09 13:13:18 +02:00
1 parent 39974e461d
commit 433ca9b747
6 files changed
+110 -126

No files matched your search

-4
View File
@@ -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.
+8 -9
View File
@@ -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.
+62 -87
View File
@@ -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);
}
+1 -1
View File
@@ -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,
+24 -14
View File
@@ -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"));
+15 -11
View File
@@ -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());
}