diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index ce74cc59b..3d73d6ed1 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -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. diff --git a/src/network/adblock/AdBlocker.zig b/src/network/adblock/AdBlocker.zig index 8021c9c59..4ddb4f50a 100644 --- a/src/network/adblock/AdBlocker.zig +++ b/src/network/adblock/AdBlocker.zig @@ -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. diff --git a/src/network/adblock/Engine.zig b/src/network/adblock/Engine.zig index c8fe924a5..14fe401e4 100644 --- a/src/network/adblock/Engine.zig +++ b/src/network/adblock/Engine.zig @@ -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); } diff --git a/src/network/adblock/NetworkFilter.zig b/src/network/adblock/NetworkFilter.zig index e38053606..ab1ef8e9d 100644 --- a/src/network/adblock/NetworkFilter.zig +++ b/src/network/adblock/NetworkFilter.zig @@ -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, diff --git a/src/network/adblock/Regex.zig b/src/network/adblock/Regex.zig index 66dee0b73..6b12d061b 100644 --- a/src/network/adblock/Regex.zig +++ b/src/network/adblock/Regex.zig @@ -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")); diff --git a/src/network/adblock/pattern.zig b/src/network/adblock/pattern.zig index 3406c5572..e04c6a10c 100644 --- a/src/network/adblock/pattern.zig +++ b/src/network/adblock/pattern.zig @@ -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()); }