From 4965fec55cae2d3f58aaa0699dfd29f5886e3890 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:30:19 +0200 Subject: [PATCH] storage cookies --- src/browser/storage/cookie.zig | 2 +- src/cdp/cdp.zig | 1 + src/cdp/domains/network.zig | 121 ++-------------- src/cdp/domains/storage.zig | 255 +++++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 114 deletions(-) create mode 100644 src/cdp/domains/storage.zig diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 55cbc6c6..aa93ba21 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -263,8 +263,8 @@ pub const Cookie = struct { arena: ArenaAllocator, name: []const u8, value: []const u8, - path: []const u8, domain: []const u8, + path: []const u8, expires: ?i64, secure: bool = false, http_only: bool = false, diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 30b05499..ed3388a4 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -203,6 +203,7 @@ pub fn CDPT(comptime TypeProvider: type) type { asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command), asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command), asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command), + asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command), else => {}, }, 8 => switch (@as(u64, @bitCast(domain[0..8].*))) { diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 55cff246..c7ad0c60 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -17,10 +17,11 @@ // along with this program. If not, see . const std = @import("std"); +const Allocator = std.mem.Allocator; + const Notification = @import("../../notification.zig").Notification; const log = @import("../../log.zig"); - -const Allocator = std.mem.Allocator; +const CdpStorage = @import("storage.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -79,13 +80,7 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -const CookiePartitionKey = struct { - topLevelSite: []const u8, - hasCrossSiteAncestor: bool, -}; - const Cookie = @import("../../browser/storage/storage.zig").Cookie; -const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { if (!std.mem.eql(u8, cookie.name, name)) return false; @@ -116,7 +111,7 @@ fn deleteCookies(cmd: anytype) !void { while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - const domain = try percentEncodedDomain(cmd.arena, params.url, params.domain); + const domain = try CdpStorage.percentEncodedDomain(cmd.arena, params.url, params.domain); // TBD does chrome take the path from the url as default? (unlike setCookies) if (cookieMatches(cookie, params.name, domain, params.path)) { cookies.swapRemove(index).deinit(); @@ -134,130 +129,30 @@ fn clearBrowserCookies(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -const SameSite = enum { - Strict, - Lax, - None, -}; -const CookiePriority = enum { - Low, - Medium, - High, -}; -const CookieSourceScheme = enum { - Unset, - NonSecure, - Secure, -}; - -fn isHostChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - ':' => true, - '[', ']' => true, - else => false, - }; -} - -const CdpCookie = struct { - name: []const u8, - value: []const u8, - url: ?[]const u8 = null, - domain: ?[]const u8 = null, - path: ?[]const u8 = null, - secure: bool = false, // 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 - expires: ?i64 = null, // -1? says google - priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 - sameParty: ?bool = null, - sourceScheme: ?CookieSourceScheme = null, - // sourcePort: Temporary ability and it will be removed from CDP - partitionKey: ?CookiePartitionKey = null, -}; - fn setCookie(cmd: anytype) !void { const params = (try cmd.params( - CdpCookie, + CdpStorage.CdpCookie, )) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try setCdpCookie(&bc.session.cookie_jar, params); + try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params); try cmd.sendResult(.{ .success = true }, .{}); } fn setCookies(cmd: anytype) !void { const params = (try cmd.params(struct { - cookies: []const CdpCookie, + cookies: []const CdpStorage.CdpCookie, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; for (params.cookies) |param| { - try setCdpCookie(&bc.session.cookie_jar, param); + try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param); } try cmd.sendResult(null, .{}); } -fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { - if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { - return error.NotYetImplementedParams; - } - if (param.name.len == 0) return error.InvalidParams; - if (param.value.len == 0) return error.InvalidParams; - - var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); - errdefer arena.deinit(); - const a = arena.allocator(); - - // NOTE: The param.url can affect the default domain, path, source port, and source scheme. - const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; - - const cookie = Cookie{ - .arena = arena, - .name = try a.dupe(u8, param.name), - .value = try a.dupe(u8, param.value), - .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". - .domain = domain, - .expires = param.expires, - .secure = param.secure, - .http_only = param.httpOnly, - .same_site = switch (param.sameSite) { - .Strict => .strict, - .Lax => .lax, - .None => .none, - }, - }; - try cookie_jar.add(cookie, std.time.timestamp()); -} - -// Note: Chrome does not apply rules like removing a leading `.` from the domain. -fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { - const toLower = @import("../../browser/storage/cookie.zig").toLower; - if (domain) |domain_| { - const output = try allocator.dupe(u8, domain_); - return toLower(output); - } else if (default_url) |url| { - const uri = std.Uri.parse(url) catch return error.InvalidParams; - - var output: []u8 = undefined; - switch (uri.host orelse return error.InvalidParams) { - .raw => |str| { - var list = std.ArrayList(u8).init(allocator); - try list.ensureTotalCapacity(str.len); // Expect no precents needed - try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); - output = list.items; // @memory retains memory used before growing - }, - .percent_encoded => |str| { - output = try allocator.dupe(u8, str); - }, - } - return toLower(output); - } else return null; -} - // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig new file mode 100644 index 00000000..654d976b --- /dev/null +++ b/src/cdp/domains/storage.zig @@ -0,0 +1,255 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const log = @import("../../log.zig"); +const Cookie = @import("../../browser/storage/storage.zig").Cookie; +const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + clearCookies, + setCookies, + getCookies, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .clearCookies => return clearCookies(cmd), + .getCookies => return getCookies(cmd), + .setCookies => return setCookies(cmd), + } +} + +fn clearCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + + bc.session.cookie_jar.clearRetainingCapacity(); + + return cmd.sendResult(null, .{}); +} + +fn getCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + const cookies = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; + try cmd.sendResult(.{ .cookies = cookies }, .{}); +} + +fn setCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + cookies: []const CdpCookie, + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + + for (params.cookies) |param| { + try setCdpCookie(&bc.session.cookie_jar, param); + } + + try cmd.sendResult(null, .{}); +} + +pub const SameSite = enum { + Strict, + Lax, + None, +}; +pub const CookiePriority = enum { + Low, + Medium, + High, +}; +pub const CookieSourceScheme = enum { + Unset, + NonSecure, + Secure, +}; + +pub const CookiePartitionKey = struct { + topLevelSite: []const u8, + hasCrossSiteAncestor: bool, +}; + +pub const CdpCookie = struct { + name: []const u8, + value: []const u8, + url: ?[]const u8 = null, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + secure: bool = false, // 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 + expires: ?i64 = null, // -1? says google + priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 + sameParty: ?bool = null, + sourceScheme: ?CookieSourceScheme = null, + // sourcePort: Temporary ability and it will be removed from CDP + partitionKey: ?CookiePartitionKey = null, +}; + +pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { + if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { + return error.NotYetImplementedParams; + } + if (param.name.len == 0) return error.InvalidParams; + if (param.value.len == 0) return error.InvalidParams; + + var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + // NOTE: The param.url can affect the default domain, path, source port, and source scheme. + const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, param.name), + .value = try a.dupe(u8, param.value), + .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". + .domain = domain, + .expires = param.expires, + .secure = param.secure, + .http_only = param.httpOnly, + .same_site = switch (param.sameSite) { + .Strict => .strict, + .Lax => .lax, + .None => .none, + }, + }; + try cookie_jar.add(cookie, std.time.timestamp()); +} + +// Note: Chrome does not apply rules like removing a leading `.` from the domain. +pub fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { + const toLower = @import("../../browser/storage/cookie.zig").toLower; + if (domain) |domain_| { + const output = try allocator.dupe(u8, domain_); + return toLower(output); + } else if (default_url) |url| { + const uri = std.Uri.parse(url) catch return error.InvalidParams; + + var output: []u8 = undefined; + switch (uri.host orelse return error.InvalidParams) { + .raw => |str| { + var list = std.ArrayList(u8).init(allocator); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); + output = list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + output = try allocator.dupe(u8, str); + }, + } + return toLower(output); + } else return null; +} + +fn isHostChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + ':' => true, + '[', ']' => true, + else => false, + }; +} + +pub const CookieWriter = struct { + cookies: []const Cookie, + + pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void { + self.writeCookies(w) catch |err| { + // The only error our jsonStringify method can return is @TypeOf(w).Error. + log.err(.cdp, "json stringify", .{ .err = err }); + return error.OutOfMemory; + }; + } + + fn writeCookies(self: CookieWriter, w: anytype) !void { + try w.beginArray(); + for (self.cookies) |*cookie| { + try writeCookie(cookie, w); + } + try w.endArray(); + } + + fn writeCookie(cookie: *const Cookie, w: anytype) !void { + try w.beginObject(); + { + try w.objectField("name"); + try w.write(cookie.name); + + try w.objectField("value"); + try w.write(cookie.value); + + try w.objectField("domain"); + try w.write(cookie.domain); + + try w.objectField("path"); + try w.write(cookie.path); + + try w.objectField("expires"); + try w.write(cookie.expires orelse -1); + + // TODO size + + try w.objectField("httpOnly"); + try w.write(cookie.http_only); + + try w.objectField("secure"); + try w.write(cookie.secure); + + // TODO session + + try w.objectField("sameSite"); + switch (cookie.same_site) { + .none => try w.write("None"), + .lax => try w.write("Lax"), + .strict => try w.write("Strict"), + } + + // TODO experimentals + } + try w.endObject(); + } +};