From 121c49e9c39edc867df68a3bfd2085b208db74c8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 8 Dec 2025 16:23:19 +0800 Subject: [PATCH] Remove std.Uri from cookies Everything now works on a [:0]const u8, with browser/URL.zig for parsing --- src/browser/Session.zig | 4 +- src/browser/URL.zig | 4 + src/browser/webapi/storage/cookie.zig | 886 ++++++++++++------------- src/browser/webapi/storage/storage.zig | 3 +- src/cdp/domains/fetch.zig | 10 +- src/cdp/domains/network.zig | 47 +- src/cdp/domains/storage.zig | 15 +- src/http/Client.zig | 41 +- 8 files changed, 485 insertions(+), 525 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index cacd6f0e..cabbf551 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -51,7 +51,7 @@ arena: Allocator, transfer_arena: Allocator, executor: js.ExecutionWorld, -cookie_jar: storage.Jar, +cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, page: ?*Page = null, @@ -73,7 +73,7 @@ pub fn init(self: *Session, browser: *Browser) !void { .storage_shed = .{}, .queued_navigation = null, .arena = browser.session_arena.allocator(), - .cookie_jar = storage.Jar.init(allocator), + .cookie_jar = storage.Cookie.Jar.init(allocator), .transfer_arena = browser.transfer_arena.allocator(), }; } diff --git a/src/browser/URL.zig b/src/browser/URL.zig index cd56bbd8..3d77acf9 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -171,6 +171,10 @@ pub fn getProtocol(raw: [:0]const u8) []const u8 { return raw[0 .. pos + 1]; } +pub fn isHTTPS(raw: [:0]const u8) bool { + return std.mem.startsWith(u8, raw, "https:"); +} + pub fn getHostname(raw: [:0]const u8) []const u8 { const host = getHost(raw); const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host; diff --git a/src/browser/webapi/storage/cookie.zig b/src/browser/webapi/storage/cookie.zig index 436d258b..33abf65b 100644 --- a/src/browser/webapi/storage/cookie.zig +++ b/src/browser/webapi/storage/cookie.zig @@ -17,22 +17,369 @@ // along with this program. If not, see . const std = @import("std"); -const Uri = std.Uri; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const URL = @import("../../URL.zig"); const log = @import("../../../log.zig"); const DateTime = @import("../../../datetime.zig").DateTime; const public_suffix_list = @import("../../../data/public_suffix_list.zig").lookup; -pub const LookupOpts = struct { - request_time: ?i64 = null, - origin_uri: ?*const Uri = null, - is_http: bool, - is_navigation: bool = true, - prefix: ?[]const u8 = null, +const Cookie = @This(); + +arena: ArenaAllocator, +name: []const u8, +value: []const u8, +domain: []const u8, +path: []const u8, +expires: ?f64, +secure: bool = false, +http_only: bool = false, +same_site: SameSite = .none, + +const SameSite = enum { + strict, + lax, + none, }; +pub fn deinit(self: *const Cookie) void { + self.arena.deinit(); +} + +// There's https://datatracker.ietf.org/doc/html/rfc6265 but browsers are +// far less strict. I only found 2 cases where browsers will reject a cookie: +// - a byte 0...32 and 127..255 anywhere in the cookie (the HTTP header +// parser might take care of this already) +// - any shenanigans with the domain attribute - it has to be the current +// domain or one of higher order, exluding TLD. +// Anything else, will turn into a cookie. +// Single value? That's a cookie with an emtpy name and a value +// Key or Values with characters the RFC says aren't allowed? Allowed! ( +// (as long as the characters are 32...126) +// Invalid attributes? Ignored. +// Invalid attribute values? Ignore. +// Duplicate attributes - use the last valid +// Value-less attributes with a value? Ignore the value +pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie { + try validateCookieString(str); + + const cookie_name, const cookie_value, const rest = parseNameValue(str) catch { + return error.InvalidNameValue; + }; + + var scrap: [8]u8 = undefined; + + var path: ?[]const u8 = null; + var domain: ?[]const u8 = null; + var secure: ?bool = null; + var max_age: ?i64 = null; + var http_only: ?bool = null; + var expires: ?[]const u8 = null; + var same_site: ?Cookie.SameSite = null; + + var it = std.mem.splitScalar(u8, rest, ';'); + while (it.next()) |attribute| { + const sep = std.mem.indexOfScalarPos(u8, attribute, 0, '=') orelse attribute.len; + const key_string = trim(attribute[0..sep]); + + if (key_string.len > 8) { + // not valid, ignore + continue; + } + + // Make sure no one changes our max length without also expanding the size of scrap + std.debug.assert(key_string.len <= 8); + + const key = std.meta.stringToEnum(enum { + path, + domain, + secure, + @"max-age", + expires, + httponly, + samesite, + }, std.ascii.lowerString(&scrap, key_string)) orelse continue; + + const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]); + switch (key) { + .path => path = value, + .domain => domain = value, + .secure => secure = true, + .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, + .expires => expires = value, + .httponly => http_only = true, + .samesite => { + same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue; + }, + } + } + + if (same_site == .none and secure == null) { + return error.InsecureSameSite; + } + + var arena = ArenaAllocator.init(allocator); + errdefer arena.deinit(); + const aa = arena.allocator(); + const owned_name = try aa.dupe(u8, cookie_name); + const owned_value = try aa.dupe(u8, cookie_value); + const owned_path = try parsePath(aa, url, path); + const owned_domain = try parseDomain(aa, url, domain); + + var normalized_expires: ?f64 = null; + if (max_age) |ma| { + normalized_expires = @floatFromInt(std.time.timestamp() + ma); + } else { + // max age takes priority over expires + if (expires) |expires_| { + var exp_dt = DateTime.parse(expires_, .rfc822) catch null; + if (exp_dt == null) { + if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) { + // Replace dashes and try again + const output = try aa.dupe(u8, expires_); + output[7] = ' '; + output[11] = ' '; + exp_dt = DateTime.parse(output, .rfc822) catch null; + } + } + if (exp_dt) |dt| { + normalized_expires = @floatFromInt(dt.unix(.seconds)); + } else { + // Algolia, for example, will call document.setCookie with + // an expired value which is literally 'Invalid Date' + // (it's trying to do something like: `new Date() + undefined`). + log.debug(.page, "cookie expires date", .{ .date = expires_ }); + } + } + } + + return .{ + .arena = arena, + .name = owned_name, + .value = owned_value, + .path = owned_path, + .same_site = same_site orelse .lax, + .secure = secure orelse false, + .http_only = http_only orelse false, + .domain = owned_domain, + .expires = normalized_expires, + }; +} + +const ValidateCookieError = error{ Empty, InvalidByteSequence }; + +/// Returns an error if cookie str length is 0 +/// or contains characters outside of the ascii range 32...126. +fn validateCookieString(str: []const u8) ValidateCookieError!void { + if (str.len == 0) { + return error.Empty; + } + + const vec_size_suggestion = std.simd.suggestVectorLength(u8); + var offset: usize = 0; + + // Fast path if possible. + if (comptime vec_size_suggestion) |size| { + while (str.len - offset >= size) : (offset += size) { + const Vec = @Vector(size, u8); + const space: Vec = @splat(32); + const tilde: Vec = @splat(126); + const chunk: Vec = str[offset..][0..size].*; + + // This creates a mask where invalid characters represented + // as ones and valid characters as zeros. We then bitCast this + // into an unsigned integer. If the integer is not equal to 0, + // we know that we've invalid characters in this chunk. + // @popCount can also be used but using integers are simpler. + const mask = (@intFromBool(chunk < space) | @intFromBool(chunk > tilde)); + const reduced: std.meta.Int(.unsigned, size) = @bitCast(mask); + + // Got match. + if (reduced != 0) { + return error.InvalidByteSequence; + } + } + + // Means str.len % size == 0; we also know str.len != 0. + // Cookie is valid. + if (offset == str.len) { + return; + } + } + + // Either remaining slice or the original if fast path not taken. + const slice = str[offset..]; + // Slow path. + const min, const max = std.mem.minMax(u8, slice); + if (min < 32 or max > 126) { + return error.InvalidByteSequence; + } +} + +pub fn parsePath(arena: Allocator, url_: ?[:0]const u8, explicit_path: ?[]const u8) ![]const u8 { + // path attribute value either begins with a '/' or we + // ignore it and use the "default-path" algorithm + if (explicit_path) |path| { + if (path.len > 0 and path[0] == '/') { + return try arena.dupe(u8, path); + } + } + + // default-path + const url = url_ orelse return "/"; + const url_path = URL.getPathname(url); + if (url_path.len == 0 or (url_path.len == 1 and url_path[0] == '/')) { + return "/"; + } + + var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar); + const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse { + return "/"; + }; + return try arena.dupe(u8, owned_path[0 .. last + 1]); +} + +pub fn parseDomain(arena: Allocator, url_: ?[:0]const u8, explicit_domain: ?[]const u8) ![]const u8 { + var encoded_host: ?[]const u8 = null; + if (url_) |url| { + const host = try percentEncode(arena, URL.getHostname(url), isHostChar); + _ = toLower(host); + encoded_host = host; + } + + if (explicit_domain) |domain| { + if (domain.len > 0) { + const no_leading_dot = if (domain[0] == '.') domain[1..] else domain; + + var aw = try std.Io.Writer.Allocating.initCapacity(arena, no_leading_dot.len + 1); + try aw.writer.writeByte('.'); + try std.Uri.Component.percentEncode(&aw.writer, no_leading_dot, isHostChar); + const owned_domain = toLower(aw.written()); + + if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) { + // can't set a cookie for a TLD + return error.InvalidDomain; + } + if (encoded_host) |host| { + if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) { + return error.InvalidDomain; + } + } + + return owned_domain; + } + } + + return encoded_host orelse return error.InvalidDomain; // default-domain +} + +pub fn percentEncode(arena: Allocator, part: []const u8, comptime isValidChar: fn (u8) bool) ![]u8 { + var aw = try std.Io.Writer.Allocating.initCapacity(arena, part.len); + try std.Uri.Component.percentEncode(&aw.writer, part, isValidChar); + return aw.written(); // @memory retains memory used before growing +} + +pub fn isHostChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + ':' => true, + '[', ']' => true, + else => false, + }; +} + +pub fn isPathChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + '/', ':', '@' => true, + else => false, + }; +} + +fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } { + const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len; + const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..]; + + const sep = std.mem.indexOfScalarPos(u8, str[0..key_value_end], 0, '=') orelse { + const value = trim(str[0..key_value_end]); + if (value.len == 0) { + return error.Empty; + } + return .{ "", value, rest }; + }; + + const name = trim(str[0..sep]); + const value = trim(str[sep + 1 .. key_value_end]); + return .{ name, value, rest }; +} + +pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, is_navigation: bool, is_http: bool) bool { + if (self.http_only and is_http == false) { + // http only cookies can be accessed from Javascript + return false; + } + + if (url.secure == false and self.secure) { + // secure cookie can only be sent over HTTPs + return false; + } + + if (same_site == false) { + // If we aren't on the "same site" (matching 2nd level domain + // taking into account public suffix list), then the cookie + // can only be sent if cookie.same_site == .none, or if + // we're navigating to (as opposed to, say, loading an image) + // and cookie.same_site == .lax + switch (self.same_site) { + .strict => return false, + .lax => if (is_navigation == false) return false, + .none => {}, + } + } + + { + if (self.domain[0] == '.') { + // When a Set-Cookie header has a Domain attribute + // Then we will _always_ prefix it with a dot, extending its + // availability to all subdomains (yes, setting the Domain + // attributes EXPANDS the domains which the cookie will be + // sent to, to always include all subdomains). + if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) { + return false; + } + } else if (std.mem.eql(u8, url.host, self.domain) == false) { + // When the Domain attribute isn't specific, then the cookie + // is only sent on an exact match. + return false; + } + } + + { + if (self.path[self.path.len - 1] == '/') { + // If our cookie has a trailing slash, we can only match is + // the target path is a perfix. I.e., if our path is + // /doc/ we can only match /doc/* + if (std.mem.startsWith(u8, url.path, self.path) == false) { + return false; + } + } else { + // Our cookie path is something like /hello + if (std.mem.startsWith(u8, url.path, self.path) == false) { + // The target path has to either be /hello (it isn't) + return false; + } else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) { + // Or it has to be something like /hello/* (it isn't) + // it isn't! + return false; + } + } + } + return true; +} + pub const Jar = struct { allocator: Allocator, cookies: std.ArrayListUnmanaged(Cookie), @@ -98,13 +445,20 @@ pub const Jar = struct { } } - pub fn forRequest(self: *Jar, target_uri: *const std.Uri, writer: anytype, opts: LookupOpts) !void { + pub const LookupOpts = struct { + is_http: bool, + request_time: ?i64 = null, + is_navigation: bool = true, + prefix: ?[]const u8 = null, + origin_url: ?[:0]const u8 = null, + }; + pub fn forRequest(self: *Jar, target_url: [:0]const u8, writer: anytype, opts: LookupOpts) !void { const target = PreparedUri{ - .host = (target_uri.host orelse return error.InvalidURI).percent_encoded, - .path = target_uri.path.percent_encoded, - .secure = std.mem.eql(u8, target_uri.scheme, "https"), + .host = URL.getHostname(target_url), + .path = URL.getPathname(target_url), + .secure = URL.isHTTPS(target_url), }; - const same_site = try areSameSite(opts.origin_uri, target.host); + const same_site = try areSameSite(opts.origin_url, target.host); removeExpired(self, opts.request_time); @@ -127,8 +481,8 @@ pub const Jar = struct { } } - pub fn populateFromResponse(self: *Jar, uri: *const Uri, set_cookie: []const u8) !void { - const c = Cookie.parse(self.allocator, uri, set_cookie) catch |err| { + pub fn populateFromResponse(self: *Jar, url: [:0]const u8, set_cookie: []const u8) !void { + const c = Cookie.parse(self.allocator, url, set_cookie) catch |err| { log.warn(.page, "cookie parse failed", .{ .raw = set_cookie, .err = err }); return; }; @@ -166,9 +520,9 @@ fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool { return true; } -fn areSameSite(origin_uri_: ?*const std.Uri, target_host: []const u8) !bool { - const origin_uri = origin_uri_ orelse return true; - const origin_host = (origin_uri.host orelse return error.InvalidURI).percent_encoded; +fn areSameSite(origin_url_: ?[:0]const u8, target_host: []const u8) !bool { + const origin_url = origin_url_ orelse return true; + const origin_host = URL.getHostname(origin_url); // common case if (std.mem.eql(u8, target_host, origin_host)) { @@ -189,370 +543,6 @@ fn findSecondLevelDomain(host: []const u8) []const u8 { } } -pub const Cookie = struct { - arena: ArenaAllocator, - name: []const u8, - value: []const u8, - domain: []const u8, - path: []const u8, - expires: ?f64, - secure: bool = false, - http_only: bool = false, - same_site: SameSite = .none, - - const SameSite = enum { - strict, - lax, - none, - }; - - pub fn deinit(self: *const Cookie) void { - self.arena.deinit(); - } - - // There's https://datatracker.ietf.org/doc/html/rfc6265 but browsers are - // far less strict. I only found 2 cases where browsers will reject a cookie: - // - a byte 0...32 and 127..255 anywhere in the cookie (the HTTP header - // parser might take care of this already) - // - any shenanigans with the domain attribute - it has to be the current - // domain or one of higher order, exluding TLD. - // Anything else, will turn into a cookie. - // Single value? That's a cookie with an emtpy name and a value - // Key or Values with characters the RFC says aren't allowed? Allowed! ( - // (as long as the characters are 32...126) - // Invalid attributes? Ignored. - // Invalid attribute values? Ignore. - // Duplicate attributes - use the last valid - // Value-less attributes with a value? Ignore the value - pub fn parse(allocator: Allocator, uri: *const std.Uri, str: []const u8) !Cookie { - try validateCookieString(str); - - const cookie_name, const cookie_value, const rest = parseNameValue(str) catch { - return error.InvalidNameValue; - }; - - var scrap: [8]u8 = undefined; - - var path: ?[]const u8 = null; - var domain: ?[]const u8 = null; - var secure: ?bool = null; - var max_age: ?i64 = null; - var http_only: ?bool = null; - var expires: ?[]const u8 = null; - var same_site: ?Cookie.SameSite = null; - - var it = std.mem.splitScalar(u8, rest, ';'); - while (it.next()) |attribute| { - const sep = std.mem.indexOfScalarPos(u8, attribute, 0, '=') orelse attribute.len; - const key_string = trim(attribute[0..sep]); - - if (key_string.len > 8) { - // not valid, ignore - continue; - } - - // Make sure no one changes our max length without also expanding the size of scrap - std.debug.assert(key_string.len <= 8); - - const key = std.meta.stringToEnum(enum { - path, - domain, - secure, - @"max-age", - expires, - httponly, - samesite, - }, std.ascii.lowerString(&scrap, key_string)) orelse continue; - - const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]); - switch (key) { - .path => path = value, - .domain => domain = value, - .secure => secure = true, - .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, - .expires => expires = value, - .httponly => http_only = true, - .samesite => { - same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue; - }, - } - } - - if (same_site == .none and secure == null) { - return error.InsecureSameSite; - } - - var arena = ArenaAllocator.init(allocator); - errdefer arena.deinit(); - const aa = arena.allocator(); - const owned_name = try aa.dupe(u8, cookie_name); - const owned_value = try aa.dupe(u8, cookie_value); - const owned_path = try parsePath(aa, uri, path); - const owned_domain = try parseDomain(aa, uri, domain); - - var normalized_expires: ?f64 = null; - if (max_age) |ma| { - normalized_expires = @floatFromInt(std.time.timestamp() + ma); - } else { - // max age takes priority over expires - if (expires) |expires_| { - var exp_dt = DateTime.parse(expires_, .rfc822) catch null; - if (exp_dt == null) { - if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) { - // Replace dashes and try again - const output = try aa.dupe(u8, expires_); - output[7] = ' '; - output[11] = ' '; - exp_dt = DateTime.parse(output, .rfc822) catch null; - } - } - if (exp_dt) |dt| { - normalized_expires = @floatFromInt(dt.unix(.seconds)); - } else { - // Algolia, for example, will call document.setCookie with - // an expired value which is literally 'Invalid Date' - // (it's trying to do something like: `new Date() + undefined`). - log.debug(.page, "cookie expires date", .{ .date = expires_ }); - } - } - } - - return .{ - .arena = arena, - .name = owned_name, - .value = owned_value, - .path = owned_path, - .same_site = same_site orelse .lax, - .secure = secure orelse false, - .http_only = http_only orelse false, - .domain = owned_domain, - .expires = normalized_expires, - }; - } - - const ValidateCookieError = error{ Empty, InvalidByteSequence }; - - /// Returns an error if cookie str length is 0 - /// or contains characters outside of the ascii range 32...126. - fn validateCookieString(str: []const u8) ValidateCookieError!void { - if (str.len == 0) { - return error.Empty; - } - - const vec_size_suggestion = std.simd.suggestVectorLength(u8); - var offset: usize = 0; - - // Fast path if possible. - if (comptime vec_size_suggestion) |size| { - while (str.len - offset >= size) : (offset += size) { - const Vec = @Vector(size, u8); - const space: Vec = @splat(32); - const tilde: Vec = @splat(126); - const chunk: Vec = str[offset..][0..size].*; - - // This creates a mask where invalid characters represented - // as ones and valid characters as zeros. We then bitCast this - // into an unsigned integer. If the integer is not equal to 0, - // we know that we've invalid characters in this chunk. - // @popCount can also be used but using integers are simpler. - const mask = (@intFromBool(chunk < space) | @intFromBool(chunk > tilde)); - const reduced: std.meta.Int(.unsigned, size) = @bitCast(mask); - - // Got match. - if (reduced != 0) { - return error.InvalidByteSequence; - } - } - - // Means str.len % size == 0; we also know str.len != 0. - // Cookie is valid. - if (offset == str.len) { - return; - } - } - - // Either remaining slice or the original if fast path not taken. - const slice = str[offset..]; - // Slow path. - const min, const max = std.mem.minMax(u8, slice); - if (min < 32 or max > 126) { - return error.InvalidByteSequence; - } - } - - pub fn parsePath(arena: Allocator, uri: ?*const std.Uri, explicit_path: ?[]const u8) ![]const u8 { - // path attribute value either begins with a '/' or we - // ignore it and use the "default-path" algorithm - if (explicit_path) |path| { - if (path.len > 0 and path[0] == '/') { - return try arena.dupe(u8, path); - } - } - - // default-path - const url_path = (uri orelse return "/").path; - - const either = url_path.percent_encoded; - if (either.len == 0 or (either.len == 1 and either[0] == '/')) { - return "/"; - } - - var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar); - const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse { - return "/"; - }; - return try arena.dupe(u8, owned_path[0 .. last + 1]); - } - - pub fn parseDomain(arena: Allocator, uri: ?*const std.Uri, explicit_domain: ?[]const u8) ![]const u8 { - var encoded_host: ?[]const u8 = null; - if (uri) |uri_| { - const uri_host = uri_.host orelse return error.InvalidURI; - const host = try percentEncode(arena, uri_host, isHostChar); - _ = toLower(host); - encoded_host = host; - } - - if (explicit_domain) |domain| { - if (domain.len > 0) { - const no_leading_dot = if (domain[0] == '.') domain[1..] else domain; - - var aw = try std.Io.Writer.Allocating.initCapacity(arena, no_leading_dot.len + 1); - try aw.writer.writeByte('.'); - try std.Uri.Component.percentEncode(&aw.writer, no_leading_dot, isHostChar); - const owned_domain = toLower(aw.written()); - - if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) { - // can't set a cookie for a TLD - return error.InvalidDomain; - } - if (encoded_host) |host| { - if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) { - return error.InvalidDomain; - } - } - - return owned_domain; - } - } - - return encoded_host orelse return error.InvalidDomain; // default-domain - } - - pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 { - switch (component) { - .raw => |str| { - var aw = try std.Io.Writer.Allocating.initCapacity(arena, str.len); - try std.Uri.Component.percentEncode(&aw.writer, str, isValidChar); - return aw.written(); // @memory retains memory used before growing - }, - .percent_encoded => |str| { - return try arena.dupe(u8, str); - }, - } - } - - pub fn isHostChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - ':' => true, - '[', ']' => true, - else => false, - }; - } - - pub fn isPathChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - '/', ':', '@' => true, - else => false, - }; - } - - fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } { - const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len; - const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..]; - - const sep = std.mem.indexOfScalarPos(u8, str[0..key_value_end], 0, '=') orelse { - const value = trim(str[0..key_value_end]); - if (value.len == 0) { - return error.Empty; - } - return .{ "", value, rest }; - }; - - const name = trim(str[0..sep]); - const value = trim(str[sep + 1 .. key_value_end]); - return .{ name, value, rest }; - } - - pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, is_navigation: bool, is_http: bool) bool { - if (self.http_only and is_http == false) { - // http only cookies can be accessed from Javascript - return false; - } - - if (url.secure == false and self.secure) { - // secure cookie can only be sent over HTTPs - return false; - } - - if (same_site == false) { - // If we aren't on the "same site" (matching 2nd level domain - // taking into account public suffix list), then the cookie - // can only be sent if cookie.same_site == .none, or if - // we're navigating to (as opposed to, say, loading an image) - // and cookie.same_site == .lax - switch (self.same_site) { - .strict => return false, - .lax => if (is_navigation == false) return false, - .none => {}, - } - } - - { - if (self.domain[0] == '.') { - // When a Set-Cookie header has a Domain attribute - // Then we will _always_ prefix it with a dot, extending its - // availability to all subdomains (yes, setting the Domain - // attributes EXPANDS the domains which the cookie will be - // sent to, to always include all subdomains). - if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) { - return false; - } - } else if (std.mem.eql(u8, url.host, self.domain) == false) { - // When the Domain attribute isn't specific, then the cookie - // is only sent on an exact match. - return false; - } - } - - { - if (self.path[self.path.len - 1] == '/') { - // If our cookie has a trailing slash, we can only match is - // the target path is a perfix. I.e., if our path is - // /doc/ we can only match /doc/* - if (std.mem.startsWith(u8, url.path, self.path) == false) { - return false; - } - } else { - // Our cookie path is something like /hello - if (std.mem.startsWith(u8, url.path, self.path) == false) { - // The target path has to either be /hello (it isn't) - return false; - } else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) { - // Or it has to be something like /hello/* (it isn't) - // it isn't! - return false; - } - } - } - return true; - } -}; - pub const PreparedUri = struct { host: []const u8, // Percent encoded, lower case path: []const u8, // Percent encoded @@ -571,7 +561,7 @@ fn trimRight(str: []const u8) []const u8 { return std.mem.trimLeft(u8, str, &std.ascii.whitespace); } -pub fn toLower(str: []u8) []u8 { +fn toLower(str: []u8) []u8 { for (str, 0..) |c, i| { str[i] = std.ascii.toLower(c); } @@ -579,6 +569,7 @@ pub fn toLower(str: []u8) []u8 { } const testing = @import("../../../testing.zig"); +const test_url = "http://lightpanda.io/"; test "cookie: findSecondLevelDomain" { const cases = [_]struct { []const u8, []const u8 }{ .{ "", "" }, @@ -619,37 +610,37 @@ test "Jar: add" { defer jar.deinit(); try expectCookies(&.{}, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000;Max-Age=0"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000;Max-Age=0"), now); try expectCookies(&.{}, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000"), now); try expectCookies(&.{.{ "over", "9000" }}, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000!!"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000!!"), now); try expectCookies(&.{.{ "over", "9000!!" }}, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flow"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flow"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flow" } }, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flows;Path=/"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flows;Path=/"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" } }, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9001;Path=/other"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9001;Path=/other"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" } }, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9002;Path=/;Domain=lightpanda.io"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9002;Path=/;Domain=lightpanda.io"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" }, .{ "over", "9002" } }, jar); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=x;Path=/other;Max-Age=-200"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "over=x;Path=/other;Max-Age=-200"), now); try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar); } test "Jar: forRequest" { const expectCookies = struct { - fn expect(expected: []const u8, jar: *Jar, target_uri: Uri, opts: LookupOpts) !void { + fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void { var arr: std.ArrayListUnmanaged(u8) = .empty; defer arr.deinit(testing.allocator); - try jar.forRequest(&target_uri, arr.writer(testing.allocator), opts); + try jar.forRequest(target_url, arr.writer(testing.allocator), opts); try testing.expectEqual(expected, arr.items); } }.expect; @@ -659,131 +650,131 @@ test "Jar: forRequest" { var jar = Jar.init(testing.allocator); defer jar.deinit(); - const test_uri_2 = Uri.parse("http://test.lightpanda.io/") catch unreachable; + const url2 = "http://test.lightpanda.io/"; { // test with no cookies - try expectCookies("", &jar, test_uri, .{ .is_http = true }); + try expectCookies("", &jar, test_url, .{ .is_http = true }); } - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global2=2;Max-Age=30;domain=lightpanda.io"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path1=3;Path=/about"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path2=4;Path=/docs/"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "secure=5;Secure"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitenone=6;SameSite=None;Path=/x/;Secure"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitelax=7;SameSite=Lax;Path=/x/"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitestrict=8;SameSite=Strict;Path=/x/"), now); - try jar.add(try Cookie.parse(testing.allocator, &test_uri_2, "domain1=9;domain=test.lightpanda.io"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "global1=1"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "global2=2;Max-Age=30;domain=lightpanda.io"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "path1=3;Path=/about"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "path2=4;Path=/docs/"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "secure=5;Secure"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "sitenone=6;SameSite=None;Path=/x/;Secure"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "sitelax=7;SameSite=Lax;Path=/x/"), now); + try jar.add(try Cookie.parse(testing.allocator, test_url, "sitestrict=8;SameSite=Strict;Path=/x/"), now); + try jar.add(try Cookie.parse(testing.allocator, url2, "domain1=9;domain=test.lightpanda.io"), now); // nothing fancy here - try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .is_http = true }); - try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .is_navigation = false, .is_http = true }); + try expectCookies("global1=1; global2=2", &jar, test_url, .{ .is_http = true }); + try expectCookies("global1=1; global2=2", &jar, test_url, .{ .origin_url = test_url, .is_navigation = false, .is_http = true }); // We have a cookie where Domain=lightpanda.io // This should _not_ match xyxlightpanda.io - try expectCookies("", &jar, try std.Uri.parse("http://anothersitelightpanda.io/"), .{ - .origin_uri = &test_uri, + try expectCookies("", &jar, "http://anothersitelightpanda.io/", .{ + .origin_url = test_url, .is_http = true, }); // matching path without trailing / - try expectCookies("global1=1; global2=2; path1=3", &jar, try std.Uri.parse("http://lightpanda.io/about"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2; path1=3", &jar, "http://lightpanda.io/about", .{ + .origin_url = test_url, .is_http = true, }); // incomplete prefix path - try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/abou", .{ + .origin_url = test_url, .is_http = true, }); // path doesn't match - try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/aboutus", .{ + .origin_url = test_url, .is_http = true, }); // path doesn't match cookie directory - try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/docs"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/docs", .{ + .origin_url = test_url, .is_http = true, }); // exact directory match - try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/", .{ + .origin_url = test_url, .is_http = true, }); // sub directory match - try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/more"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/more", .{ + .origin_url = test_url, .is_http = true, }); // secure - try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{ - .origin_uri = &test_uri, + try expectCookies("global1=1; global2=2; secure=5", &jar, "https://lightpanda.io/", .{ + .origin_url = test_url, .is_http = true, }); // navigational cross domain, secure - try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{ - .origin_uri = &(try std.Uri.parse("https://example.com/")), + try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, "https://lightpanda.io/x/", .{ + .origin_url = "https://example.com/", .is_http = true, }); // navigational cross domain, insecure - try expectCookies("global1=1; global2=2; sitelax=7", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ - .origin_uri = &(try std.Uri.parse("https://example.com/")), + try expectCookies("global1=1; global2=2; sitelax=7", &jar, "http://lightpanda.io/x/", .{ + .origin_url = "https://example.com/", .is_http = true, }); // non-navigational cross domain, insecure - try expectCookies("", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ - .origin_uri = &(try std.Uri.parse("https://example.com/")), + try expectCookies("", &jar, "http://lightpanda.io/x/", .{ + .origin_url = "https://example.com/", .is_http = true, .is_navigation = false, }); // non-navigational cross domain, secure - try expectCookies("sitenone=6", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{ - .origin_uri = &(try std.Uri.parse("https://example.com/")), + try expectCookies("sitenone=6", &jar, "https://lightpanda.io/x/", .{ + .origin_url = "https://example.com/", .is_http = true, .is_navigation = false, }); // non-navigational same origin - try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ - .origin_uri = &(try std.Uri.parse("https://lightpanda.io/")), + try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, "http://lightpanda.io/x/", .{ + .origin_url = "https://lightpanda.io/", .is_http = true, .is_navigation = false, }); // exact domain match + suffix - try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{ - .origin_uri = &test_uri, + try expectCookies("global2=2; domain1=9", &jar, "http://test.lightpanda.io/", .{ + .origin_url = test_url, .is_http = true, }); // domain suffix match + suffix - try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{ - .origin_uri = &test_uri, + try expectCookies("global2=2; domain1=9", &jar, "http://1.test.lightpanda.io/", .{ + .origin_url = test_url, .is_http = true, }); // non-matching domain - try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{ - .origin_uri = &test_uri, + try expectCookies("global2=2", &jar, "http://other.lightpanda.io/", .{ + .origin_url = test_url, .is_http = true, }); const l = jar.cookies.items.len; - try expectCookies("global1=1", &jar, test_uri, .{ + try expectCookies("global1=1", &jar, test_url, .{ .request_time = now + 100, - .origin_uri = &test_uri, + .origin_url = test_url, .is_http = true, }); try testing.expectEqual(l - 1, jar.cookies.items.len); @@ -979,9 +970,8 @@ const ExpectedCookie = struct { same_site: Cookie.SameSite = .lax, }; -fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u8) !void { - const uri = try Uri.parse(url); - var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie); +fn expectCookie(expected: ExpectedCookie, url: [:0]const u8, set_cookie: []const u8) !void { + var cookie = try Cookie.parse(testing.allocator, url, set_cookie); defer cookie.deinit(); try testing.expectEqual(expected.name, cookie.name); @@ -995,9 +985,8 @@ fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u try testing.expectDelta(expected.expires, cookie.expires, 2.0); } -fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void { - const uri = if (url) |u| try Uri.parse(u) else test_uri; - var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie); +fn expectAttribute(expected: anytype, url_: ?[:0]const u8, set_cookie: []const u8) !void { + var cookie = try Cookie.parse(testing.allocator, url_ orelse test_url, set_cookie); defer cookie.deinit(); inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| { @@ -1012,9 +1001,6 @@ fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) } } -fn expectError(expected: anyerror, url: ?[]const u8, set_cookie: []const u8) !void { - const uri = if (url) |u| try Uri.parse(u) else test_uri; - try testing.expectError(expected, Cookie.parse(testing.allocator, &uri, set_cookie)); +fn expectError(expected: anyerror, url: ?[:0]const u8, set_cookie: []const u8) !void { + try testing.expectError(expected, Cookie.parse(testing.allocator, url orelse test_url, set_cookie)); } - -const test_uri = Uri.parse("http://lightpanda.io/") catch unreachable; diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index acaaa3fd..e5c8e22e 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -26,8 +26,7 @@ pub fn registerTypes() []const type { return &.{Lookup}; } -pub const Jar = @import("cookie.zig").Jar; -pub const Cookie = @import("cookie.zig").Cookie; +pub const Cookie = @import("Cookie.zig"); pub const Shed = struct { _origins: std.StringHashMapUnmanaged(*Bucket) = .empty, diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index ef11e15d..5a55fc63 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -208,7 +208,7 @@ pub fn requestIntercept(arena: Allocator, bc: anytype, intercept: *const Notific log.debug(.cdp, "request intercept", .{ .state = "paused", .id = transfer.id, - .url = transfer.uri, + .url = transfer.url, }); // Await either continueRequest, failRequest or fulfillRequest @@ -237,7 +237,7 @@ fn continueRequest(cmd: anytype) !void { log.debug(.cdp, "request intercept", .{ .state = "continue", .id = transfer.id, - .url = transfer.uri, + .url = transfer.url, .new_url = params.url, }); @@ -342,7 +342,7 @@ fn fulfillRequest(cmd: anytype) !void { log.debug(.cdp, "request intercept", .{ .state = "fulfilled", .id = transfer.id, - .url = transfer.uri, + .url = transfer.url, .status = params.responseCode, .body = params.body != null, }); @@ -376,7 +376,7 @@ fn failRequest(cmd: anytype) !void { log.info(.cdp, "request intercept", .{ .state = "fail", .id = request_id, - .url = transfer.uri, + .url = transfer.url, .reason = params.errorReason, }); return cmd.sendResult(null, .{}); @@ -420,7 +420,7 @@ pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Noti log.debug(.cdp, "request auth required", .{ .state = "paused", .id = transfer.id, - .url = transfer.uri, + .url = transfer.url, }); // Await continueWithAuth diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index c41d1988..ea4ebf60 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const CdpStorage = @import("storage.zig"); +const URL = @import("../../browser/URL.zig"); const Transfer = @import("../../http/Client.zig").Transfer; const Notification = @import("../../Notification.zig"); @@ -107,7 +108,7 @@ fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, p fn deleteCookies(cmd: anytype) !void { const params = (try cmd.params(struct { name: []const u8, - url: ?[]const u8 = null, + url: ?[:0]const u8 = null, domain: ?[]const u8 = null, path: ?[]const u8 = null, partitionKey: ?CdpStorage.CookiePartitionKey = null, @@ -117,15 +118,12 @@ fn deleteCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const cookies = &bc.session.cookie_jar.cookies; - const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; - const uri_ptr = if (uri) |u| &u else null; - var index = cookies.items.len; while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - const domain = try Cookie.parseDomain(cmd.arena, uri_ptr, params.domain); - const path = try Cookie.parsePath(cmd.arena, uri_ptr, params.path); + const domain = try Cookie.parseDomain(cmd.arena, params.url, params.domain); + const path = try Cookie.parsePath(cmd.arena, params.url, params.path); // We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match. // Similar to deduplicating with areCookiesEqual, except domain and path are optional. @@ -167,23 +165,23 @@ fn setCookies(cmd: anytype) !void { try cmd.sendResult(null, .{}); } -const GetCookiesParam = struct { urls: ?[]const []const u8 = null }; +const GetCookiesParam = struct { + urls: ?[]const [:0]const u8 = null, +}; fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; // If not specified, use the URLs of the page and all of its subframes. TODO subframes const page_url = if (bc.session.page) |page| page.url else null; - const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams}; + const param_urls = params.urls orelse &[_][:0]const u8{page_url orelse return error.InvalidParams}; var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len); for (param_urls) |url| { - const uri = std.Uri.parse(url) catch return error.InvalidParams; - urls.appendAssumeCapacity(.{ - .host = try Cookie.parseDomain(cmd.arena, &uri, null), - .path = try Cookie.parsePath(cmd.arena, &uri, null), - .secure = std.mem.eql(u8, uri.scheme, "https"), + .host = try Cookie.parseDomain(cmd.arena, url, null), + .path = try Cookie.parsePath(cmd.arena, url, null), + .secure = URL.isHTTPS(url), }); } @@ -300,23 +298,19 @@ pub const TransferAsRequestWriter = struct { try jws.objectField("url"); try jws.beginWriteRaw(); try writer.writeByte('\"'); - try transfer.uri.writeToStream(writer, .{ - .scheme = true, - .authentication = true, - .authority = true, - .path = true, - .query = true, - }); + // #ZIGDOM shouldn't include the hash? + try writer.writeAll(transfer.url); try writer.writeByte('\"'); jws.endWriteRaw(); } { - if (transfer.uri.fragment) |frag| { + const frag = URL.getHash(transfer.url); + if (frag.len > 0) { try jws.objectField("urlFragment"); try jws.beginWriteRaw(); try writer.writeAll("\"#"); - try writer.writeAll(frag.percent_encoded); + try writer.writeAll(frag); try writer.writeByte('\"'); jws.endWriteRaw(); } @@ -370,13 +364,8 @@ const TransferAsResponseWriter = struct { try jws.objectField("url"); try jws.beginWriteRaw(); try writer.writeByte('\"'); - try transfer.uri.writeToStream(writer, .{ - .scheme = true, - .authentication = true, - .authority = true, - .path = true, - .query = true, - }); + // #ZIGDOM shouldn't include the hash? + try writer.writeAll(transfer.url); try writer.writeByte('\"'); jws.endWriteRaw(); } diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 83547502..da26132f 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -19,9 +19,10 @@ const std = @import("std"); const log = @import("../../log.zig"); +const URL = @import("../../browser/URL.zig"); const Cookie = @import("../../browser/webapi/storage/storage.zig").Cookie; -const CookieJar = @import("../../browser/webapi/storage/storage.zig").Jar; -pub const PreparedUri = @import("../../browser/webapi/storage/cookie.zig").PreparedUri; +const CookieJar = Cookie.Jar; +pub const PreparedUri = Cookie.PreparedUri; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -112,9 +113,9 @@ pub const CookiePartitionKey = struct { pub const CdpCookie = struct { name: []const u8, value: []const u8, - url: ?[]const u8 = null, + url: ?[:0]const u8 = null, domain: ?[]const u8 = null, - path: ?[]const u8 = null, + path: ?[:0]const u8 = null, secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies @@ -136,12 +137,10 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { const a = arena.allocator(); // NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme. - const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; - const uri_ptr = if (uri) |*u| u else null; - const domain = try Cookie.parseDomain(a, uri_ptr, param.domain); + const domain = try Cookie.parseDomain(a, param.url, param.domain); const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path); - const secure = if (param.secure) |s| s else if (uri) |uri_| std.mem.eql(u8, uri_.scheme, "https") else false; + const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false; const cookie = Cookie{ .arena = arena, diff --git a/src/http/Client.zig b/src/http/Client.zig index 693d2fce..69e6d9e1 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -23,7 +23,7 @@ const builtin = @import("builtin"); const Http = @import("Http.zig"); const URL = @import("../browser/URL.zig"); const Notification = @import("../Notification.zig"); -const CookieJar = @import("../browser/webapi/storage/cookie.zig").Jar; +const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; const c = Http.c; const posix = std.posix; @@ -257,12 +257,6 @@ pub fn fulfillTransfer(self: *Client, transfer: *Transfer, status: u16, headers: fn makeTransfer(self: *Client, req: Request) !*Transfer { errdefer req.headers.deinit(); - // we need this for cookies - const uri = std.Uri.parse(req.url) catch |err| { - log.warn(.http, "invalid url", .{ .err = err, .url = req.url }); - return err; - }; - const transfer = try self.transfer_pool.create(); errdefer self.transfer_pool.destroy(transfer); @@ -271,7 +265,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer { transfer.* = .{ .arena = ArenaAllocator.init(self.allocator), .id = id, - .uri = uri, + .url = req.url, .req = req, .ctx = req.ctx, .client = self, @@ -593,26 +587,16 @@ pub const Handle = struct { pub const RequestCookie = struct { is_http: bool, + jar: *CookieJar, is_navigation: bool, origin: [:0]const u8, - jar: *@import("../browser/webapi/storage/cookie.zig").Jar, pub fn headersForRequest(self: *const RequestCookie, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void { - const uri = std.Uri.parse(url) catch |err| { - log.warn(.http, "invalid url", .{ .err = err, .url = url }); - return error.InvalidUrl; - }; - - const origin_uri = std.Uri.parse(self.origin) catch |err| { - log.warn(.http, "invalid url", .{ .err = err, .url = self.origin }); - return error.InvalidUrl; - }; - var arr: std.ArrayListUnmanaged(u8) = .{}; - try self.jar.forRequest(&uri, arr.writer(temp), .{ + try self.jar.forRequest(url, arr.writer(temp), .{ .is_http = self.is_http, .is_navigation = self.is_navigation, - .origin_uri = &origin_uri, + .origin_url = self.origin, }); if (arr.items.len > 0) { @@ -692,7 +676,7 @@ pub const Transfer = struct { arena: ArenaAllocator, id: usize = 0, req: Request, - uri: std.Uri, // used for setting/getting the cookie + url: [:0]const u8, ctx: *anyopaque, // copied from req.ctx to make it easier for callback handlers client: *Client, // total bytes received in the response, including the response status line, @@ -778,7 +762,7 @@ pub const Transfer = struct { pub fn updateURL(self: *Transfer, url: [:0]const u8) !void { // for cookies - self.uri = try std.Uri.parse(url); + self.url = url; // for the request itself self.req.url = url; @@ -846,7 +830,7 @@ pub const Transfer = struct { while (true) { const ct = getResponseHeader(easy, "set-cookie", i); if (ct == null) break; - try req.cookie_jar.populateFromResponse(&transfer.uri, ct.?.value); + try req.cookie_jar.populateFromResponse(transfer.url, ct.?.value); i += 1; if (i >= ct.?.amount) break; } @@ -860,13 +844,12 @@ pub const Transfer = struct { try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_EFFECTIVE_URL, &base_url)); const url = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); - const uri = try std.Uri.parse(url); - transfer.uri = uri; + transfer.url = url; var cookies: std.ArrayListUnmanaged(u8) = .{}; - try req.cookie_jar.forRequest(&uri, cookies.writer(arena), .{ + try req.cookie_jar.forRequest(url, cookies.writer(arena), .{ .is_http = true, - .origin_uri = &transfer.uri, + .origin_url = url, // used to enforce samesite cookie rules .is_navigation = req.resource_type == .document, }); @@ -895,7 +878,7 @@ pub const Transfer = struct { while (true) { const ct = getResponseHeader(easy, "set-cookie", i); if (ct == null) break; - transfer.req.cookie_jar.populateFromResponse(&transfer.uri, ct.?.value) catch |err| { + transfer.req.cookie_jar.populateFromResponse(transfer.url, ct.?.value) catch |err| { log.err(.http, "set cookie", .{ .err = err, .req = transfer }); return err; };